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).
This commit is contained in:
jochen
2026-10-04 04:02:54 +02:00
parent 38b56a7877
commit 566739e02c
8 changed files with 522 additions and 91 deletions
+24 -59
View File
@@ -1,74 +1,43 @@
// zsh's tools — the module's own, and its implementation of the login-shell seat's one verb
// (novox/hq ADR 0176). Served by the node tools runtime (ADR 0175); nothing here runs a process.
//
// `execute` runs as the operator account. The runtime runs as the node's account — root when the
// host started it — so the command is handed to the account through `runuser` when we are not
// already that account. Root is the module's concern (ADR 0175 §4): a command that needs it uses
// sudo inside the shell like a person would.
// zsh's tools: its implementation of node-login-shell's one verb, `execute`, and its own
// `zsh_config` (novox/hq ADR 0176, ADR 0204). The node tools runtime launches this bundle as a
// process of its own and serves what it registers (ADR 0188, ADR 0193); it runs as the operator
// account, so the command runs as that account with nothing switched (shell.ts).
import { spawn } from "node:child_process";
import { readFile } from "node:fs/promises";
import { homedir, userInfo } from "node:os";
import { userInfo } from "node:os";
import { join } from "node:path";
import { registerModuleTools, type ToolDefinition } from "@novox/mesh-sdk/tools";
import { DEFAULT_TIMEOUT_SECONDS, MAX_TIMEOUT_SECONDS, commandEnv, execute, homeOf, timeoutOf, zshFile } from "../shell.js";
/** The operator account on this machine, as the mesh told the runtime; the current user otherwise. */
/** The operator account on this machine, as the mesh told the runtime. */
function account(env: NodeJS.ProcessEnv): string {
return env.MESH_OPERATOR_ACCOUNT?.trim() || userInfo().username;
}
interface Executed {
command: string;
account: string;
status: number | null;
signal: string | null;
stdout: string;
stderr: string;
timed_out: boolean;
}
/** Run one command line in a zsh login shell as the account, capturing everything. */
export async function execute(command: string, who: string, timeoutSeconds: number): Promise<Executed> {
const self = userInfo().username;
const argv = who === self
? ["zsh", "-lc", command]
: ["runuser", "-u", who, "--", "zsh", "-lc", command];
return new Promise((resolve) => {
const child = spawn(argv[0], argv.slice(1), { stdio: ["ignore", "pipe", "pipe"] });
let stdout = "";
let stderr = "";
let timedOut = false;
child.stdout.on("data", (d: Buffer) => { stdout += d.toString(); });
child.stderr.on("data", (d: Buffer) => { stderr += d.toString(); });
const timer = setTimeout(() => { timedOut = true; child.kill("SIGKILL"); }, timeoutSeconds * 1000);
child.on("error", (err) => {
clearTimeout(timer);
resolve({ command, account: who, status: null, signal: null, stdout, stderr: stderr + err.message, timed_out: false });
});
child.on("close", (status, signal) => {
clearTimeout(timer);
resolve({ command, account: who, status, signal, stdout, stderr, timed_out: timedOut });
});
});
}
function seatVerbs(env: NodeJS.ProcessEnv): ToolDefinition[] {
return [
{
name: "execute",
description: "Run one command on this machine as the operator account, in a login shell; answers with what it printed and how it exited.",
description:
`Run one command on this machine as the operator account, in a zsh login shell in the account's home; answers with what it printed (each stream cut at 256 KiB, said in truncated) and how it exited. Ended, with everything it started, after timeout_seconds (default ${DEFAULT_TIMEOUT_SECONDS}, at most ${MAX_TIMEOUT_SECONDS}).`,
input: {
type: "object",
properties: {
command: { type: "string", description: "the command line, as you would type it" },
timeout_seconds: { type: "number", description: "give up after this long (default 60)" },
timeout_seconds: { type: "number", description: `give up after this long (default ${DEFAULT_TIMEOUT_SECONDS}, at most ${MAX_TIMEOUT_SECONDS})` },
},
required: ["command"],
},
run: async (args) => {
const command = String(args.command ?? "").trim();
if (!command) throw new Error("execute: a command is required");
const timeout = Number(args.timeout_seconds ?? 60);
return execute(command, account(env), Number.isFinite(timeout) && timeout > 0 ? timeout : 60);
const who = account(env);
const self = userInfo().username;
if (who !== self) {
// The runtime is the account; anything else is a runtime this was not written for, and
// a command run as the wrong user is worse than one not run.
throw new Error(`execute runs as ${who}, and this runtime runs as ${self}`);
}
return execute(command, { cwd: homeOf(env), env: commandEnv(env), timeoutSeconds: timeoutOf(args.timeout_seconds), account: who });
},
},
];
@@ -78,21 +47,17 @@ function ownTools(env: NodeJS.ProcessEnv): ToolDefinition[] {
return [
{
name: "zsh_config",
description: "The operator account's ~/.zshrc on this machine as it is now: the mesh's block and the lines around it.",
description: "The operator account's ~/.zshenv and ~/.zshrc on this machine as they are now: each file's lines, how many are the mesh's block, and the content.",
input: { type: "object", properties: {} },
run: async () => {
const who = account(env);
const home = env.MESH_OPERATOR_HOME?.trim() || (who === userInfo().username ? homedir() : `/home/${who}`);
const path = `${home}/.zshrc`;
const text = await readFile(path, "utf8").catch(() => "");
const inBlock = /# BEGIN mesh [^\n]*\n([\s\S]*?)# END mesh/.exec(text);
return { account: who, path, lines: text.split("\n").length, mesh_block_lines: inBlock ? inBlock[1].split("\n").length - 1 : 0, content: text };
const home = homeOf(env);
return { account: account(env), zshenv: await zshFile(join(home, ".zshenv")), zshrc: await zshFile(join(home, ".zshrc")) };
},
},
];
}
// The seat's verb is registered under the seat's name (what the runtime serves on the seat's
// subject when this module holds it) and the module's own tools under the module's.
registerModuleTools("login-shell", seatVerbs);
// subject while this module holds it) and the module's own tool under the module's.
registerModuleTools("node-login-shell", seatVerbs);
registerModuleTools("zsh", ownTools);