diff --git a/modules/zsh/README.md b/modules/zsh/README.md new file mode 100644 index 0000000..df0fbe2 --- /dev/null +++ b/modules/zsh/README.md @@ -0,0 +1,85 @@ +# zsh + +The login shell as a module (novox/hq ADR 0176, ADR 0204, to-be 41). + +- Installs the `zsh` package and makes it the operator account's login shell through the `user` + shape. The host gives the found shell back when the module goes. +- Claims the mesh's `node-login-shell` seat and serves its verb `execute`: one command, run as the + account in a zsh login shell in the account's home. It is ended with everything it started after + 20 s by default (25 s at most, below the runtime's 30 s call limit). Each stream is cut at 256 KiB + and the answer says so in `truncated`. +- Its own tool `zsh_config` shows `~/.zshenv` and `~/.zshrc` as they are, with the mesh block's line + count in each. +- Contributes to the account's environment (ADR 0203): `EDITOR` and `VISUAL` (vim), `XDG_CONFIG_HOME`, + and `~/.local/bin`, `~/scripts`, `~/scripts/bin` at the start of `PATH`. The holder of + `node-environment` (`node-env`) writes them; this module writes no `export` of its own. + +## The two blocks + +Each block is the mesh's, between `# BEGIN mesh zsh.` and `# END mesh zsh.`. Every line +outside a block is yours, kept byte for byte, and given back when the module goes. + +- **`~/.zshenv`, at the start:** sources `~/.config/mesh/environment.sh` if it is readable. Every zsh + reads this file: a login, a script, and `execute`. +- **`~/.zshrc`, at the start:** the defaults every machine shares, with the three slots other modules + fill (`first`, the defaults, `normal`, `last`). The defaults are the terminal title, seven + keybindings, the colour aliases, `ll`/`la`/`l`, `drun`, `disksize` and `sudo-disksize`. Your lines + below it run after it, so they win. + +## The one-off migration (ADR 0182) + +The mesh removes nothing it did not make. After the first push that assigns this module, the lines +below are in the block **and** in your own part of `~/.zshrc`. Until you delete your copies, they run +twice, which is harmless and visible. Deleting them is a person's act, once per machine. + +**Delete once `zsh` is assigned.** The block or the environment now carries these: + +1. `export EDITOR=vim` +2. `export VISUAL=vim` +3. `export XDG_CONFIG_HOME=$HOME/.config` +4. `export PATH="$HOME/.local/bin:$HOME/scripts:$HOME/scripts/bin:$HOME/.dotnet:$PATH"`. Keep the + one entry no module carries yet, as `export PATH="$HOME/.dotnet:$PATH"`. +5. The terminal title: `function set_terminal_title() { … }` and + `precmd_functions+=(set_terminal_title)`. +6. The seven `bindkey` lines (Home twice, Ctrl+A, End twice, Ctrl+E, Del). +7. `alias drun='docker run -it --rm'`, and the functions `disksize() { … }` and + `sudo-disksize() { … }`. +8. The `if [ -x /usr/bin/dircolors ]; then … fi` block, with its aliases `ls`, `dir`, `vdir`, + `grep`, `fgrep` and `egrep`. +9. `alias ll='ls -alhF'`, `alias la='ls -Ah'` and `alias l='ls -CFh'`. + +**Delete once `powerlevel10k` is assigned.** Its contribution in the `normal` slot loads the prompt: + +1. `#user_p10k_cache_file="${XDG_CACHE_HOME:-$HOME/.cache}/p10k-instant-prompt-${USER}.zsh"`, and the + `if [[ -r "$user_p10k_cache_file" ]]; then … fi` after it. It does nothing today, because the + variable is commented out. +2. `[[ ! -f ~/.zsh/themes/powerlevel10k/powerlevel10k.zsh-theme ]] || source …` +3. `[[ ! -f ~/.p10k.zsh ]] || source ~/.p10k.zsh` + +**Delete once `zsh-autosuggestions` / `zsh-syntax-highlighting` are assigned.** Each module loads +the distribution's copy from its own slot: + +1. `[[ ! -f ~/.zsh/plugins/zsh-autosuggestions/zsh-autosuggestions.zsh ]] || source …` +2. `[[ ! -f ~/.zsh/plugins/zsh-syntax-highlighting/zsh-syntax-highlighting.zsh ]] || source …` + +**What stays yours.** Keep these below the block until a module carries them: + +- the `NVM_DIR` lines; +- `MY_KV_PATH` and `MY_LIB_PATH`; +- `CLAUDE_CODE_DISABLE_TERMINAL_TITLE`; +- the `killport` and `findport` aliases; +- the `zstyle` lines; +- the line sourcing `~/.zshrc.local`, and that file itself. + +**Paths no longer used.** The predecessor cloned these and nothing of the mesh reads them. Remove +them by hand once the module that replaces each is assigned: + +- `~/.zsh/themes/powerlevel10k`: replaced by `powerlevel10k`'s pinned copy in + `~/.local/share/powerlevel10k`; +- `~/.p10k.zsh`: replaced by `~/.config/powerlevel10k/p10k.zsh`, the same bytes, once + `powerlevel10k` is assigned; +- `~/.zsh/plugins/zsh-autosuggestions` and `~/.zsh/plugins/zsh-syntax-highlighting`: replaced by + the distribution's packages; +- `~/.zsh/plugins/zsh-autocomplete`: loaded by nothing today. + +Anything else under `~/.zsh/` is yours. diff --git a/modules/zsh/module.json b/modules/zsh/module.json index 1cd43b3..1c17c97 100644 --- a/modules/zsh/module.json +++ b/modules/zsh/module.json @@ -4,37 +4,9 @@ "capabilities": [ "package-manager" ], - "seats": [ - { - "name": "login-shell", - "scope": "node", - "serves": [ - { - "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 (novox/hq ADR 0176).", - "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)" - } - }, - "required": [ - "command" - ] - } - } - ] - } - ], "claims": [ { - "name": "login-shell", + "name": "node-login-shell", "scope": "node", "serves": [ "execute" @@ -44,12 +16,43 @@ "tools": [ "zsh_config" ], + "environment": { + "variables": { + "EDITOR": "vim", + "VISUAL": "vim", + "XDG_CONFIG_HOME": "${machine:account-home}/.config" + }, + "path": [ + { + "entry": "${machine:account-home}/.local/bin", + "at": "start" + }, + { + "entry": "${machine:account-home}/scripts", + "at": "start" + }, + { + "entry": "${machine:account-home}/scripts/bin", + "at": "start" + } + ] + }, "resources": [ { "id": "package", "type": "package", "package": "zsh" }, + { + "id": "env", + "type": "file", + "path": "${machine:account-home}/.zshenv", + "owner": "${machine:account}", + "mode": "0644", + "into": "block", + "at": "start", + "content": "# The mesh's block (module zsh, novox/hq ADR 0203, ADR 0204): the account's environment, read by\n# every zsh — a login, a script, and node-login-shell's execute. Replaced at every push; everything\n# outside it is yours.\nif [[ -r \"${machine:account-home}/.config/mesh/environment.sh\" ]]; then\n source \"${machine:account-home}/.config/mesh/environment.sh\"\nfi\n" + }, { "id": "rc", "type": "file", @@ -57,7 +60,8 @@ "owner": "${machine:account}", "mode": "0644", "into": "block", - "content": "# The mesh's default zsh configuration (module zsh). Everything OUTSIDE this block is yours and\n# survives every push; everything inside it is replaced on the next one (novox/hq ADR 0174).\n# Machine-specific lines go in ~/.zshrc.local, which this sources last.\n\nexport EDITOR=vim\nexport VISUAL=vim\nexport XDG_CONFIG_HOME=\"$HOME/.config\"\nexport PATH=\"$HOME/.local/bin:$HOME/scripts:$HOME/scripts/bin:$PATH\"\n\n# Terminal title: host, directory, git branch\nfunction set_terminal_title() {\n local git_branch=\"\"\n if git rev-parse --is-inside-work-tree &>/dev/null; then\n git_branch=\" ($(git branch --show-current 2>/dev/null))\"\n fi\n print -Pn \"\\e]2;%m: %~${git_branch}\\a\"\n}\nprecmd_functions+=(set_terminal_title)\n\n# A prompt theme and plugins, when a module placed them (the prompt module owns ~/.p10k.zsh and\n# ~/.zsh/themes; this only loads what is there).\n[[ ! -f ~/.zsh/themes/powerlevel10k/powerlevel10k.zsh-theme ]] || source ~/.zsh/themes/powerlevel10k/powerlevel10k.zsh-theme\n[[ ! -f ~/.p10k.zsh ]] || source ~/.p10k.zsh\n[[ ! -f ~/.zsh/plugins/zsh-autosuggestions/zsh-autosuggestions.zsh ]] || source ~/.zsh/plugins/zsh-autosuggestions/zsh-autosuggestions.zsh\n[[ ! -f ~/.zsh/plugins/zsh-syntax-highlighting/zsh-syntax-highlighting.zsh ]] || source ~/.zsh/plugins/zsh-syntax-highlighting/zsh-syntax-highlighting.zsh\n\n# Keybindings: Home, End, Ctrl-A, Ctrl-E, Del\nbindkey \"^[[H\" beginning-of-line\nbindkey \"^[OH\" beginning-of-line\nbindkey \"^A\" beginning-of-line\nbindkey \"^[[F\" end-of-line\nbindkey \"^[OF\" end-of-line\nbindkey \"^E\" end-of-line\nbindkey \"^[[3~\" delete-char\n\n# Colour and the usual ls aliases\nif [ -x /usr/bin/dircolors ]; then\n test -r \"$HOME/.dircolors\" && eval \"$(dircolors -b \"$HOME/.dircolors\")\" || eval \"$(dircolors -b)\"\n alias ls='ls --color=auto'\n alias grep='grep --color=auto'\nfi\nalias ll='ls -alhF'\nalias la='ls -Ah'\nalias l='ls -CFh'\nalias drun='docker run -it --rm'\ndisksize() { du -h --max-depth=1 \"${1:-.}\" | sort -h; }\n\n# Machine-specific configuration, kept by you\n[[ ! -f ~/.zshrc.local ]] || source ~/.zshrc.local\n" + "at": "start", + "content": "# The mesh's block (module zsh, novox/hq ADR 0204): the defaults every machine shares, and the code\n# other modules contribute in three slots. It is replaced at every push. Everything below it is\n# yours, kept as you wrote it, and runs after it, so your lines win.\n\n${shell:zsh:first}\n# Terminal title: host, directory, git branch\nfunction set_terminal_title() {\n local git_branch=\"\"\n if git rev-parse --is-inside-work-tree &>/dev/null; then\n git_branch=\" ($(git branch --show-current 2>/dev/null))\"\n fi\n print -Pn \"\\e]2;%m: %~${git_branch}\\a\"\n}\nprecmd_functions+=(set_terminal_title)\n\n# Keybindings\nbindkey \"^[[H\" beginning-of-line # Home\nbindkey \"^[OH\" beginning-of-line # Home\nbindkey \"^A\" beginning-of-line # Ctrl + A\nbindkey \"^[[F\" end-of-line # End\nbindkey \"^[OF\" end-of-line # End\nbindkey \"^E\" end-of-line # Ctrl + E\nbindkey \"^[[3~\" delete-char # Del\n\n# Colour support of ls and friends\nif [ -x /usr/bin/dircolors ]; then\n test -r $HOME/.dircolors && eval \"$(dircolors -b $HOME/.dircolors)\" || eval \"$(dircolors -b)\"\n alias ls='ls --color=auto'\n alias dir='dir --color=auto'\n alias vdir='vdir --color=auto'\n alias grep='grep --color=auto'\n alias fgrep='fgrep --color=auto'\n alias egrep='egrep --color=auto'\nfi\nalias ll='ls -alhF'\nalias la='ls -Ah'\nalias l='ls -CFh'\n\n# A throwaway container, and what takes the space under a directory\nalias drun='docker run -it --rm'\ndisksize() {\n du -h --max-depth=1 \"${1:-.}\" | sort -h\n}\nsudo-disksize() {\n sudo du -h --max-depth=1 \"${1:-.}\" | sort -h\n}\n\n${shell:zsh:normal}${shell:zsh:last}\n" }, { "id": "login", diff --git a/modules/zsh/package.json b/modules/zsh/package.json index 00bbc02..9f37ce0 100644 --- a/modules/zsh/package.json +++ b/modules/zsh/package.json @@ -1,14 +1,18 @@ { "name": "@novox/module-zsh", "version": "0.1.0", - "description": "zsh \u2014 the shell as a module: the package, the mesh's default ~/.zshrc as a block the operator's own lines survive around, the login-shell seat and its execute verb (novox/hq ADR 0176).", + "description": "zsh \u2014 the login shell as a module: the package, the login shell set through the user shape, the mesh's blocks at the start of ~/.zshrc (defaults and three slots other modules fill) and in ~/.zshenv (the account's environment), and node-login-shell's execute (novox/hq ADR 0176, ADR 0204).", "type": "module", "private": true, "dependencies": { - "@novox/mesh-sdk": "^0.1.0" + "@novox/mesh-sdk": "^0.1.1" }, "devDependencies": { "@types/node": "^22.0.0", "typescript": "^5.6.0" + }, + "scripts": { + "build": "tsc shell.ts tools/index.ts --module NodeNext --moduleResolution NodeNext --target ES2022 --rootDir . --outDir dist", + "test": "node --test --experimental-strip-types 'test/*.test.ts'" } } diff --git a/modules/zsh/shell.ts b/modules/zsh/shell.ts new file mode 100644 index 0000000..8537569 --- /dev/null +++ b/modules/zsh/shell.ts @@ -0,0 +1,179 @@ +// 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, + }; +} diff --git a/modules/zsh/test/execute.test.ts b/modules/zsh/test/execute.test.ts new file mode 100644 index 0000000..ceecb55 --- /dev/null +++ b/modules/zsh/test/execute.test.ts @@ -0,0 +1,122 @@ +// node-login-shell's execute over real child processes (novox/hq ADR 0204 §4, to-be 41 WP3): bounded +// in time with its whole process group ended, bounded in output and saying so, run in the account's +// home, answering the exit status. `sh` stands in for zsh where the test needs no zsh of its own, so +// the bounds are proven wherever the tests run; one test runs zsh itself when it is installed. +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { execFileSync } from "node:child_process"; +import { mkdtempSync, readFileSync, realpathSync, writeFileSync, mkdirSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { DEFAULT_TIMEOUT_SECONDS, MAX_TIMEOUT_SECONDS, commandEnv, execute, homeOf, timeoutOf, zshFile } from "../shell.ts"; + +const dir = realpathSync(mkdtempSync(join(tmpdir(), "zsh-execute-"))); +const base = { shell: "sh", cwd: dir, env: { PATH: process.env.PATH, HOME: dir }, timeoutSeconds: 5 }; + +function alive(pid: number): boolean { + try { + process.kill(pid, 0); + return true; + } catch { + return false; + } +} + +async function gone(pid: number, ms = 2000): Promise { + const until = Date.now() + ms; + while (Date.now() < until) { + if (!alive(pid)) return true; + await new Promise((r) => setTimeout(r, 50)); + } + return !alive(pid); +} + +test("the exit status and both streams are answered", async () => { + const r = await execute("echo out; echo err >&2; exit 3", base); + assert.equal(r.status, 3); + assert.equal(r.stdout, "out\n"); + assert.equal(r.stderr, "err\n"); + assert.equal(r.timed_out, false); + assert.deepEqual(r.truncated, { stdout: false, stderr: false }); +}); + +test("it runs in the directory it is given", async () => { + const r = await execute("pwd", base); + assert.equal(r.stdout.trim(), dir); + assert.equal(r.cwd, dir); +}); + +test("on timeout the whole process group is ended, a backgrounded grandchild included", async () => { + const pidfile = join(dir, "grandchild.pid"); + const started = Date.now(); + const r = await execute(`sh -c 'sleep 100 & echo $! > ${pidfile}; wait'`, { ...base, timeoutSeconds: 0.5 }); + assert.equal(r.timed_out, true); + assert.ok(Date.now() - started < 5000, "answered soon after the timeout, not when the sleep ended"); + assert.equal(r.status, null); + assert.equal(r.signal, "SIGKILL"); + const pid = Number(readFileSync(pidfile, "utf8").trim()); + assert.ok(pid > 0); + assert.ok(await gone(pid), `the grandchild ${pid} is gone`); +}); + +test("output beyond the bound is cut and the answer says so", async () => { + const r = await execute("head -c 300000 /dev/zero | tr '\\0' a; head -c 10 /dev/zero | tr '\\0' b >&2", { ...base, capBytes: 1024 }); + assert.equal(r.stdout.length, 1024); + assert.equal(r.truncated.stdout, true); + assert.equal(r.stderr, "bbbbbbbbbb"); + assert.equal(r.truncated.stderr, false); + assert.equal(r.status, 0); +}); + +test("a job left in the background does not hold the answer", async () => { + const pidfile = join(dir, "background.pid"); + const started = Date.now(); + const r = await execute(`sleep 30 & echo $! > ${pidfile}; echo done`, base); + assert.ok(Date.now() - started < 3000); + assert.equal(r.stdout, "done\n"); + assert.equal(r.status, 0); + process.kill(Number(readFileSync(pidfile, "utf8").trim()), "SIGKILL"); +}); + +test("zsh runs the command as a login shell, when zsh is here", async (t) => { + try { + execFileSync("zsh", ["-c", "true"]); + } catch { + t.skip("zsh is not installed here"); + return; + } + const home = join(dir, "home"); + mkdirSync(home, { recursive: true }); + writeFileSync(join(home, ".zshenv"), "export FROM_ZSHENV=yes\n"); + const r = await execute("print -r -- $FROM_ZSHENV; [[ -o login ]] && print login", { cwd: home, env: { PATH: process.env.PATH, HOME: home, ZDOTDIR: home }, timeoutSeconds: 5 }); + assert.equal(r.stdout, "yes\nlogin\n"); + assert.equal(r.status, 0); +}); + +test("the timeout is held below the runtime's call limit", () => { + assert.equal(timeoutOf(undefined), DEFAULT_TIMEOUT_SECONDS); + assert.equal(timeoutOf(5), 5); + assert.equal(timeoutOf(600), MAX_TIMEOUT_SECONDS); + assert.equal(timeoutOf(-1), DEFAULT_TIMEOUT_SECONDS); + assert.equal(timeoutOf("x"), DEFAULT_TIMEOUT_SECONDS); + assert.ok(MAX_TIMEOUT_SECONDS < 30 && DEFAULT_TIMEOUT_SECONDS <= MAX_TIMEOUT_SECONDS); +}); + +test("the command's environment drops the mesh's words and gains the account's session words when they exist", () => { + const env = { PATH: "/usr/bin", HOME: "/home/op", MESH_OPERATOR_ACCOUNT: "op", MESH_OPERATOR_HOME: "/home/op", MESH_ANYTHING: "x" }; + const there = commandEnv(env, 1000, (p) => p === "/run/user/1000"); + assert.deepEqual(there, { PATH: "/usr/bin", HOME: "/home/op", XDG_RUNTIME_DIR: "/run/user/1000", DBUS_SESSION_BUS_ADDRESS: "unix:path=/run/user/1000/bus" }); + const absent = commandEnv(env, 1000, () => false); + assert.deepEqual(absent, { PATH: "/usr/bin", HOME: "/home/op" }); + assert.equal(homeOf(env), "/home/op"); + assert.equal(homeOf({ HOME: "/h" }), "/h"); +}); + +test("zsh_config counts the mesh's block in a file", async () => { + const path = join(dir, ".zshrc"); + writeFileSync(path, "# BEGIN mesh zsh.rc\na\nb\nc\n# END mesh zsh.rc\nmine\n"); + const r = await zshFile(path); + assert.equal(r.lines, 6); + assert.equal(r.mesh_block_lines, 3); + assert.deepEqual(await zshFile(join(dir, "absent")), { path: join(dir, "absent"), exists: false }); +}); diff --git a/modules/zsh/test/manifest.test.ts b/modules/zsh/test/manifest.test.ts new file mode 100644 index 0000000..7a3f6bc --- /dev/null +++ b/modules/zsh/test/manifest.test.ts @@ -0,0 +1,71 @@ +// The zsh module's shape (novox/hq ADR 0203, ADR 0204, to-be 41 WP3): it claims the mesh's +// node-login-shell and declares no seat; its ~/.zshrc block goes at the start and holds the three +// slots in order; its ~/.zshenv block sources the path node-environment fixes and nothing else; its +// environment is a contribution, never an export in its own lines. +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; + +const m = JSON.parse(readFileSync(new URL("../module.json", import.meta.url), "utf8")); +const resource = (id: string) => m.resources.find((r: { id: string }) => r.id === id); + +test("it claims node-login-shell serving execute, and declares no seat", () => { + assert.equal(m.seats, undefined); + assert.deepEqual(m.claims, [{ name: "node-login-shell", scope: "node", serves: ["execute"] }]); +}); + +test("the .zshrc block is at the start and holds the three slots in order, after the defaults' header", () => { + const rc = resource("rc"); + assert.equal(rc.path, "${machine:account-home}/.zshrc"); + assert.equal(rc.into, "block"); + assert.equal(rc.at, "start"); + const c: string = rc.content; + const first = c.indexOf("${shell:zsh:first}"); + const normal = c.indexOf("${shell:zsh:normal}"); + const last = c.indexOf("${shell:zsh:last}"); + assert.ok(c.startsWith("# The mesh's block"), "it opens by saying whose it is"); + assert.ok(first > 0 && first < normal && normal < last, "first, normal, last, in that order"); + assert.ok(c.indexOf("set_terminal_title") > first && c.indexOf("sudo-disksize") < normal, "the defaults sit between first and normal"); + for (const slot of ["first", "normal", "last"]) assert.equal(c.split(`\${shell:zsh:${slot}}`).length, 2, `${slot} appears once`); +}); + +test("the .zshrc block carries the shared defaults and none of the operator's own lines", () => { + const c: string = resource("rc").content; + for (const want of ["precmd_functions+=(set_terminal_title)", "alias egrep='egrep --color=auto'", "alias ll='ls -alhF'", "alias drun=", "disksize()", "sudo-disksize()"]) { + assert.ok(c.includes(want), want); + } + assert.equal((c.match(/^bindkey /gm) ?? []).length, 7); + for (const not of ["export ", "p10k", "powerlevel10k", "nvm", ".dotnet", "MY_KV_PATH", "MY_LIB_PATH", "CLAUDE_CODE_DISABLE_TERMINAL_TITLE", "killport", "findport", ".zshrc.local", "zsh-autosuggestions", "zsh-syntax-highlighting"]) { + assert.ok(!c.includes(not), `the block holds no ${not}`); + } +}); + +test("the .zshenv block sources exactly the environment file node-environment fixes", () => { + const env = resource("env"); + assert.equal(env.path, "${machine:account-home}/.zshenv"); + assert.equal(env.into, "block"); + assert.equal(env.at, "start"); + const sourced = [...(env.content as string).matchAll(/^\s*(?:source|\.)\s+"?([^"\s]+)"?/gm)].map((x) => x[1]); + assert.deepEqual(sourced, ["${machine:account-home}/.config/mesh/environment.sh"]); +}); + +test("its environment is a contribution: the editor, the configuration home, and three PATH entries at the start", () => { + assert.deepEqual(m.environment.variables, { EDITOR: "vim", VISUAL: "vim", XDG_CONFIG_HOME: "${machine:account-home}/.config" }); + assert.deepEqual(m.environment.path, [ + { entry: "${machine:account-home}/.local/bin", at: "start" }, + { entry: "${machine:account-home}/scripts", at: "start" }, + { entry: "${machine:account-home}/scripts/bin", at: "start" }, + ]); +}); + +test("it keeps the package and sets the login shell through the user shape", () => { + assert.deepEqual(resource("package"), { id: "package", type: "package", package: "zsh" }); + assert.deepEqual(resource("login"), { id: "login", type: "user", name: "${machine:account}", shell: "/usr/bin/zsh" }); + assert.ok(m.capabilities.includes("package-manager")); +}); + +test("the tools bundle registers the seat's verb under node-login-shell", () => { + const src = readFileSync(new URL("../tools/index.ts", import.meta.url), "utf8"); + assert.match(src, /registerModuleTools\("node-login-shell", seatVerbs\)/); + assert.doesNotMatch(src, /runuser|"login-shell"/); +}); diff --git a/modules/zsh/tools/index.ts b/modules/zsh/tools/index.ts index 7058fd5..3591d0f 100644 --- a/modules/zsh/tools/index.ts +++ b/modules/zsh/tools/index.ts @@ -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 { - 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); diff --git a/modules/zsh/tsconfig.json b/modules/zsh/tsconfig.json index bccdc23..b48f0d8 100644 --- a/modules/zsh/tsconfig.json +++ b/modules/zsh/tsconfig.json @@ -9,6 +9,7 @@ "noEmit": true }, "include": [ + "shell.ts", "tools/index.ts" ] }