zsh: let a machine withhold node-login-shell's execute
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
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.
This commit is contained in:
@@ -8,8 +8,16 @@ The login shell as a module (novox/hq ADR 0176, ADR 0204, to-be 41).
|
|||||||
account in a zsh login shell in the account's home. It is ended with everything it started after
|
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
|
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`.
|
and the answer says so in `truncated`.
|
||||||
|
- **A machine may withhold `execute`** (hq ADR 0268). It runs any command as the operator account,
|
||||||
|
which can become root without a person. The setting `execute` is `serve` by default; a machine's
|
||||||
|
layer `withhold` (`mesh-controller.settings` with `module` zsh, `node` the machine, `values`
|
||||||
|
`{"execute":"withhold"}`, then a push) makes the runtime there offer no `execute` at all. The mesh
|
||||||
|
writes the value into a root-owned `tools.conf` in the module's directory, which the tools bundle
|
||||||
|
reads when the runtime starts it; a change restarts the runtime. Anything but `serve` (a wrong value,
|
||||||
|
no file) withholds it, and says why on stderr and in `zsh_config`'s `execute`. The control-node
|
||||||
|
withholds it until a call to it needs a person's approval (hq research 039).
|
||||||
- Its own tool `zsh_config` shows `~/.zshenv` and `~/.zshrc` as they are, with the mesh block's line
|
- Its own tool `zsh_config` shows `~/.zshenv` and `~/.zshrc` as they are, with the mesh block's line
|
||||||
count in each.
|
count in each, and whether this machine serves `execute`, and why.
|
||||||
- Contributes to the account's environment (ADR 0203): `EDITOR` and `VISUAL` (vim), `XDG_CONFIG_HOME`,
|
- 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
|
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.
|
`node-environment` (`node-env`) writes them; this module writes no `export` of its own.
|
||||||
|
|||||||
+26
-1
@@ -16,6 +16,13 @@
|
|||||||
"tools": [
|
"tools": [
|
||||||
"zsh_config"
|
"zsh_config"
|
||||||
],
|
],
|
||||||
|
"settings": {
|
||||||
|
"execute": {
|
||||||
|
"kind": "preference",
|
||||||
|
"default": "serve",
|
||||||
|
"why": "execute runs any command as the operator account, which can become root without a person; a machine whose layer says withhold does not offer it (hq ADR 0268): the control-node, until a call to it needs a person's approval"
|
||||||
|
}
|
||||||
|
},
|
||||||
"environment": {
|
"environment": {
|
||||||
"variables": {
|
"variables": {
|
||||||
"EDITOR": "vim",
|
"EDITOR": "vim",
|
||||||
@@ -68,6 +75,21 @@
|
|||||||
"type": "user",
|
"type": "user",
|
||||||
"name": "${machine:account}",
|
"name": "${machine:account}",
|
||||||
"shell": "/usr/bin/zsh"
|
"shell": "/usr/bin/zsh"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "state",
|
||||||
|
"type": "directory",
|
||||||
|
"place": ".",
|
||||||
|
"owner": "root",
|
||||||
|
"mode": "0755"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "tools-conf",
|
||||||
|
"type": "file",
|
||||||
|
"path": "${dir:state}/tools.conf",
|
||||||
|
"owner": "root",
|
||||||
|
"mode": "0644",
|
||||||
|
"content": "# Written by the mesh (module zsh, hq ADR 0268) from this machine's setting execute: serve or withhold.\n# Read by the zsh tools bundle when the runtime starts it; change the setting, not this.\nexecute=${setting:execute}\n"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"build": {
|
"build": {
|
||||||
@@ -78,7 +100,10 @@
|
|||||||
"language": "typescript",
|
"language": "typescript",
|
||||||
"entrypoints": [
|
"entrypoints": [
|
||||||
"tools/index.js"
|
"tools/index.js"
|
||||||
]
|
],
|
||||||
|
"env": {
|
||||||
|
"ZSH_TOOLS_CONF": "${dir:state}/tools.conf"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
+47
-1
@@ -15,7 +15,7 @@
|
|||||||
// and the answer says it was cut.
|
// and the answer says it was cut.
|
||||||
|
|
||||||
import { spawn } from "node:child_process";
|
import { spawn } from "node:child_process";
|
||||||
import { existsSync } from "node:fs";
|
import { existsSync, readFileSync } from "node:fs";
|
||||||
import { readFile } from "node:fs/promises";
|
import { readFile } from "node:fs/promises";
|
||||||
import { homedir, userInfo } from "node:os";
|
import { homedir, userInfo } from "node:os";
|
||||||
|
|
||||||
@@ -177,3 +177,49 @@ export async function zshFile(path: string, cap: number = OUTPUT_CAP_BYTES): Pro
|
|||||||
content: text.length > cap ? text.slice(0, cap) : text,
|
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);
|
||||||
|
}
|
||||||
|
|||||||
@@ -69,3 +69,26 @@ test("the tools bundle registers the seat's verb under node-login-shell", () =>
|
|||||||
assert.match(src, /registerModuleTools\("node-login-shell", seatVerbs\)/);
|
assert.match(src, /registerModuleTools\("node-login-shell", seatVerbs\)/);
|
||||||
assert.doesNotMatch(src, /runuser|"login-shell"/);
|
assert.doesNotMatch(src, /runuser|"login-shell"/);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("execute is a per-machine setting, served unless a machine withholds it (ADR 0268)", () => {
|
||||||
|
assert.deepEqual(Object.keys(m.settings), ["execute"]);
|
||||||
|
assert.equal(m.settings.execute.kind, "preference");
|
||||||
|
assert.equal(m.settings.execute.default, "serve");
|
||||||
|
assert.match(m.settings.execute.why, /withhold/);
|
||||||
|
const dir = resource("state");
|
||||||
|
assert.deepEqual(dir, { id: "state", type: "directory", place: ".", owner: "root", mode: "0755" });
|
||||||
|
const conf = resource("tools-conf");
|
||||||
|
assert.equal(conf.type, "file");
|
||||||
|
assert.equal(conf.path, "${dir:state}/tools.conf");
|
||||||
|
assert.equal(conf.owner, "root", "the account the runtime runs as may read it, never change it");
|
||||||
|
assert.equal(conf.mode, "0644");
|
||||||
|
assert.match(conf.content, /^execute=\$\{setting:execute\}$/m);
|
||||||
|
const [bundle] = m.build.artifacts;
|
||||||
|
assert.deepEqual(bundle.env, { ZSH_TOOLS_CONF: conf.path }, "the word names the file exactly, so its change restarts the runtime");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("the tools bundle registers execute only where the machine's setting serves it", () => {
|
||||||
|
const src = readFileSync(new URL("../tools/index.ts", import.meta.url), "utf8");
|
||||||
|
assert.match(src, /readExecuteSetting\(env\)/);
|
||||||
|
assert.match(src, /if \(!setting\.served\)[\s\S]{0,200}return \[\]/);
|
||||||
|
});
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
// Withholding node-login-shell's `execute` on one machine (novox/hq ADR 0268). It runs any command as the
|
||||||
|
// operator account, which can become root without a person, so the operator withholds it on the
|
||||||
|
// control-node until a call to it needs a person's approval. The machine's choice is the module's
|
||||||
|
// setting `execute`, written by the mesh into a root-owned file the tools bundle reads at start; the
|
||||||
|
// bundle then registers the verb or not, so the machine's runtime does not offer it at all.
|
||||||
|
import { test } from "node:test";
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { mkdtempSync, writeFileSync } from "node:fs";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import { join } from "node:path";
|
||||||
|
import { executeServed, readExecuteSetting, TOOLS_CONF_WORD } from "../shell.ts";
|
||||||
|
|
||||||
|
test("execute is served only when the machine's setting says serve", () => {
|
||||||
|
assert.equal(executeServed("execute=serve\n").served, true);
|
||||||
|
assert.equal(executeServed("# the mesh's\nexecute = serve \n").served, true);
|
||||||
|
const withheld = executeServed("execute=withhold\n");
|
||||||
|
assert.equal(withheld.served, false);
|
||||||
|
assert.match(withheld.why, /withhold/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("anything but serve withholds it: a wrong value, no line, no file", () => {
|
||||||
|
for (const text of ["execute=yes\n", "execute=\n", "", "other=serve\n", "execute=serve-ish\n"]) {
|
||||||
|
const got = executeServed(text);
|
||||||
|
assert.equal(got.served, false, JSON.stringify(text));
|
||||||
|
assert.ok(got.why.length > 0, "it says why");
|
||||||
|
}
|
||||||
|
const none = executeServed(undefined);
|
||||||
|
assert.equal(none.served, false);
|
||||||
|
assert.match(none.why, /no setting/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("the setting is read from the file the bundle's word names", () => {
|
||||||
|
const dir = mkdtempSync(join(tmpdir(), "zsh-tools-"));
|
||||||
|
const file = join(dir, "tools.conf");
|
||||||
|
writeFileSync(file, "execute=serve\n");
|
||||||
|
assert.equal(readExecuteSetting({ [TOOLS_CONF_WORD]: file }).served, true);
|
||||||
|
writeFileSync(file, "execute=withhold\n");
|
||||||
|
assert.equal(readExecuteSetting({ [TOOLS_CONF_WORD]: file }).served, false);
|
||||||
|
const missing = readExecuteSetting({ [TOOLS_CONF_WORD]: join(dir, "absent.conf") });
|
||||||
|
assert.equal(missing.served, false);
|
||||||
|
assert.match(missing.why, /cannot read/);
|
||||||
|
const unnamed = readExecuteSetting({});
|
||||||
|
assert.equal(unnamed.served, false);
|
||||||
|
assert.match(unnamed.why, new RegExp(TOOLS_CONF_WORD));
|
||||||
|
});
|
||||||
@@ -2,11 +2,14 @@
|
|||||||
// `zsh_config` (novox/hq ADR 0176, ADR 0204). The node tools runtime launches this bundle as a
|
// `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
|
// 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).
|
// account, so the command runs as that account with nothing switched (shell.ts).
|
||||||
|
//
|
||||||
|
// `execute` is registered only where this machine's setting serves it (novox/hq ADR 0268): the
|
||||||
|
// control-node withholds it, so its runtime offers no such verb and a call to it finds nothing.
|
||||||
|
|
||||||
import { userInfo } from "node:os";
|
import { userInfo } from "node:os";
|
||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
import { registerModuleTools, type ToolDefinition } from "@novox/mesh-sdk/tools";
|
import { registerModuleTools, type ToolDefinition } from "@novox/mesh-sdk/tools";
|
||||||
import { DEFAULT_TIMEOUT_SECONDS, MAX_TIMEOUT_SECONDS, commandEnv, execute, homeOf, timeoutOf, zshFile } from "../shell.js";
|
import { DEFAULT_TIMEOUT_SECONDS, MAX_TIMEOUT_SECONDS, commandEnv, execute, homeOf, readExecuteSetting, timeoutOf, zshFile } from "../shell.js";
|
||||||
|
|
||||||
/** The operator account on this machine, as the mesh told the runtime. */
|
/** The operator account on this machine, as the mesh told the runtime. */
|
||||||
function account(env: NodeJS.ProcessEnv): string {
|
function account(env: NodeJS.ProcessEnv): string {
|
||||||
@@ -14,6 +17,11 @@ function account(env: NodeJS.ProcessEnv): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function seatVerbs(env: NodeJS.ProcessEnv): ToolDefinition[] {
|
function seatVerbs(env: NodeJS.ProcessEnv): ToolDefinition[] {
|
||||||
|
const setting = readExecuteSetting(env);
|
||||||
|
if (!setting.served) {
|
||||||
|
console.error(`[zsh] node-login-shell's execute is not served on this machine: ${setting.why}`);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
name: "execute",
|
name: "execute",
|
||||||
@@ -47,11 +55,12 @@ function ownTools(env: NodeJS.ProcessEnv): ToolDefinition[] {
|
|||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
name: "zsh_config",
|
name: "zsh_config",
|
||||||
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.",
|
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; and whether this machine serves node-login-shell's execute, and why.",
|
||||||
input: { type: "object", properties: {} },
|
input: { type: "object", properties: {} },
|
||||||
run: async () => {
|
run: async () => {
|
||||||
const home = homeOf(env);
|
const home = homeOf(env);
|
||||||
return { account: account(env), zshenv: await zshFile(join(home, ".zshenv")), zshrc: await zshFile(join(home, ".zshrc")) };
|
const setting = readExecuteSetting(env);
|
||||||
|
return { account: account(env), execute: { served: setting.served, why: setting.why }, zshenv: await zshFile(join(home, ".zshenv")), zshrc: await zshFile(join(home, ".zshrc")) };
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|||||||
Reference in New Issue
Block a user