Author SHA1 Message Date
jochen 7b5e1d3362 dbus: hold node-message-bus, and never restart the bus live (hq ADR 0215)
A live restart of the system bus during an upgrade hung every login on a
workstation until a reboot. The module owns the bus's packages, declares
the bus running with no restart or reload trigger, publishes only curated
events (health, services, denials; never traffic) and serves tools to look
at both buses.
2026-10-05 11:50:52 +02:00
mesh-admin 4224ac7252 Merge pull request 'i3: other modules' lines are contributions to node-display-session (hq ADR 0212)' (#47) from feat/i3-fragments-as-contributions into main 2026-10-05 08:11:45 +00:00
jochen 1d4de00603 i3: other modules' lines are contributions to node-display-session (hq ADR 0212)
rofi, clipmenu, feh, i3status-rust and the laptop's model module wrote files into i3's config.d,
naming no dependency on the window manager. They now contribute their lines; i3 places them under a
line naming each module, and config.d is the operator's alone. The catalogue-wide test composes the
contributions as the controller does and checks the whole with i3 -C.
2026-10-05 10:11:27 +02:00
mesh-admin 7047198146 Merge pull request 'power: a lock problem is no longer said once the lock is held' (#46) from fix/power-clears-a-solved-problem into main 2026-10-05 08:03:44 +00:00
jochen 815a55a9fd power: a lock problem is no longer said once the lock is held 2026-10-05 10:03:30 +02:00
mesh-admin a3c8086d52 Merge pull request 'Apply a binding that differs, and never repeat a generation a node passed (hq issue 243)' (#45) from fix/a-reset-generation-silences-no-node into main 2026-10-05 08:01:12 +00:00
jochen b91b4c427d Apply a binding that differs, and never repeat a generation a node passed
A licence store rebuilt after issue 241 counted generations from one again,
and nodes that apply only a higher number discarded the login move and the
rotations unseen. Nodes now apply any binding other than the one applied;
the manager moves its sequence past every generation a node reports. hq
issue 243.
2026-10-05 09:59:19 +02:00
jschoubben 316576f1da Merge pull request 'A withdrawn consumer keeps its data, in every provider that holds some (hq issue 241)' (#44) from fix/a-withdrawn-consumer-keeps-its-data into main 2026-10-04 23:45:16 +00:00
jschoubben 1fb7ca3d72 A withdrawn consumer keeps its data, in every provider that holds some (hq issue 241)
mssql disables the login, mongodb takes the user's roles, minio revokes the key and keeps the bucket,
mailu disables the mailbox, gitea prohibits the login instead of purging the user and their
repositories, umami keeps the website. Each provider's create already enables what this locks.
2026-10-05 00:34:40 +02:00
jschoubben 190d711a2a postgres: a withdrawn consumer keeps its database; retiring renames, never drops (hq issue 241) 2026-10-05 00:33:12 +02:00
mesh-admin 9275a9e295 Merge pull request 'power: polkit is the module's package' (#289) from fix/power-needs-polkit into main 2026-10-04 15:53:42 +00:00
jochen d26eb1d9a6 power: polkit is the module's package; without it logind refused the watcher its delay lock on a server 2026-10-04 17:53:31 +02:00
mesh-admin 6c73848ba1 Merge pull request 'power: the watcher checks its lock is logind's, and takes it again when it is not' (#288) from fix/power-watcher-verifies-its-lock into main 2026-10-04 15:49:31 +00:00
jochen 94d0caa09c power: the watcher checks its lock is logind's, and takes it again when it is not
On one server the watcher reported a lock that logind did not list. A descriptor that is not an
inhibitor reference is never wrapped (0 would be the bundle's stdin, its channel to the runtime), and
every poll checks the lock is still held, taking it again and saying why when it is not.
2026-10-04 17:49:19 +02:00
mesh-admin 5f8869b165 Merge pull request 'power: the supply rule matches only the charger' (#287) from fix/power-supply-rule-only-the-charger into main 2026-10-04 15:44:46 +00:00
jochen 91f69d1dd3 power: the supply rule matches only the charger; the battery's level changes started the unit every second 2026-10-04 17:44:30 +02:00
mesh-admin 1ace62b969 Merge pull request 'claude-code: let the operator set the agent's managed settings' (#286) from feat/claude-code-agent-settings into main 2026-10-04 15:34:08 +00:00
jochen 8067e91409 Let the operator set the agent's managed settings through claude-code
managed-settings.json carried only the mesh's fixed keys, so permissions and
auto-mode rules could only be set by hand per machine, outside the mesh.
A managed_settings setting is laid under the mesh's keys, which still win.
2026-10-04 17:31:20 +02:00
mesh-admin bd22b53672 Merge pull request 'power: the sleep hooks are wanted by the sleep targets, not declared as services' (#285) from fix/power-sleep-hooks-wanted-by-the-targets into main 2026-10-04 15:22:48 +00:00
jochen ac1a6fca38 power: the sleep hooks are wanted by the sleep targets, not declared as services
The host reads a one-shot that is not running as having run, so a before-sleep or after-wake unit
declared stopped failed on its first apply; drop-ins on the sleep targets pull them in instead.
2026-10-04 17:22:35 +02:00
mesh-admin fee36954e5 Merge pull request 'power: a machine's power as a module holding node-power (hq ADR 0211)' (#284) from feat/power-module into main 2026-10-04 15:19:44 +00:00
jochen 6315556152 power: a machine's power as a module holding node-power; the laptop's resume and lid move onto it (hq ADR 0211)
Code around sleep was written into the service manager's sleep units by the module that needed it,
and the mesh could not tell a sleeping machine from a lost one. power runs every module's code for
the six moments, each piece bounded, owns logind's power handling from its settings, and says
booted, sleeping, woke, shutting-down and the power source on the bus, sleeping under logind's
delay lock before the machine sleeps.
2026-10-04 17:19:27 +02:00
jochen 791d0f62ce WIP: power module (in progress) 2026-10-04 17:14:44 +02:00
mesh-admin a831087a02 Merge pull request 'screen-lock: recognise i3lock-color by its version scheme' (#283) from fix/screen-lock-detects-the-colour-build into main 2026-10-04 15:07:50 +00:00
jochen ba96c59c50 screen-lock: recognise the colour build by its version scheme; its version line never says color 2026-10-04 17:07:40 +02:00
mesh-admin 5443223842 Merge pull request 'screen-lock: keep the operator's lock screen (i3lock-color: blur, ring, clock)' (#282) from fix/screen-lock-keeps-the-operators-look into main 2026-10-04 15:06:10 +00:00
jochen 3c832f3f06 screen-lock: the operator's lock screen is kept, i3lock-color with its blur, ring and clock
The first version swapped the colour build for the distribution's plain i3lock and locked to black;
adopting means keeping what the operator had. Plain i3lock remains the fallback, with a blurred
screenshot of its own.
2026-10-04 17:05:58 +02:00
jschoubben 7aaf784bb5 Merge pull request 'Remove the catalogue's photos: the app's own repository defines it' (#278) from fix/photos-lives-in-its-own-repository into main 2026-10-04 15:05:31 +00:00
mesh-admin 8b97cc9b41 Merge pull request 'Bind the bus's monitoring inside its container, published on the machine's loopback alone' (#281) from fix/nats-monitoring-on-the-machines-loopback into main 2026-10-04 14:53:24 +00:00
jochen ed695328e8 Bind the bus's monitoring inside its container, published on the machine's loopback alone
Bound to the container's own loopback, the published 127.0.0.1:8222 answered
nothing; the nats tools had to go through docker exec.
2026-10-04 16:53:16 +02:00
mesh-admin 04b46f8bd1 Merge pull request 'The bus's own tools (nats): server, connections, subscriptions, streams, backlog, buckets, users, user_can' (#280) from feat/nats-tools into main 2026-10-04 14:51:38 +00:00
jochen f53fc0929b The bus's own tools: server, connections, subscriptions, streams, backlog, buckets, users, user_can
A Go bundle the runtime launches beside the nats module's server. It reads
the server's monitoring API and the composed user list — never a password
hash — and changes nothing. Reached directly when the endpoint is published,
through the container otherwise: its configuration binds monitoring to the
container's own loopback, so the published port answers nothing today.
2026-10-04 16:51:33 +02:00
mesh-admin 4a9f072692 Merge pull request 'triggerhappy: the machine's hotkeys as a module holding node-hotkeys (hq ADR 0212)' (#279) from feat/triggerhappy-module into main 2026-10-04 14:51:25 +00:00
jochen afce6b03f4 triggerhappy: the machine's hotkeys as a module holding node-hotkeys (hq ADR 0212)
The laptop's model module owned triggerhappy's trigger file and service, although the daemon is a
general piece others have keys for. triggerhappy now owns the daemon, reads only the mesh's file,
runs every trigger as the account, and the model module contributes its vendor keys.
2026-10-04 16:50:57 +02:00
jschoubben 4004dee825 Remove the catalogue's photos: the app's own repository defines it
Two definitions held one module name. Rebuilt to this catalogue's main on 2026-10-04, the mesh took
this stub — a server and an admin client — over the app's definition in photos.git, and five of its
six sites lost their routes. The app's repository is the source, as de-spiegel's and link2pay's are.
2026-10-04 16:35:28 +02:00
mesh-admin 2d613ae274 Merge pull request 'Retire anthropic-manager and anthropic-consumer (hq ADR 0183)' (#277) from chore/retire-the-old-anthropic-modules into main 2026-10-04 14:35:20 +00:00
jochen a8113da12e Retire anthropic-manager and anthropic-consumer (novox/hq ADR 0183)
ADR 0183: retired once the licence manager runs. claude-licence-manager has
held the anthropic-licence-manager seat since 2026-10-04; neither old module
was assigned anywhere.
2026-10-04 16:35:12 +02:00
mesh-admin 211f7466f6 Merge pull request 'lemurs, clipmenu: X keeps its resources; an image in the clipboard is never read as text' (#276) from fix/x-resources-and-clipboard-images into main 2026-10-04 14:34:16 +00:00
jochen 493651776c lemurs, clipmenu: X keeps its resources, and an image in the clipboard is never read as text
X reset twice during the session start and threw away the resources xrdb had just merged, so
xterm came up in the bitmap fixed font. clipmenud's one-second xsel read of a screenshot was
killed mid-transfer and left the image's owner hung, so every paste after it hung.
2026-10-04 16:34:02 +02:00
mesh-admin 1e4706f5ba Merge pull request 'Phase 3: asus-zephyrus-g14 and memory-pressure (hq to-be 42), the laptop's keys owned' (#275) from feat/phase-3-laptop-and-memory into main 2026-10-04 13:51:21 +00:00
jochen 1059f12ee0 asus-zephyrus-g14: the laptop's keys, scripts and i3 lines are the module's, with a resume backstop
The i3 and laptop READMEs each pointed at the other for 10-asus.conf and 20-g14.conf,
so nobody owned them. The module now writes both (adopted paths, so no duplicate
binding breaks i3's config check), ships the scripts they call, runs the media keys
with notifications again, and brings back the touchpad reset after resume as a unit
the sleep services want. zephyrus_keys answers what each custom key runs; the check
flags as-user, thd's account and the resume unit. xorg-xinput is the xorg module's.
2026-10-04 15:49:46 +02:00
jochen b098b64b22 Phase 3: asus-zephyrus-g14 and memory-pressure, with Go tools and long-running code
The laptop model's hardware module and a memory-pressure module for any
machine (hq research 027/03, 026/05, to-be 42 phase 3). The predecessor's
polling auto-profile and mem-guard user scripts become each module's own
Go code launched by the node runtime (ADR 0198): a profile switcher woken
by the kernel's power-supply uevents, and a guard that warns on RAM, swap
or PSI before systemd-oomd acts, on the desktop over the account's bus and
always as an event. supergfxctl and triggerhappy are kept as found
(research 027 Q1).
2026-10-04 15:42:56 +02:00
mesh-admin f2601e40de Merge pull request 'The licence manager's verb is public-key' (#274) from fix/the-verb-is-public-key into main 2026-10-04 13:33:34 +00:00
jochen 0dc13965e5 The licence manager's verb is public-key: a seat's verb is lower-case letters, digits and hyphens
public_key failed the builder's manifest check and stopped the manager's
rebuild; claude-code asks the new name.
2026-10-04 15:33:28 +02:00
mesh-admin 948e794e8b Merge pull request 'The licence manager's seat declares public_key' (#273) from fix/the-seat-serves-public-key into main 2026-10-04 13:22:27 +00:00
jochen e8562b58ce The licence manager's seat declares public_key, which claude_code_add_api_key asks (novox/hq ADR 0209) 2026-10-04 15:22:15 +02:00
mesh-admin 1db3fdeb2f Merge pull request 'lemurs, i3status-rust: what the first assignment refused' (#272) from fix/desktop-first-assign into main 2026-10-04 13:17:21 +00:00
jochen c790ee24d3 lemurs, i3status-rust: what the first assignment refused
The host refuses boot on a service that leaves its state to the machine, so lemurs.service
is declared running (no trigger, so a push still never restarts it). pacman-contrib is the
pacman module's, and two modules declaring one package make a node unresolvable.
2026-10-04 15:17:07 +02:00
mesh-admin c5c47b92f4 Merge pull request 'A login moves its node; an API key is added from any node, sealed (hq ADR 0209)' (#271) from feat/a-login-moves-its-node into main 2026-10-04 13:12:03 +00:00
jochen 4ef4983d0d A login moves its node; an API key is added from any node, sealed (novox/hq ADR 0209)
The manager binds the node a login was adopted from to that login's licence,
switching it if it was bound to another; serves public_key; adopt takes a
key sealed to it. claude-code gains claude_code_add_api_key: read a file on
this node, seal, hand to adopt, remove the file, optionally switch here.
2026-10-04 15:11:54 +02:00
mesh-admin 1c799101fa Merge pull request 'Phase 2 desktop: thirteen modules, one per piece of the graphical session (hq ADR 0208, to-be 42)' (#270) from feat/phase-2-desktop into main 2026-10-04 13:09:48 +00:00
jochen 19a79bef86 i3, screen-lock: the session's output to the journal, and the colour locker removed before i3lock is checked
lemurs leaves the session a stdout nobody reads, so a program writing to it dies of EPIPE.
i3lock-color provides i3lock, so with it still installed the host saw i3lock as present,
skipped the install and then removed the only locker; removing it first lets the same
apply install i3lock.
2026-10-04 15:09:39 +02:00
jochen 12ee8f41d9 Merge branch 'feat/phase-2-desktop-core' of /tmp/claude-1000/-home-jochen-projects-novox-hq/a2753bc7-871c-4aac-b6b6-21e3919ee6cd/scratchpad/wg/mesh-catalog into HEAD 2026-10-04 14:56:11 +02:00
jochen 6d06648bc1 i3status-rust: no domain names in the icon file's comments (the catalogue check refuses them) 2026-10-04 13:16:02 +02:00
jochen e810afb3eb gnome-keyring: the secret service as a module, claiming node-secret-service (hq ADR 0208, ADR 0102)
PAM lines written into login and passwd as blocks, so login unlocks the keyring
on both workstations; no daemon of its own; gcr's ssh agent named for the session
until the environment can say a runtime-directory path. Go tools unlocked, lock,
collections and ssh-keys, never reading a secret.
2026-10-04 13:15:39 +02:00
jochen 838e1c2616 i3status-rust: the bars as a module, claiming node-bar (hq ADR 0208)
Owns both bars and their icons, the bar blocks as an i3 drop-in; the battery,
GPU and headset blocks leave (hardware and a person's devices), the weather needs
no key; the update count and the bar watchdog become module code, the watchdog
started once per session. Go tools reload, blocks, block-run and themes.
2026-10-04 13:15:39 +02:00
jochen 91c6fa6fce feh: the wallpaper as a module, its image an archive of its own (hq ADR 0208, ADR 0205)
~/.fehbg stops pointing into the predecessor's tree; the session's xinitrc slot
runs it once; Go tools set (for the session) and current.
2026-10-04 13:15:39 +02:00
jochen c42cd285e2 clipmenu: the clipboard manager as a module, claiming node-clipboard and serving history and copy (hq ADR 0208)
Replaces the AUR greenclip (declared absent) with the official clipmenu, started
once from the session's xinitrc slot, its menu the launcher's dmenu command bound
as an i3 drop-in, its history in the runtime directory. Go tools read and change
clipmenu's own store under its lock.
2026-10-04 13:15:39 +02:00
jochen 0fa90e5cf2 screen-lock: the lock screen as a module, claiming node-lock-screen and serving lock (hq ADR 0208)
The distribution's i3lock behind a locker that releases xss-lock's sleep lock
once it is up; timeouts and xss-lock from the session's xinitrc slot, ending
with the session; i3lock-color and xscreensaver declared absent; Go tools lock,
idle, inhibit and locked.
2026-10-04 13:15:39 +02:00
jochen 8bbea4a2ad dunst: the notifier as a module, claiming node-notifier and serving send and history (hq ADR 0208)
Owns one dunstrc (the laptop's, in the interface face, its menu on the seat's
dmenu command) and the dunstrc.d directory for other modules' rules; D-Bus
starts it, so nothing else does. Go tools over the session bus.
2026-10-04 13:15:39 +02:00
jochen f0f0a79623 rofi: the launcher as a module, claiming node-launcher and serving menu (hq ADR 0208)
Places the seat's dmenu-compatible command, the launcher and power menu, its
themes in the decided faces, and its key bindings as an i3 drop-in; Go tools
menu, applications, themes and run.
2026-10-04 13:15:39 +02:00
jochen 04894e6618 picom: the compositor as a module, claiming node-compositor (hq ADR 0208)
Owns its configuration, moved to picom's window rules; started once from the
session's xinitrc slot; Go tools restart, rules, window-opacity and toggle, which
find the operator's X session from the window manager's environment.
2026-10-04 13:15:39 +02:00
271 changed files with 26862 additions and 1651 deletions
-75
View File
@@ -1,75 +0,0 @@
// The consumer's scheduled run: take the ACCESS token the mesh delivered and write it where the
// Claude CLI reads it, access-token-only (novox/hq ADR 0050). The refresh token is never here to
// strip — the manager holds it, and a holder's delivery has only ever been the access token.
//
// What the host delivers, per the manifest:
// secrets.model-access -> a file holding the sealed-then-unsealed ACCESS token (the host opened it
// with this node's private key; this process reads plaintext).
// binds.model-access -> a JSON file of the non-secret facts the licence serves (which licence,
// model, and — when the control plane carries them — grant expiry/scopes).
//
// Runs as `mesh-tools run` (no broker) on a schedule, so it is idempotent: same token in, same file
// out.
import { readFileSync } from "node:fs";
import { deliver, type DeliveredGrant } from "../credentials.js";
import { readAccountUuid, check } from "../identity.js";
function required(name: string): string {
const v = process.env[name];
if (!v) throw new Error(`${name} is not set — the consumer runtime was deployed without it`);
return v;
}
/** Read optional non-secret grant metadata (expiry, scopes, subscription) from the bound facts file. */
function readBoundMeta(path: string | undefined): Partial<DeliveredGrant> {
if (!path) return {};
try {
const raw = JSON.parse(readFileSync(path, "utf8")) as Record<string, unknown>;
return {
expiresAt: typeof raw.expiresAt === "number" ? raw.expiresAt : null,
refreshTokenExpiresAt: typeof raw.refreshTokenExpiresAt === "number" ? raw.refreshTokenExpiresAt : null,
scopes: Array.isArray(raw.scopes) ? (raw.scopes as string[]) : null,
subscriptionType: typeof raw.subscriptionType === "string" ? raw.subscriptionType : null,
};
} catch {
return {};
}
}
function main(): void {
const accessToken = readFileSync(required("MESH_MODEL_ACCESS_SECRET_FILE"), "utf8").trim();
if (!accessToken) {
// Nothing was delivered — which reads exactly like a credential that never arrived, so it is
// said rather than written as an empty file the CLI would take for a login it should not do.
throw new Error("[anthropic-consumer] the delivered access token is empty; nothing was written");
}
const meta = readBoundMeta(process.env.MESH_MODEL_ACCESS_BIND_FILE);
const grant: DeliveredGrant = { accessToken, ...meta };
const target = process.env.MESH_CLAUDE_CREDENTIALS_FILE ?? `${homedir()}/.claude/.credentials.json`;
deliver(target, grant);
console.error(`[anthropic-consumer] wrote an access-token-only credential to ${target}`);
// The mis-binding guard, best-effort and fail-closed. The expected account uuid is not yet plumbed
// (identity.ts TODO), so this reports what it can see rather than acting on it — it never delivers
// to a wrong account because it never learns one to deliver to.
const identityFile = process.env.MESH_CLAUDE_IDENTITY_FILE ?? `${homedir()}/.claude.json`;
const found = readAccountUuid(identityFile);
const expected = process.env.MESH_MODEL_ACCESS_ACCOUNT_UUID ?? null;
const verdict = check(found, expected);
if (verdict.state === "wrong-account") {
throw new Error(
`[anthropic-consumer] the CLI is logged in as ${verdict.found}, not the licensed ${verdict.expected}; refusing`,
);
}
console.error(`[anthropic-consumer] identity check: ${verdict.state}`);
}
function homedir(): string {
return process.env.HOME ?? "/root";
}
main();
-82
View File
@@ -1,82 +0,0 @@
// Writing the access token where the Claude CLI reads it — the consumer half of model-access
// (novox/hq ADR 0050). A node holds an ACCESS token and nothing else: it cannot rotate, so it is
// never given a refresh token, and this enforces that on every write.
//
// The file shape and the strip are ported byte-exact from the mature implementation (see the port
// map): `~/.claude/.credentials.json` → `{ claudeAiOauth: { accessToken, expiresAt,
// refreshTokenExpiresAt?, scopes?, subscriptionType? } }`, and the refresh token is deleted, not
// merely omitted, so a full grant left by an interactive login is stripped back to access-only.
import { readFileSync, writeFileSync, renameSync, mkdirSync } from "node:fs";
import { dirname } from "node:path";
/** The access-token-only grant the mesh delivered — what the manager submitted, minus the refresh. */
export interface DeliveredGrant {
readonly accessToken: string;
readonly expiresAt?: number | null;
readonly refreshTokenExpiresAt?: number | null;
readonly scopes?: string[] | null;
readonly subscriptionType?: string | null;
}
interface ClaudeOauth {
accessToken?: string;
expiresAt?: number;
refreshTokenExpiresAt?: number;
scopes?: string[];
subscriptionType?: string;
refreshToken?: string;
}
interface Credentials {
claudeAiOauth?: ClaudeOauth;
[key: string]: unknown;
}
/** Read the existing credentials file, or an empty object if there is none or it is unreadable. */
function readLocal(path: string): Credentials {
try {
return JSON.parse(readFileSync(path, "utf8")) as Credentials;
} catch {
return {};
}
}
/**
* Overlay the delivered grant onto whatever is on disk, then STRIP the refresh token — the node
* carve-out. Returns the object to write, so the strip is testable without touching a file.
*/
export function applyGrant(local: Credentials, grant: DeliveredGrant): Credentials {
const oauth = local.claudeAiOauth ?? {};
const next: Credentials = {
...local,
claudeAiOauth: {
...oauth,
accessToken: grant.accessToken,
...(grant.expiresAt != null ? { expiresAt: grant.expiresAt } : {}),
...(grant.refreshTokenExpiresAt != null
? { refreshTokenExpiresAt: grant.refreshTokenExpiresAt }
: {}),
...(grant.scopes ? { scopes: grant.scopes } : {}),
...(grant.subscriptionType ? { subscriptionType: grant.subscriptionType } : {}),
},
};
// A node NEVER holds a refresh token: delete it, so a full grant on disk is reduced to access-only.
delete next.claudeAiOauth!.refreshToken;
return next;
}
/** Atomic write-then-rename at 0600 — a partial credentials file must never be read as a whole one. */
export function writeCredentials(path: string, creds: Credentials): void {
mkdirSync(dirname(path), { recursive: true });
const tmp = `${path}.tmp`;
writeFileSync(tmp, JSON.stringify(creds, null, 2), { mode: 0o600 });
renameSync(tmp, path);
}
/** Read, overlay, strip, write — the whole consumer credential update, in one call. */
export function deliver(path: string, grant: DeliveredGrant): Credentials {
const next = applyGrant(readLocal(path), grant);
writeCredentials(path, next);
return next;
}
-49
View File
@@ -1,49 +0,0 @@
// The mis-binding guard (novox/hq ADR 0050, port map §identity). Account identity is NOT in the
// token or any API — it lives in a sibling CLI state file, `~/.claude.json` →
// `oauthAccount.accountUuid`. The guard compares the account the CLI is actually logged in as to the
// account the licence was recorded against, and FAILS CLOSED: an absent file or an unrecorded licence
// account refuses rather than guesses, because delivering an access token to the wrong account is the
// exact fault this exists to catch.
//
// **Partial first cut, FLAGGED.** Reading the sibling file is implemented; the licence's recorded
// account uuid is not yet plumbed from the control plane to the consumer (the bound `model.json` does
// not carry it today). So `check` returns `licence-not-adopted` when no expected uuid is supplied,
// which is the fail-closed answer, and the wiring of the expected uuid is a TODO below.
import { readFileSync } from "node:fs";
export type IdentityVerdict =
| { state: "verified"; accountUuid: string }
| { state: "no-identity-file" }
| { state: "licence-not-adopted" }
| { state: "wrong-account"; found: string; expected: string };
interface ClaudeJson {
oauthAccount?: { accountUuid?: string; emailAddress?: string; organizationUuid?: string };
}
/** Read `oauthAccount.accountUuid` from `~/.claude.json`, or null if the file or field is absent. */
export function readAccountUuid(path: string): string | null {
try {
const raw = JSON.parse(readFileSync(path, "utf8")) as ClaudeJson;
return raw.oauthAccount?.accountUuid ?? null;
} catch {
return null;
}
}
/**
* Compare the CLI's logged-in account to the one the licence was recorded against. Pure over its
* inputs so the fail-closed logic is tested without a filesystem.
*
* TODO(novox/hq ADR 0050, Phase C): plumb `expected` — the licence's recorded account uuid — from the
* control plane into the consumer's bound `model.json`, then adopt-on-first-sight or refuse per the
* port map's five states. Until then only the two safe verdicts are reachable: verified when an
* expected uuid is provided and matches, refuse otherwise.
*/
export function check(found: string | null, expected: string | null): IdentityVerdict {
if (found === null) return { state: "no-identity-file" };
if (!expected) return { state: "licence-not-adopted" };
if (found === expected) return { state: "verified", accountUuid: found };
return { state: "wrong-account", found, expected };
}
-76
View File
@@ -1,76 +0,0 @@
{
"module": "anthropic-consumer",
"version": "1",
"slug": "claude",
"capabilities": [
"container-runtime"
],
"requires": [
"model-access"
],
"binds": {
"model-access": "${dir:state}/model.json"
},
"secrets": {
"model-access": "${dir:state}/access-token"
},
"emits": [
"usage.session"
],
"resources": [
{
"id": "state",
"type": "directory",
"mode": "0700",
"place": "."
},
{
"id": "claude-home",
"type": "directory",
"path": "/var/lib/anthropic-consumer/claude",
"mode": "0700"
},
{
"id": "out",
"type": "directory",
"mode": "0700"
},
{
"id": "apply",
"type": "process",
"name": "anthropic-consumer-apply",
"artifact": "code",
"run": [
"node",
"apply/index.js"
],
"schedule": "*/5 * * * *",
"env": {
"MESH_MODEL_ACCESS_SECRET_FILE": "${dir:state}/access-token",
"MESH_MODEL_ACCESS_BIND_FILE": "${dir:state}/model.json",
"MESH_CLAUDE_CREDENTIALS_FILE": "${dir:state}/claude/.credentials.json",
"MESH_CLAUDE_IDENTITY_FILE": "${dir:state}/claude/.claude.json"
}
}
],
"build": {
"artifacts": [
{
"name": "code",
"kind": "bundle",
"language": "typescript",
"entrypoints": [
"apply/index.js",
"usage/index.js"
],
"loads": [
"usage/index.js"
],
"env": {
"MESH_CLAUDE_PROJECTS_DIR": "${dir:state}/claude/projects",
"MESH_ANTHROPIC_USAGE_OUT": "${dir:state}/out/session-usage.json"
}
}
]
}
}
-14
View File
@@ -1,14 +0,0 @@
{
"name": "@novox/module-anthropic-consumer",
"version": "0.1.0",
"description": "anthropic-consumer — the consumer side of model-access (ADR 0050): writes the delivered access token to ~/.claude/.credentials.json (access-token-only) and reports session-grain usage from the CLI transcripts (ADR 0054).",
"type": "module",
"private": true,
"dependencies": {
"@novox/mesh-sdk": "^0.1.0"
},
"devDependencies": {
"@types/node": "^22.0.0",
"typescript": "^5.6.0"
}
}
@@ -1,32 +0,0 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { mkdtempSync, readFileSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { applyGrant, deliver } from "../credentials.ts";
test("applyGrant strips the refresh token a full grant on disk left behind", () => {
const local = { claudeAiOauth: { accessToken: "at-old", refreshToken: "rt-must-not-survive" } };
const next = applyGrant(local, { accessToken: "at-new", expiresAt: 123 });
assert.equal(next.claudeAiOauth!.accessToken, "at-new");
assert.equal(next.claudeAiOauth!.expiresAt, 123);
assert.ok(!("refreshToken" in next.claudeAiOauth!), "a node held onto a refresh token");
});
test("deliver writes the port-map shape, access-token-only, and never a refresh token", () => {
const dir = mkdtempSync(join(tmpdir(), "anthropic-consumer-"));
const path = join(dir, ".credentials.json");
// A prior interactive login left a full grant on disk.
writeFileSync(path, JSON.stringify({ claudeAiOauth: { accessToken: "at-old", refreshToken: "rt-login" } }));
deliver(path, { accessToken: "at-delivered", expiresAt: 999, subscriptionType: "max" });
const raw = readFileSync(path, "utf8");
const creds = JSON.parse(raw);
assert.equal(creds.claudeAiOauth.accessToken, "at-delivered");
assert.equal(creds.claudeAiOauth.expiresAt, 999);
assert.equal(creds.claudeAiOauth.subscriptionType, "max");
assert.doesNotMatch(raw, /rt-login/, "the refresh token is still on disk");
assert.ok(!("refreshToken" in creds.claudeAiOauth));
});
@@ -1,48 +0,0 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { foldTranscript } from "../transcript.ts";
// A captured-shape transcript: two assistant turns and a user line, exactly the fields the port map
// names. Not imagined — the field names match the mature implementation's parse.
const TRANSCRIPT = [
JSON.stringify({ type: "user", timestamp: "2026-01-01T00:00:00Z", cwd: "/work/app", gitBranch: "main" }),
JSON.stringify({
type: "assistant",
timestamp: "2026-01-01T00:00:01Z",
costUSD: 0.01,
message: {
model: "claude-opus-4-8",
usage: { input_tokens: 100, cache_creation_input_tokens: 20, cache_read_input_tokens: 5, output_tokens: 40 },
},
}),
JSON.stringify({
type: "assistant",
timestamp: "2026-01-01T00:00:02Z",
costUSD: 0.02,
message: { model: "claude-opus-4-8", usage: { input_tokens: 200, output_tokens: 60 } },
}),
"", // a half-written trailing line is ordinary and must not be fatal.
].join("\n");
test("a transcript sums per-session token counts, cost, and metadata", () => {
const s = foldTranscript("session-abc", TRANSCRIPT);
assert.equal(s.sessionId, "session-abc");
assert.equal(s.turns, 2);
assert.equal(s.inputTokens, 300);
assert.equal(s.cacheCreationTokens, 20);
assert.equal(s.cacheReadTokens, 5);
assert.equal(s.outputTokens, 100);
assert.equal(Math.round(s.costUSD * 100) / 100, 0.03);
assert.equal(s.model, "claude-opus-4-8");
assert.equal(s.gitBranch, "main");
assert.equal(s.cwd, "/work/app");
assert.equal(s.startedAt, "2026-01-01T00:00:00Z");
assert.equal(s.lastActive, "2026-01-01T00:00:02Z");
});
test("a malformed line is skipped, not fatal", () => {
const s = foldTranscript("s", 'not json\n{"type":"assistant","message":{"usage":{"output_tokens":7}}}');
assert.equal(s.outputTokens, 7);
assert.equal(s.turns, 1);
});
-113
View File
@@ -1,113 +0,0 @@
// Session-grain usage from the CLI's own transcripts (novox/hq ADR 0054). The mature implementation
// reads `~/.claude/projects/<projDir>/<sessionId>.jsonl` and sums the token counts each assistant
// message reports; this ports the token extraction and DROPS the per-message account-attribution
// timeline — the nox (node,module) session has a fixed licence binding (port map "don't-map" #3), so
// there is nothing to attribute per message.
//
// The fields are ported from the port map: assistant lines carry
// `message.usage.{input_tokens,cache_creation_input_tokens,cache_read_input_tokens,output_tokens}`,
// `costUSD`, `message.model`, `timestamp`; user lines carry `cwd`, `gitBranch`.
import { createInterface } from "node:readline";
import { createReadStream } from "node:fs";
/** One session's totals — the session-grain usage row ADR 0054 fixes. */
export interface SessionUsage {
sessionId: string;
model: string | null;
gitBranch: string | null;
cwd: string | null;
turns: number;
inputTokens: number;
cacheCreationTokens: number;
cacheReadTokens: number;
outputTokens: number;
costUSD: number;
startedAt: string | null;
lastActive: string | null;
}
interface Line {
type?: string;
timestamp?: string;
cwd?: string;
gitBranch?: string;
costUSD?: number;
message?: {
model?: string;
usage?: {
input_tokens?: number;
cache_creation_input_tokens?: number;
cache_read_input_tokens?: number;
output_tokens?: number;
};
};
}
function empty(sessionId: string): SessionUsage {
return {
sessionId,
model: null,
gitBranch: null,
cwd: null,
turns: 0,
inputTokens: 0,
cacheCreationTokens: 0,
cacheReadTokens: 0,
outputTokens: 0,
costUSD: 0,
startedAt: null,
lastActive: null,
};
}
/** Fold one transcript line into a session's running totals. Pure, so it is tested on fixtures. */
export function foldLine(acc: SessionUsage, raw: string): SessionUsage {
const line = parse(raw);
if (!line) return acc;
if (line.timestamp) {
if (!acc.startedAt || line.timestamp < acc.startedAt) acc.startedAt = line.timestamp;
if (!acc.lastActive || line.timestamp > acc.lastActive) acc.lastActive = line.timestamp;
}
if (line.type === "user") {
if (line.cwd) acc.cwd = line.cwd;
if (line.gitBranch) acc.gitBranch = line.gitBranch;
}
if (line.type === "assistant") {
acc.turns += 1;
const u = line.message?.usage ?? {};
acc.inputTokens += u.input_tokens ?? 0;
acc.cacheCreationTokens += u.cache_creation_input_tokens ?? 0;
acc.cacheReadTokens += u.cache_read_input_tokens ?? 0;
acc.outputTokens += u.output_tokens ?? 0;
acc.costUSD += line.costUSD ?? 0;
if (!acc.model && line.message?.model) acc.model = line.message.model;
}
return acc;
}
function parse(raw: string): Line | null {
const trimmed = raw.trim();
if (!trimmed) return null;
try {
return JSON.parse(trimmed) as Line;
} catch {
// A malformed line is skipped, never fatal: a transcript is an append-only log the CLI owns, and
// a half-written last line is ordinary.
return null;
}
}
/** Sum a whole transcript string into one session's usage — the tested core of the streaming read. */
export function foldTranscript(sessionId: string, text: string): SessionUsage {
return text.split("\n").reduce(foldLine, empty(sessionId));
}
/** Stream one `<sessionId>.jsonl` file line by line, so a large transcript never loads whole. */
export async function readSessionFile(path: string, sessionId: string): Promise<SessionUsage> {
const acc = empty(sessionId);
const rl = createInterface({ input: createReadStream(path), crlfDelay: Infinity });
for await (const line of rl) foldLine(acc, line);
return acc;
}
-18
View File
@@ -1,18 +0,0 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"noEmit": true
},
"include": [
"credentials.ts",
"transcript.ts",
"identity.ts",
"apply/index.ts",
"usage/index.ts"
]
}
-138
View File
@@ -1,138 +0,0 @@
// Session-grain usage emission (novox/hq ADR 0054). On a schedule, read every transcript under
// `~/.claude/projects/*/<sessionId>.jsonl`, sum its tokens, and emit one session-grain usage event
// per session. The consumer IS the (node,module) session's fixed binding, so no per-message account
// attribution is done — just the totals (port map "don't-map" #3).
//
// Runs in the node's runtime (novox/hq ADR 0198), every five minutes, so events are emitted through
// the runtime as this module; the totals are also written to a file so the reading is observable
// without one.
import { readdirSync, statSync, readFileSync, writeFileSync, renameSync, mkdirSync } from "node:fs";
import { join, dirname } from "node:path";
import { emit } from "@novox/mesh-sdk/events";
import { readSessionFile, type SessionUsage } from "../transcript.js";
/** The vendor-neutral usage row ADR 0054 fixes — the shape the model-usage store upserts. Kept local
* to the producer (the normalisation lives in the adapter), so nothing here couples to the SDK. */
interface UsageRow {
licence: string;
consumer: string;
period: string;
metric: string;
value: number;
}
/** The licence this session's usage is charged to. The model-access binding names it; failing that,
* the deployed env; failing that, "unknown" — a reading is never dropped for want of a licence. */
function boundLicence(): string {
const bindFile = process.env.MESH_MODEL_ACCESS_BIND_FILE;
if (bindFile) {
try {
const raw = JSON.parse(readFileSync(bindFile, "utf8")) as { licence?: unknown };
if (typeof raw.licence === "string" && raw.licence) return raw.licence;
} catch {
// A missing or unreadable bind file is not fatal — fall through to the env, then to "unknown".
}
}
return process.env.MESH_ANTHROPIC_LICENCE || "unknown";
}
/** Normalise one session reading into ADR 0054 rows: one row per metric, a row omitted when its
* number is not finite. `consumer` is node/module/session — the session grain. */
function sessionRows(licence: string, node: string, module: string, r: SessionUsage): UsageRow[] {
const consumer = `${node}/${module}/${r.sessionId}`;
const rows: UsageRow[] = [];
const add = (metric: string, value: number): void => {
if (Number.isFinite(value)) rows.push({ licence, consumer, period: "session", metric, value });
};
add("input_tokens", r.inputTokens);
add("output_tokens", r.outputTokens);
add("cache_creation_tokens", r.cacheCreationTokens);
add("cache_read_tokens", r.cacheReadTokens);
add("cost_usd", r.costUSD);
return rows;
}
function projectsDir(): string {
return process.env.MESH_CLAUDE_PROJECTS_DIR ?? `${process.env.HOME ?? "/root"}/.claude/projects`;
}
/** Every `<sessionId>.jsonl` under the projects tree, with the project directory it sits in. */
function transcripts(root: string): { path: string; sessionId: string }[] {
const found: { path: string; sessionId: string }[] = [];
let projects: string[];
try {
projects = readdirSync(root);
} catch {
return found; // no projects yet is not a failure — there is simply nothing to report.
}
for (const proj of projects) {
const dir = join(root, proj);
let entries: string[];
try {
if (!statSync(dir).isDirectory()) continue;
entries = readdirSync(dir);
} catch {
continue;
}
for (const file of entries) {
if (!file.endsWith(".jsonl")) continue;
found.push({ path: join(dir, file), sessionId: file.replace(/\.jsonl$/, "") });
}
}
return found;
}
async function main(): Promise<void> {
const module = process.env.MESH_MODULE ?? "anthropic-consumer";
const node = process.env.MESH_NODE ?? "unknown";
const readings: SessionUsage[] = [];
for (const t of transcripts(projectsDir())) {
try {
readings.push(await readSessionFile(t.path, t.sessionId));
} catch (err) {
console.error(`[anthropic-consumer] could not read ${t.path}: ${err}`);
}
}
// Emit ADR-0054 rows, not a vendor-shaped body: the consumer of module.*.usage.* is the
// vendor-neutral model-usage store, so the session→row normalisation is done HERE. The full
// SessionUsage rides as `raw`, so model, branch, cwd and timestamps are not lost.
const licence = boundLicence();
for (const r of readings) {
await emitUsage({ rows: sessionRows(licence, node, module, r), raw: r });
}
if (process.env.MESH_ANTHROPIC_USAGE_OUT) {
atomicWrite(process.env.MESH_ANTHROPIC_USAGE_OUT, JSON.stringify(readings, null, 2));
}
console.error(`[anthropic-consumer] reported ${readings.length} session(s)`);
}
function atomicWrite(path: string, content: string): void {
mkdirSync(dirname(path), { recursive: true });
const tmp = `${path}.tmp`;
writeFileSync(tmp, content, { mode: 0o600 });
renameSync(tmp, path);
}
/** Emit best-effort through the runtime: a reading that could not be announced is still in the file. */
async function emitUsage(body: Record<string, unknown>): Promise<void> {
try {
await emit("usage.session", body);
} catch (err) {
console.error(`[anthropic-consumer] could not emit usage: ${err}`);
}
}
// The cadence the scheduled container had: once at start, then every five minutes. Not awaited, so the
// runtime's handshake is answered while a long first reading is still under way.
const EVERY_MS = 5 * 60 * 1000;
const tick = (): void => {
void main().catch((err) => console.error(`[anthropic-consumer] usage reading failed: ${err}`));
};
tick();
setInterval(tick, EVERY_MS);
-35
View File
@@ -1,35 +0,0 @@
// Adoption: the ONE time an operator's refresh token enters the mesh, and it enters already sealed.
//
// The refresh token is read here, on the MANAGER NODE, sealed to that node's PUBLIC sealing key, and
// only the sealed box leaves this process (novox/hq ADR 0050). The control plane stores that box via
// `licence set-grant` without ever seeing the refresh token in the clear — the same bound every
// delivery keeps. This is the counterpart to `refresh/index.js`: adoption seals the first box, refresh
// re-seals a rotated one; both use the very anonymous box (`crypto_box_seal`) the mesh seals every
// credential with, so the HOST unseals the stored box to mount the cleartext back — this module is
// never given a private key and opens nothing.
//
// MESH_ANTHROPIC_ADOPT_TOKEN_FILE the operator's refresh token, read once and never written out
// MESH_MODEL_ACCESS_BIND_FILE the manager holder's bound facts, carrying manager_public_key
// MESH_ANTHROPIC_GRANT_OUT where the sealed box is written, for `licence set-grant`
import { readFileSync } from "node:fs";
import { seal } from "../sealedbox.js";
import { managerPublicKey, writeSealedGrant } from "../grantfile.js";
function required(name: string): string {
const v = process.env[name];
if (!v) throw new Error(`${name} is not set — adoption needs it`);
return v;
}
const refreshToken = readFileSync(required("MESH_ANTHROPIC_ADOPT_TOKEN_FILE"), "utf8").trim();
if (!refreshToken) throw new Error("[anthropic-manager] there is no refresh token to adopt");
// The node's PUBLIC sealing key, delivered by the mesh in the manager holder's bound facts. Public,
// so it is safe to hand a module; the private half stays with the host, which is what opens the box.
const nodePub = managerPublicKey(required("MESH_MODEL_ACCESS_BIND_FILE"));
const sealed = seal(new Uint8Array(Buffer.from(refreshToken, "utf8")), nodePub);
writeSealedGrant(required("MESH_ANTHROPIC_GRANT_OUT"), sealed, nodePub);
console.error("[anthropic-manager] sealed the refresh token to this node's key; only the host opens it");
-135
View File
@@ -1,135 +0,0 @@
// The only file that talks to Anthropic — the vendor half of the refreshable-grant adapter
// (novox/hq ADR 0050). Isolated exactly as cloudflare-dns isolates its registrar call, so the
// vendor is swappable and the one place a token endpoint is reached is auditable.
//
// Two endpoints, and they are different hosts (port-map "don't-map" #1): the TOKEN host mints a new
// access token from the refresh token; the USAGE host reports utilisation against an access token.
/** The token endpoint, overridable so the lab can point the whole flow at a stub without a vendor. */
export function tokenEndpoint(env = process.env): string {
return env.MESH_ANTHROPIC_TOKEN_ENDPOINT ?? "https://platform.claude.com/v1/oauth/token";
}
/** The usage endpoint, likewise overridable for the lab. */
export function usageEndpoint(env = process.env): string {
return env.MESH_ANTHROPIC_USAGE_ENDPOINT ?? "https://api.anthropic.com/api/oauth/usage";
}
// The OAuth client id is a hard-won constant, ported byte-exact from the mature implementation: a
// metadata URL in its place yields 400. It is not a secret (it identifies the public Claude Code
// client), so it lives in code.
const CLIENT_ID = "9d1c250a-e61b-44d9-88ed-5944d1962f5e";
/** The vendor's token response, snake_case as the wire has it. */
export interface RefreshedGrant {
readonly access_token?: string;
readonly refresh_token?: string;
readonly expires_in?: number;
readonly refresh_token_expires_in?: number;
readonly scopes?: string[];
readonly subscription_type?: string;
}
/**
* Exchange a refresh token for a fresh grant. Returns null on any non-ok response, surfacing the
* OAuth error body (invalid_grant/invalid_client/…) — the difference between "the token is dead" and
* "the endpoint was unreachable", which a bare status hides.
*/
export async function refreshGrant(
refreshToken: string,
env = process.env,
): Promise<RefreshedGrant | null> {
const resp = await fetch(tokenEndpoint(env), {
method: "POST",
headers: { "content-type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({
grant_type: "refresh_token",
refresh_token: refreshToken,
client_id: CLIENT_ID,
}),
});
if (!resp.ok) {
const body = await resp.text().catch(() => "<unreadable>");
console.error(
`[anthropic-manager] token refresh failed: ${resp.status} ${resp.statusText} — ${body.slice(0, 400)}`,
);
return null;
}
return (await resp.json()) as RefreshedGrant;
}
/** The vendor's usage response — utilisation percentages against several windows. */
export interface UsageLimits {
readonly five_hour?: { utilization: number; resets_at?: string };
readonly seven_day?: { utilization: number; resets_at?: string };
readonly seven_day_sonnet?: { utilization: number; resets_at?: string };
readonly seven_day_opus?: { utilization: number; resets_at?: string };
readonly extra_usage?: { utilization: number };
readonly [key: string]: unknown;
}
/**
* Read utilisation for an access token. Never refreshes here (a 401 is just reported): a second
* refresh source racing the first is the fault the mature implementation warns against.
*/
export async function readUsage(accessToken: string, env = process.env): Promise<UsageLimits | null> {
const resp = await fetch(usageEndpoint(env), {
headers: { authorization: `Bearer ${accessToken}` },
});
if (!resp.ok) {
const body = await resp.text().catch(() => "");
console.error(`[anthropic-manager] usage endpoint returned ${resp.status}: ${body.slice(0, 200)}`);
return null;
}
return (await resp.json()) as UsageLimits;
}
/** The licence-grain reading ADR 0054 fixes, flattened from the vendor's windows. */
export interface UsageReading {
readonly sessionPct: number | null;
readonly sessionResetsAt: string | null;
readonly weeklyPct: number | null;
readonly sonnetPct: number | null;
readonly extraPct: number | null;
readonly raw: UsageLimits;
}
export function flattenUsage(u: UsageLimits): UsageReading {
return {
sessionPct: u.five_hour?.utilization ?? null,
sessionResetsAt: u.five_hour?.resets_at ?? null,
weeklyPct: u.seven_day?.utilization ?? null,
sonnetPct: u.seven_day_sonnet?.utilization ?? null,
extraPct: u.extra_usage?.utilization ?? null,
raw: u,
};
}
/** The access-token-only grant a holder is delivered — the port-map credential-file shape's fields. */
export interface AccessGrant {
readonly accessToken: string;
readonly expiresAt: number | null;
readonly refreshTokenExpiresAt: number | null;
readonly scopes: string[] | null;
readonly subscriptionType: string | null;
}
/**
* Turn a vendor refresh into what the manager submits: the access-token-only grant for holders, and
* the rotated refresh token if the vendor sent one. Never clobbers a good grant from an empty
* response — no access_token means the caller keeps what it had.
*/
export function grantFromRefresh(r: RefreshedGrant, nowMs: number): { access: AccessGrant; rotatedRefresh: string | null } | null {
if (!r.access_token) return null;
return {
access: {
accessToken: r.access_token,
expiresAt: typeof r.expires_in === "number" ? nowMs + r.expires_in * 1000 : null,
refreshTokenExpiresAt:
typeof r.refresh_token_expires_in === "number" ? nowMs + r.refresh_token_expires_in * 1000 : null,
scopes: r.scopes ?? null,
subscriptionType: r.subscription_type ?? null,
},
rotatedRefresh: r.refresh_token ?? null,
};
}
-32
View File
@@ -1,32 +0,0 @@
// Reading the manager node's PUBLIC sealing key out of the bound facts the mesh delivers, and
// writing a sealed refresh token in the wire shape mesh-controller reads.
//
// **The public key is delivered, not derived.** The manager module holds no node key of its own
// (novox/hq ADR 0050) — it is deliberately never given one. To seal a refresh token to this node it
// needs the node's PUBLIC sealing key, and mesh-controller puts that in the manager holder's bound facts
// (`serves.manager_public_key`), safe to disclose because it is public. Both adoption and every
// rotation read it from there.
import { readFileSync, writeFileSync, renameSync, mkdirSync } from "node:fs";
import { dirname } from "node:path";
/** The manager node's public sealing key, from the bound facts file the mesh delivers. */
export function managerPublicKey(boundFile: string): string {
const raw = JSON.parse(readFileSync(boundFile, "utf8")) as { serves?: Record<string, unknown> };
const key = raw.serves?.["manager_public_key"];
if (typeof key !== "string" || key === "") {
throw new Error(
"the bound facts carry no manager_public_key — this node is not the licence's manager, or " +
"the manager holder has not been delivered yet",
);
}
return key;
}
/** Write a sealed refresh token in the {sealed, manager_key} wire shape mesh-controller reads. */
export function writeSealedGrant(path: string, sealed: string, managerKey: string): void {
mkdirSync(dirname(path), { recursive: true });
const tmp = `${path}.tmp`;
writeFileSync(tmp, JSON.stringify({ sealed, manager_key: managerKey }), { mode: 0o600 });
renameSync(tmp, path);
}
-63
View File
@@ -1,63 +0,0 @@
{
"module": "anthropic-manager",
"version": "1",
"slug": "anthmgr",
"capabilities": [
"container-runtime"
],
"requires": [
"model-access"
],
"binds": {
"model-access": "${dir:mesh-state}/model.json"
},
"secrets": {
"model-access": "${dir:mesh-state}/refresh-token"
},
"own-secrets": {
"broker": "${dir:mesh-state}/broker"
},
"emits": [
"usage.read"
],
"resources": [
{
"id": "mesh-state",
"type": "directory",
"mode": "0700",
"place": "mesh"
},
{
"id": "out",
"type": "directory",
"path": "${dir:mesh-state}/out",
"mode": "0700"
},
{
"id": "refresh",
"type": "container",
"name": "mesh-anthropic-manager-refresh",
"image": "mesh-runtime-anthropic-manager@sha256:0000000000000000000000000000000000000000000000000000000000000000",
"network": "host",
"schedule": "*/5 * * * *",
"args": [
"run",
"/app/modules/anthropic-manager/dist/refresh/index.js"
],
"volumes": [
"${dir:mesh-state}/broker:/run/secrets/broker:ro",
"${dir:mesh-state}:/run/state"
],
"env": {
"MESH_BROKER_FILE": "/run/secrets/broker",
"MESH_ANTHROPIC_LICENCE": "personal",
"MESH_MODEL_ACCESS_SECRET_FILE": "/run/state/refresh-token",
"MESH_MODEL_ACCESS_BIND_FILE": "/run/state/model.json",
"MESH_ANTHROPIC_ACCESS_OUT": "/run/state/out/access-token",
"MESH_ANTHROPIC_GRANT_OUT": "/run/state/out/grant.json",
"MESH_ANTHROPIC_USAGE_OUT": "/run/state/out/usage.json",
"MESH_TOOLS_MAIN": "/app/dist/main.js"
}
}
]
}
-16
View File
@@ -1,16 +0,0 @@
{
"name": "@novox/module-anthropic-manager",
"version": "0.1.0",
"description": "anthropic-manager — the manager side of the model-access refreshable-grant (ADR 0050): opens the refresh token on the manager node alone, refreshes it against Anthropic's OAuth endpoint, and submits back only the access token and the re-sealed refresh envelope.",
"type": "module",
"private": true,
"dependencies": {
"@novox/mesh-sdk": "^0.1.0",
"tweetnacl": "^1.0.3",
"tweetnacl-sealedbox-js": "^1.2.0"
},
"devDependencies": {
"@types/node": "^22.0.0",
"typescript": "^5.6.0"
}
}
-157
View File
@@ -1,157 +0,0 @@
// The manager's scheduled run (novox/hq ADR 0050/0053). It is the whole of the carve-out in one
// place, and it runs on the MANAGER NODE, never in the control plane:
//
// 1. read the refresh token as CLEARTEXT — the host unsealed the stored box with THIS node's private
// key and mounted it at the module's bound secret path, exactly as it delivers any credential.
// This module holds no node key and opens nothing itself;
// 2. call the vendor's OAuth token endpoint to mint a fresh access token (and maybe a rotated
// refresh token);
// 3. if the vendor rotated the refresh token, SEAL the new one to this node's PUBLIC sealing key
// (delivered in the bound facts) with the same anonymous box the mesh seals every credential with;
// 4. hand the control plane back ONLY the access token in the clear + the opaque re-sealed box —
// never the refresh token — which it seals per consumer holder and stores;
// 5. poll usage with the fresh access token and record the licence-grain reading.
//
// mesh-controller receives the products of steps 3–4 through `licence submit-refresh` (access token +
// sealed box). The refresh token never leaves this process except as ciphertext, and it never had to
// be opened here at all — the host did that.
//
// This runs as `mesh-tools run`, which connects no broker, so the outputs are written to files the
// host mounts; the submit itself (the transport to mesh-controller) is done by the caller invoking
// `mesh-controller licence submit-refresh`. In the lab that caller is the scenario; in production it is
// an authenticated call the manager node makes. The transport is the one part stubbed here — FLAGGED
// — because a cross-node authenticated command surface is out of this module's scope.
import { readFileSync, writeFileSync, renameSync, mkdirSync } from "node:fs";
import { dirname } from "node:path";
import { readEnv } from "@novox/mesh-sdk/primitives";
import { seal } from "../sealedbox.js";
import { managerPublicKey, writeSealedGrant } from "../grantfile.js";
import { refreshGrant, grantFromRefresh, readUsage, flattenUsage, type UsageReading } from "../client.js";
/** The vendor-neutral usage row ADR 0054 fixes — the shape the model-usage store upserts. Kept local
* to the producer (the normalisation lives in the adapter), so nothing here couples to the SDK. */
interface UsageRow {
licence: string;
consumer: string;
period: string;
metric: string;
value: number;
}
/** Normalise a licence-grain reading into ADR 0054 rows: one utilization row per window, a row
* omitted when its percentage is absent. `consumer` is the holding module — the licence grain. */
function licenceRows(licence: string, consumer: string, reading: UsageReading): UsageRow[] {
const rows: UsageRow[] = [];
const add = (period: string, pct: number | null): void => {
if (pct !== null && pct !== undefined && Number.isFinite(pct)) {
rows.push({ licence, consumer, period, metric: "utilization", value: pct });
}
};
add("5h", reading.sessionPct);
add("7d", reading.weeklyPct);
add("extra", reading.extraPct);
return rows;
}
function required(name: string): string {
const v = process.env[name];
if (!v) throw new Error(`${name} is not set — the manager runtime was deployed without it`);
return v;
}
function atomicWrite(path: string, content: string): void {
mkdirSync(dirname(path), { recursive: true });
const tmp = `${path}.tmp`;
writeFileSync(tmp, content, { mode: 0o600 });
renameSync(tmp, path);
}
async function main(): Promise<void> {
const licence = process.env.MESH_ANTHROPIC_LICENCE ?? "unknown";
// Step 1: the refresh token as cleartext, unsealed and mounted by the HOST. No open here.
const refreshToken = readFileSync(required("MESH_MODEL_ACCESS_SECRET_FILE"), "utf8").trim();
if (!refreshToken) {
// Nothing was delivered — the manager has not adopted a refresh token yet, or the push has not
// landed. Said rather than treated as an empty token the vendor would reject obscurely.
throw new Error("[anthropic-manager] no refresh token was delivered; adopt one first");
}
// The node's PUBLIC sealing key, to re-seal a rotated refresh token. Public, delivered in the facts.
const nodePub = managerPublicKey(required("MESH_MODEL_ACCESS_BIND_FILE"));
// Step 2: the vendor call.
const refreshed = await refreshGrant(refreshToken);
if (!refreshed) {
// A dead endpoint or a rejected token: nothing to publish, and we do not clobber a good grant.
throw new Error(`[anthropic-manager] the refresh of ${licence} produced no grant`);
}
const grant = grantFromRefresh(refreshed, Date.now());
if (!grant) {
throw new Error(`[anthropic-manager] the refresh of ${licence} returned no access token`);
}
// Step 3: re-seal the rotated refresh token, if the vendor rotated it. Nothing to store otherwise.
if (grant.rotatedRefresh) {
const sealed = seal(new Uint8Array(Buffer.from(grant.rotatedRefresh, "utf8")), nodePub);
if (process.env.MESH_ANTHROPIC_GRANT_OUT) {
writeSealedGrant(process.env.MESH_ANTHROPIC_GRANT_OUT, sealed, nodePub);
}
}
// Step 4: the access token in the clear, for the control plane to seal per consumer holder. This is
// all it ever receives that is not ciphertext.
atomicWrite(required("MESH_ANTHROPIC_ACCESS_OUT"), grant.access.accessToken);
console.error(
`[anthropic-manager] refreshed ${licence}: access token minted` +
(grant.rotatedRefresh ? ", refresh token rotated and re-sealed" : ", refresh token unchanged"),
);
// Step 5: licence-grain usage, best-effort — a usage read failing must not fail the refresh.
try {
const usage = await readUsage(grant.access.accessToken);
if (usage) {
const reading = flattenUsage(usage);
if (process.env.MESH_ANTHROPIC_USAGE_OUT) {
atomicWrite(
process.env.MESH_ANTHROPIC_USAGE_OUT,
JSON.stringify({ licence, grain: "licence", ...reading }),
);
}
// Emit ADR-0054 rows, not a vendor-shaped body: the consumer of module.*.usage.* is the
// vendor-neutral model-usage store, so the normalisation is done HERE. The node names the
// holding module; with MESH_NODE unset the consumer is the module alone.
const node = readEnv("MESH_NODE", "");
const consumer = node ? `${node}/anthropic-manager` : "anthropic-manager";
await emitUsage({ rows: licenceRows(licence, consumer, reading), raw: usage });
}
} catch (err) {
console.error(`[anthropic-manager] usage poll for ${licence} failed: ${err}`);
}
}
/**
* Emit a usage event best-effort by shelling out to the sibling mesh-tools `emit` primitive, which
* is the one path that wires a broker from a run-once/scheduled step (which itself connects none).
* A broker hiccup must never fail a refresh that already happened.
*/
async function emitUsage(body: Record<string, unknown>): Promise<void> {
const main = process.env.MESH_TOOLS_MAIN ?? "/app/dist/main.js";
const { spawn } = await import("node:child_process");
await new Promise<void>((resolve) => {
const child = spawn(process.execPath, [main, "emit", "usage.read", JSON.stringify(body)], {
stdio: "inherit",
});
child.on("exit", () => resolve());
child.on("error", (err) => {
console.error(`[anthropic-manager] could not emit usage: ${err}`);
resolve();
});
});
}
await main();
-57
View File
@@ -1,57 +0,0 @@
// A NaCl `crypto_box_seal`, byte-compatible with Go's `box.SealAnonymous`, over the audited
// `tweetnacl-sealedbox-js`.
//
// **Why this file exists, and why it is exactly this.** novox/hq ADR 0050's refreshable-grant
// carve-out delivers the refresh token to the manager module the way the mesh delivers every other
// credential: sealed to the node's key, and unsealed by the *host* — never by the module. The host
// unseals with Go's `golang.org/x/crypto/nacl/box.OpenAnonymous` (mesh-host
// internal/identity/sealing.go), and mesh-controller seals with `box.SealAnonymous`
// (mesh-controller internal/secrets/seal.go). Both are NaCl `crypto_box_seal`:
//
// sealed = ephemeralPub(32) ‖ crypto_box(msg, nonce, recipientPub, ephemeralSecret)
// nonce = blake2b( ephemeralPub ‖ recipientPub , 24 bytes, unkeyed )
//
// When the vendor rotates the refresh token, the manager module must store the new one back the
// same way — sealed to the manager node's own sealing key — so mesh-controller keeps it without ever
// reading it and the host can later unseal it to deliver the cleartext again. That reseal happens
// here, on the manager node, in TypeScript. It therefore has to produce the *identical* byte format
// Go's `Open` accepts, or the host would refuse the delivery.
//
// **The crypto is not ours.** `tweetnacl-sealedbox-js` is `crypto_box_seal` built on the audited
// TweetNaCl (`tweetnacl`) and blakejs — the same construction, and the same libraries, the mesh used
// to validate this seal during Phase C. It generates the ephemeral X25519 key pair, derives the
// nonce as `blake2b(ephemeralPub ‖ recipientPub, 24)`, and produces `ephemeralPub ‖ box`. That is
// exactly what Go's `box.OpenAnonymous` opens: the wire format is unchanged from the hand-transcribed
// version this replaces — only the implementation is now a maintained, reviewed dependency rather
// than a copy of TweetNaCl and blakejs carried inline. The module runtime image bundles it
// (package.json dependencies; novox/hq ADR 0052).
//
// **How it is kept honest.** A cross-language test seals a fixture here and opens it in Go
// (mesh-controller internal/secrets/sealedbox_xcheck_test.go); the fixture is regenerated from this
// `seal()`. A drift between this seal and Go's box surfaces there as a seal Go cannot open, not as a
// refresh token silently mangled in production.
//
// This module SEALS only. It never opens — opening is the host's job, with the node private key the
// module is deliberately never given.
// A default import, not `{ seal }`: the library is a CommonJS UMD bundle, and Node's ESM loader
// cannot statically see its named exports — only its default, which is the whole module object.
import sealedbox from "tweetnacl-sealedbox-js";
const RAW_KEY_LEN = 32;
/**
* Seal a value to a node's public sealing key, producing what Go's `box.OpenAnonymous` opens.
*
* @param value the plaintext (e.g. a rotated refresh token)
* @param recipientPublicB64 the node's raw 32-byte X25519 public key, standard base64
* @returns standard-base64( ephemeralPub ‖ box )
*/
export function seal(value: Uint8Array, recipientPublicB64: string): string {
const recipientPub = Buffer.from(recipientPublicB64, "base64");
if (recipientPub.length !== RAW_KEY_LEN) {
throw new Error(`a sealing public key is 32 bytes, not ${recipientPub.length}`);
}
const sealed = sealedbox.seal(value, new Uint8Array(recipientPub));
return Buffer.from(sealed).toString("base64");
}
@@ -1,43 +0,0 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { grantFromRefresh, flattenUsage } from "../client.ts";
test("an empty refresh response never clobbers a good grant", () => {
assert.equal(grantFromRefresh({}, 1000), null);
});
test("a refresh with an access token yields an access-token-only grant and epoch expiry", () => {
const out = grantFromRefresh(
{ access_token: "at-new", expires_in: 3600, refresh_token: "rt-rotated", subscription_type: "pro" },
1_000_000,
);
assert.ok(out);
assert.equal(out!.access.accessToken, "at-new");
assert.equal(out!.access.expiresAt, 1_000_000 + 3600 * 1000);
assert.equal(out!.access.subscriptionType, "pro");
// The rotated refresh token is reported separately, for the manager to re-seal — never put in the
// holder grant.
assert.equal(out!.rotatedRefresh, "rt-rotated");
assert.ok(!("refreshToken" in (out!.access as object)));
});
test("a refresh that did not rotate the refresh token reports none to re-seal", () => {
const out = grantFromRefresh({ access_token: "at-new" }, 0);
assert.ok(out);
assert.equal(out!.rotatedRefresh, null);
});
test("usage flattens the vendor windows to the ADR 0054 grain", () => {
const r = flattenUsage({
five_hour: { utilization: 42, resets_at: "2026-01-01T00:00:00Z" },
seven_day: { utilization: 10 },
seven_day_sonnet: { utilization: 5 },
extra_usage: { utilization: 1 },
});
assert.equal(r.sessionPct, 42);
assert.equal(r.sessionResetsAt, "2026-01-01T00:00:00Z");
assert.equal(r.weeklyPct, 10);
assert.equal(r.sonnetPct, 5);
assert.equal(r.extraPct, 1);
});
@@ -1,36 +0,0 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { generateKeyPairSync } from "node:crypto";
import { seal } from "../sealedbox.ts";
// The definitive proof that this seal interoperates with Go's box.OpenAnonymous (the host's Unseal
// and mesh-controller's secrets.Seal/Open) is a cross-language test in mesh-controller
// (internal/secrets/sealedbox_xcheck_test.go), which opens a fixture this module's seal() produced.
// These tests hold the TypeScript side: the output has the crypto_box_seal shape, and it is
// randomised so a rotation that changed nothing looks nothing like one that changed everything.
/** A node public key as the mesh records it: raw 32-byte X25519, standard base64. */
function aNodePublicKey(): string {
const kp = generateKeyPairSync("x25519");
const x = (kp.publicKey.export({ format: "jwk" }) as { x: string }).x;
return Buffer.from(x, "base64url").toString("base64");
}
test("a seal has the crypto_box_seal shape: ephemeralPub(32) + tag(16) + ciphertext(len)", () => {
const pub = aNodePublicKey();
const msg = Buffer.from("rt-a-refresh-token", "utf8");
const blob = Buffer.from(seal(new Uint8Array(msg), pub), "base64");
// 32 (ephemeral public key) + 16 (Poly1305 tag) + message length.
assert.equal(blob.length, 32 + 16 + msg.length);
});
test("two seals of the same value differ — a fresh ephemeral key each time", () => {
const pub = aNodePublicKey();
const msg = new Uint8Array(Buffer.from("rt-a-refresh-token", "utf8"));
assert.notEqual(seal(msg, pub), seal(msg, pub));
});
test("a public key that is not 32 bytes is refused before anything is sealed", () => {
assert.throws(() => seal(new Uint8Array([1, 2, 3]), Buffer.from("short").toString("base64")));
});
-19
View File
@@ -1,19 +0,0 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"noEmit": true
},
"include": [
"tweetnacl-sealedbox-js.d.ts",
"sealedbox.ts",
"grantfile.ts",
"client.ts",
"adopt/index.ts",
"refresh/index.ts"
]
}
-13
View File
@@ -1,13 +0,0 @@
// Ambient types for `tweetnacl-sealedbox-js` (crypto_box_seal), which ships without its own.
// The library is a small UMD bundle over `tweetnacl` and `blakejs`; only `seal` is used here.
declare module "tweetnacl-sealedbox-js" {
/** crypto_box_seal: returns ephemeralPub(32) ‖ box, sealed to `recipientPublicKey`. */
export function seal(message: Uint8Array, recipientPublicKey: Uint8Array): Uint8Array;
/** crypto_box_seal_open: returns the plaintext, or null if it does not open. */
export function open(
sealed: Uint8Array,
recipientPublicKey: Uint8Array,
recipientSecretKey: Uint8Array,
): Uint8Array | null;
export const overheadLength: number;
}
+385
View File
@@ -0,0 +1,385 @@
# asus-zephyrus-g14
The hardware module for the **ASUS ROG Zephyrus G14** laptop: its vendor daemon and platform
profiles, the hybrid GPU's mode and driver options, suspend, the lid and power key, low battery,
the backlights, the vendor keys and the touchpad (novox/hq research 027/03 *Power management on
the laptop*, research 026/05, to-be 42 phase 3).
## Why this name
A module is named after the hardware model, never the node (novox/hq ADR 0112; research 026/03:
no flavors, no machine names). `asus-zephyrus-g14` is the model family exactly as the firmware
reports it (`/sys/class/dmi/id/product_family` = `ROG Zephyrus G14`). The module's code checks that
value and its switcher does nothing on any other model, and `zephyrus_check` reports it.
A wider name such as `asus-rog-laptop` would promise what this module cannot keep. Its contents
belong to this family: the vendor-key scan codes, the eDP panel beside an NVIDIA dGPU, and the NVIDIA
D3 workaround. A second G14 is assigned the same module. Another ROG model gets its own.
Written against the GA403 (2024, Ryzen 8945HS, RTX 4070 Laptop, hybrid). Older G14 years have the same
daemons and probably the same keys. Their GPU options are unverified.
## What it owns
| | what | how |
|---|---|---|
| package | `asusctl` (asusd + client) | the distribution's package (`extra`). The machine was found with a local build of 6.4.0. The host only asserts *present*, so the switch to 6.5.0 from `extra` happens at the next `pacman -Syu` (or `pacman -S asusctl`). `zephyrus_check` flags a local build |
| package | `upower`, `playerctl` | what the low-battery drop-in and the media keys use. `xinput` is the `xorg` module's (one package, one module on a node) |
| service | `asusd` running (static unit: no boot state to declare), `supergfxd` running and enabled | |
| archive | `/usr/local/lib/asus-zephyrus-g14/bin/` | the module's scripts, from `files/bin` (below) |
| file ×2 | `~/.config/i3/config.d/10-asus.conf`, `20-g14.conf` | the laptop's lines in i3: the keys the firmware sends as ordinary presses (Fn+F6, Fn+F9), the keyboard-backlight notifier, the panel as primary, the touchpad key. The paths are adopted, because a second file binding the same keys makes i3's configuration check fail, and the `i3` module's watcher then reloads nothing |
| file ×4 | `asus-zephyrus-g14-touchpad-resume.service`, and a drop-in `asus-zephyrus-g14-touchpad.conf` on each sleep service | the touchpad's settings once more after a resume (below) |
| file | `/etc/modprobe.d/g14-nvidia-power.conf` | `NVreg_DynamicPowerManagement=0x00` (runtime D3 off: the ACPI D-Notifier hang) and `NVreg_PreserveVideoMemoryAllocations=1`. The path is adopted (ADR 0182) |
| file | `/etc/modprobe.d/video-brightness-switch.conf` | `video.brightness_switch_enabled=0`, so the ACPI video driver does not also move a backlight on the keys. The file was on the machine and owned by nothing |
| file ×3 | `systemd-{suspend,hibernate,suspend-then-hibernate}.service.d/asus-zephyrus-g14-nvidia.conf` | `Wants=` the matching `nvidia-*` sleep units and `nvidia-resume` (see *suspend units* below) |
| file | `nvidia-powerd.service.d/asus-zephyrus-g14.conf` | `ConditionKernelCommandLine=zephyrus.nvidia-powerd`: Dynamic Boost runs only when the operator opts in at boot |
| file | `/etc/systemd/logind.conf.d/power.conf` | the power key and the lid suspend, on battery, on mains and docked. `systemd-logind` is reloaded, never restarted |
| file | `/etc/udev/rules.d/90-backlight.rules` | backlights writable by the `video` group. `systemd-udevd` is reloaded |
| file | `triggerhappy.service.d/asus-zephyrus-g14.conf` | `thd … --user ${machine:account}`: the triggers run as the operator's account (below) |
| file | `/etc/triggerhappy/triggers.d/asus-g14.conf` | the vendor keys: media (`KEY_PROG1/3/4`), panel brightness, touchpad (`KEY_F21`). The path is adopted, because two trigger files would fire every key twice |
| file | `/etc/UPower/UPower.conf.d/50-asus-zephyrus-g14.conf` | low battery at 15/10/7 %; at 7 % **suspend**, not power off. A drop-in over the package's own file |
| file | `/etc/X11/xorg.conf.d/30-asus-zephyrus-g14-touchpad.conf` | tap to click, natural scrolling, acceleration 0.15, as an X input class |
**What it does not own, on purpose:**
- `/etc/asusd/*.ron` belong to asusd, which rewrites them whenever a setting changes. RON is not a
format the host writes into (ADR 0102 speaks JSON and marked blocks). Owning the file whole would
repeat the predecessor's freeze: the measured file already differs from the one the predecessor
shipped. The settings the module needs are set through asusd, by its code (below).
- `/etc/supergfxd.conf` and `/etc/modprobe.d/supergfxd.conf` belong to supergfxd, which writes both.
- The swap file, its unit and the swap partition are the machine's swap layout (research 027,
question 3). They are not this module's, nor `memory-pressure`'s.
- **Places.** The screen layouts for named places (`$mod+Alt+1…7`, `~/.screenlayout/`,
`~/scripts/.screenlayouts/@*.sh`) name where the operator works, which no module may (ADR 0112).
They are the operator's own lines until autorandr profiles replace them (the `xorg` module).
- **The monitor-hotplug wizard** (`/etc/udev/rules.d/91-monitor-hotplug.rules`,
`~/scripts/.screenlayouts/monitor-wizard.sh`) is any laptop's, not this model's. The predecessor said
so itself (its `laptop` flavor). It is the display server's to replace with autorandr's own hotplug
handling. Until then it stays as found, and it still calls the predecessor's `as-user`.
- **The screenshot script** (`~/.config/i3/scripts/screenshot.sh`) is any machine's. The `i3` module
binds it too. This module only binds the key the firmware sends for it.
- **The bar's battery block.** It belongs here (a block that follows this model's hardware), but the
bar has no way in yet (`i3status-rust` README). Once ADR 0210's contributions reach the bar seat,
this module contributes it.
**Requires `x11-display`.** The i3 lines and the session scripts need a display, so the module is
assigned where the display server is.
## Software outside the distribution (ADR 0205, research 027 question 1)
`supergfxctl` (5.2.7, from the asus-linux repository, which is no longer configured) and
`triggerhappy` (AUR) are **kept as found, and depended on**. The module declares no package for
either, because the host installs from the official repositories only. It declares their services
(`supergfxd` running, `triggerhappy` running), so on a machine without them the host refuses the
service by name: *does not exist on this machine*. The refusal is loud, never a silent pass.
`zephyrus_check` names both as foreign.
This module does not choose between the options of research 027 question 1. Under the starting
position (P2: the build machine builds AUR packages into a repository the mesh serves), both become
`package` resources here, and a fresh G14 installs them. **Until P2 exists, a fresh G14 is blocked
on installing these two by hand.** ADR 0205's vendored archive (P1) does not fit: supergfxctl is a
daemon with a system-bus policy and udev rules, and triggerhappy is C.
A later option for the keys: the module's own Go code could read the vendor keys from evdev, which
the operator's account may do through the `input` group. That would retire triggerhappy entirely.
It is not done here, because it would put the keys behind the node's runtime, and the runtime
restarts a bundle that dies only on its next call (below).
## The long-running code: the profile switcher (ADR 0198)
The module's Go bundle serves the tools and runs the platform-profile switcher in the same process.
The node's runtime launches the bundle at the runtime's start. It replaces the predecessor's
`auto-profile`, a user unit that woke every five seconds, on battery too.
- **Policy** (constants until settings exist, issue 168): battery → `Quiet`; mains → `Balanced`;
mains with the CPU at or above 50 % for 3 samples of 10 s → `Performance`, back to `Balanced` after
3 samples at or below 20 %. Between the lines nothing moves (hysteresis). iowait counts as idle.
- **Woken by events, not a poll.** The kernel's power-supply uevents (netlink, group 1) wake the
switcher. Any account may listen on that group, and it needs no daemon, bus client or dependency;
upower re-announces the same changes but would need a D-Bus client in the bundle. The CPU is
sampled only on mains, every 10 s, because only there does the answer depend on it. On battery,
a safety re-read every 5 minutes covers an event lost across a suspend. If the uevent socket
cannot be opened, the switcher polls every 10 s and says so in `zephyrus_profile_policy`.
- **The battery decides the source.** A battery that is *discharging* means battery, whatever any
adapter says. The predecessor took any `online` file reading 1 as mains, and on this model the USB-C
ports report `online`. Batteries of `scope=Device` (a mouse, a headset) are ignored.
- **It acts on a change of its decision, never to restore one.** A profile someone chose by hand (the
profile key, asusctl, `zephyrus_profile`) stays until the power source changes or the load crosses
a line. The predecessor re-asserted its choice every five seconds, which made the profile key
useless. **Starting is not a decision**: the runtime restarts the bundle on every push that changes
one, and a push must not reset the operator's profile.
- **A hold.** `zephyrus_profile` holds the profile it sets for 60 min (`hold_minutes`). A change of
power source ends the hold.
- **One assertion at start:** through asusctl, the charge limit (80 %) and asusd's own on-mains and
on-battery profiles (`Balanced`, `Quiet`), each read first and set only if it differs. asusd's own
switching on a change of power source then agrees with the switcher's. A limit set later with
`zephyrus_charge_limit` stands until the bundle next starts. For a one-off full charge, use its
`oneshot`.
- **Events:** `profile.switched` (`profile`, `from`, `reason`, `source`), published through the
runtime.
No root is involved. asusd's and supergfxd's bus policies admit the `users` and `wheel` groups, and the
runtime runs as the operator's account. The one write that may escalate is the panel's backlight,
when the udev rule has not run yet. It uses `sudo -n` and never prompts. Every command is bounded at
20 s.
**Known limit.** The runtime restarts a launched bundle that exits *on its next tool call*, not at
once (mesh-tools `launch.ts`), so a crashed switcher stays down until a tool is called. ADR 0198 §1
says *started again when it exits*. The switcher recovers from a panic and reports it in
`zephyrus_profile_policy` and `zephyrus_check`, but a crash of the process is the runtime's to restart.
## The vendor keys and the scripts
triggerhappy opens the input devices as root, then **drops to the operator's account with its groups**
(`initgroups`: `input`, `video`). The packaged unit already drops to `nobody`, and the module's drop-in
names the account instead. The predecessor replaced the packaged unit with one that ran every trigger
as root, then `su`-ed to a named person with a hard-coded uid and display, and sourced a file of
secrets on the way (research 027 question 2). Now:
- `zephyrus-session CMD…`: runs a command in the account's graphical session. It sets the account's
own bus (`/run/user/<uid>/bus`) and takes the display and its authority from the session's window
manager's own environment, as the desktop modules' session finder does. If i3 is not running, it
asks logind, and then any process of the account that has a display. Nothing is sourced.
triggerhappy's `--user` changes the user and its groups and nothing else: the triggers start with
the service's bare environment, which is why every key that needs the session goes through this.
- `zephyrus-media play-pause | next | previous`: the media keys through MPRIS, with a notification of
what happened and a lock against the key's own repeat.
- `zephyrus-display primary | order`: the internal panel as the primary output, and the display key's
workspace split (odd workspaces on the panel, even ones on the first external output). The panel is
found as the connected `eDP` output. The predecessor named `eDP-1` and used `jq`. This reads i3's
answer without it.
- `zephyrus-kbd-notify`: the keyboard backlight's level, shown when UPower says it changed. One
instance per session, because i3 runs its `exec` lines again on an in-place restart.
- `zephyrus-backlight + | - | N`: the panel in 5 % steps, never below 1 %. **The panel is the
backlight under the eDP connector**, because this model also registers `nvidia_0`, which moves
nothing. The predecessor named `amdgpu_bl1` literally.
- `zephyrus-notify ID TEXT`: one replacing notification, through `busctl` (the service manager's
client, so no libnotify).
- `zephyrus-touchpad reset | toggle`: bound to the touchpad key (`KEY_F21`).
**Media keys** go to MPRIS through `playerctl`. The predecessor's fallback to a media server's local
API needed a token from the secrets file, and is dropped until a module can be handed a secret
(research 027 question 2). A player that does not speak MPRIS is told as "Media: no player".
## The touchpad: an input class instead of a sleep hook
The predecessor re-ran `xinput` from `/etc/systemd/system-sleep/` after every resume, as a named person
on a guessed display, because settings made with `xinput` are lost when the device initialises again.
An X input class is applied by X **every time the device appears**: at login, on hotplug and after a
resume. So the cause is fixed. The class matches any touchpad on the machine, which is the model's, so it
holds across G14 years whose touchpads differ. It takes effect at the next X start.
`zephyrus-touchpad reset` stays as the manual form, on the touchpad key and `$mod+Shift+x`.
**And a backstop after resume.** A resume that does not initialise the device again does not make X
apply the class either, and the predecessor's i3 file says the touchpad "sometimes needs re-init after
sleep". So `asus-zephyrus-g14-touchpad-resume.service` runs `zephyrus-touchpad reset` as the account,
two seconds after the machine is awake. It is never enabled. Each sleep service `Wants=` it through a
drop-in, and it is ordered `After=` them, which is how `nvidia-resume` is started too.
`zephyrus_check` says whether the sleep wants it.
## Suspend units without enabling them
`nvidia-suspend`, `-hibernate`, `-suspend-then-hibernate` and `-resume` are enabled with links in the
sleep services' `.wants` directories. The mesh makes no links (ADR 0012). The host's service shape
cannot declare them either: it may only say *running* or *stopped*, and *running* on a one-shot that
last failed would start `nvidia-sleep.sh suspend` with the machine awake. So the module asks for them
from the other side: a drop-in on each sleep service that `Wants=` them. The units' own
`Before=`/`After=` order them. The found links stay and are harmless.
`suspend-then-hibernate` now also gets `nvidia-suspend-then-hibernate`, which the machine lacked.
The drop-ins take effect at the service manager's next `daemon-reload`. In the same apply, the restart
of `triggerhappy` (whose drop-in changes) performs one.
## Tools
| tool | r/a | what |
|---|---|---|
| `zephyrus_brightness` | r/a | panel (percent or ±step, floor 1 %) and keyboard (off/low/med/high, 0-3, ±) through asusd |
| `zephyrus_battery` | r | charge, energy in Wh, health (full ÷ design), cycles (the firmware reports 0, and this is said), limit, watts, hours left |
| `zephyrus_charge_limit` | r/a | 20-100 through asusd; `oneshot` |
| `zephyrus_gpu_mode` | r/a | mode, supported modes, dGPU power, the pending mode and action; says that asusd switches the mode on every change of power source |
| `zephyrus_profile` | r/a | active, on-mains and on-battery profile, kernel platform profile; set with a hold |
| `zephyrus_thermals` | r | every hwmon temperature and fan, the hottest, the dGPU's temperature **only when it is awake** (nvidia-smi wakes a suspended GPU) |
| `zephyrus_power_draw` | r | battery flow, APU package power (PPT), dGPU draw when awake, power source and why |
| `zephyrus_profile_policy` | r | what the switcher would choose now and why: source, recent load against the thresholds, decision, hold, last switch, what woke it, what it asserted at start, and whether the predecessor's switcher still runs |
| `zephyrus_fan_curves` | r | asusd's curves per profile and fan |
| `zephyrus_keys` | r | every custom key: each triggerhappy trigger, the module's i3 lines and the keys the firmware handles; what runs and the file it is in. Warns when one key is in two trigger files, which fires it twice |
| `zephyrus_check` | r | every expectation: model, packages (local or foreign), daemons, nvidia-powerd, sleep units, the NVIDIA options **in force** (`/proc/driver/nvidia/params`), charge limit, one authority each over the profile and the GPU mode, predecessor leftovers; the touchpad resume unit wanted by the sleep; triggerhappy running as the operator's account; any trigger, udev rule or sleep hook still naming `as-user`. It also lists what it did not check |
`profile` is a candidate verb for a future `node-power-profile` seat (research 027/03). That seat has
no record yet, so this is the module's own tool.
## Found on the laptop, 2026-10-04 (read-only)
- **Two authorities over the GPU mode.** `asusd.ron` has `ac_command: "supergfxctl -m Hybrid"` and
`bat_command: "supergfxctl -m Integrated"`, so asusd switches the GPU mode on every change of power
source. **supergfxd 5.2.7 cannot read logind's sessions** (`manager is an invalid variant`, every
boot), so a switch that needs a logout times out. `zephyrus_check` reports both. The fix is the
operator's, in asusd's file: clear both commands, or update supergfxctl once it can be packaged.
- **`brightness.conf` did nothing.** `HandleBrightnessKey` is not a logind key, and logind logs
*Unknown key … ignoring* at every start. The module does not carry it. The brightness keys were
always triggerhappy's, with the ACPI video switch off.
- **Two profile switchers** would run at once until `auto-profile` is stopped (below).
- **asusctl is a local build** (6.4.0, *Unknown Packager*) beside a foreign `asusctl-debug`.
## When assigned to the laptop: what changes
1. `/usr/local/lib/asus-zephyrus-g14/` appears (seven scripts).
2. Written over found files (each original kept once by the host): `g14-nvidia-power.conf` and
`video-brightness-switch.conf` (same options, so no change until the next boot either),
`logind.conf.d/power.conf` (same keys; logind reloaded), `90-backlight.rules` (same effect;
udevd reloaded), `triggers.d/asus-g14.conf` (now the module's scripts), and i3's `10-asus.conf` and
`20-g14.conf` (the module's lines; the `i3` module's watcher checks and reloads them).
3. New: the three sleep drop-ins (behaviour gained: `nvidia-suspend-then-hibernate`), the
nvidia-powerd drop-in (no effect while it is masked), the triggerhappy drop-in, the UPower drop-in
(same values as today), the touchpad input class (at the next X start), and the resume unit with
its three drop-ins.
4. `daemon-reload` and a `triggerhappy` restart. thd now runs as the account and the keys run the
module's scripts. `upower` restarts.
5. Packages, asusd and supergfxd: already as declared, so nothing changes. asusctl stays the local
6.4.0 until the next upgrade.
6. The node runtime restarts with the new bundle. The switcher asserts the limit (80, already) and
asusd's profiles (Balanced and Quiet, already), so it sets nothing. It takes the current decision
as applied and acts from the first event on.
## Predecessor files this module makes redundant — the operator removes them once (ADR 0182)
**On the laptop:**
1. `systemctl --user disable --now auto-profile.service`, then delete
`~/.config/systemd/user/auto-profile.service` and `~/scripts/auto-profile`. **Do this right after
the push**, or two switchers run at once.
2. **Before the push, keep the place layouts.** The module writes `20-g14.conf` over the found file,
and the found file carries the seven `$mod+Alt+1…7` layout bindings, which name places. Move them,
and the `exec … ~/.screenlayout/@default.sh` line, to a file of your own in the same directory,
e.g. `~/.config/i3/config.d/90-layouts.conf`. i3 reads it the same way, and it stays yours until
autorandr profiles replace it. The host keeps the found `20-g14.conf` once in any case.
3. `~/scripts/asus-bright`, `~/scripts/asusctl-kbd-bright`, `~/scripts/xrandr-bright`,
`~/scripts/media-control`, `~/scripts/xinput-reset-touchpad`,
`~/scripts/.screenlayouts/orden-workspaces.sh` and `~/.config/i3/scripts/kbd-brightness-notify.sh`:
no trigger and no line of the module's uses them any more.
**Keep `~/scripts/as-user`** while `/etc/udev/rules.d/91-monitor-hotplug.rules` exists: that rule
still runs the monitor wizard through it. `zephyrus_check` names every place that still calls it.
4. `/etc/systemd/system/triggerhappy.service`: the predecessor's replacement of the packaged unit. The
module's drop-in works over either, so delete it and `systemctl daemon-reload` to return to the
packaged unit (`Type=notify`, socket).
5. `/etc/systemd/logind.conf.d/brightness.conf`: the unknown key, which does nothing.
6. `/etc/systemd/system-sleep/xinput-reset-touchpad.sh`, if it is still there: replaced by the input
class and the resume unit. (It was already gone on 2026-10-04.)
7. `/etc/UPower/UPower.conf`: the predecessor's replacement of the package's file. Its values are now
the module's drop-in. Restore the package's copy (`rm` it, then `pacman -S upower`).
8. Optional: `systemctl disable nvidia-suspend nvidia-resume nvidia-hibernate` (the drop-ins carry them
now), `/etc/asusd/*.ron-old` and `fan_curves.ron.bak`, the foreign `asusctl-debug` package, and
`pacman -S asusctl` for the distribution's build.
**Stays the machine's:** `/swapfile` and `/etc/systemd/system/swapfile.swap` (the swap layout),
`/etc/udev/rules.d/91-monitor-hotplug.rules` (the display's, phase 2), and the place layouts.
**On the desktop** (the predecessor's G14 flavor reached it; part was removed on 2026-10-04): none of
this module applies there. Still present and to be deleted:
`/etc/systemd/logind.conf.d/brightness.conf`, `/etc/systemd/system-sleep/xinput-reset-touchpad.sh`,
`~/scripts/xinput-reset-touchpad`, `~/scripts/xrandr-bright` and
`~/.config/i3/scripts/kbd-brightness-notify.sh`.
## The predecessor, file by file
The predecessor carried this model in its desktop module's `g14` and `laptop` flavors, and in a
`g14-power` module. Every model-specific file of the desktop module, and where it is now:
| predecessor file | now |
|---|---|
| `i3-asus.conf` → `config.d/10-asus.conf` | **this module**, the same path; the scripts it calls are the module's |
| `i3-g14.conf` → `config.d/20-g14.conf` | **this module**, the same path. The place layouts in it are the operator's own file (migration, step 2) |
| `g14-triggerhappy-asus-g14.conf` | **this module**, the same path; the triggers run the module's scripts |
| `g14-triggerhappy.service` (a replacement of the packaged unit) | **retired**: a drop-in over the packaged unit (`--user`) |
| `as-user` | **retired**: thd runs as the account, and `zephyrus-session` finds the session. Kept on the machine while the monitor wizard's udev rule calls it |
| `asus-bright` | **this module**: `zephyrus-backlight`, the panel found by its connector |
| `media-control` (bound by the `g14` triggers) | **this module**: `zephyrus-media`, without the fallback that needed a secret |
| `xinput-reset-touchpad` | **this module**: the input class, `zephyrus-touchpad`, and the resume unit |
| `g14-system-sleep-xinput-reset-touchpad.sh` | **this module**: the input class, and the resume unit that the sleep services want |
| `kbd-brightness-notify.sh` | **this module**: `zephyrus-kbd-notify`, one instance per session |
| `asusctl-kbd-bright` | **retired**: the keyboard keys are the firmware's, and `zephyrus_brightness` sets it by hand |
| `xrandr-bright` | **retired**: a software dimming; the panel's backlight is `zephyrus-backlight` |
| `screenlayout-orden-workspaces.sh` | **this module**: `zephyrus-display order`, on Fn+F9 |
| `screenlayout-*.sh` (six named places) | **the operator's**, until autorandr profiles (the `xorg` module) replace them |
| `g14-90-backlight.rules` | **this module**, the same path |
| `g14-logind-brightness.conf` | **retired**: an unknown logind key that did nothing |
| `laptop-monitor-wizard.sh`, `laptop-91-monitor-hotplug.rules` | **any laptop's, not this module's**: kept as found, for the `xorg` module's autorandr to replace |
| `bottom-bar.g14.toml` (the battery block) | **waits**: this module's, once the bar takes contributions (ADR 0210) |
| `razer-basilisk-battery-percentage` | **not this model's**: a mouse. No module carries it (`i3status-rust` README) |
| `screenshot.sh` | **not this model's**: any machine's script, which the `i3` module binds too; this module binds Fn+F6 to it |
| package `triggerhappy` | **depended on, kept as found** (outside the distribution, above) |
`g14-power`'s pieces (the NVIDIA options, logind, UPower, the sleep units, `auto-profile`) are in
*What it owns* and the switcher above. Its memory guard is the `memory-pressure` module's.
## What the predecessor paid for, and what this module does about it
- **One file for every machine overwrote what each machine needed** (the predecessor's split of its i3
configuration into a base, an ASUS and a G14 layer, 2026-03). Here the model's lines are this
module's files, and nothing else writes them.
- **The layers silently stopped applying.** The predecessor's chain *laptop → g14* was stated in two
places, and the one its installer read lacked it. For weeks the G14 received only its 21
G14-tagged files, and the 49 base and laptop files were never seeded. Validation and the pipeline
both reported success (2026-08, found at a cutover). Here a module is one manifest. Its tests assert
that every trigger runs a script it ships, and `zephyrus_check` and `zephyrus_keys` read back what is
on the machine.
- **A hook wrote asusd's own files with sudo,** because configuration sync might not run on install
(2026-04), and froze them. asusd rewrites those files whenever a setting changes. Here the module
sets asusd through its client and never writes the RON files.
- **A profile daemon fought the profile key.** The predecessor turned asusd's own switching off and
re-asserted its choice every five seconds (2026-03). Here the switcher acts on a change of its
decision, never to restore one.
- **The overnight freeze** was an ACPI power-source event hanging the NVIDIA GPU in runtime D3
(2026-06). It is fixed by the driver options, which `zephyrus_check` reads from the running driver
rather than from the file.
- **A unit restarted about 73,000 times, unnoticed** (2026-06). Here every expectation is in
`zephyrus_check`, and so is a list of what it did not check.
- **The vendor keys ran as root and `su`-ed to a named person** on a guessed display, sourcing a file
of secrets. Here thd drops to the account, and nothing is sourced.
- **The touchpad lost its settings after a resume,** because `xinput` settings vanish when the device
initialises again. Here an input class re-applies them, with the resume unit as a backstop.
## Tests
`go test ./...` in this directory. Every tool runs against a tree standing in for `/sys`, `/proc` and
`/etc`, and an injected runner answering with what asusctl 6.4 and supergfxctl 5.2 said on the laptop.
The tests cover:
- the power-source rule;
- battery arithmetic from `charge_*`;
- the eDP panel choice;
- brightness bounds;
- the policy's sustain, relax and hysteresis, with iowait counted as idle;
- the switcher: no act at start, one switch per change of source, a published event, boost from
samples, holds, retry after failure, start-up assertions only where they differ, inert on another
model;
- the uevent filter;
- the manifest: tools listed equal tools served, no machine named, triggers exist, every key runs a
shipped executable script, every script passes `bash -n`;
- `zephyrus_keys` over the module's own triggers and i3 lines, and a key in two trigger files;
- the checks for `as-user`, triggerhappy's account and the resume unit.
## The vendor keys are a contribution (changed 2026-10-04, novox/hq ADR 0212)
The trigger file and triggerhappy's service drop-in are no longer this module's. The `triggerhappy`
module holds `node-hotkeys`, owns the daemon, and reads only the mesh's trigger file. This module
contributes its eight trigger lines (media, panel brightness, touchpad) to that seat, so it depends
on a hotkey holder being assigned beside it. Its keys still run this module's own scripts.
`zephyrus_keys` reads the trigger directory as before.
## The touchpad after waking, and the lid, move to the power module (changed 2026-10-04, novox/hq ADR 0211)
The touchpad resume unit and its three drop-ins on the sleep services are gone. The reset is now this
module's contribution to `node-power`'s `after-wake` moment, so the module depends on the power
module. `logind.conf.d/power.conf` is the power module's. This laptop's values (suspend on the power
key and on the lid in every case) are that module's settings for this machine. The NVIDIA driver's
sleep drop-ins stay here: they must run inside the sleep transaction, which a contribution cannot.
## Its i3 lines are a contribution (changed 2026-10-05, novox/hq ADR 0212)
The module no longer writes a file into i3's `config.d`. Its window-manager lines (the source is still
under `files/i3/` where it had one) are a contribution to `node-display-session`. The i3 module places
them in its own configuration under a `# <module>` line, so this module depends on a window manager
being assigned beside it.
@@ -0,0 +1,264 @@
package main
import (
"context"
"fmt"
"regexp"
"strconv"
"strings"
)
// The vendor daemons are reached through their own command-line clients, which speak to them on the
// system bus. Their bus policy admits the `users` and `wheel` groups, so none of this needs root.
// Profiles are the platform profiles asusd offers on this model, in its spelling.
var Profiles = []string{"Quiet", "Balanced", "Performance"}
// canonicalProfile accepts any case and answers asusd's spelling, or an error naming the choices.
func canonicalProfile(s string) (string, error) {
for _, p := range Profiles {
if strings.EqualFold(strings.TrimSpace(s), p) {
return p, nil
}
}
return "", fmt.Errorf("profile %q is not one of %s", s, strings.Join(Profiles, ", "))
}
// ProfileState is what asusd says about the platform profile.
type ProfileState struct {
Active string `json:"active"`
OnAC string `json:"on_ac,omitempty"`
Battery string `json:"on_battery,omitempty"`
Platform string `json:"platform_profile,omitempty"`
Choices string `json:"platform_profile_choices,omitempty"`
}
var (
activeProfile = regexp.MustCompile(`(?m)^Active profile:\s*(\S+)`)
acProfile = regexp.MustCompile(`(?m)^AC profile\s+(\S+)`)
batteryProfile = regexp.MustCompile(`(?m)^Battery profile\s+(\S+)`)
)
// ParseProfileGet reads `asusctl profile get`.
func ParseProfileGet(out string) (ProfileState, error) {
var p ProfileState
if m := activeProfile.FindStringSubmatch(out); m != nil {
p.Active = m[1]
} else {
return p, fmt.Errorf("asusctl profile get said no active profile: %q", strings.TrimSpace(out))
}
if m := acProfile.FindStringSubmatch(out); m != nil {
p.OnAC = m[1]
}
if m := batteryProfile.FindStringSubmatch(out); m != nil {
p.Battery = m[1]
}
return p, nil
}
// Profile reads the platform profile from asusd and the kernel.
func (m *Machine) Profile(ctx context.Context) (ProfileState, error) {
out, err := m.Run(ctx, "asusctl", "profile", "get")
if err != nil {
return ProfileState{}, vendor("asusctl", err)
}
p, err := ParseProfileGet(out)
if err != nil {
return p, err
}
p.Platform = m.read("/sys/firmware/acpi/platform_profile")
p.Choices = m.read("/sys/firmware/acpi/platform_profile_choices")
return p, nil
}
// SetProfile has asusd switch the active profile.
func (m *Machine) SetProfile(ctx context.Context, profile string) error {
_, err := m.Run(ctx, "asusctl", "profile", "set", profile)
return vendor("asusctl", err)
}
var chargeLimit = regexp.MustCompile(`charge limit:\s*(\d+)\s*%`)
// ChargeLimit is the battery's charge limit as asusd reports it.
func (m *Machine) ChargeLimit(ctx context.Context) (int, error) {
out, err := m.Run(ctx, "asusctl", "battery", "info")
if err != nil {
return 0, vendor("asusctl", err)
}
g := chargeLimit.FindStringSubmatch(out)
if g == nil {
return 0, fmt.Errorf("asusctl battery info said no limit: %q", strings.TrimSpace(out))
}
n, _ := strconv.Atoi(g[1])
return n, nil
}
// Keyboard backlight levels in asusd's spelling, index = the kernel's brightness value.
var KeyboardLevels = []string{"off", "low", "med", "high"}
var ledLevel = regexp.MustCompile(`(?i)brightness:\s*(off|low|med|high)`)
// ParseLeds reads `asusctl leds get`.
func ParseLeds(out string) (string, error) {
g := ledLevel.FindStringSubmatch(out)
if g == nil {
return "", fmt.Errorf("asusctl leds get said no level: %q", strings.TrimSpace(out))
}
return strings.ToLower(g[1]), nil
}
// FanCurve is one fan's curve in one profile: eight points of temperature (°C) and duty (0-255).
type FanCurve struct {
Fan string `json:"fan"`
Enabled bool `json:"enabled"`
Temp []int `json:"temp_c"`
PWM []int `json:"pwm"`
}
var (
fanBlock = regexp.MustCompile(`(?s)fan:\s*(\w+),\s*pwm:\s*\(([^)]*)\),\s*temp:\s*\(([^)]*)\),\s*enabled:\s*(true|false)`)
)
// ParseFanCurves reads `asusctl fan-curve --mod-profile <p>`.
func ParseFanCurves(out string) []FanCurve {
var curves []FanCurve
for _, g := range fanBlock.FindAllStringSubmatch(out, -1) {
curves = append(curves, FanCurve{Fan: g[1], PWM: ints(g[2]), Temp: ints(g[3]), Enabled: g[4] == "true"})
}
return curves
}
func ints(list string) []int {
var out []int
for _, f := range strings.Split(list, ",") {
if n, err := strconv.Atoi(strings.TrimSpace(f)); err == nil {
out = append(out, n)
}
}
return out
}
// GPUState is what supergfxd says about the hybrid GPU.
type GPUState struct {
Mode string `json:"mode"`
Supported []string `json:"supported"`
Power string `json:"dgpu_power,omitempty"`
PendingAction string `json:"pending_action,omitempty"`
PendingMode string `json:"pending_mode,omitempty"`
Vendor string `json:"dgpu_vendor,omitempty"`
}
// ParseSupported reads `supergfxctl -s`: `[Integrated, Hybrid, AsusMuxDgpu]`.
func ParseSupported(out string) []string {
out = strings.Trim(strings.TrimSpace(out), "[]")
var modes []string
for _, f := range strings.Split(out, ",") {
if f = strings.TrimSpace(f); f != "" {
modes = append(modes, f)
}
}
return modes
}
// GPU reads supergfxd.
func (m *Machine) GPU(ctx context.Context) (GPUState, error) {
var g GPUState
mode, err := m.Run(ctx, "supergfxctl", "-g")
if err != nil {
return g, vendor("supergfxctl", err)
}
g.Mode = strings.TrimSpace(mode)
if s, err := m.Run(ctx, "supergfxctl", "-s"); err == nil {
g.Supported = ParseSupported(s)
}
if s, err := m.Run(ctx, "supergfxctl", "-S"); err == nil {
g.Power = strings.TrimSpace(s)
}
if s, err := m.Run(ctx, "supergfxctl", "-p"); err == nil {
g.PendingAction = strings.TrimSpace(s)
}
if s, err := m.Run(ctx, "supergfxctl", "-P"); err == nil {
g.PendingMode = strings.TrimSpace(s)
}
if s, err := m.Run(ctx, "supergfxctl", "-V"); err == nil {
g.Vendor = strings.TrimSpace(s)
}
return g, nil
}
// vendor names a vendor client that is not installed, rather than passing on "executable file not
// found". asusctl is in the distribution's repositories; supergfxctl is not, and the module keeps it as
// it was found until the mesh can build packages from the user repository (research 027, question 1).
func vendor(name string, err error) error {
if err == nil {
return nil
}
if notInstalled(err) {
switch name {
case "supergfxctl":
return fmt.Errorf("supergfxctl is not installed: it is not in the distribution's repositories, " +
"and this module keeps the copy it finds rather than install one (novox/hq research 027, question 1)")
default:
return fmt.Errorf("%s is not installed; the module's package resource installs it", name)
}
}
return err
}
// AsusdConfig is the few settings of asusd's own file that decide what this module's code does. The
// file is asusd's: it rewrites it whenever a setting changes, so the module reads it and never writes
// it.
type AsusdConfig struct {
ChargeLimit *int `json:"charge_control_end_threshold,omitempty"`
ProfileOnAC string `json:"platform_profile_on_ac,omitempty"`
ProfileOnBattery string `json:"platform_profile_on_battery,omitempty"`
ChangesProfileOnAC *bool `json:"change_platform_profile_on_ac,omitempty"`
ChangesProfileOnBatt *bool `json:"change_platform_profile_on_battery,omitempty"`
ACCommand string `json:"ac_command,omitempty"`
BatteryCommand string `json:"bat_command,omitempty"`
DisablesPowerdOnBatt *bool `json:"disable_nvidia_powerd_on_battery,omitempty"`
}
var ronField = regexp.MustCompile(`(?m)^\s{4}([a-z_]+):\s*(.*?),?\s*$`)
// ParseAsusdRon reads the top-level scalar fields of asusd.ron. RON is not a format the mesh
// speaks; these are one line each, and nothing nested is read.
func ParseAsusdRon(text string) AsusdConfig {
var c AsusdConfig
for _, g := range ronField.FindAllStringSubmatch(text, -1) {
key, value := g[1], strings.TrimSuffix(strings.TrimSpace(g[2]), ",")
unquoted := strings.Trim(value, `"`)
boolean := func() *bool { b := value == "true"; return &b }
switch key {
case "charge_control_end_threshold":
if n, err := strconv.Atoi(value); err == nil {
c.ChargeLimit = &n
}
case "platform_profile_on_ac":
c.ProfileOnAC = unquoted
case "platform_profile_on_battery":
c.ProfileOnBattery = unquoted
case "change_platform_profile_on_ac":
c.ChangesProfileOnAC = boolean()
case "change_platform_profile_on_battery":
c.ChangesProfileOnBatt = boolean()
case "ac_command":
c.ACCommand = unquoted
case "bat_command":
c.BatteryCommand = unquoted
case "disable_nvidia_powerd_on_battery":
c.DisablesPowerdOnBatt = boolean()
}
}
return c
}
// Asusd reads asusd's file; nil when it is not there.
func (m *Machine) Asusd() *AsusdConfig {
text := m.read("/etc/asusd/asusd.ron")
if text == "" {
return nil
}
c := ParseAsusdRon(text)
return &c
}
@@ -0,0 +1,146 @@
package main
import (
"context"
"strings"
"testing"
)
// What asusctl 6.4 and supergfxctl 5.2 said on the laptop on 2026-10-04.
const fanCurveQuiet = `
Fan curves for Quiet
[
(
fan: CPU,
pwm: (2, 0, 10, 20, 35, 55, 80, 100),
temp: (35, 45, 50, 55, 60, 65, 70, 80),
enabled: true,
),
(
fan: GPU,
pwm: (0, 0, 10, 20, 35, 65, 90, 115),
temp: (35, 45, 50, 55, 60, 65, 70, 80),
enabled: false,
),
]
`
const asusdRon = `(
charge_control_end_threshold: 80,
base_charge_control_end_threshold: 0,
disable_nvidia_powerd_on_battery: true,
ac_command: "supergfxctl -m Hybrid",
bat_command: "supergfxctl -m Integrated",
platform_profile_linked_epp: true,
platform_profile_on_battery: Quiet,
change_platform_profile_on_battery: true,
platform_profile_on_ac: Balanced,
change_platform_profile_on_ac: true,
ac_profile_tunings: {
Quiet: (
enabled: false,
group: {},
),
},
)`
func TestAsusctlsAnswersAreRead(t *testing.T) {
p, err := ParseProfileGet(profileGetBalanced)
if err != nil || p.Active != "Balanced" || p.OnAC != "Balanced" || p.Battery != "Quiet" {
t.Fatalf("%+v %v", p, err)
}
if _, err := ParseProfileGet("something else"); err == nil {
t.Fatal("an answer with no profile was read as one")
}
if l, err := ParseLeds("Current keyboard led brightness: High\n"); err != nil || l != "high" {
t.Fatalf("%q %v", l, err)
}
curves := ParseFanCurves(fanCurveQuiet)
if len(curves) != 2 || curves[0].Fan != "CPU" || curves[0].PWM[7] != 100 || curves[0].Temp[0] != 35 || curves[1].Enabled {
t.Fatalf("%+v", curves)
}
if got := ParseSupported("[Integrated, Hybrid, AsusMuxDgpu]\n"); strings.Join(got, ",") != "Integrated,Hybrid,AsusMuxDgpu" {
t.Fatalf("%v", got)
}
}
func TestAsusdsFileIsReadForWhatDecidesTheModulesCodeAndNothingNested(t *testing.T) {
c := ParseAsusdRon(asusdRon)
if *c.ChargeLimit != 80 || c.ProfileOnAC != "Balanced" || c.ProfileOnBattery != "Quiet" ||
c.ACCommand != "supergfxctl -m Hybrid" || c.BatteryCommand != "supergfxctl -m Integrated" ||
!*c.ChangesProfileOnAC || !*c.DisablesPowerdOnBatt {
t.Fatalf("%+v", c)
}
}
func TestAMissingVendorClientIsNamedWithWhyItIsMissing(t *testing.T) {
f := newFake(t)
f.fails["supergfxctl"] = notFound
_, err := f.machine().GPU(context.Background())
if err == nil || !strings.Contains(err.Error(), "research 027") {
t.Fatalf("%v", err)
}
f.fails["asusctl"] = notFound
_, err = f.machine().Profile(context.Background())
if err == nil || !strings.Contains(err.Error(), "package resource installs it") {
t.Fatalf("%v", err)
}
}
func TestAGPUModeIsSetOnlyWhenTheMachineSupportsItAndAsusdsSwitchingIsSaid(t *testing.T) {
f := newFake(t)
f.answers["supergfxctl -g"] = "Hybrid\n"
f.answers["supergfxctl -s"] = "[Integrated, Hybrid, AsusMuxDgpu]\n"
f.file("/etc/asusd/asusd.ron", asusdRon)
m := f.machine()
if _, err := GPUModeTool(context.Background(), m, map[string]any{"mode": "Vfio"}); err == nil {
t.Fatal("an unsupported mode was accepted")
}
out, err := GPUModeTool(context.Background(), m, map[string]any{"mode": "integrated"})
if err != nil {
t.Fatal(err)
}
if !f.called("supergfxctl -m Integrated") {
t.Fatalf("calls %v", f.calls)
}
if _, said := out.(map[string]any)["asusd_switches_it"]; !said {
t.Fatalf("asusd's own switching of the mode was not said: %+v", out)
}
}
func TestTheChargeLimitIsBoundedAndSetThroughAsusd(t *testing.T) {
f := newFake(t)
f.answers["asusctl battery info"] = "Current battery charge limit: 60%\n"
m := f.machine()
for _, bad := range []any{float64(10), float64(101), "x", 55.5} {
if _, err := ChargeLimitTool(context.Background(), m, map[string]any{"limit": bad}); err == nil {
t.Errorf("limit %v was accepted", bad)
}
}
out, err := ChargeLimitTool(context.Background(), m, map[string]any{"limit": float64(60)})
if err != nil || !f.called("asusctl battery limit 60") || out.(map[string]any)["asusd_limit_percent"] != 60 {
t.Fatalf("%+v %v %v", out, err, f.calls)
}
}
func TestAProfileSetByToolIsHeldAndAnUnknownOneRefused(t *testing.T) {
f := newFake(t)
f.onMains()
f.answers["asusctl profile get"] = profileGetBalanced
m := f.machine()
sw := NewSwitcher(m, nil)
if _, err := ProfileTool(context.Background(), m, sw, map[string]any{"profile": "Turbo"}); err == nil {
t.Fatal("an unknown profile was accepted")
}
out, err := ProfileTool(context.Background(), m, sw, map[string]any{"profile": "performance", "hold_minutes": float64(30)})
if err != nil || !f.called("asusctl profile set Performance") {
t.Fatalf("%v %v", err, f.calls)
}
if _, held := out.(map[string]any)["held_until"]; !held {
t.Fatalf("not held: %+v", out)
}
if r := sw.Report(); r.Held != "Performance" {
t.Fatalf("%+v", r)
}
}
@@ -0,0 +1,203 @@
package main
import (
"context"
"fmt"
"os"
"path/filepath"
"regexp"
"strings"
)
// Check is one thing the module expects of the machine, and whether it holds.
type Check struct {
Name string `json:"name"`
OK bool `json:"ok"`
Detail string `json:"detail"`
}
// CheckReport is what zephyrus_check answers. NotChecked says what it did not look at, because a
// check that reads as clean while skipping something is the predecessor's verifier again.
type CheckReport struct {
Model string `json:"model"`
Checks []Check `json:"checks"`
Failing int `json:"failing"`
NotChecked []string `json:"not_checked"`
}
var (
pacmanVersion = regexp.MustCompile(`(?m)^Version\s*:\s*(\S+)`)
pacmanPackager = regexp.MustCompile(`(?m)^Packager\s*:\s*(.+)$`)
nvidiaParam = regexp.MustCompile(`(?m)^(\w+):\s*(\S+)`)
)
// ParseNvidiaParams reads /proc/driver/nvidia/params.
func ParseNvidiaParams(text string) map[string]string {
out := map[string]string{}
for _, g := range nvidiaParam.FindAllStringSubmatch(text, -1) {
out[g[1]] = g[2]
}
return out
}
// predecessorProcess finds a running process whose command line names the predecessor's script.
func (m *Machine) predecessorProcess(name string) (int, bool) {
for _, dir := range m.glob("/proc/[0-9]*") {
cmd := strings.ReplaceAll(m.read(dir+"/cmdline"), "\x00", " ")
if strings.Contains(cmd, "/"+name) && !strings.Contains(cmd, "zephyrus") {
var pid int
fmt.Sscanf(filepath.Base(dir), "%d", &pid)
return pid, true
}
}
return 0, false
}
func (m *Machine) unitIs(ctx context.Context, verb, unit string) string {
out, _ := m.Run(ctx, "systemctl", verb, unit)
return strings.TrimSpace(out)
}
// Check reads every expectation and reports each.
func (m *Machine) Check(ctx context.Context, sw *Switcher) CheckReport {
r := CheckReport{Model: m.Model(), NotChecked: []string{
"the fan curves (asusd's own, read them with zephyrus_fan_curves)",
"whether the initramfs carries the NVIDIA options (they are read from the running driver instead)",
"the vendor keys themselves (press them)",
}}
add := func(name string, ok bool, format string, a ...any) {
r.Checks = append(r.Checks, Check{Name: name, OK: ok, Detail: fmt.Sprintf(format, a...)})
if !ok {
r.Failing++
}
}
add("model", m.ThisModel(), "the firmware reports %q; this module is for %q", r.Model, ModelFamily)
// asusctl: present, and from the distribution rather than a local build.
if info, err := m.Run(ctx, "pacman", "-Qi", "asusctl"); err != nil {
add("asusctl package", false, "not installed: %v", err)
} else {
v, p := "", ""
if g := pacmanVersion.FindStringSubmatch(info); g != nil {
v = g[1]
}
if g := pacmanPackager.FindStringSubmatch(info); g != nil {
p = strings.TrimSpace(g[1])
}
local := p == "Unknown Packager"
add("asusctl package", !local, "version %s, packager %s%s", v, p,
map[bool]string{true: "; a local build — the distribution's package replaces it at the next upgrade (pacman -S asusctl)", false: ""}[local])
}
for _, foreign := range []string{"supergfxctl", "triggerhappy"} {
_, err := m.Run(ctx, "pacman", "-Q", foreign)
add(foreign+" package", err == nil, "%s; not in the distribution's repositories, kept as found (novox/hq research 027, question 1)",
map[bool]string{true: "installed", false: "NOT installed"}[err == nil])
}
for _, unit := range []string{"asusd.service", "supergfxd.service", "triggerhappy.service"} {
state := m.unitIs(ctx, "is-active", unit)
add(unit, state == "active", "%s", state)
}
powerd := m.unitIs(ctx, "is-enabled", "nvidia-powerd.service")
add("nvidia-powerd.service", powerd == "masked" || powerd == "disabled" || powerd == "" || strings.Contains(powerd, "not-found"),
"%s; the module's drop-in keeps it from starting unless the kernel command line says zephyrus.nvidia-powerd", orNone(powerd))
wants, _ := m.Run(ctx, "systemctl", "show", "-p", "Wants", "systemd-suspend.service")
add("nvidia suspend and resume", strings.Contains(wants, "nvidia-suspend.service") && strings.Contains(wants, "nvidia-resume.service"),
"systemd-suspend.service %s", strings.TrimSpace(wants))
// The touchpad reset after waking is this module's contribution to node-power's after-wake moment
// (novox/hq ADR 0211), placed in the power module's moment file under a "# asus-zephyrus-g14" line.
afterWake := m.read("/etc/mesh-power/moments/after-wake")
placed := strings.Contains(afterWake, "# asus-zephyrus-g14\n") && strings.Contains(afterWake, "zephyrus-touchpad reset")
add("touchpad after resume", placed,
"the reset is placed in the power module's after-wake moment: %v (it needs the power module on this machine)", placed)
if uid := m.triggerhappyUID(); uid < 0 {
add("triggerhappy as the account", false, "no thd process runs: the vendor keys do nothing")
} else {
add("triggerhappy as the account", uid == operatorUID(), "thd runs as uid %d; the operator's account is %d (the module's drop-in passes --user)", uid, operatorUID())
}
refs := m.asUserReferences()
add("no as-user", len(refs) == 0, "%s", orNone(map[bool]string{true: "", false: "the predecessor's as-user is still named in " + strings.Join(refs, ", ") +
": it su-s to a named person on a guessed display and sources a file of secrets"}[len(refs) == 0]))
params := ParseNvidiaParams(m.read("/proc/driver/nvidia/params"))
if len(params) == 0 {
add("nvidia options", false, "the NVIDIA driver is not loaded (no /proc/driver/nvidia/params)")
} else {
add("nvidia options", params["PreserveVideoMemoryAllocations"] == "1" && params["DynamicPowerManagement"] == "0",
"PreserveVideoMemoryAllocations=%s DynamicPowerManagement=%s (want 1 and 0; a change applies when the driver loads again)",
params["PreserveVideoMemoryAllocations"], params["DynamicPowerManagement"])
}
for _, b := range m.Batteries() {
ok := b.LimitPercent != nil && *b.LimitPercent == ChargeLimitPercent
have := "unknown"
if b.LimitPercent != nil {
have = fmt.Sprintf("%d%%", *b.LimitPercent)
}
add("charge limit", ok, "%s is %s, the module's is %d%%", b.Name, have, ChargeLimitPercent)
}
if c := m.Asusd(); c != nil && (c.ACCommand != "" || c.BatteryCommand != "") {
add("one authority over the GPU mode", false,
"asusd runs %q on mains and %q on battery: it switches the GPU mode on every change of power source, "+
"so a mode set with zephyrus_gpu_mode lasts until the next one. Clear ac_command and bat_command in /etc/asusd/asusd.ron (asusd's file) to make it the operator's alone",
c.ACCommand, c.BatteryCommand)
}
if out, err := m.Run(ctx, "journalctl", "-b", "-u", "supergfxd.service", "-g", "invalid variant", "-n", "1", "-q", "-o", "cat"); err == nil && strings.TrimSpace(out) != "" {
add("supergfxd and logind", false, "supergfxd cannot read logind's sessions this boot (%s): a mode change that needs a logout times out", strings.TrimSpace(out))
}
if pid, ok := m.predecessorProcess("auto-profile"); ok {
add("one profile switcher", false, "the predecessor's auto-profile still runs (pid %d) and switches the profile every five seconds; "+
"stop it: systemctl --user disable --now auto-profile.service", pid)
} else {
add("one profile switcher", true, "no predecessor auto-profile is running")
}
if sw != nil {
rep := sw.Report()
add("profile switcher", rep.Running, "%s", orNone(firstNonEmpty(rep.Disabled, rep.LastError, "woken by "+rep.Watching)))
}
if home := os.Getenv("MESH_OPERATOR_HOME"); home != "" {
var left []string
for _, p := range PredecessorHomeFiles {
if _, err := os.Stat(filepath.Join(m.Root, home, p)); err == nil {
left = append(left, "~/"+p)
}
}
add("predecessor files in the home", len(left) == 0, "%s", orNone(strings.Join(left, ", ")))
} else {
r.NotChecked = append(r.NotChecked, "the predecessor's files in the operator's home (MESH_OPERATOR_HOME is not set)")
}
return r
}
// PredecessorHomeFiles are what the predecessor placed in the operator's home for this model and this
// module replaces. The mesh removes nothing it did not make (novox/hq ADR 0182): the operator does,
// once, and this list is how the check knows.
var PredecessorHomeFiles = []string{
"scripts/auto-profile",
".config/systemd/user/auto-profile.service",
"scripts/asus-bright",
"scripts/asusctl-kbd-bright",
"scripts/xrandr-bright",
"scripts/media-control",
"scripts/xinput-reset-touchpad",
".config/i3/scripts/kbd-brightness-notify.sh",
"scripts/.screenlayouts/orden-workspaces.sh",
}
func orNone(s string) string {
if strings.TrimSpace(s) == "" {
return "none"
}
return s
}
func firstNonEmpty(ss ...string) string {
for _, s := range ss {
if s != "" {
return s
}
}
return ""
}
@@ -0,0 +1,202 @@
package main
import (
"context"
"fmt"
"math"
"os"
"path"
"sort"
"strconv"
"strings"
)
// MinPanelPercent is the floor a brightness change never goes below: a panel at zero is a black
// screen that looks like a dead machine, and the keys cannot be seen to bring it back.
const MinPanelPercent = 1
// Panel is the internal display's backlight.
type Panel struct {
Device string `json:"device"`
Percent float64 `json:"percent"`
Raw int64 `json:"raw"`
Max int64 `json:"max"`
Others []string `json:"other_backlights,omitempty"`
}
// panelDevice chooses the backlight that drives the internal panel.
//
// **This model registers two.** In hybrid mode the integrated GPU drives the panel (amdgpu_bl1,
// beneath the eDP connector) and the discrete GPU's driver registers one of its own (nvidia_0) that
// moves nothing. The predecessor's scripts named amdgpu_bl1 literally, which is right until the GPU
// mode puts the panel on the other GPU. The one that sits under an eDP connector is the panel's; failing
// that, the kernel's own preference: firmware, then platform, then raw.
func (m *Machine) panelDevice() (string, []string, error) {
all := m.glob("/sys/class/backlight/*")
if len(all) == 0 {
return "", nil, fmt.Errorf("this machine has no backlight in /sys/class/backlight")
}
names := make([]string, 0, len(all))
for _, d := range all {
names = append(names, path.Base(d))
}
sort.Strings(names)
rank := func(name string) int {
dir := "/sys/class/backlight/" + name
if target, err := os.Readlink(m.path(dir)); err == nil && strings.Contains(target, "-eDP-") {
return 0
}
switch m.read(dir + "/type") {
case "firmware":
return 1
case "platform":
return 2
}
return 3
}
best := names[0]
for _, n := range names[1:] {
if rank(n) < rank(best) {
best = n
}
}
var others []string
for _, n := range names {
if n != best {
others = append(others, n)
}
}
return best, others, nil
}
// PanelBrightness reads the panel.
func (m *Machine) PanelBrightness() (Panel, error) {
dev, others, err := m.panelDevice()
if err != nil {
return Panel{}, err
}
dir := "/sys/class/backlight/" + dev
raw, ok1 := m.readInt(dir + "/brightness")
max, ok2 := m.readInt(dir + "/max_brightness")
if !ok1 || !ok2 || max <= 0 {
return Panel{}, fmt.Errorf("%s does not say its brightness", dir)
}
return Panel{Device: dev, Raw: raw, Max: max, Percent: round1(float64(raw) / float64(max) * 100), Others: others}, nil
}
// PanelTarget turns a request — "40", "40%", "+5", "-10" — into the percentage to set, clamped to
// [MinPanelPercent, 100].
func PanelTarget(current float64, request string) (float64, error) {
r := strings.TrimSuffix(strings.TrimSpace(request), "%")
if r == "" {
return 0, fmt.Errorf("panel needs a percentage (40) or a step (+5, -5)")
}
n, err := strconv.ParseFloat(r, 64)
if err != nil || math.IsNaN(n) || math.IsInf(n, 0) {
return 0, fmt.Errorf("panel %q is not a percentage or a step", request)
}
target := n
if strings.HasPrefix(r, "+") || strings.HasPrefix(r, "-") {
target = current + n
}
return math.Max(MinPanelPercent, math.Min(100, target)), nil
}
// SetPanel sets the panel to a percentage.
func (m *Machine) SetPanel(ctx context.Context, request string) (Panel, error) {
p, err := m.PanelBrightness()
if err != nil {
return p, err
}
target, err := PanelTarget(p.Percent, request)
if err != nil {
return p, err
}
raw := int64(math.Round(target / 100 * float64(p.Max)))
if raw < 1 {
raw = 1
}
if err := m.write(ctx, "/sys/class/backlight/"+p.Device+"/brightness", strconv.FormatInt(raw, 10)); err != nil {
return p, err
}
return m.PanelBrightness()
}
// Keyboard is the keyboard's backlight.
type Keyboard struct {
Device string `json:"device"`
Level string `json:"level"`
Value int64 `json:"value"`
Max int64 `json:"max"`
}
// KeyboardBrightness reads the keyboard backlight from the kernel.
func (m *Machine) KeyboardBrightness() (Keyboard, error) {
found := m.glob("/sys/class/leds/*kbd_backlight*")
if len(found) == 0 {
return Keyboard{}, fmt.Errorf("this machine has no keyboard backlight in /sys/class/leds")
}
dir := found[0]
v, ok1 := m.readInt(dir + "/brightness")
max, ok2 := m.readInt(dir + "/max_brightness")
if !ok1 || !ok2 {
return Keyboard{}, fmt.Errorf("%s does not say its brightness", dir)
}
k := Keyboard{Device: path.Base(dir), Value: v, Max: max}
if max == int64(len(KeyboardLevels)-1) && v >= 0 && v <= max {
k.Level = KeyboardLevels[v]
}
return k, nil
}
// KeyboardTarget turns a request — off/low/med/high, 0-3, "+", "-" — into asusd's level name.
func KeyboardTarget(current int64, request string) (string, error) {
r := strings.ToLower(strings.TrimSpace(request))
switch r {
case "medium":
r = "med"
case "+", "up":
r = strconv.FormatInt(min64(current+1, int64(len(KeyboardLevels)-1)), 10)
case "-", "down":
r = strconv.FormatInt(max64(current-1, 0), 10)
}
for _, l := range KeyboardLevels {
if r == l {
return l, nil
}
}
if n, err := strconv.Atoi(r); err == nil && n >= 0 && n < len(KeyboardLevels) {
return KeyboardLevels[n], nil
}
return "", fmt.Errorf("keyboard %q is not one of off, low, med, high, 0-3, + or -", request)
}
// SetKeyboard has asusd set the keyboard backlight, so its own record of the level stays true.
func (m *Machine) SetKeyboard(ctx context.Context, request string) (Keyboard, error) {
k, err := m.KeyboardBrightness()
if err != nil {
return k, err
}
level, err := KeyboardTarget(k.Value, request)
if err != nil {
return k, err
}
if _, err := m.Run(ctx, "asusctl", "leds", "set", level); err != nil {
return k, vendor("asusctl", err)
}
return m.KeyboardBrightness()
}
func min64(a, b int64) int64 {
if a < b {
return a
}
return b
}
func max64(a, b int64) int64 {
if a > b {
return a
}
return b
}
@@ -0,0 +1,91 @@
package main
import (
"context"
"os"
"path/filepath"
"strings"
"testing"
)
// backlight makes a backlight the way sysfs does: a link from /sys/class/backlight into the device
// tree, which is where the eDP connector shows.
func (f *fake) backlight(name, device string, raw, max string) {
dev := "/sys/devices/" + device + "/" + name
f.file(dev+"/brightness", raw)
f.file(dev+"/max_brightness", max)
f.file(dev+"/type", "raw")
link := filepath.Join(f.root, "/sys/class/backlight", name)
os.MkdirAll(filepath.Dir(link), 0o755)
if err := os.Symlink(filepath.Join(f.root, dev), link); err != nil {
f.t.Fatal(err)
}
}
func TestThePanelIsTheBacklightUnderTheEDPConnectorNotTheDiscreteGPUs(t *testing.T) {
f := newFake(t)
f.backlight("amdgpu_bl1", "pci0000:00/0000:65:00.0/drm/card1/card1-eDP-1", "199500", "399000")
f.backlight("nvidia_0", "pci0000:00/0000:01:00.0/backlight", "100", "100")
p, err := f.machine().PanelBrightness()
if err != nil {
t.Fatal(err)
}
if p.Device != "amdgpu_bl1" || p.Percent != 50 || len(p.Others) != 1 || p.Others[0] != "nvidia_0" {
t.Fatalf("%+v", p)
}
}
func TestAPanelRequestIsAPercentageOrAStepAndNeverGoesDark(t *testing.T) {
for _, c := range []struct {
cur float64
req string
want float64
}{{50, "40", 40}, {50, "40%", 40}, {50, "+5", 55}, {50, "-10", 40}, {3, "-10", 1}, {98, "+5", 100}, {50, "0", 1}} {
got, err := PanelTarget(c.cur, c.req)
if err != nil || got != c.want {
t.Errorf("%v %q: %v %v, want %v", c.cur, c.req, got, err, c.want)
}
}
for _, bad := range []string{"", "bright", "NaN"} {
if _, err := PanelTarget(50, bad); err == nil {
t.Errorf("%q was accepted", bad)
}
}
}
func TestSettingThePanelWritesTheRawValue(t *testing.T) {
f := newFake(t)
f.backlight("amdgpu_bl1", "card1-eDP-1", "399000", "399000")
p, err := f.machine().SetPanel(context.Background(), "25")
if err != nil {
t.Fatal(err)
}
raw, _ := os.ReadFile(filepath.Join(f.root, "/sys/devices/card1-eDP-1/amdgpu_bl1/brightness"))
if strings.TrimSpace(string(raw)) != "99750" || p.Percent != 25 {
t.Fatalf("wrote %q, read back %+v", raw, p)
}
}
func TestTheKeyboardIsSetThroughAsusdByLevel(t *testing.T) {
f := newFake(t)
f.file("/sys/class/leds/asus::kbd_backlight/brightness", "1")
f.file("/sys/class/leds/asus::kbd_backlight/max_brightness", "3")
k, err := f.machine().KeyboardBrightness()
if err != nil || k.Level != "low" {
t.Fatalf("%+v %v", k, err)
}
if _, err := f.machine().SetKeyboard(context.Background(), "+"); err != nil {
t.Fatal(err)
}
if !f.called("asusctl leds set med") {
t.Fatalf("calls: %v", f.calls)
}
for req, want := range map[string]string{"high": "high", "0": "off", "medium": "med", "-": "off"} {
if got, err := KeyboardTarget(1, req); err != nil || got != want {
t.Errorf("%q: %q %v", req, got, err)
}
}
if _, err := KeyboardTarget(1, "7"); err == nil {
t.Error("level 7 was accepted")
}
}
@@ -0,0 +1,103 @@
package main
import (
"context"
"os"
"os/exec"
"path/filepath"
"strings"
"sync"
"testing"
)
// fake is a machine for a test: a tree standing in for /, and a runner answering from a table and
// recording every command it was asked to run.
type fake struct {
t *testing.T
root string
mu sync.Mutex
answers map[string]string
fails map[string]error
calls []string
}
func newFake(t *testing.T) *fake {
t.Helper()
return &fake{t: t, root: t.TempDir(), answers: map[string]string{}, fails: map[string]error{}}
}
func (f *fake) machine() *Machine { return &Machine{Root: f.root, Run: f.run} }
func (f *fake) run(_ context.Context, name string, args ...string) (string, error) {
line := strings.TrimSpace(name + " " + strings.Join(args, " "))
f.mu.Lock()
defer f.mu.Unlock()
f.calls = append(f.calls, line)
if err, ok := f.fails[line]; ok {
return "", err
}
if out, ok := f.answers[line]; ok {
return out, nil
}
if err, ok := f.fails[name]; ok {
return "", err
}
return "", nil
}
func (f *fake) called(line string) bool {
f.mu.Lock()
defer f.mu.Unlock()
for _, c := range f.calls {
if c == line {
return true
}
}
return false
}
func (f *fake) callsLike(prefix string) []string {
f.mu.Lock()
defer f.mu.Unlock()
var out []string
for _, c := range f.calls {
if strings.HasPrefix(c, prefix) {
out = append(out, c)
}
}
return out
}
// file writes a file under the fake root.
func (f *fake) file(path, content string) {
f.t.Helper()
full := filepath.Join(f.root, path)
if err := os.MkdirAll(filepath.Dir(full), 0o755); err != nil {
f.t.Fatal(err)
}
if err := os.WriteFile(full, []byte(content), 0o644); err != nil {
f.t.Fatal(err)
}
}
// supply writes one power supply's attributes.
func (f *fake) supply(name string, attrs map[string]string) {
for k, v := range attrs {
f.file("/sys/class/power_supply/"+name+"/"+k, v+"\n")
}
}
// onMains and onBattery are this model's two states as measured on 2026-10-04.
func (f *fake) onMains() {
f.supply("ACAD", map[string]string{"type": "Mains", "online": "1"})
f.supply("BAT1", map[string]string{"type": "Battery", "status": "Not charging", "capacity": "80"})
}
func (f *fake) onBattery() {
f.supply("ACAD", map[string]string{"type": "Mains", "online": "0"})
f.supply("BAT1", map[string]string{"type": "Battery", "status": "Discharging", "capacity": "79"})
}
var notFound = &exec.Error{Name: "x", Err: exec.ErrNotFound}
const profileGetBalanced = "Active profile: Balanced\n\nAC profile Balanced\nBattery profile Quiet\n"
@@ -0,0 +1,192 @@
package main
import (
"fmt"
"os"
"path/filepath"
"regexp"
"sort"
"strings"
)
// Key is one custom key on the laptop: what it is, what runs, and where that is defined.
type Key struct {
Key string `json:"key"`
Physical string `json:"physical,omitempty"`
When string `json:"when,omitempty"`
Runs string `json:"runs"`
From string `json:"from"`
}
// KeysReport is what zephyrus_keys answers.
type KeysReport struct {
Keys []Key `json:"keys"`
Warnings []string `json:"warnings"`
NotRead []string `json:"not_read"`
}
// TriggerDir is triggerhappy's directory of trigger files; the module owns one file in it.
const TriggerDir = "/etc/triggerhappy/triggers.d"
// I3Config is the window manager's configuration, relative to the account's home. This module's lines
// are its contribution to node-display-session (novox/hq ADR 0212), placed there by the i3 module
// under a line that names this module.
const I3Config = ".config/i3/config"
// ModuleName is the line the controller writes before this module's contributed lines.
const ModuleName = "asus-zephyrus-g14"
// ownSection is this module's contributed lines in a placed file: from the line naming it to the next
// line naming another module, a section header, or the file's end.
func ownSection(text string) []string {
var out []string
in := false
for _, line := range strings.Split(text, "\n") {
t := strings.TrimSpace(line)
if t == "# "+ModuleName {
in = true
continue
}
if !in {
continue
}
if strings.HasPrefix(t, "####") || (strings.HasPrefix(t, "# ") && isModuleLine(strings.TrimPrefix(t, "# "))) {
break
}
out = append(out, line)
}
return out
}
// isModuleLine is a comment that is one catalogue module's name: lower case, digits and dashes.
func isModuleLine(s string) bool {
if s == "" {
return false
}
for _, r := range s {
if !(r >= 'a' && r <= 'z' || r >= '0' && r <= '9' || r == '-') {
return false
}
}
return true
}
// physicalKeys names the key behind an evdev code, as far as it is known on this model. The media
// codes are the M4 key and Fn+F4/F5 together; which code is which key was not recorded.
var physicalKeys = map[string]string{
"KEY_PROG1": "a media key (M4, Fn+F4 or Fn+F5)",
"KEY_PROG3": "a media key (M4, Fn+F4 or Fn+F5)",
"KEY_PROG4": "a media key (M4, Fn+F4 or Fn+F5)",
"KEY_BRIGHTNESSDOWN": "Fn+F7",
"KEY_BRIGHTNESSUP": "Fn+F8",
"KEY_F21": "Fn+F10 (touchpad)",
"$mod+Shift+s": "Fn+F6 (screenshot; the firmware sends Super+Shift+S)",
"$mod+p": "Fn+F9 (display; the firmware sends Super+P)",
}
// firmwareKeys are handled below any configuration file.
var firmwareKeys = []Key{
{Key: "Fn+F2 / Fn+F3", Physical: "keyboard backlight", Runs: "the firmware and asusd; zephyrus-kbd-notify shows the level", From: "firmware"},
}
var (
triggerLine = regexp.MustCompile(`^(\S+)\s+([0-9]+)\s+(.+)$`)
i3Bind = regexp.MustCompile(`^bindsym\s+((?:--\S+\s+)*)(\S+)\s+(.+)$`)
i3Exec = regexp.MustCompile(`^exec(?:_always)?\s+(?:--no-startup-id\s+)?(.+)$`)
)
var triggerWhen = map[string]string{"0": "released", "1": "pressed", "2": "held (repeat)"}
// Keys lists the laptop's custom keys: every triggerhappy trigger, the module's i3 lines and the keys
// the firmware handles itself.
func (m *Machine) Keys() KeysReport {
r := KeysReport{Keys: []Key{}, Warnings: []string{}, NotRead: []string{}}
seen := map[string][]string{}
files := m.glob(TriggerDir + "/*.conf")
sort.Strings(files)
if len(files) == 0 {
r.Warnings = append(r.Warnings, "no trigger file in "+TriggerDir+": the vendor keys do nothing")
}
for _, f := range files {
for _, line := range strings.Split(m.read(f), "\n") {
line = strings.TrimSpace(line)
if line == "" || strings.HasPrefix(line, "#") {
continue
}
g := triggerLine.FindStringSubmatch(line)
if g == nil {
continue
}
r.Keys = append(r.Keys, Key{Key: g[1], Physical: physicalKeys[g[1]], When: triggerWhen[g[2]], Runs: g[3], From: f})
id := g[1] + " " + g[2]
seen[id] = append(seen[id], f)
}
}
for id, fs := range seen {
if len(fs) > 1 {
r.Warnings = append(r.Warnings, fmt.Sprintf("%s is bound in %d places (%s): it fires every one", id, len(fs), strings.Join(fs, ", ")))
}
}
home := os.Getenv("MESH_OPERATOR_HOME")
if home == "" {
r.NotRead = append(r.NotRead, "the module's i3 lines (MESH_OPERATOR_HOME is not set)")
}
if home != "" {
lines := ownSection(m.read(filepath.Join(home, I3Config)))
if len(lines) == 0 {
r.Warnings = append(r.Warnings, "~/"+I3Config+" holds no lines of this module's: is the i3 module assigned and pushed?")
}
from := "~/" + I3Config + " (" + ModuleName + "'s contribution)"
for _, line := range lines {
line = strings.TrimSpace(line)
if g := i3Bind.FindStringSubmatch(line); g != nil {
when := "pressed"
if strings.Contains(g[1], "--release") {
when = "released"
}
r.Keys = append(r.Keys, Key{Key: g[2], Physical: physicalKeys[g[2]], When: when, Runs: strings.TrimPrefix(strings.TrimPrefix(g[3], "exec "), "--no-startup-id "), From: from})
} else if g := i3Exec.FindStringSubmatch(line); g != nil {
r.Keys = append(r.Keys, Key{Key: "(session start)", When: "at login", Runs: g[1], From: from})
}
}
}
r.Keys = append(r.Keys, firmwareKeys...)
sort.Strings(r.Warnings)
return r
}
// asUserReferences finds the predecessor's as-user wrapper still named in a trigger, a udev rule or
// the module's i3 lines.
func (m *Machine) asUserReferences() []string {
var at []string
for _, pattern := range []string{TriggerDir + "/*.conf", "/etc/udev/rules.d/*.rules", "/etc/systemd/system-sleep/*"} {
for _, f := range m.glob(pattern) {
if strings.Contains(m.read(f), "as-user") {
at = append(at, f)
}
}
}
sort.Strings(at)
return at
}
// triggerhappyUID is the real user id the running thd has, or -1 when none runs.
func (m *Machine) triggerhappyUID() int {
for _, dir := range m.glob("/proc/[0-9]*") {
if strings.TrimSpace(m.read(dir+"/comm")) != "thd" {
continue
}
for _, line := range strings.Split(m.read(dir+"/status"), "\n") {
if f := strings.Fields(line); len(f) >= 2 && f[0] == "Uid:" {
var uid int
if _, err := fmt.Sscanf(f[1], "%d", &uid); err == nil {
return uid
}
}
}
}
return -1
}
// operatorUID is the account the module's code runs as: the runtime runs it as the operator's.
var operatorUID = os.Getuid
@@ -0,0 +1,113 @@
package main
import (
"context"
"encoding/json"
"os"
"path/filepath"
"strings"
"testing"
)
func put(t *testing.T, root, p, content string) {
t.Helper()
full := filepath.Join(root, p)
if err := os.MkdirAll(filepath.Dir(full), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(full, []byte(content), 0o644); err != nil {
t.Fatal(err)
}
}
// The module's shipped triggers and i3 lines, read back as keys: what each runs and where.
func TestKeysListsTriggersI3LinesAndFirmwareKeys(t *testing.T) {
f := newFake(t)
m := readManifest(t)
content := map[string]string{}
for _, r := range m.Resources {
if c, ok := r["content"].(string); ok {
content[r["id"].(string)] = c
}
}
put(t, f.root, TriggerDir+"/mesh.conf", "# asus-zephyrus-g14\n"+m.triggers())
home := "/home/operator"
t.Setenv("MESH_OPERATOR_HOME", home)
// The i3 module's file as the controller composes it: other modules' lines around this one's.
var own string
for _, c := range m.Contributions {
if c.Seat == "node-display-session" {
own += c.Content
}
}
put(t, f.root, home+"/"+I3Config, "set $mod Mod4\n# rofi\nbindsym $mod+d exec rofi\n# "+ModuleName+"\n"+own+
"# triggerhappy-not-here\nbindsym $mod+Shift+z exec other\n#########################################\ninclude x\n")
r := f.machine().Keys()
got := map[string]Key{}
for _, k := range r.Keys {
got[k.Key+"|"+k.When] = k
}
if k := got["KEY_F21|pressed"]; !strings.Contains(k.Runs, "zephyrus-touchpad reset") || k.Physical == "" {
t.Errorf("touchpad key: %+v", k)
}
if k := got["KEY_PROG1|pressed"]; !strings.Contains(k.Runs, "zephyrus-media play-pause") {
t.Errorf("media key: %+v", k)
}
if k := got["$mod+Shift+s|released"]; !strings.Contains(k.Runs, "screenshot") || !strings.Contains(k.Physical, "Fn+F6") {
t.Errorf("screenshot key: %+v", k)
}
if k := got["$mod+p|pressed"]; !strings.Contains(k.Runs, "zephyrus-display order") {
t.Errorf("display key: %+v", k)
}
if k := got["(session start)|at login"]; k.Runs == "" {
t.Errorf("no session-start line read")
}
if k := got["Fn+F2 / Fn+F3|"]; k.From != "firmware" {
t.Errorf("firmware keys missing: %+v", r.Keys)
}
if len(r.Warnings) != 0 || len(r.NotRead) != 0 {
t.Errorf("warnings %v, not read %v", r.Warnings, r.NotRead)
}
if _, err := json.Marshal(r); err != nil {
t.Fatal(err)
}
}
// A key bound in two trigger files fires twice, which is said.
func TestKeysWarnsAboutAKeyInTwoTriggerFiles(t *testing.T) {
f := newFake(t)
put(t, f.root, TriggerDir+"/asus-g14.conf", "KEY_F21\t1\t/x reset\n")
put(t, f.root, TriggerDir+"/old.conf", "KEY_F21\t1\t/y reset\n")
t.Setenv("MESH_OPERATOR_HOME", "")
r := f.machine().Keys()
if len(r.Warnings) != 1 || !strings.Contains(r.Warnings[0], "KEY_F21") || len(r.NotRead) != 1 {
t.Errorf("warnings %v, not read %v", r.Warnings, r.NotRead)
}
}
// The check names what still calls as-user, and whether triggerhappy runs as the account.
func TestCheckFindsAsUserAndTriggerhappysAccount(t *testing.T) {
f := newFake(t)
put(t, f.root, "/etc/udev/rules.d/91-monitor-hotplug.rules", `RUN+="/x/scripts/as-user setsid wizard"`)
put(t, f.root, "/proc/4242/comm", "thd\n")
put(t, f.root, "/proc/4242/status", "Name:\tthd\nUid:\t0\t0\t0\t0\n")
was := operatorUID
operatorUID = func() int { return 1000 }
defer func() { operatorUID = was }()
t.Setenv("MESH_OPERATOR_HOME", "")
r := f.machine().Check(context.Background(), nil)
by := map[string]Check{}
for _, c := range r.Checks {
by[c.Name] = c
}
if c := by["no as-user"]; c.OK || !strings.Contains(c.Detail, "91-monitor-hotplug.rules") {
t.Errorf("as-user: %+v", c)
}
if c := by["triggerhappy as the account"]; c.OK || !strings.Contains(c.Detail, "uid 0") {
t.Errorf("triggerhappy: %+v", c)
}
if c := by["touchpad after resume"]; c.OK {
t.Errorf("resume: %+v", c)
}
}
@@ -0,0 +1,124 @@
package main
import (
"bytes"
"context"
"errors"
"fmt"
"os"
"os/exec"
"path/filepath"
"strconv"
"strings"
"time"
)
// CommandTimeout bounds every command a tool or the switcher runs: a vendor daemon that hangs on its
// bus must cost a tool call twenty seconds, never the runtime's thirty.
const CommandTimeout = 20 * time.Second
// Runner runs one command and answers its standard output. It is injected so that every tool is
// tested against recorded answers rather than this machine's daemons.
type Runner func(ctx context.Context, name string, args ...string) (string, error)
// ExecRunner runs a command on the machine, bounded by CommandTimeout. A failure carries what the
// command said on stderr, because "exit status 1" names nothing.
func ExecRunner(ctx context.Context, name string, args ...string) (string, error) {
ctx, cancel := context.WithTimeout(ctx, CommandTimeout)
defer cancel()
cmd := exec.CommandContext(ctx, name, args...)
var stdout, stderr bytes.Buffer
cmd.Stdout, cmd.Stderr = &stdout, &stderr
err := cmd.Run()
if ctx.Err() == context.DeadlineExceeded {
return stdout.String(), fmt.Errorf("%s did not answer within %s", name, CommandTimeout)
}
if err != nil {
said := strings.TrimSpace(stderr.String())
if said == "" {
said = strings.TrimSpace(stdout.String())
}
if said != "" {
return stdout.String(), fmt.Errorf("%s %s: %w: %s", name, strings.Join(args, " "), err, said)
}
return stdout.String(), fmt.Errorf("%s %s: %w", name, strings.Join(args, " "), err)
}
return stdout.String(), nil
}
// Machine is what the module reads and acts on: a filesystem root (the real one, or a test's tree of
// /sys and /proc and /etc) and a way to run commands.
type Machine struct {
Root string
Run Runner
}
// Here is the machine this process runs on.
func Here() *Machine { return &Machine{Root: "/", Run: ExecRunner} }
func (m *Machine) path(p string) string { return filepath.Join(m.Root, p) }
// read is a file's content, trimmed; "" when it cannot be read.
func (m *Machine) read(p string) string {
b, err := os.ReadFile(m.path(p))
if err != nil {
return ""
}
return strings.TrimSpace(string(b))
}
// readInt is a file holding one integer; ok false when it is absent or not a number.
func (m *Machine) readInt(p string) (int64, bool) {
s := m.read(p)
if s == "" {
return 0, false
}
n, err := strconv.ParseInt(s, 10, 64)
return n, err == nil
}
func (m *Machine) glob(pattern string) []string {
found, _ := filepath.Glob(m.path(pattern))
out := make([]string, 0, len(found))
for _, f := range found {
rel, err := filepath.Rel(m.Root, f)
if err != nil {
continue
}
out = append(out, "/"+filepath.ToSlash(rel))
}
return out
}
// write puts a value into a file of the kernel's (a backlight). Where the account may not write it
// — the udev rule that gives the video group the panel has not run yet — it escalates with `sudo -n`,
// which never prompts: the operator's account may escalate without one, and when it may not, the
// tool says so in sudo's words.
func (m *Machine) write(ctx context.Context, p, value string) error {
err := os.WriteFile(m.path(p), []byte(value), 0)
if err == nil {
return nil
}
if !errors.Is(err, os.ErrPermission) {
return err
}
if _, serr := m.Run(ctx, "sudo", "-n", "sh", "-c", `printf '%s' "$1" > "$2"`, "sh", value, m.path(p)); serr != nil {
return fmt.Errorf("%s is not writable by this account and sudo -n refused: %v", p, serr)
}
return nil
}
// notInstalled says a command failed because it is not on this machine at all.
func notInstalled(err error) bool { return errors.Is(err, exec.ErrNotFound) }
// round to one decimal, for watts and percentages a person reads.
func round1(f float64) float64 {
return float64(int64(f*10+sign(f)*0.5)) / 10
}
func sign(f float64) float64 {
if f < 0 {
return -1
}
return 1
}
@@ -0,0 +1,25 @@
// The asus-zephyrus-g14 module's Go bundle (novox/hq ADR 0188, ADR 0193, ADR 0198): one process the
// node's runtime launches, serving the module's tools over MCP on stdio and running its long-running
// code — the platform-profile switcher — beside them.
package main
import (
"context"
"fmt"
"os"
stdio "git.novox.be/novox/mesh-sdk/go"
)
func main() {
m := Here()
sw := NewSwitcher(m, func(eventType string, body any) error { return stdio.Emit(eventType, body) })
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
go sw.Run(ctx)
// An empty name serves as the module the runtime names (MESH_SERVED_MODULE).
if err := stdio.Serve("", Tools(m, sw)); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
}
@@ -0,0 +1,121 @@
package main
import (
"encoding/json"
"os"
"os/exec"
"path/filepath"
"sort"
"strings"
"testing"
)
type manifest struct {
Module string `json:"module"`
Tools []string `json:"tools"`
Emits []string `json:"emits"`
Resources []map[string]any `json:"resources"`
// Contributions to other modules' seats (novox/hq ADR 0212): the vendor keys go to node-hotkeys.
Contributions []struct {
Seat string `json:"seat"`
Kind string `json:"kind"`
Content string `json:"content"`
} `json:"contributions"`
}
// triggers is the module's contribution to node-hotkeys: its vendor keys.
func (m manifest) triggers() string {
for _, c := range m.Contributions {
if c.Seat == "node-hotkeys" && c.Kind == "trigger" {
return c.Content
}
}
return ""
}
func readManifest(t *testing.T) manifest {
t.Helper()
raw, err := os.ReadFile("../../module.json")
if err != nil {
t.Fatal(err)
}
var m manifest
if err := json.Unmarshal(raw, &m); err != nil {
t.Fatal(err)
}
return m
}
func TestTheManifestNamesExactlyTheToolsTheBundleServes(t *testing.T) {
m := readManifest(t)
var served []string
for _, tool := range Tools(&Machine{Root: t.TempDir(), Run: newFake(t).run}, nil) {
served = append(served, tool.Name)
}
sort.Strings(served)
listed := append([]string(nil), m.Tools...)
sort.Strings(listed)
if strings.Join(served, ",") != strings.Join(listed, ",") {
t.Fatalf("served %v, listed %v", served, listed)
}
if len(m.Emits) != 1 || m.Emits[0] != "profile.switched" {
t.Fatalf("emits %v", m.Emits)
}
}
// The module names the model, never a node, a person or a user id (novox/hq ADR 0112), and every
// trigger it names a service restart or reload on is one of its own resources.
func TestTheManifestNamesNoMachineAndItsTriggersExist(t *testing.T) {
m := readManifest(t)
ids := map[string]bool{}
for _, r := range m.Resources {
ids[r["id"].(string)] = true
}
raw, _ := os.ReadFile("../../module.json")
for _, banned := range []string{"jochen", "/home/", "/run/user/1000", "\"g14\"", "shanks"} {
if strings.Contains(string(raw), banned) {
t.Errorf("the manifest says %q", banned)
}
}
for _, r := range m.Resources {
for _, key := range []string{"restart-on", "reload-on"} {
list, _ := r[key].([]any)
for _, id := range list {
if !ids[id.(string)] {
t.Errorf("%s %s names %v, which is not a resource", r["id"], key, id)
}
}
}
}
}
// Every trigger runs a script the module ships, and every script parses.
func TestTheVendorKeysRunTheModulesOwnScriptsAndTheyParse(t *testing.T) {
m := readManifest(t)
triggers := m.triggers()
if triggers == "" {
t.Fatal("no trigger contribution to node-hotkeys")
}
for _, line := range strings.Split(triggers, "\n") {
f := strings.Split(line, "\t")
if strings.HasPrefix(line, "#") || len(f) < 3 {
continue
}
script := strings.Fields(f[2])[0]
local := filepath.Join("../../files/bin", filepath.Base(script))
if !strings.HasPrefix(script, "/usr/local/lib/asus-zephyrus-g14/bin/") {
t.Errorf("%s runs %s, which the module does not ship", f[0], script)
} else if st, err := os.Stat(local); err != nil || st.Mode()&0o111 == 0 {
t.Errorf("%s: %s is missing or not executable", f[0], local)
}
}
scripts, _ := filepath.Glob("../../files/bin/*")
if len(scripts) == 0 {
t.Fatal("no scripts")
}
for _, s := range scripts {
if out, err := exec.Command("bash", "-n", s).CombinedOutput(); err != nil {
t.Errorf("%s: %v %s", s, err, out)
}
}
}
@@ -0,0 +1,137 @@
package main
import (
"fmt"
"strconv"
"strings"
"time"
)
// The policy, as constants until the mesh has settings a module can read (novox/hq issue 168). The
// values are the predecessor's, made explicit, and two of its behaviours are changed on purpose:
//
// - **Sustained, not momentary.** The predecessor boosted on one five-second sample above 50 %: a
// compile's first second, a browser's tab restore. Here the load must stay above the line for
// SustainSamples samples in a row, and below the lower line as long, before the profile moves.
// - **Waiting on a disk is not load.** iowait is counted as idle: a machine stalled on its SSD does
// not get faster with a higher power limit, only hotter.
const (
ProfileOnBattery = "Quiet"
ProfileOnAC = "Balanced"
ProfileUnderLoad = "Performance"
CPUHighPercent = 50.0 // on mains, sustained at or above this boosts to ProfileUnderLoad
CPULowPercent = 20.0 // and sustained at or below this goes back to ProfileOnAC
SampleEvery = 10 * time.Second // CPU is sampled only on mains; on battery nothing is sampled
SustainSamples = 3 // 30 s above CPUHighPercent to boost
RelaxSamples = 3 // 30 s below CPULowPercent to relax
// SafetyRecheck is how often the power source is read when no event has said it changed: the
// kernel's event is the trigger, and this only covers one lost across a suspend.
SafetyRecheck = 5 * time.Minute
// DefaultHold is how long a profile chosen through the profile tool is kept before the switcher
// may move it again. A change of power source ends a hold at once.
DefaultHold = 60 * time.Minute
// ChargeLimitPercent is the battery charge limit the module asserts through asusd at start.
ChargeLimitPercent = 80
)
// Policy is the switcher's memory of recent load: how many samples in a row were above the upper line
// or below the lower one, and whether it is boosted.
type Policy struct {
Boosted bool `json:"boosted"`
Above int `json:"samples_above"`
Below int `json:"samples_below"`
Recent []float64 `json:"recent_cpu_percent"`
BoostedSince time.Time `json:"boosted_since,omitempty"`
}
// Observe takes one CPU sample (busy percent since the previous one) taken on mains.
func (p *Policy) Observe(cpu float64, at time.Time) {
p.Recent = append(p.Recent, round1(cpu))
if len(p.Recent) > 6 {
p.Recent = p.Recent[len(p.Recent)-6:]
}
switch {
case cpu >= CPUHighPercent:
p.Above++
p.Below = 0
if !p.Boosted && p.Above >= SustainSamples {
p.Boosted = true
p.BoostedSince = at
}
case cpu <= CPULowPercent:
p.Below++
p.Above = 0
if p.Boosted && p.Below >= RelaxSamples {
p.Boosted = false
p.BoostedSince = time.Time{}
}
default:
// Between the lines: no direction is sustained, and the profile stays where it is.
p.Above, p.Below = 0, 0
}
}
// Reset forgets the load, for a change of power source.
func (p *Policy) Reset() { *p = Policy{} }
// Decision is what the switcher would choose, and why.
type Decision struct {
Profile string `json:"profile"`
Reason string `json:"reason"`
}
// Decide is the policy: battery → ProfileOnBattery; mains → ProfileOnAC, or ProfileUnderLoad while
// boosted.
func (p *Policy) Decide(src Source) Decision {
if !src.OnAC {
return Decision{ProfileOnBattery, "on battery (" + src.Reason + ")"}
}
if p.Boosted {
return Decision{ProfileUnderLoad, fmt.Sprintf("on mains (%s) and CPU load sustained at or above %s%% for %d samples of %s",
src.Reason, strconv.FormatFloat(CPUHighPercent, 'f', -1, 64), SustainSamples, SampleEvery)}
}
return Decision{ProfileOnAC, fmt.Sprintf("on mains (%s), and CPU load not sustained at or above %s%%",
src.Reason, strconv.FormatFloat(CPUHighPercent, 'f', -1, 64))}
}
// CPUTimes is the first line of /proc/stat: total and idle jiffies (iowait counted as idle).
type CPUTimes struct{ Total, Idle uint64 }
// ParseProcStat reads the aggregate cpu line of /proc/stat.
func ParseProcStat(text string) (CPUTimes, error) {
line := strings.SplitN(text, "\n", 2)[0]
f := strings.Fields(line)
if len(f) < 6 || f[0] != "cpu" {
return CPUTimes{}, fmt.Errorf("/proc/stat does not start with the cpu line")
}
var t CPUTimes
for i, s := range f[1:] {
if i >= 8 { // user nice system idle iowait irq softirq steal; guest is already in user
break
}
n, err := strconv.ParseUint(s, 10, 64)
if err != nil {
return CPUTimes{}, fmt.Errorf("/proc/stat: %v", err)
}
t.Total += n
if i == 3 || i == 4 {
t.Idle += n
}
}
return t, nil
}
// Busy is the percentage of time not idle between two readings.
func Busy(before, after CPUTimes) (float64, bool) {
if after.Total <= before.Total {
return 0, false
}
total := float64(after.Total - before.Total)
idle := float64(after.Idle - before.Idle)
return (total - idle) / total * 100, true
}
@@ -0,0 +1,180 @@
package main
import (
"path"
"sort"
"strings"
)
// Supply is one entry of /sys/class/power_supply as the kernel reports it.
type Supply struct {
Name string `json:"name"`
Type string `json:"type"`
Scope string `json:"scope,omitempty"`
Status string `json:"status,omitempty"`
Online *bool `json:"online,omitempty"`
}
// Supplies is every power supply the kernel knows, sorted by name.
func (m *Machine) Supplies() []Supply {
var out []Supply
for _, dir := range m.glob("/sys/class/power_supply/*") {
s := Supply{
Name: path.Base(dir),
Type: m.read(dir + "/type"),
Scope: m.read(dir + "/scope"),
Status: m.read(dir + "/status"),
}
if v, ok := m.readInt(dir + "/online"); ok {
on := v == 1
s.Online = &on
}
out = append(out, s)
}
sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name })
return out
}
// system is a supply that powers this machine. A mouse's or a headset's battery reports scope
// Device, and it says nothing about whether the laptop is on mains.
func (s Supply) system() bool { return !strings.EqualFold(s.Scope, "Device") }
// Source is where the machine draws its power from, and why that was concluded.
type Source struct {
OnAC bool `json:"on_ac"`
Source string `json:"source"`
Reason string `json:"reason"`
}
// PowerSource decides mains or battery.
//
// **A battery that says it is discharging wins over any adapter that says it is online.** The
// predecessor's script took any `online` file reading 1 as mains, and a USB-C port reports `online`
// for things that do not power the machine. The battery's own status is the one fact that cannot be
// misread: it discharges exactly when nothing outside is carrying the load. Only when no battery says
// so are the adapters asked, and a machine with no system battery at all is on mains.
func PowerSource(supplies []Supply) Source {
batteries := 0
for _, s := range supplies {
if s.Type == "Battery" && s.system() {
batteries++
if strings.EqualFold(s.Status, "Discharging") {
return Source{OnAC: false, Source: "battery", Reason: s.Name + " is discharging"}
}
}
}
for _, s := range supplies {
if (s.Type == "Mains" || strings.HasPrefix(s.Type, "USB")) && s.system() && s.Online != nil && *s.Online {
return Source{OnAC: true, Source: "ac", Reason: s.Name + " (" + s.Type + ") is online"}
}
}
if batteries == 0 {
return Source{OnAC: true, Source: "ac", Reason: "this machine has no system battery"}
}
return Source{OnAC: false, Source: "battery", Reason: "no mains or USB supply is online"}
}
// Battery is what the battery tool answers.
type Battery struct {
Name string `json:"name"`
Status string `json:"status"`
ChargePercent *int64 `json:"charge_percent,omitempty"`
EnergyWh *float64 `json:"energy_wh,omitempty"`
FullWh *float64 `json:"full_wh,omitempty"`
DesignWh *float64 `json:"design_wh,omitempty"`
HealthPercent *float64 `json:"health_percent,omitempty"`
Cycles *int64 `json:"cycles"`
CyclesNote string `json:"cycles_note,omitempty"`
LimitPercent *int64 `json:"charge_limit_percent,omitempty"`
PowerW *float64 `json:"power_w,omitempty"`
HoursRemaining *float64 `json:"hours_remaining,omitempty"`
Technology string `json:"technology,omitempty"`
Model string `json:"model,omitempty"`
Manufacturer string `json:"manufacturer,omitempty"`
}
// Batteries reads every system battery.
func (m *Machine) Batteries() []Battery {
var out []Battery
for _, s := range m.Supplies() {
if s.Type != "Battery" || !s.system() {
continue
}
out = append(out, m.battery(s))
}
return out
}
func (m *Machine) battery(s Supply) Battery {
dir := "/sys/class/power_supply/" + s.Name
b := Battery{
Name: s.Name, Status: s.Status,
Technology: m.read(dir + "/technology"),
Model: m.read(dir + "/model_name"),
Manufacturer: strings.TrimSpace(m.read(dir + "/manufacturer")),
}
if v, ok := m.readInt(dir + "/capacity"); ok {
b.ChargePercent = &v
}
// Energy in Wh: energy_* (µWh) where the firmware reports it, else charge_* (µAh) times the
// design minimum voltage, which is how upower converts it too.
wh := func(energy, charge string) *float64 {
if v, ok := m.readInt(dir + "/" + energy); ok {
f := round1(float64(v) / 1e6)
return &f
}
c, okc := m.readInt(dir + "/" + charge)
volts, okv := m.readInt(dir + "/voltage_min_design")
if okc && okv {
f := round1(float64(c) * float64(volts) / 1e12)
return &f
}
return nil
}
b.EnergyWh = wh("energy_now", "charge_now")
b.FullWh = wh("energy_full", "charge_full")
b.DesignWh = wh("energy_full_design", "charge_full_design")
if b.FullWh != nil && b.DesignWh != nil && *b.DesignWh > 0 {
h := round1(*b.FullWh / *b.DesignWh * 100)
b.HealthPercent = &h
}
if v, ok := m.readInt(dir + "/cycle_count"); ok && v > 0 {
b.Cycles = &v
} else {
b.CyclesNote = "the firmware does not report a cycle count (it reads 0)"
}
if v, ok := m.readInt(dir + "/charge_control_end_threshold"); ok {
b.LimitPercent = &v
}
if w := m.batteryWatts(dir); w != nil {
b.PowerW = w
if strings.EqualFold(s.Status, "Discharging") && b.EnergyWh != nil && *w > 0.5 {
h := round1(*b.EnergyWh / *w)
b.HoursRemaining = &h
}
}
return b
}
// batteryWatts is how much the battery is giving or taking, in watts, unsigned: power_now where the
// firmware reports it, else current times voltage.
func (m *Machine) batteryWatts(dir string) *float64 {
if v, ok := m.readInt(dir + "/power_now"); ok {
f := round1(abs(float64(v)) / 1e6)
return &f
}
i, oki := m.readInt(dir + "/current_now")
u, oku := m.readInt(dir + "/voltage_now")
if oki && oku {
f := round1(abs(float64(i)) * float64(u) / 1e12)
return &f
}
return nil
}
func abs(f float64) float64 {
if f < 0 {
return -f
}
return f
}
@@ -0,0 +1,73 @@
package main
import "testing"
func on(b bool) *bool { return &b }
func TestADischargingBatteryWinsOverAnAdapterThatSaysOnline(t *testing.T) {
got := PowerSource([]Supply{
{Name: "BAT1", Type: "Battery", Status: "Discharging"},
{Name: "ucsi-source-psy-USBC000:001", Type: "USB", Scope: "System", Online: on(true)},
})
if got.OnAC {
t.Fatalf("a USB-C port reporting online while the battery discharges was read as mains: %+v", got)
}
}
func TestMainsOnlineIsAC(t *testing.T) {
got := PowerSource([]Supply{
{Name: "ACAD", Type: "Mains", Online: on(true)},
{Name: "BAT1", Type: "Battery", Status: "Not charging"},
})
if !got.OnAC || got.Reason != "ACAD (Mains) is online" {
t.Fatalf("%+v", got)
}
}
func TestAPeripheralsBatteryDecidesNothing(t *testing.T) {
got := PowerSource([]Supply{
{Name: "hidpp_battery_0", Type: "Battery", Scope: "Device", Status: "Discharging"},
{Name: "ACAD", Type: "Mains", Online: on(true)},
{Name: "BAT1", Type: "Battery", Status: "Charging"},
})
if !got.OnAC {
t.Fatalf("a mouse's discharging battery put the laptop on battery: %+v", got)
}
}
func TestNoSupplyOnlineWithABatteryIsBatteryAndNoBatteryIsMains(t *testing.T) {
if got := PowerSource([]Supply{{Name: "ACAD", Type: "Mains", Online: on(false)}, {Name: "BAT1", Type: "Battery", Status: "Unknown"}}); got.OnAC {
t.Fatalf("%+v", got)
}
if got := PowerSource(nil); !got.OnAC {
t.Fatalf("a machine with no battery is on mains: %+v", got)
}
}
func TestTheBatteryIsReadInWattHoursFromChargeAndHealthAgainstDesign(t *testing.T) {
f := newFake(t)
// The laptop's own battery, as measured: charge_* in µAh, no energy_* and no power_now.
f.supply("BAT1", map[string]string{
"type": "Battery", "status": "Discharging", "capacity": "80",
"charge_now": "3073000", "charge_full": "3865000", "charge_full_design": "4580000",
"voltage_min_design": "15939000", "current_now": "1000000", "voltage_now": "16000000",
"cycle_count": "0", "charge_control_end_threshold": "80", "manufacturer": "ASUS ",
})
bs := f.machine().Batteries()
if len(bs) != 1 {
t.Fatalf("%+v", bs)
}
b := bs[0]
if *b.EnergyWh != 49 || *b.FullWh != 61.6 || *b.DesignWh != 73 || *b.HealthPercent != 84.4 {
t.Fatalf("energy %v full %v design %v health %v", *b.EnergyWh, *b.FullWh, *b.DesignWh, *b.HealthPercent)
}
if b.Cycles != nil || b.CyclesNote == "" {
t.Fatal("a cycle count of 0 is the firmware not reporting one, and said so")
}
if *b.LimitPercent != 80 || *b.PowerW != 16 || b.HoursRemaining == nil || *b.HoursRemaining != 3.1 {
t.Fatalf("limit %v power %v hours %v", *b.LimitPercent, *b.PowerW, b.HoursRemaining)
}
if b.Manufacturer != "ASUS" {
t.Fatalf("manufacturer %q", b.Manufacturer)
}
}
@@ -0,0 +1,303 @@
package main
import (
"context"
"fmt"
"os"
"strconv"
"strings"
"sync"
"time"
)
// The module's long-running code (novox/hq ADR 0198): the profile switcher, launched with the tools
// by the node's runtime and running beside them in the same process.
//
// It replaces the predecessor's `auto-profile`, a user unit that woke every five seconds for ever —
// read the adapters, read /proc/stat, maybe call asusctl — on battery too, where its only possible
// answer was the one it had already given. Here the kernel's power-supply event is the trigger; the
// CPU is sampled only on mains, where the answer depends on it; and on battery the process sleeps
// until the adapter comes back.
//
// **It acts on a change of its decision, never to restore one.** A profile chosen by hand — the
// vendor's profile key, asusctl in a terminal, the profile tool — stays until the power source
// changes or the load crosses a line. The predecessor re-asserted its choice every five seconds and so
// made the profile key useless on battery.
// Emitter publishes an event as the module; nil when the process is not under the runtime.
type Emitter func(eventType string, body any) error
// Switcher is the switcher's state, shared with the tools that report it.
type Switcher struct {
m *Machine
now func() time.Time
emit Emitter
mu sync.Mutex
policy Policy
source *Source
decision *Decision
applied string
appliedAt time.Time
lastError string
holdUntil time.Time
holdOf string
watching string
cpuPrev *CPUTimes
disabled string
asserted []string
}
func NewSwitcher(m *Machine, emit Emitter) *Switcher {
return &Switcher{m: m, now: time.Now, emit: emit}
}
// Model is the machine's product family as its firmware reports it.
func (m *Machine) Model() string { return m.read("/sys/class/dmi/id/product_family") }
// ModelFamily is the family this module is written for.
const ModelFamily = "ROG Zephyrus G14"
// ThisModel says whether the machine is the model this module is written for.
func (m *Machine) ThisModel() bool { return strings.EqualFold(m.Model(), ModelFamily) }
// sampleCPU reads /proc/stat and answers the busy percentage since the previous reading.
func (s *Switcher) sampleCPU() (float64, bool) {
t, err := ParseProcStat(s.m.read("/proc/stat"))
if err != nil {
return 0, false
}
prev := s.cpuPrev
s.cpuPrev = &t
if prev == nil {
return 0, false
}
return Busy(*prev, t)
}
// Evaluate reads the power source, takes a CPU sample when asked and on mains, decides, and applies
// the decision when it changed. It is the whole of one wake-up and what the tests drive.
func (s *Switcher) Evaluate(ctx context.Context, sample bool) {
if body := s.evaluate(ctx, sample); body != nil && s.emit != nil {
// Outside the lock: publishing waits for the bus, and the tools that report the switcher
// must not wait with it.
if err := s.emit("profile.switched", body); err != nil {
fmt.Fprintf(os.Stderr, "profile.switched not published: %v\n", err)
}
}
}
// evaluate is Evaluate under the lock; it answers the event to publish when it switched.
func (s *Switcher) evaluate(ctx context.Context, sample bool) map[string]any {
s.mu.Lock()
defer s.mu.Unlock()
if s.disabled != "" {
return nil
}
src := PowerSource(s.m.Supplies())
now := s.now()
first := s.source == nil
if first || s.source.OnAC != src.OnAC {
// A new power source: what was learnt about load on the other one says nothing here, and a
// hold was for the source it was asked on.
s.policy.Reset()
s.cpuPrev = nil
s.holdUntil = time.Time{}
s.holdOf = ""
s.sampleCPU() // the first reading on this source, so the next sample is a difference
} else if sample && src.OnAC {
if busy, ok := s.sampleCPU(); ok {
s.policy.Observe(busy, now)
}
}
s.source = &src
d := s.policy.Decide(src)
s.decision = &d
// **Starting is not a reason to switch.** The runtime starts this process on every push that
// changes a bundle; at boot and at every change of power source asusd has already applied its own
// profile for the source, which AssertVendorSettings made the policy's. So the first decision is
// taken as applied, and a profile someone chose by hand survives a push.
if first {
s.applied = d.Profile
return nil
}
// Compared with what the switcher itself last applied, never with the profile in force: a profile
// someone chose by hand is not a reason to act, a new decision is.
if d.Profile == s.applied || now.Before(s.holdUntil) {
return nil
}
from := s.applied
if err := s.m.SetProfile(ctx, d.Profile); err != nil {
s.lastError = err.Error() // and tried again at the next wake-up, since applied did not move
return nil
}
s.lastError = ""
s.applied, s.appliedAt = d.Profile, now
body := map[string]any{"profile": d.Profile, "reason": d.Reason, "source": src.Source}
if from != "" {
body["from"] = from
}
return body
}
// Hold keeps a profile chosen through the tool for a while: the switcher does not move it until the
// hold ends or the power source changes.
func (s *Switcher) Hold(profile string, d time.Duration) time.Time {
s.mu.Lock()
defer s.mu.Unlock()
if d <= 0 {
s.holdUntil, s.holdOf = time.Time{}, ""
return time.Time{}
}
s.holdUntil, s.holdOf = s.now().Add(d), profile
return s.holdUntil
}
// Run is the switcher's life: assert asusd's settings once, then wake on each power-supply event, on
// each CPU sample while on mains, and at SafetyRecheck otherwise.
func (s *Switcher) Run(ctx context.Context) {
defer func() {
if r := recover(); r != nil {
s.mu.Lock()
s.disabled = fmt.Sprintf("the switcher stopped on a fault: %v", r)
s.mu.Unlock()
fmt.Fprintln(os.Stderr, s.disabled)
}
}()
if !s.m.ThisModel() {
s.mu.Lock()
s.disabled = fmt.Sprintf("this machine reports %q, not %q: the switcher does not act on another model",
s.m.Model(), ModelFamily)
s.mu.Unlock()
fmt.Fprintln(os.Stderr, s.disabled)
return
}
s.AssertVendorSettings(ctx)
events, err := listenPowerSupply(ctx)
s.mu.Lock()
if err != nil {
s.watching = "polling every " + SampleEvery.String() + ": " + err.Error()
} else {
s.watching = "the kernel's power-supply events"
}
s.mu.Unlock()
s.Evaluate(ctx, false)
timer := time.NewTimer(s.interval(err != nil))
defer timer.Stop()
for {
select {
case <-ctx.Done():
return
case _, open := <-events:
if !open {
events = nil
s.mu.Lock()
s.watching = "polling every " + SampleEvery.String() + ": the uevent socket closed"
s.mu.Unlock()
err = fmt.Errorf("closed")
continue
}
// Settle: an adapter change arrives as several events within a moment.
time.Sleep(time.Second)
s.Evaluate(ctx, false)
case <-timer.C:
s.Evaluate(ctx, true)
timer.Reset(s.interval(err != nil))
}
}
}
// interval is how long to sleep: a CPU sample's period on mains (or with no events to wake on), the
// safety recheck on battery.
func (s *Switcher) interval(polling bool) time.Duration {
s.mu.Lock()
defer s.mu.Unlock()
if polling || s.source == nil || s.source.OnAC {
return SampleEvery
}
return SafetyRecheck
}
// AssertVendorSettings puts asusd's own settings where the module wants them, once at start: the
// battery charge limit, and the profiles asusd itself switches to on mains and on battery, so that the
// vendor daemon's own switching and this module's never disagree. Each is read first and set only if
// it differs. A value changed later with a tool stands until the next start.
func (s *Switcher) AssertVendorSettings(ctx context.Context) []string {
var said []string
if limit, err := s.m.ChargeLimit(ctx); err != nil {
said = append(said, "charge limit not read: "+err.Error())
} else if limit != ChargeLimitPercent {
if _, err := s.m.Run(ctx, "asusctl", "battery", "limit", strconv.Itoa(ChargeLimitPercent)); err != nil {
said = append(said, "charge limit not set: "+vendor("asusctl", err).Error())
} else {
said = append(said, fmt.Sprintf("charge limit %d%% → %d%%", limit, ChargeLimitPercent))
}
} else {
said = append(said, fmt.Sprintf("charge limit already %d%%", ChargeLimitPercent))
}
p, err := s.m.Profile(ctx)
if err != nil {
said = append(said, "asusd's profiles not read: "+err.Error())
} else {
for _, want := range []struct{ flag, have, want, what string }{
{"-a", p.OnAC, ProfileOnAC, "on mains"},
{"-b", p.Battery, ProfileOnBattery, "on battery"},
} {
if want.have == "" || strings.EqualFold(want.have, want.want) {
continue
}
if _, err := s.m.Run(ctx, "asusctl", "profile", "set", want.flag, want.want); err != nil {
said = append(said, "asusd's profile "+want.what+" not set: "+err.Error())
} else {
said = append(said, fmt.Sprintf("asusd's profile %s %s → %s", want.what, want.have, want.want))
}
}
}
s.mu.Lock()
s.asserted = said
s.mu.Unlock()
for _, line := range said {
fmt.Fprintln(os.Stderr, line)
}
return said
}
// SwitcherReport is the switcher's state as the profile-policy tool shows it.
type SwitcherReport struct {
Running bool `json:"running"`
Disabled string `json:"disabled,omitempty"`
Watching string `json:"woken_by,omitempty"`
Source *Source `json:"source,omitempty"`
Decision *Decision `json:"decision,omitempty"`
Load Policy `json:"load"`
LastApplied string `json:"last_applied,omitempty"`
LastAppliedAt *time.Time `json:"last_applied_at,omitempty"`
LastError string `json:"last_error,omitempty"`
HeldUntil *time.Time `json:"held_until,omitempty"`
Held string `json:"held_profile,omitempty"`
AssertedAtStart []string `json:"asserted_at_start,omitempty"`
}
func (s *Switcher) Report() SwitcherReport {
s.mu.Lock()
defer s.mu.Unlock()
r := SwitcherReport{
Running: s.watching != "" && s.disabled == "", Disabled: s.disabled, Watching: s.watching,
Source: s.source, Decision: s.decision, Load: s.policy, LastApplied: s.applied,
LastAppliedAt: when(s.appliedAt), LastError: s.lastError, AssertedAtStart: s.asserted,
}
if s.now().Before(s.holdUntil) {
r.HeldUntil, r.Held = when(s.holdUntil), s.holdOf
}
return r
}
// when is a time for a report: absent rather than the zero time.
func when(t time.Time) *time.Time {
if t.IsZero() {
return nil
}
return &t
}
@@ -0,0 +1,190 @@
package main
import (
"context"
"errors"
"strconv"
"testing"
"time"
)
func TestTheLoadMustBeSustainedToBoostAndToRelaxAndBetweenTheLinesNothingMoves(t *testing.T) {
var p Policy
at := time.Now()
mains := Source{OnAC: true, Reason: "ACAD (Mains) is online"}
for i := 0; i < SustainSamples-1; i++ {
p.Observe(90, at)
}
if p.Decide(mains).Profile != ProfileOnAC {
t.Fatal("boosted before the load was sustained")
}
p.Observe(35, at) // between the lines breaks the run
p.Observe(90, at)
if p.Boosted {
t.Fatal("a broken run still counted")
}
for i := 0; i < SustainSamples; i++ {
p.Observe(CPUHighPercent, at)
}
if d := p.Decide(mains); d.Profile != ProfileUnderLoad {
t.Fatalf("%+v", d)
}
p.Observe(35, at)
if !p.Boosted {
t.Fatal("load between the lines relaxed the boost")
}
for i := 0; i < RelaxSamples; i++ {
p.Observe(5, at)
}
if p.Decide(mains).Profile != ProfileOnAC {
t.Fatal("did not relax after a sustained low")
}
p.Boosted = true
if d := p.Decide(Source{OnAC: false, Reason: "BAT1 is discharging"}); d.Profile != ProfileOnBattery {
t.Fatalf("battery: %+v", d)
}
}
func TestIOWaitIsIdle(t *testing.T) {
a, err := ParseProcStat("cpu 100 0 100 700 100 0 0 0 0 0\ncpu0 1 2 3\n")
if err != nil {
t.Fatal(err)
}
b, _ := ParseProcStat("cpu 150 0 150 700 200 0 0 0 0 0\n")
busy, ok := Busy(a, b)
if !ok || busy != 50 {
t.Fatalf("%v %v", busy, ok)
}
if _, ok := Busy(b, b); ok {
t.Fatal("no time passed and a load was answered")
}
if _, err := ParseProcStat("intr 1 2"); err == nil {
t.Fatal("a file without the cpu line was read")
}
}
func switcherOn(t *testing.T) (*fake, *Switcher, *[]map[string]any) {
f := newFake(t)
f.onMains()
f.file("/proc/stat", "cpu 0 0 0 0 0 0 0 0\n")
var emitted []map[string]any
sw := NewSwitcher(f.machine(), func(_ string, body any) error {
emitted = append(emitted, body.(map[string]any))
return nil
})
return f, sw, &emitted
}
func TestStartingIsNotAReasonToSwitch(t *testing.T) {
f, sw, emitted := switcherOn(t)
sw.Evaluate(context.Background(), false)
if calls := f.callsLike("asusctl profile set"); len(calls) != 0 || len(*emitted) != 0 {
t.Fatalf("the first decision acted: %v %v", calls, *emitted)
}
}
func TestAChangeOfPowerSourceSwitchesOnceAndPublishesIt(t *testing.T) {
f, sw, emitted := switcherOn(t)
ctx := context.Background()
sw.Evaluate(ctx, false)
f.onBattery()
sw.Evaluate(ctx, false)
sw.Evaluate(ctx, true) // nothing changed: nothing done, and on battery nothing sampled
if calls := f.callsLike("asusctl profile set"); len(calls) != 1 || calls[0] != "asusctl profile set Quiet" {
t.Fatalf("%v", calls)
}
if len(*emitted) != 1 || (*emitted)[0]["profile"] != "Quiet" || (*emitted)[0]["from"] != "Balanced" {
t.Fatalf("%v", *emitted)
}
f.onMains()
sw.Evaluate(ctx, false)
if calls := f.callsLike("asusctl profile set"); len(calls) != 2 || calls[1] != "asusctl profile set Balanced" {
t.Fatalf("%v", calls)
}
}
func TestSustainedLoadOnMainsBoostsFromSamples(t *testing.T) {
f, sw, _ := switcherOn(t)
ctx := context.Background()
sw.Evaluate(ctx, false)
var user int
for i := 1; i <= SustainSamples; i++ {
user += 90
f.file("/proc/stat", "cpu "+itoa(user)+" 0 0 "+itoa(i*10)+" 0 0 0 0\n")
sw.Evaluate(ctx, true)
}
if !f.called("asusctl profile set Performance") {
t.Fatalf("%v", f.calls)
}
}
func TestAHoldKeepsTheProfileUntilItEndsAndAFailureIsTriedAgain(t *testing.T) {
f, sw, _ := switcherOn(t)
ctx := context.Background()
now := time.Now()
sw.now = func() time.Time { return now }
sw.Evaluate(ctx, false)
f.onBattery()
sw.Evaluate(ctx, false) // source change ends any hold; switches to Quiet
sw.Hold("Performance", time.Hour)
f.fails["asusctl profile set Balanced"] = errors.New("asusd is restarting")
f.onMains()
sw.Evaluate(ctx, false) // a change of source: the hold ends, the switch is attempted and fails
if r := sw.Report(); r.LastError == "" || r.Held != "" {
t.Fatalf("%+v", r)
}
delete(f.fails, "asusctl profile set Balanced")
sw.Evaluate(ctx, false)
if r := sw.Report(); r.LastApplied != "Balanced" || r.LastError != "" {
t.Fatalf("not tried again: %+v", r)
}
// A hold on the same source keeps a new decision from acting until it ends.
sw.Hold("Quiet", time.Hour)
sw.policy.Boosted = true
sw.Evaluate(ctx, false)
if f.called("asusctl profile set Performance") {
t.Fatal("switched during a hold")
}
now = now.Add(2 * time.Hour)
sw.Evaluate(ctx, false)
if !f.called("asusctl profile set Performance") {
t.Fatal("did not act once the hold ended")
}
}
func TestAsusdsSettingsAreSetOnlyWhereTheyDiffer(t *testing.T) {
f := newFake(t)
f.answers["asusctl battery info"] = "Current battery charge limit: 100%\n"
f.answers["asusctl profile get"] = "Active profile: Balanced\nAC profile Performance\nBattery profile Quiet\n"
sw := NewSwitcher(f.machine(), nil)
sw.AssertVendorSettings(context.Background())
if !f.called("asusctl battery limit 80") || !f.called("asusctl profile set -a Balanced") || f.called("asusctl profile set -b Quiet") {
t.Fatalf("%v", f.calls)
}
}
func TestTheSwitcherDoesNotActOnAnotherModel(t *testing.T) {
f := newFake(t)
f.file("/sys/class/dmi/id/product_family", "ROG Strix\n")
sw := NewSwitcher(f.machine(), nil)
done := make(chan struct{})
go func() { sw.Run(context.Background()); close(done) }()
select {
case <-done:
case <-time.After(2 * time.Second):
t.Fatal("the switcher ran on another model")
}
if r := sw.Report(); r.Running || r.Disabled == "" || len(f.calls) != 0 {
t.Fatalf("%+v %v", r, f.calls)
}
}
func TestOnlyPowerSupplyUeventsWake(t *testing.T) {
yes := []byte("change@/devices/LNXSYSTM:00/ACPI0003:00/power_supply/ACAD\x00ACTION=change\x00SUBSYSTEM=power_supply\x00POWER_SUPPLY_ONLINE=0\x00")
no := []byte("change@/devices/virtual/net/wlan0\x00ACTION=change\x00SUBSYSTEM=net\x00")
if !powerSupplyEvent(yes) || powerSupplyEvent(no) {
t.Fatal("the uevent filter")
}
}
func itoa(n int) string { return strconv.Itoa(n) }
@@ -0,0 +1,168 @@
package main
import (
"context"
"path"
"sort"
"strconv"
"strings"
)
// Sensor is one temperature, fan or power reading from hwmon.
type Sensor struct {
Chip string `json:"chip"`
Label string `json:"label"`
Value float64 `json:"value"`
}
// DGPU is the discrete GPU as the PCI bus and its driver see it.
type DGPU struct {
Address string `json:"pci_address"`
Runtime string `json:"runtime_status"`
Name string `json:"name,omitempty"`
TempC *float64 `json:"temp_c,omitempty"`
PowerW *float64 `json:"power_w,omitempty"`
PState string `json:"pstate,omitempty"`
Note string `json:"note,omitempty"`
}
// hwmon reads every hwmon reading of one kind: "temp" (°C), "fan" (RPM) or "power" (W).
func (m *Machine) hwmon(kind string) []Sensor {
var out []Sensor
for _, dir := range m.glob("/sys/class/hwmon/hwmon*") {
chip := m.read(dir + "/name")
inputs := m.glob(dir + "/" + kind + "*_input")
if kind == "power" {
inputs = append(inputs, m.glob(dir+"/power*_average")...)
}
for _, in := range inputs {
v, ok := m.readInt(in)
if !ok {
continue
}
base := path.Base(in)
stem := base[:strings.LastIndex(base, "_")]
label := m.read(dir + "/" + stem + "_label")
if label == "" {
label = base
} else if strings.HasSuffix(base, "_average") {
label += " (average)"
}
value := float64(v)
switch kind {
case "temp":
value = round1(value / 1000)
case "power":
value = round1(value / 1e6)
}
out = append(out, Sensor{Chip: chip, Label: label, Value: value})
}
}
sort.Slice(out, func(i, j int) bool {
if out[i].Chip != out[j].Chip {
return out[i].Chip < out[j].Chip
}
return out[i].Label < out[j].Label
})
return out
}
// dgpu finds the NVIDIA display controller and, only when it is already awake, asks its driver for
// its temperature and draw. **Asking wakes it**: nvidia-smi brings a suspended GPU out of D3, which
// is the power a reading of power draw should not cost.
func (m *Machine) dgpu(ctx context.Context) *DGPU {
for _, dir := range m.glob("/sys/bus/pci/devices/*") {
if m.read(dir+"/vendor") != "0x10de" || !strings.HasPrefix(m.read(dir+"/class"), "0x03") {
continue
}
g := &DGPU{Address: path.Base(dir), Runtime: m.read(dir + "/power/runtime_status")}
if g.Runtime != "active" {
g.Note = "the discrete GPU is " + g.Runtime + "; not woken to be read"
return g
}
out, err := m.Run(ctx, "nvidia-smi", "--query-gpu=name,temperature.gpu,power.draw,pstate", "--format=csv,noheader,nounits")
if err != nil {
g.Note = "nvidia-smi: " + err.Error()
return g
}
f := strings.Split(strings.TrimSpace(strings.SplitN(out, "\n", 2)[0]), ",")
if len(f) >= 4 {
g.Name = strings.TrimSpace(f[0])
if t, err := strconv.ParseFloat(strings.TrimSpace(f[1]), 64); err == nil {
g.TempC = &t
}
if w, err := strconv.ParseFloat(strings.TrimSpace(f[2]), 64); err == nil {
w = round1(w)
g.PowerW = &w
}
g.PState = strings.TrimSpace(f[3])
}
return g
}
return nil
}
// Thermals is what the thermals tool answers.
type Thermals struct {
Temperatures []Sensor `json:"temperatures_c"`
Fans []Sensor `json:"fans_rpm"`
DGPU *DGPU `json:"dgpu,omitempty"`
Profile string `json:"platform_profile,omitempty"`
Hottest *Sensor `json:"hottest,omitempty"`
}
func (m *Machine) Thermals(ctx context.Context) Thermals {
t := Thermals{Temperatures: m.hwmon("temp"), Fans: m.hwmon("fan"), DGPU: m.dgpu(ctx),
Profile: m.read("/sys/firmware/acpi/platform_profile")}
if t.Temperatures == nil {
t.Temperatures = []Sensor{}
}
if t.Fans == nil {
t.Fans = []Sensor{}
}
for i := range t.Temperatures {
if t.Hottest == nil || t.Temperatures[i].Value > t.Hottest.Value {
h := t.Temperatures[i]
t.Hottest = &h
}
}
return t
}
// PowerDraw is what the power-draw tool answers.
type PowerDraw struct {
Source Source `json:"source"`
BatteryW *float64 `json:"battery_w,omitempty"`
BatteryFlow string `json:"battery_flow,omitempty"`
CPUPackageW *float64 `json:"apu_package_w,omitempty"`
DGPU *DGPU `json:"dgpu,omitempty"`
Note string `json:"note"`
}
func (m *Machine) PowerDraw(ctx context.Context) PowerDraw {
p := PowerDraw{Source: PowerSource(m.Supplies()), DGPU: m.dgpu(ctx),
Note: "on battery, battery_w is what the whole machine draws; on mains it is only what the battery takes or gives"}
for _, b := range m.Batteries() {
if b.PowerW != nil {
w := *b.PowerW
p.BatteryW = &w
switch strings.ToLower(b.Status) {
case "discharging":
p.BatteryFlow = "discharging"
case "charging":
p.BatteryFlow = "charging"
default:
p.BatteryFlow = strings.ToLower(b.Status)
}
break
}
}
// The integrated GPU's hwmon reports the whole APU's package power (PPT) on this model.
for _, s := range m.hwmon("power") {
if s.Chip == "amdgpu" && s.Label == "PPT" {
w := s.Value
p.CPUPackageW = &w
}
}
return p
}
@@ -0,0 +1,315 @@
package main
import (
"context"
"fmt"
"math"
"strconv"
"strings"
"time"
stdio "git.novox.be/novox/mesh-sdk/go"
)
// Tools is the module's tools, over one machine and its switcher.
func Tools(m *Machine, sw *Switcher) []stdio.Tool {
ctx := context.Background
return []stdio.Tool{
{
Name: "zephyrus_brightness",
Description: "Read or set the internal panel's and the keyboard's backlight. With no argument, reads both. " +
"panel is a percentage (40) or a step (+5, -10), never below 1 %; keyboard is off, low, med, high, 0-3, + or -.",
Input: map[string]any{
"panel": map[string]any{"type": "string", "description": "percentage or step, e.g. 40, +5, -10"},
"keyboard": map[string]any{"type": "string", "description": "off, low, med, high, 0-3, + or -"},
},
Run: func(args map[string]any) (any, error) {
out := map[string]any{}
if p := str(args, "panel"); p != "" {
got, err := m.SetPanel(ctx(), p)
if err != nil {
return nil, err
}
out["panel"] = got
} else if got, err := m.PanelBrightness(); err == nil {
out["panel"] = got
} else {
out["panel_error"] = err.Error()
}
if k := str(args, "keyboard"); k != "" {
got, err := m.SetKeyboard(ctx(), k)
if err != nil {
return nil, err
}
out["keyboard"] = got
} else if got, err := m.KeyboardBrightness(); err == nil {
out["keyboard"] = got
} else {
out["keyboard_error"] = err.Error()
}
return out, nil
},
},
{
Name: "zephyrus_battery",
Description: "The battery: charge, energy, health (full against design), cycles, the charge limit, the power it gives or takes, and time left when discharging.",
Run: func(map[string]any) (any, error) {
return map[string]any{"source": PowerSource(m.Supplies()), "batteries": orEmpty(m.Batteries())}, nil
},
},
{
Name: "zephyrus_charge_limit",
Description: fmt.Sprintf("Read or set the battery charge limit through asusd. limit is 20-100; oneshot charges to full once "+
"and goes back to the limit. The module asserts %d %% again when its process next starts.", ChargeLimitPercent),
Input: map[string]any{
"limit": map[string]any{"type": "integer", "description": "20-100"},
"oneshot": map[string]any{"type": "boolean", "description": "charge to full once, keeping the limit"},
},
Run: func(args map[string]any) (any, error) { return ChargeLimitTool(ctx(), m, args) },
},
{
Name: "zephyrus_gpu_mode",
Description: "Read or set the hybrid GPU's mode through supergfxd: Integrated, Hybrid or AsusMuxDgpu as the machine supports. " +
"Answers the mode, the discrete GPU's power state, any pending mode and the action it waits for (a logout, a reboot), " +
"and whether asusd will switch it again on the next change of power source.",
Input: map[string]any{
"mode": map[string]any{"type": "string", "description": "a supported mode, e.g. Integrated or Hybrid"},
},
Run: func(args map[string]any) (any, error) { return GPUModeTool(ctx(), m, args) },
},
{
Name: "zephyrus_profile",
Description: "Read or set the platform profile (Quiet, Balanced, Performance) through asusd. A profile set here is held " +
fmt.Sprintf("for hold_minutes (default %d, 0 for none) before the module's switcher may move it; a change of power source ends the hold.", int(DefaultHold.Minutes())),
Input: map[string]any{
"profile": map[string]any{"type": "string", "enum": Profiles},
"hold_minutes": map[string]any{"type": "integer", "description": "how long the switcher leaves it (default 60, at most 1440)"},
},
Run: func(args map[string]any) (any, error) { return ProfileTool(ctx(), m, sw, args) },
},
{
Name: "zephyrus_thermals",
Description: "Every temperature and fan the hardware reports (°C, RPM), the hottest, the platform profile, and the discrete GPU's temperature when it is awake (it is not woken to be read).",
Run: func(map[string]any) (any, error) { return m.Thermals(ctx()), nil },
},
{
Name: "zephyrus_power_draw",
Description: "What the machine draws: the battery's flow in watts, the APU's package power, the discrete GPU's draw when awake, and the power source with the reason it was decided.",
Run: func(map[string]any) (any, error) { return m.PowerDraw(ctx()), nil },
},
{
Name: "zephyrus_profile_policy",
Description: "What the module's profile switcher would choose now and why: the power source, recent CPU load against the thresholds, " +
"the decision, the profile in force, any hold, what woke it, and what it asserted in asusd at start.",
Run: func(map[string]any) (any, error) { return PolicyTool(ctx(), m, sw), nil },
},
{
Name: "zephyrus_fan_curves",
Description: "The fan curves asusd holds for each profile (or one profile): per fan, eight points of temperature and duty.",
Input: map[string]any{
"profile": map[string]any{"type": "string", "enum": Profiles},
},
Run: func(args map[string]any) (any, error) { return FanCurvesTool(ctx(), m, args) },
},
{
Name: "zephyrus_keys",
Description: "Every custom key on the laptop: each triggerhappy trigger (the vendor keys that reach no X client), the module's own i3 lines " +
"(keys the firmware sends as ordinary presses, and what starts with the session), and the keys the firmware handles itself — " +
"what each runs and the file it is defined in. Warns when one key is bound in two trigger files, which fires it twice.",
Run: func(map[string]any) (any, error) { return m.Keys(), nil },
},
{
Name: "zephyrus_check",
Description: "Check what this module expects of the machine: the model, the vendor packages and daemons, the NVIDIA options in force, " +
"suspend and resume, the charge limit, one authority each over the profile and the GPU mode, and the predecessor's leftovers. Says what it did not check.",
Run: func(map[string]any) (any, error) { return m.Check(ctx(), sw), nil },
},
}
}
// ChargeLimitTool reads or sets the limit.
func ChargeLimitTool(ctx context.Context, m *Machine, args map[string]any) (any, error) {
out := map[string]any{"module_limit_percent": ChargeLimitPercent}
if v, given := args["limit"]; given && v != nil {
n, err := whole(v, "limit")
if err != nil {
return nil, err
}
if n < 20 || n > 100 {
return nil, fmt.Errorf("limit %d is outside 20-100", n)
}
if _, err := m.Run(ctx, "asusctl", "battery", "limit", strconv.Itoa(n)); err != nil {
return nil, vendor("asusctl", err)
}
out["set"] = n
}
if b, _ := args["oneshot"].(bool); b {
if _, err := m.Run(ctx, "asusctl", "battery", "oneshot"); err != nil {
return nil, vendor("asusctl", err)
}
out["oneshot"] = "charging to full once; the limit returns after"
}
if n, err := m.ChargeLimit(ctx); err == nil {
out["asusd_limit_percent"] = n
} else {
out["asusd_error"] = err.Error()
}
for _, b := range m.Batteries() {
if b.LimitPercent != nil {
out["kernel_limit_percent"] = *b.LimitPercent
}
}
return out, nil
}
// GPUModeTool reads or sets the GPU mode.
func GPUModeTool(ctx context.Context, m *Machine, args map[string]any) (any, error) {
g, err := m.GPU(ctx)
if err != nil {
return nil, err
}
out := map[string]any{}
if want := str(args, "mode"); want != "" {
mode := ""
for _, s := range g.Supported {
if strings.EqualFold(s, want) {
mode = s
}
}
if mode == "" {
return nil, fmt.Errorf("mode %q is not one this machine supports (%s)", want, strings.Join(g.Supported, ", "))
}
said, err := m.Run(ctx, "supergfxctl", "-m", mode)
if err != nil {
return nil, vendor("supergfxctl", err)
}
out["requested"] = mode
if s := strings.TrimSpace(said); s != "" {
out["supergfxctl_said"] = s
}
if g, err = m.GPU(ctx); err != nil {
return nil, err
}
}
out["gpu"] = g
if c := m.Asusd(); c != nil && (c.ACCommand != "" || c.BatteryCommand != "") {
out["asusd_switches_it"] = map[string]string{"on_ac": c.ACCommand, "on_battery": c.BatteryCommand,
"note": "asusd runs these on every change of power source, so a mode set here lasts until the next one"}
}
return out, nil
}
// ProfileTool reads or sets the profile.
func ProfileTool(ctx context.Context, m *Machine, sw *Switcher, args map[string]any) (any, error) {
out := map[string]any{}
if want := str(args, "profile"); want != "" {
p, err := canonicalProfile(want)
if err != nil {
return nil, err
}
hold := DefaultHold
if v, given := args["hold_minutes"]; given && v != nil {
n, err := whole(v, "hold_minutes")
if err != nil {
return nil, err
}
if n < 0 {
return nil, fmt.Errorf("hold_minutes must not be negative")
}
hold = time.Duration(min(n, 1440)) * time.Minute
}
if err := m.SetProfile(ctx, p); err != nil {
return nil, err
}
out["set"] = p
if sw != nil {
if until := sw.Hold(p, hold); !until.IsZero() {
out["held_until"] = until
}
}
}
state, err := m.Profile(ctx)
if err != nil {
return nil, err
}
out["profile"] = state
return out, nil
}
// PolicyTool reports the switcher and, independently of it, what the policy says now.
func PolicyTool(ctx context.Context, m *Machine, sw *Switcher) any {
out := map[string]any{
"thresholds": map[string]any{
"on_battery": ProfileOnBattery, "on_ac": ProfileOnAC, "under_load": ProfileUnderLoad,
"cpu_high_percent": CPUHighPercent, "cpu_low_percent": CPULowPercent,
"sample_every": SampleEvery.String(), "sustain_samples": SustainSamples, "relax_samples": RelaxSamples,
"set_by": "constants until settings exist (novox/hq issue 168)",
},
}
if sw != nil {
out["switcher"] = sw.Report()
} else {
var p Policy
out["decision_now"] = p.Decide(PowerSource(m.Supplies()))
}
if state, err := m.Profile(ctx); err == nil {
out["in_force"] = state
} else {
out["in_force_error"] = err.Error()
}
if pid, ok := m.predecessorProcess("auto-profile"); ok {
out["second_switcher"] = fmt.Sprintf("the predecessor's auto-profile still runs (pid %d) and overrides this every five seconds", pid)
}
return out
}
// FanCurvesTool reads asusd's fan curves.
func FanCurvesTool(ctx context.Context, m *Machine, args map[string]any) (any, error) {
profiles := Profiles
if want := str(args, "profile"); want != "" {
p, err := canonicalProfile(want)
if err != nil {
return nil, err
}
profiles = []string{p}
}
out := map[string]any{}
for _, p := range profiles {
said, err := m.Run(ctx, "asusctl", "fan-curve", "--mod-profile", strings.ToLower(p))
if err != nil {
return nil, vendor("asusctl", err)
}
out[p] = orEmpty(ParseFanCurves(said))
}
return out, nil
}
func str(args map[string]any, key string) string {
s, _ := args[key].(string)
return strings.TrimSpace(s)
}
// whole is an integer argument given as a JSON number or a numeric string.
func whole(v any, key string) (int, error) {
switch n := v.(type) {
case float64:
if n != math.Trunc(n) {
return 0, fmt.Errorf("%s must be a whole number, not %v", key, n)
}
return int(n), nil
case string:
i, err := strconv.Atoi(strings.TrimSpace(n))
if err != nil {
return 0, fmt.Errorf("%s must be a whole number, not %q", key, n)
}
return i, nil
}
return 0, fmt.Errorf("%s must be a whole number", key)
}
func orEmpty[T any](s []T) []T {
if s == nil {
return []T{}
}
return s
}
@@ -0,0 +1,72 @@
package main
import (
"bytes"
"context"
"fmt"
"syscall"
)
// The kernel announces every change of a power supply — an adapter plugged or pulled, a battery
// starting or stopping to discharge — as a uevent on a netlink socket that any account may listen
// on. That is the event the switcher reacts to: no daemon, no bus client, no polling.
//
// upower re-announces the same changes on the system bus, and listening there would need a D-Bus
// client in the bundle; udev's re-broadcast (netlink group 2) carries a libudev header. The kernel's
// own group (1) is the source both of them read.
// powerSupplyEvent says whether a uevent is about a power supply.
func powerSupplyEvent(msg []byte) bool {
for _, field := range bytes.Split(msg, []byte{0}) {
if bytes.Equal(field, []byte("SUBSYSTEM=power_supply")) {
return true
}
}
return false
}
// listenPowerSupply opens the kernel's uevent socket and sends on the channel for each power-supply
// event, never blocking: a burst of events is one wake-up. It stops when ctx ends.
func listenPowerSupply(ctx context.Context) (<-chan struct{}, error) {
fd, err := syscall.Socket(syscall.AF_NETLINK, syscall.SOCK_RAW|syscall.SOCK_CLOEXEC, syscall.NETLINK_KOBJECT_UEVENT)
if err != nil {
return nil, fmt.Errorf("opening the kernel's uevent socket: %w", err)
}
if err := syscall.Bind(fd, &syscall.SockaddrNetlink{Family: syscall.AF_NETLINK, Groups: 1}); err != nil {
syscall.Close(fd)
return nil, fmt.Errorf("joining the kernel's uevent group: %w", err)
}
events := make(chan struct{}, 1)
go func() {
<-ctx.Done()
syscall.Close(fd)
}()
go func() {
defer close(events)
buf := make([]byte, 64*1024)
for {
n, _, err := syscall.Recvfrom(fd, buf, 0)
if err != nil {
if err == syscall.EINTR || err == syscall.ENOBUFS {
// ENOBUFS: events were dropped. Treat it as one, since a dropped one may have
// been the adapter.
if err == syscall.ENOBUFS {
select {
case events <- struct{}{}:
default:
}
}
continue
}
return
}
if powerSupplyEvent(buf[:n]) {
select {
case events <- struct{}{}:
default:
}
}
}
}()
return events, nil
}
+33
View File
@@ -0,0 +1,33 @@
#!/bin/bash
# zephyrus-backlight + | - | PERCENT — step or set the internal panel's backlight, never below 1 %.
# Shipped by the mesh's asus-zephyrus-g14 module; edit the catalogue.
#
# The panel is the backlight beneath the eDP connector, not a name: in hybrid mode this model also
# registers the discrete GPU's backlight (nvidia_0), which moves nothing. Writable by the video group
# through the module's udev rule, so the vendor-key trigger needs no root.
set -u
STEP=5
dev=""
for d in /sys/class/backlight/*; do
[ -e "$d" ] || continue
case "$(readlink -f "$d")" in *-eDP-*) dev=$d; break ;; esac
done
if [ -z "$dev" ]; then
for d in /sys/class/backlight/*; do [ -e "$d" ] && { dev=$d; break; }; done
fi
[ -n "$dev" ] || { echo "zephyrus-backlight: no backlight" >&2; exit 1; }
cur=$(cat "$dev/brightness")
max=$(cat "$dev/max_brightness")
pct=$(( cur * 100 / max ))
case "${1:-}" in
+|up|Up) pct=$(( pct + STEP )) ;;
-|down|Down) pct=$(( pct - STEP )) ;;
''|*[!0-9]*) echo "usage: zephyrus-backlight + | - | PERCENT" >&2; exit 2 ;;
*) pct=$1 ;;
esac
(( pct < 1 )) && pct=1
(( pct > 100 )) && pct=100
new=$(( max * pct / 100 ))
(( new < 1 )) && new=1
printf '%s' "$new" >"$dev/brightness" || exit 1
exec "$(dirname "$0")/zephyrus-notify" 5555 "Brightness: ${pct}%"
+41
View File
@@ -0,0 +1,41 @@
#!/bin/bash
# zephyrus-display primary | order — the laptop's internal panel among the outputs.
# Shipped by the mesh's asus-zephyrus-g14 module; edit the catalogue.
#
# primary makes the internal panel X's primary output, where the bars' tray goes.
# order the display key (Fn+F9, which the firmware sends as Super+P): odd workspaces to the
# internal panel, even ones to the first external output, or all to the panel when it
# is alone. The predecessor's orden-workspaces.
#
# The panel is found, not named: the connected output whose name starts with eDP. The predecessor
# wrote eDP-1, which is what this model calls it today and not a promise.
set -u
here=$(dirname "$0")
panel=$(xrandr --query 2>/dev/null | awk '$2 == "connected" && $1 ~ /^eDP/ { print $1; exit }')
[ -n "$panel" ] || { echo "zephyrus-display: no internal panel connected" >&2; exit 1; }
case "${1:-}" in
primary)
exec xrandr --output "$panel" --primary
;;
order)
external=$(xrandr --listmonitors 2>/dev/null | awk -v p="$panel" 'NR > 1 && $NF != p { print $NF; exit }')
[ -n "$external" ] || external=$panel
# i3's workspace list, one object per workspace; num and output read from each. No jq: the
# fields are flat strings and numbers, and rect, the only nested value, holds neither name.
i3-msg -t get_workspaces 2>/dev/null | sed 's/},{"id"/}\n{"id"/g' |
while read -r ws; do
num=$(printf '%s' "$ws" | grep -o '"num":-\?[0-9]*' | cut -d: -f2)
out=$(printf '%s' "$ws" | grep -o '"output":"[^"]*"' | cut -d'"' -f4)
[ -n "$num" ] && [ "$num" -ge 0 ] || continue
if [ $((num % 2)) -eq 0 ]; then dest=$external; else dest=$panel; fi
[ "$out" = "$dest" ] && continue
i3-msg "workspace number $num; move workspace to output $dest" >/dev/null
done
if [ "$external" = "$panel" ]; then
"$here/zephyrus-notify" 7780 "Workspaces: all on the panel"
else
"$here/zephyrus-notify" 7780 "Workspaces: odd on the panel, even on $external"
fi
;;
*) echo "usage: zephyrus-display primary | order" >&2; exit 2 ;;
esac
+20
View File
@@ -0,0 +1,20 @@
#!/bin/bash
# zephyrus-kbd-notify — shows the keyboard backlight's level when it changes. The level itself is set
# by the firmware and asusd (Fn+F2/F3), which tell UPower; this only listens and notifies. Started once
# per session from the module's i3 fragment. Shipped by the mesh's asus-zephyrus-g14 module.
#
# One instance per session: i3 runs its `exec` lines again on an in-place restart, and the
# predecessor's listener ran twice after one, showing every change twice.
set -u
here=$(dirname "$0")
lock="${XDG_RUNTIME_DIR:-/run/user/$(id -u)}/zephyrus-kbd-notify.lock"
exec 9>"$lock"
flock -n 9 || exit 0
levels=(Off Low Med High)
dbus-monitor --system "type='signal',interface='org.freedesktop.UPower.KbdBacklight',member='BrightnessChanged'" 2>/dev/null |
while read -r line; do
if [[ $line =~ int32\ ([0-9]+) ]]; then
v=${BASH_REMATCH[1]}
"$here/zephyrus-notify" 5556 "Keyboard: ${levels[$v]:-$v}"
fi
done
+27
View File
@@ -0,0 +1,27 @@
#!/bin/bash
# zephyrus-media play-pause | next | previous — the media keys, through MPRIS (playerctl), with a short
# notification of what happened. Shipped by the mesh's asus-zephyrus-g14 module; edit the catalogue.
#
# From the predecessor's media-control, kept: the lock against a double fire (the vendor keys can
# repeat), and the notification. Dropped: its fallback to a media server's local API, which needed a
# token from a file of secrets (novox/hq research 027 question 2). A player that speaks MPRIS is
# reached; one that does not says so.
set -u
here=$(dirname "$0")
action=${1:-play-pause}
case "$action" in play-pause | next | previous) ;; *) echo "usage: zephyrus-media play-pause | next | previous" >&2; exit 2 ;; esac
lock="${XDG_RUNTIME_DIR:-/run/user/$(id -u)}/zephyrus-media.lock"
exec 9>"$lock"
flock -n 9 || exit 0
if ! playerctl status >/dev/null 2>&1; then
"$here/zephyrus-notify" 7777 "Media: no player"
exit 0
fi
playerctl "$action" 2>/dev/null
if [ "$action" = play-pause ]; then
sleep 0.2
[ "$(playerctl status 2>/dev/null)" = Playing ] && label=Playing || label=Paused
else
label=${action^}
fi
"$here/zephyrus-notify" 7777 "Media: $label"
+12
View File
@@ -0,0 +1,12 @@
#!/bin/bash
# zephyrus-notify ID SUMMARY — a short desktop notification that replaces the previous one with the
# same ID, through the session's notification service on its bus. busctl is the service manager's
# own client, so nothing is installed for it. Shipped by the mesh's asus-zephyrus-g14 module.
set -u
id=${1:-0}
summary=${2:-}
uid=$(id -u)
DBUS_SESSION_BUS_ADDRESS="unix:path=/run/user/${uid}/bus" \
busctl --user call org.freedesktop.Notifications /org/freedesktop/Notifications \
org.freedesktop.Notifications Notify susssasa{sv}i \
asus-zephyrus-g14 "$id" "" "$summary" "" 0 1 urgency y 0 1500 >/dev/null 2>&1 || true
+46
View File
@@ -0,0 +1,46 @@
#!/bin/bash
# zephyrus-session COMMAND [ARG...] — run a command in the operator's graphical session from outside
# it: from a vendor-key trigger, which triggerhappy runs as the operator's account but with none of
# the session's environment. Shipped by the mesh's asus-zephyrus-g14 module; edit the catalogue.
#
# What it replaces: the predecessor's `as-user`, which triggerhappy ran as root and which `su`-ed to
# a named person with a hard-coded user id and display, and sourced a file of secrets on the way.
# Here the account is whoever runs it, the bus is that account's, and the display is the one the
# account's own session uses. Nothing is sourced.
set -u
uid=$(id -u)
export XDG_RUNTIME_DIR="/run/user/${uid}"
export DBUS_SESSION_BUS_ADDRESS="unix:path=${XDG_RUNTIME_DIR}/bus"
home=$(getent passwd "$uid" | cut -d: -f6)
[ -n "$home" ] && export HOME="$home"
# The session's own window manager says best which display and authority the session uses: read
# them from its environment, as the desktop modules' session finder does. Then logind, then any
# process of this account that has a display.
from_environ() {
env=$(tr '\0' '\n' <"/proc/$1/environ" 2>/dev/null) || return 1
d=$(printf '%s\n' "$env" | sed -n 's/^DISPLAY=//p' | head -n1)
[ -n "$d" ] || return 1
export DISPLAY="$d"
a=$(printf '%s\n' "$env" | sed -n 's/^XAUTHORITY=//p' | head -n1)
[ -n "$a" ] && export XAUTHORITY="$a"
return 0
}
if [ -z "${DISPLAY:-}" ]; then
for pid in $(pgrep -xu "$uid" i3 2>/dev/null); do
from_environ "$pid" && break
done
fi
if [ -z "${DISPLAY:-}" ]; then
for s in $(loginctl list-sessions --no-legend 2>/dev/null | awk -v u="$uid" '$2 == u { print $1 }'); do
d=$(loginctl show-session "$s" -p Display --value 2>/dev/null)
if [ -n "$d" ]; then export DISPLAY="$d"; break; fi
done
fi
if [ -z "${DISPLAY:-}" ]; then
for pid in $(pgrep -u "$uid" 2>/dev/null); do
from_environ "$pid" && break
done
fi
: "${XAUTHORITY:=${HOME}/.Xauthority}"
export XAUTHORITY
exec "$@"
+29
View File
@@ -0,0 +1,29 @@
#!/bin/bash
# zephyrus-touchpad reset | toggle — apply the touchpad's settings again, or switch it on or off.
# Shipped by the mesh's asus-zephyrus-g14 module; edit the catalogue.
#
# The settings themselves are an X input class (/etc/X11/xorg.conf.d/30-asus-zephyrus-g14-touchpad.conf),
# which X applies every time the device appears — after a resume too, which is what the predecessor's
# sleep hook existed for. This is the manual form, bound to the touchpad key.
set -u
here=$(dirname "$0")
name=$("$here/zephyrus-session" xinput list --name-only 2>/dev/null | grep -m1 -i 'touchpad')
[ -n "$name" ] || { echo "zephyrus-touchpad: no touchpad in this session" >&2; exit 1; }
x() { "$here/zephyrus-session" xinput "$@"; }
case "${1:-reset}" in
reset)
x set-prop "$name" "libinput Tapping Enabled" 1
x set-prop "$name" "libinput Natural Scrolling Enabled" 1
x set-prop "$name" "libinput Accel Speed" 0.15
x enable "$name"
"$here/zephyrus-session" "$here/zephyrus-notify" 7779 "Touchpad: reset"
;;
toggle)
if x list-props "$name" | grep -q 'Device Enabled ([0-9]*):[[:space:]]*1'; then
x disable "$name"; "$here/zephyrus-session" "$here/zephyrus-notify" 7779 "Touchpad: off"
else
x enable "$name"; "$here/zephyrus-session" "$here/zephyrus-notify" 7779 "Touchpad: on"
fi
;;
*) echo "usage: zephyrus-touchpad reset | toggle" >&2; exit 2 ;;
esac
+5
View File
@@ -0,0 +1,5 @@
module asuszephyrusg14
go 1.22
require git.novox.be/novox/mesh-sdk/go v0.1.6
+2
View File
@@ -0,0 +1,2 @@
git.novox.be/novox/mesh-sdk/go v0.1.6 h1:9qzdYONYbJdWcu6sxQcq9v1LI0JxcfkiKYkMUzJSkVQ=
git.novox.be/novox/mesh-sdk/go v0.1.6/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY=
+224
View File
@@ -0,0 +1,224 @@
{
"module": "asus-zephyrus-g14",
"version": "1",
"capabilities": [
"package-manager",
"service-manager"
],
"requires": [
"x11-display"
],
"emits": [
"profile.switched"
],
"tools": [
"zephyrus_brightness",
"zephyrus_battery",
"zephyrus_charge_limit",
"zephyrus_gpu_mode",
"zephyrus_profile",
"zephyrus_thermals",
"zephyrus_power_draw",
"zephyrus_profile_policy",
"zephyrus_fan_curves",
"zephyrus_keys",
"zephyrus_check"
],
"resources": [
{
"id": "asusctl",
"type": "package",
"package": "asusctl"
},
{
"id": "playerctl",
"type": "package",
"package": "playerctl"
},
{
"id": "asusd",
"type": "service",
"unit": "asusd.service",
"state": "running"
},
{
"id": "supergfxd",
"type": "service",
"unit": "supergfxd.service",
"state": "running",
"boot": "enabled"
},
{
"id": "scripts",
"type": "archive",
"path": "/usr/local/lib/asus-zephyrus-g14",
"artifact": "scripts"
},
{
"id": "nvidia-options",
"type": "file",
"path": "/etc/modprobe.d/g14-nvidia-power.conf",
"mode": "0644",
"content": "# Managed by the mesh (module asus-zephyrus-g14). Replaced on every push; edit the catalogue instead.\n#\n# The discrete GPU's driver options on the ROG Zephyrus G14 (GA403, RTX 40 series, hybrid graphics).\n#\n# NVreg_DynamicPowerManagement=0x00 turns runtime D3 off. With it on, a change of power source sends\n# the driver an ACPI notification it fails to handle on this model (\"RmHandleDNotifierEvent: Failed to\n# handle ACPI D-Notifier event, status=0x62\"), and the GPU stops making progress until the machine is\n# powered off. Off costs a few idle watts in hybrid mode and keeps the machine up.\n#\n# NVreg_PreserveVideoMemoryAllocations=1 saves video memory across suspend, so what used the GPU still\n# works after waking. It needs nvidia-suspend, -hibernate and -resume to run around a sleep, which this\n# module's drop-ins on the sleep services ask for.\n#\n# A change here applies when the driver next loads: at the next boot.\noptions nvidia NVreg_PreserveVideoMemoryAllocations=1\noptions nvidia NVreg_DynamicPowerManagement=0x00\n"
},
{
"id": "video-options",
"type": "file",
"path": "/etc/modprobe.d/video-brightness-switch.conf",
"mode": "0644",
"content": "# Managed by the mesh (module asus-zephyrus-g14). Replaced on every push; edit the catalogue instead.\n#\n# The ACPI video driver does not change the backlight itself on the brightness keys: on this model it\n# moves the wrong one. The keys are triggerhappy's (see /etc/triggerhappy/triggers.d/asus-g14.conf).\n# Applies when the module next loads: at the next boot.\noptions video brightness_switch_enabled=0\n"
},
{
"id": "suspend-drop-ins",
"type": "directory",
"path": "/etc/systemd/system/systemd-suspend.service.d",
"mode": "0755"
},
{
"id": "nvidia-on-suspend",
"type": "file",
"path": "/etc/systemd/system/systemd-suspend.service.d/asus-zephyrus-g14-nvidia.conf",
"mode": "0644",
"content": "# Managed by the mesh (module asus-zephyrus-g14). Replaced on every push; edit the catalogue instead.\n#\n# The NVIDIA driver's own sleep actions, asked for by the sleep itself rather than enabled as\n# links: the mesh declares files and never makes links (novox/hq ADR 0012), and the host's service\n# shape must not start these units by hand, which would put the GPU to sleep with the machine awake.\n[Unit]\nWants=nvidia-suspend.service nvidia-resume.service\n"
},
{
"id": "hibernate-drop-ins",
"type": "directory",
"path": "/etc/systemd/system/systemd-hibernate.service.d",
"mode": "0755"
},
{
"id": "nvidia-on-hibernate",
"type": "file",
"path": "/etc/systemd/system/systemd-hibernate.service.d/asus-zephyrus-g14-nvidia.conf",
"mode": "0644",
"content": "# Managed by the mesh (module asus-zephyrus-g14). Replaced on every push; edit the catalogue instead.\n#\n# The NVIDIA driver's own sleep actions, asked for by the sleep itself rather than enabled as\n# links: the mesh declares files and never makes links (novox/hq ADR 0012), and the host's service\n# shape must not start these units by hand, which would put the GPU to sleep with the machine awake.\n[Unit]\nWants=nvidia-hibernate.service nvidia-resume.service\n"
},
{
"id": "suspend-then-hibernate-drop-ins",
"type": "directory",
"path": "/etc/systemd/system/systemd-suspend-then-hibernate.service.d",
"mode": "0755"
},
{
"id": "nvidia-on-suspend-then-hibernate",
"type": "file",
"path": "/etc/systemd/system/systemd-suspend-then-hibernate.service.d/asus-zephyrus-g14-nvidia.conf",
"mode": "0644",
"content": "# Managed by the mesh (module asus-zephyrus-g14). Replaced on every push; edit the catalogue instead.\n#\n# The NVIDIA driver's own sleep actions, asked for by the sleep itself rather than enabled as\n# links: the mesh declares files and never makes links (novox/hq ADR 0012), and the host's service\n# shape must not start these units by hand, which would put the GPU to sleep with the machine awake.\n[Unit]\nWants=nvidia-suspend-then-hibernate.service nvidia-resume.service\n"
},
{
"id": "powerd-drop-ins",
"type": "directory",
"path": "/etc/systemd/system/nvidia-powerd.service.d",
"mode": "0755"
},
{
"id": "powerd-opt-in",
"type": "file",
"path": "/etc/systemd/system/nvidia-powerd.service.d/asus-zephyrus-g14.conf",
"mode": "0644",
"content": "# Managed by the mesh (module asus-zephyrus-g14). Replaced on every push; edit the catalogue instead.\n#\n# nvidia-powerd (Dynamic Boost) was the first error in the chain that hung this model's GPU on a change\n# of power source, and asusd starts it on mains. It runs only when the kernel command line says\n# zephyrus.nvidia-powerd — an explicit opt-in, at boot.\n[Unit]\nConditionKernelCommandLine=zephyrus.nvidia-powerd\n"
},
{
"id": "backlight-rule",
"type": "file",
"path": "/etc/udev/rules.d/90-backlight.rules",
"mode": "0644",
"content": "# Managed by the mesh (module asus-zephyrus-g14). Replaced on every push; edit the catalogue instead.\n#\n# The backlights are writable by the video group, so the brightness keys and the module's brightness tool\n# move the panel without root.\nACTION==\"add\", SUBSYSTEM==\"backlight\", RUN+=\"/usr/bin/chgrp video /sys/class/backlight/%k/brightness\", RUN+=\"/usr/bin/chmod g+w /sys/class/backlight/%k/brightness\"\n"
},
{
"id": "udev",
"type": "service",
"unit": "systemd-udevd.service",
"reload-on": [
"backlight-rule"
]
},
{
"id": "upower-package",
"type": "package",
"package": "upower"
},
{
"id": "upower-drop-ins",
"type": "directory",
"path": "/etc/UPower/UPower.conf.d",
"mode": "0755"
},
{
"id": "low-battery",
"type": "file",
"path": "/etc/UPower/UPower.conf.d/50-asus-zephyrus-g14.conf",
"mode": "0644",
"content": "# Managed by the mesh (module asus-zephyrus-g14). Replaced on every push; edit the catalogue instead.\n#\n# On low battery the machine suspends rather than powering off, at 7 % — s2idle still draws a little,\n# so it leaves headroom. A drop-in over the package's own UPower.conf, which stays the package's.\n[UPower]\nUsePercentageForPolicy=true\nPercentageLow=15.0\nPercentageCritical=10.0\nPercentageAction=7.0\nCriticalPowerAction=Suspend\nAllowRiskyCriticalPowerAction=true\n"
},
{
"id": "upower",
"type": "service",
"unit": "upower.service",
"state": "running",
"boot": "enabled",
"restart-on": [
"low-battery"
]
},
{
"id": "xorg-drop-ins",
"type": "directory",
"path": "/etc/X11/xorg.conf.d",
"mode": "0755"
},
{
"id": "touchpad",
"type": "file",
"path": "/etc/X11/xorg.conf.d/30-asus-zephyrus-g14-touchpad.conf",
"mode": "0644",
"content": "# Managed by the mesh (module asus-zephyrus-g14). Replaced on every push; edit the catalogue instead.\n#\n# The touchpad's settings, applied by X every time the device appears — at login and after every\n# resume, when the device is initialised again. This replaces the predecessor's sleep hook, which ran\n# xinput after a resume as a named person on a guessed display.\nSection \"InputClass\"\n Identifier \"asus-zephyrus-g14 touchpad\"\n MatchIsTouchpad \"on\"\n Option \"Tapping\" \"on\"\n Option \"NaturalScrolling\" \"true\"\n Option \"AccelSpeed\" \"0.15\"\nEndSection\n"
}
],
"build": {
"artifacts": [
{
"name": "tools-go",
"kind": "bundle",
"language": "go",
"system": "arch",
"from": "cmd/zephyrus",
"binary": "zephyrus",
"loads": [
"zephyrus"
]
},
{
"name": "scripts",
"kind": "archive",
"from": "files"
}
]
},
"contributions": [
{
"seat": "node-hotkeys",
"kind": "trigger",
"content": "# The ROG Zephyrus G14's vendor keys, which reach no X client: media (the M-keys), panel brightness,\n# and the touchpad key. Each runs this module's own script, as the operator's account.\nKEY_PROG1\t1\t/usr/local/lib/asus-zephyrus-g14/bin/zephyrus-session /usr/local/lib/asus-zephyrus-g14/bin/zephyrus-media play-pause\nKEY_PROG3\t1\t/usr/local/lib/asus-zephyrus-g14/bin/zephyrus-session /usr/local/lib/asus-zephyrus-g14/bin/zephyrus-media previous\nKEY_PROG4\t1\t/usr/local/lib/asus-zephyrus-g14/bin/zephyrus-session /usr/local/lib/asus-zephyrus-g14/bin/zephyrus-media next\nKEY_BRIGHTNESSDOWN\t1\t/usr/local/lib/asus-zephyrus-g14/bin/zephyrus-backlight -\nKEY_BRIGHTNESSDOWN\t2\t/usr/local/lib/asus-zephyrus-g14/bin/zephyrus-backlight -\nKEY_BRIGHTNESSUP\t1\t/usr/local/lib/asus-zephyrus-g14/bin/zephyrus-backlight +\nKEY_BRIGHTNESSUP\t2\t/usr/local/lib/asus-zephyrus-g14/bin/zephyrus-backlight +\nKEY_F21\t1\t/usr/local/lib/asus-zephyrus-g14/bin/zephyrus-touchpad reset\n"
},
{
"seat": "node-display-session",
"kind": "config",
"content": "# The laptop's own lines in i3 (module asus-zephyrus-g14, novox/hq ADR 0208, ADR 0210). Owned by the\n# mesh: replaced at every push. Once the controller places contributions to node-display-session\n# (ADR 0210), these become the module's contribution instead of a file in i3's directory.\n#\n# The keys the firmware turns into ordinary key presses. The vendor keys that reach no X client are\n# triggerhappy's (/etc/triggerhappy/triggers.d/asus-g14.conf): M4 and Fn+F4/F5 for media, Fn+F7/F8 for\n# the panel, Fn+F10 for the touchpad. The keyboard backlight (Fn+F2/F3) is the firmware's and asusd's.\n\n# Fn+F6, the screenshot key: the firmware sends Super+Shift+S. Released before it runs, because the\n# screenshot grabs the pointer to select a region, which fails while the key is still held.\nbindsym --release $mod+Shift+s exec --no-startup-id $XDG_CONFIG_HOME/i3/scripts/screenshot.sh\n\n# Fn+F9, the display key: the firmware sends Super+P. Odd workspaces to the panel, even ones to the\n# external output.\nbindsym $mod+p exec --no-startup-id /usr/local/lib/asus-zephyrus-g14/bin/zephyrus-display order\n\n# The keyboard backlight's level, shown when it changes.\nexec --no-startup-id /usr/local/lib/asus-zephyrus-g14/bin/zephyrus-kbd-notify\n"
},
{
"seat": "node-display-session",
"kind": "config",
"content": "# The laptop's own lines in i3 (module asus-zephyrus-g14, novox/hq ADR 0208, ADR 0210). Owned by the\n# mesh: replaced at every push. Once the controller places contributions to node-display-session\n# (ADR 0210), these become the module's contribution instead of a file in i3's directory.\n#\n# The model's panel and touchpad.\n\n# The internal panel is the primary output, where the bars' tray goes. Which monitors are on and where\n# is the display server's (autorandr, run at every session start).\nexec --no-startup-id /usr/local/lib/asus-zephyrus-g14/bin/zephyrus-display primary\n\n# The touchpad's settings again, by hand. X applies them itself whenever the device appears.\nbindsym $mod+Shift+x exec --no-startup-id /usr/local/lib/asus-zephyrus-g14/bin/zephyrus-touchpad reset\n"
}
],
"shell": [
{
"for": "after-wake",
"slot": "normal",
"code": "# The touchpad's settings again after waking, in the operator's session: X applies the module's\n# input class when the device appears, and this covers a wake that does not initialise it again.\n# Runs as root from the power module; the reset itself runs as the session's owner.\nsleep 2\nowner=$(ps -o user= -C i3 | head -n 1)\n[ -n \"$owner\" ] && runuser -u \"$owner\" -- /usr/local/lib/asus-zephyrus-g14/bin/zephyrus-touchpad reset\ntrue\n"
}
]
}
+7 -1
View File
@@ -22,7 +22,7 @@ whenever the node's tool runtime collects the module's tools:
| file | holds |
|---|---|
| `managed-mcp.json` | the tool servers every session loads: the mesh's console as `mesh`, and the servers set in this module's `mcp_servers` setting. **Exclusive**: a server not listed here does not load — not one added with `claude mcp add`, not a project's `.mcp.json`, not a plugin's |
| `managed-settings.json` | the repositories' attribution convention, the claude.ai connectors kept beside the managed servers, and the key-helper while the node holds an API-key licence |
| `managed-settings.json` | the keys set in this module's `managed_settings` setting, under the mesh's own: the repositories' attribution convention, the claude.ai connectors kept beside the managed servers, and the key-helper while the node holds an API-key licence |
| `CLAUDE.md` | how a session on this mesh works, this node's name and role, the conventions |
Under the operator's home, only `~/.claude/.credentials.json`, and only when the licence manager hands
@@ -58,6 +58,12 @@ Per node or for the whole mesh, through `mesh-controller.settings module=claude-
registered through the tools; keyed by name, in the vendor's `.mcp.json` entry shape
(`{"type":"http","url":…}` or `{"type":"stdio","command":…,"args":[…]}`). The name `mesh` is the
module's own and cannot be set. Put a person's own servers here, or they stop loading.
- `managed_settings` — keys of the agent's managed settings, in the vendor's `settings.json` shape:
`permissions` (allow, ask, deny), `autoMode` (environment, allow, soft_deny), `env`, hooks and so on.
Managed settings outrank every other scope, so a rule here holds in every session on the node. The
mesh's own keys (`attribution`, `allowAllClaudeAiMcps`, `apiKeyHelper`) are laid last and cannot be
set. A setting layer is replaced whole: setting `managed_settings` without `role` or `mcp_servers`
clears those in that layer.
## On a machine that carried the predecessor
@@ -94,6 +94,40 @@ func TestASettingCannotReplaceTheMeshsOwnEntryAndABadNameIsLeftOut(t *testing.T)
}
}
func TestTheOperatorsManagedSettingsAreLaidUnderTheMeshsOwnKeys(t *testing.T) {
settings := Settings{ManagedSettings: map[string]any{
"autoMode": map[string]any{"allow": []any{"merging an approved pull request"}},
"permissions": map[string]any{"deny": []any{"Bash(rm -rf /)"}},
"attribution": map[string]any{"commit": "made by a machine"},
"allowAllClaudeAiMcps": false,
"apiKeyHelper": "/somewhere/else",
}}
read := func(binding *Binding) map[string]any {
var m map[string]any
out := Render(Facts{Console: "x"}, settings, binding, "/h", nil)
if err := json.Unmarshal([]byte(out["managed-settings.json"]), &m); err != nil {
t.Fatal(err)
}
return m
}
m := read(nil)
if allow := m["autoMode"].(map[string]any)["allow"].([]any); len(allow) != 1 || allow[0] != "merging an approved pull request" {
t.Errorf("the operator's auto mode was not carried: %v", m["autoMode"])
}
if m["permissions"] == nil {
t.Errorf("the operator's permissions were not carried: %v", m)
}
if !reflect.DeepEqual(m["attribution"], map[string]any{"commit": "", "pr": ""}) || m["allowAllClaudeAiMcps"] != true {
t.Errorf("a setting replaced the mesh's own keys: %v", m)
}
if _, ok := m["apiKeyHelper"]; ok {
t.Errorf("a setting named a key-helper the licence did not: %v", m)
}
if helper := read(&Binding{Licence: "api", Kind: "api-key"})["apiKeyHelper"]; helper != "/h" {
t.Errorf("an API-key licence's key-helper was replaced: %v", helper)
}
}
// ---- the credentials file -----------------------------------------------------------------------------
func i64(v int64) *int64 { return &v }
@@ -362,3 +396,68 @@ func TestABadEntryIsRefusedBeforeAnythingIsPut(t *testing.T) {
t.Fatal("another node's key changed this one")
}
}
// An API key goes to the manager sealed to its key, never in the clear, and its file is gone afterwards.
func TestAnAPIKeyIsHandedOverSealedAndItsFileRemoved(t *testing.T) {
p, _ := node(t, "laptop")
manager, _ := GenerateKeyPair()
file := filepath.Join(p.Home, "api-key")
writeFile(t, file, "sk-ant-api03-secret\n")
var sent []string
ask := func(address string, args any) (json.RawMessage, error) {
raw, _ := json.Marshal(args)
sent = append(sent, address+" "+string(raw))
switch address {
case "seat:anthropic-licence-manager.public-key":
return json.Marshal(map[string]any{"public_key": manager.PublicKey})
case "seat:anthropic-licence-manager.adopt":
box := args.(map[string]any)["sealed"].(SealedBox)
if key, err := Open(box, manager.PrivateKey); err != nil || key != "sk-ant-api03-secret" {
t.Fatalf("the manager opened %q, %v", key, err)
}
return json.Marshal(map[string]any{"adopted": true})
case "seat:anthropic-licence-manager.switch":
return json.Marshal(map[string]any{"licence": "api"})
}
t.Fatalf("asked %s", address)
return nil, nil
}
out, err := AddAPIKey(p, "api", file, true, ask)
if err != nil || out["file"] != "removed" || out["switched"] == nil {
t.Fatalf("%v %v", out, err)
}
if _, err := os.Stat(file); !os.IsNotExist(err) {
t.Fatal("the key file is still there")
}
if strings.Contains(strings.Join(sent, "\n"), "sk-ant") {
t.Fatalf("the key crossed in the clear: %v", sent)
}
}
// A manager whose store was rebuilt counts generations from one again (novox/hq issue 243): a binding with a
// lower generation than the one applied is still a binding to apply, and only the one applied is skipped.
func TestALowerGenerationAfterTheManagerWasRebuiltIsStillApplied(t *testing.T) {
p, w := node(t, "laptop")
var asked []string
if _, err := OnBinding(p, &BindingState{Licence: "personal", Kind: "subscription", Generation: 16},
seat(t, "personal", "at-old", 16, &asked), writer(w)); err != nil {
t.Fatal(err)
}
// The rotation that came with it: a later token, as a refresh hands one over.
later := func(address string, args any) (json.RawMessage, error) {
asked = append(asked, address)
g, _ := json.Marshal(Grant{AccessToken: "at-new", ExpiresAt: now + 7_200_000})
box, err := Seal(string(g), args.(map[string]any)["public_key"].(string))
if err != nil {
t.Fatal(err)
}
return json.Marshal(Current{Licence: "personal", Kind: "subscription", Generation: 3, Sealed: &box})
}
if _, err := OnBinding(p, &BindingState{Licence: "personal", Kind: "subscription", Generation: 3},
later, writer(w)); err != nil {
t.Fatal(err)
}
if len(asked) != 2 || creds(t, p)["accessToken"] != "at-new" || HoldingsOf(p).Generation != 3 {
t.Fatalf("asked %v, credentials %v: a lower generation was ignored", asked, creds(t, p))
}
}
@@ -177,6 +177,25 @@ func tools(p Paths, servers ServerState, view *ServerView) []stdio.Tool {
}
return GrantFor(p, key)
}},
{Name: "claude_code_add_api_key",
Description: "Add an Anthropic API key as a licence (ADR 0209): read from a file on this machine — never typed as an argument — sealed to the licence manager's key, handed over, and the file removed once taken. With use_here, this machine switches to it at once; other machines move with the manager's `switch`.",
Input: map[string]any{
"name": str("the licence's name, e.g. api"),
"file": str("a file on this machine holding the key, e.g. ~/api-key (removed once the manager has it)"),
"use_here": map[string]any{"type": "boolean", "description": "switch this machine to the new licence"},
},
Run: func(a map[string]any) (any, error) {
name, _ := a["name"].(string)
file, _ := a["file"].(string)
if strings.TrimSpace(name) == "" || strings.TrimSpace(file) == "" {
return nil, errors.New("name and file are required")
}
if strings.HasPrefix(file, "~/") {
file = filepath.Join(p.Home, file[2:])
}
useHere, _ := a["use_here"].(bool)
return AddAPIKey(p, strings.TrimSpace(name), file, useHere, ask)
}},
{Name: "claude_code_mcp_list",
Description: "The MCP servers registered through this module: those that apply on this node (beside the console, `mesh`, and those set in the module's settings), and every registration on the mesh, by key — `all.<server>` for every node, `<node>.<server>` for one.",
Run: func(map[string]any) (any, error) {
+56 -3
View File
@@ -255,14 +255,19 @@ func Pull(p Paths, ask Ask, write WriteManaged) (map[string]any, error) {
}
// OnBinding takes a change to this node's key in the manager's `bindings` state (ADR 0206): the token is
// fetched when the generation is newer than the one applied. A released binding keeps the last token,
// which lives hours, and says so.
// fetched when the binding differs from the one applied. A released binding keeps the last token, which
// lives hours, and says so.
//
// **Differs, not "is newer"** (novox/hq issue 243). The state keeps only the latest value per node, so
// nothing older can arrive. A manager whose store was rebuilt counts generations from one again, and
// a node that waited for a number above its own ignored every binding it was sent, its login and the
// licence's rotations included, until the count caught up. Only the binding already applied is skipped.
func OnBinding(p Paths, b *BindingState, ask Ask, write WriteManaged) (string, error) {
if b == nil {
return "this node's binding was released; it keeps its last token until it expires", nil
}
var applied Binding
if readJSON(p.binding(), &applied) && applied.Generation >= b.Generation {
if readJSON(p.binding(), &applied) && applied.Generation == b.Generation && applied.Licence == b.Licence {
return "", nil
}
out, err := Pull(p, ask, write)
@@ -343,6 +348,54 @@ func Apply(p Paths, c Current, write WriteManaged) (map[string]any, error) {
return out, nil
}
// AddAPIKey adds an API key from a file on this node to the licence manager (ADR 0209): sealed to the
// manager's public key, handed to the seat's `adopt` on request/reply, and the file removed once taken. With
// useHere, this node is switched to the new licence. The key never crosses the bus in the clear and is
// never an argument.
func AddAPIKey(p Paths, name, file string, useHere bool, ask Ask) (map[string]any, error) {
raw, err := os.ReadFile(file)
if err != nil {
return nil, fmt.Errorf("the key is read from a file on this node: %w", err)
}
key := strings.TrimSpace(string(raw))
if key == "" {
return nil, fmt.Errorf("%s is empty", file)
}
answer, err := ask(SeatVerb("public-key"), map[string]any{})
if err != nil {
return nil, err
}
var pk struct {
PublicKey string `json:"public_key"`
}
if err := json.Unmarshal(answer, &pk); err != nil || !strings.Contains(pk.PublicKey, "PUBLIC KEY") {
return nil, errors.New("the licence manager did not say what key to seal to")
}
box, err := Seal(key, pk.PublicKey)
if err != nil {
return nil, err
}
adopted, err := ask(SeatVerb("adopt"), map[string]any{"name": name, "sealed": box, "from": p.Node})
if err != nil {
return nil, err
}
out := map[string]any{"adopted": json.RawMessage(adopted)}
// Taken: the key now lives encrypted in the manager's store alone.
if err := os.Remove(file); err != nil {
out["file"] = "could not be removed: " + err.Error()
} else {
out["file"] = "removed"
}
if useHere {
switched, err := ask(SeatVerb("switch"), map[string]any{"consumer": p.Node, "licence": name})
if err != nil {
return out, fmt.Errorf("adopted, and switching this node to it failed: %w", err)
}
out["switched"] = json.RawMessage(switched)
}
return out, nil
}
// ---- MCP servers ----------------------------------------------------------------------------------
// Registration is a server registered (or, with no entry, unregistered) through this module.
+15 -4
View File
@@ -10,9 +10,11 @@ package main
// servers the operator declared or registered through this module. Exclusive by
// the vendor's rule — a server not listed here does not load (operator's choice,
// 2026-10-03).
// managed-settings.json the mesh's keys only: the repositories' attribution convention, the claude.ai
// connectors kept beside the managed servers, and — for an API-key licence only —
// the key-helper. A person's preferences are theirs.
// managed-settings.json the keys the operator set in this module's `managed_settings` (the agent's
// permissions and auto mode, say), under the mesh's own keys, which always win:
// the repositories' attribution convention, the claude.ai connectors kept beside
// the managed servers, and — for an API-key licence only — the key-helper. A
// person's preferences are theirs, in their own settings.
// CLAUDE.md how a session on this mesh works, who this node is, the conventions.
import (
@@ -38,6 +40,8 @@ type Facts struct {
type Settings struct {
Role string `json:"role"`
MCPServers map[string]map[string]any `json:"mcp_servers"`
// ManagedSettings are keys of the agent's managed settings the operator sets, for the mesh or a node.
ManagedSettings map[string]any `json:"managed_settings"`
}
// Binding is the licence this node holds, as it was last applied.
@@ -105,7 +109,14 @@ func Render(facts Facts, settings Settings, binding *Binding, helperPath string,
}
servers[meshEntry] = map[string]any{"type": "http", "url": facts.Console}
managed := map[string]any{"attribution": map[string]any{"commit": "", "pr": ""}, "allowAllClaudeAiMcps": true}
managed := map[string]any{}
for key, value := range settings.ManagedSettings {
managed[key] = value
}
// The mesh's own keys are laid last: a setting never replaces them.
managed["attribution"] = map[string]any{"commit": "", "pr": ""}
managed["allowAllClaudeAiMcps"] = true
delete(managed, "apiKeyHelper")
if binding != nil && binding.Kind == "api-key" {
managed["apiKeyHelper"] = helperPath
}
+2 -1
View File
@@ -23,6 +23,7 @@
"claude_code_render",
"claude_code_pull",
"claude_code_grant",
"claude_code_add_api_key",
"claude_code_mcp_list",
"claude_code_mcp_register",
"claude_code_mcp_unregister"
@@ -68,7 +69,7 @@
"mode": "0600",
"owner": "${machine:account}",
"merge": "json",
"content": "{\n \"role\": \"\",\n \"mcp_servers\": {}\n}\n"
"content": "{\n \"role\": \"\",\n \"mcp_servers\": {},\n \"managed_settings\": {}\n}\n"
}
],
"build": {
@@ -290,7 +290,7 @@ func tools() []stdio.Tool {
}
return m.Bind(ctx, c, l, "bind")
}),
verb("switch", "Move a consumer to another licence. Its node fetches the new licence's token at once and points the agent's account at it.",
verb("switch", "Move a consumer to another licence. Its node fetches the new licence's token at once and points the agent's account at it. A login on a node does this by itself (ADR 0209); this is for moving one without a login, or back.",
map[string]any{"consumer": consumer, "licence": str("the licence to move to")},
func(ctx context.Context, m *Manager, a map[string]any) (any, error) {
c, l, err := two(a, "consumer", "licence")
@@ -326,15 +326,34 @@ func tools() []stdio.Tool {
}
return m.Store.Usage(ctx, l, limit)
}),
verb("adopt", "Adopt an API key from a file on the manager's node, never as an argument. Subscriptions are adopted from the nodes' logins by themselves.",
map[string]any{"name": str("the licence's name"), "file": str("a file on the manager's node holding the key")},
verb("adopt", "Adopt an API key — never as an argument: from a file on the manager's node (`file`), or sealed to the manager's `public-key` by a node's claude-code (`sealed`; its `claude_code_add_api_key` does this). Subscriptions are adopted from the nodes' logins by themselves.",
map[string]any{"name": str("the licence's name"), "file": str("a file on the manager's node holding the key"),
"sealed": map[string]any{"type": "object", "description": "the key sealed to the manager's public key"},
"from": str("which node it came from, for the audit")},
func(ctx context.Context, m *Manager, a map[string]any) (any, error) {
n, f, err := two(a, "name", "file")
n, err := text(a, "name")
if err != nil {
return nil, err
}
if raw, ok := a["sealed"]; ok && raw != nil {
b, _ := json.Marshal(raw)
var box SealedBox
if err := json.Unmarshal(b, &box); err != nil {
return nil, err
}
from, _ := a["from"].(string)
return m.AdoptSealedKey(ctx, n, box, "a node: "+from)
}
f, err := text(a, "file")
if err != nil {
return nil, errors.New("adopt takes a `file` on the manager's node, or a `sealed` key")
}
return m.AdoptKey(ctx, n, f)
}),
verb("public-key", "The manager's public key, PEM: what a node seals an API key or a login to before handing it over.",
nil, func(_ context.Context, m *Manager, _ map[string]any) (any, error) {
return map[string]any{"public_key": m.Keys.PublicKey}, nil
}),
verb("current", "For a consumer's agent module (ADR 0206): its token, sealed to the public key it sends, with the licence, kind and generation. Null when it is bound to nothing.",
map[string]any{"consumer": consumer, "public_key": str("the consumer's public key, PEM")},
func(ctx context.Context, m *Manager, a map[string]any) (any, error) {
@@ -49,6 +49,8 @@ type Holdings struct {
Fingerprint string `json:"fingerprint"`
} `json:"refresh"`
ChangedAt string `json:"changedAt"`
// Generation is the binding generation the node last applied (novox/hq issue 243).
Generation int64 `json:"generation"`
}
// BindingState is what `bindings` holds for one consumer: no secret, only what it should hold and which
@@ -189,6 +191,17 @@ func (m *Manager) CandidatesIn(ctx context.Context, reports []Holdings) (map[str
// newest first; the first that refreshes is adopted and the rest are settled as skipped without being
// exchanged. Answers what was adopted.
func (m *Manager) Consider(ctx context.Context, reports []Holdings) ([]string, error) {
// **Never a generation a node has already passed** (novox/hq issue 243). A store rebuilt after a loss
// counts from one again, while every node still holds the number it last applied. The nodes no longer
// wait for a higher number, but a binding numbered exactly as the one a node applied would still be
// skipped. So the count is moved past every number reported, before anything here binds.
var highest int64
for _, r := range reports {
highest = max(highest, r.Generation)
}
if err := m.Store.GenerationsAbove(ctx, highest); err != nil {
return nil, err
}
byAccount, err := m.CandidatesIn(ctx, reports)
if err != nil {
return nil, err
@@ -336,7 +349,24 @@ func (m *Manager) adoptOne(ctx context.Context, c Candidate, reports []Holdings)
_ = m.Store.Audit(ctx, "adopted", map[string]any{"licence": l.Name, "node": c.Node, "account": c.Identity.AccountUUID,
"vendorNamedAccount": r.Account != ""})
// A first binding follows the login (ADR 0206 §7): every node reporting this account and bound to nothing.
// A login moves its node (ADR 0209): the node this login came from is bound to its licence —
// switched, if it was bound to another. Then every node reporting this account and bound to nothing.
if b, err := m.Store.Binding(ctx, c.Node); err != nil {
return "", err
} else if b == nil || b.Licence != l.Name {
if _, err := m.Store.Bind(ctx, c.Node, l.Name); err != nil {
return "", err
}
from := ""
if b != nil {
from = b.Licence
}
_ = m.Store.Audit(ctx, map[bool]string{true: "switched", false: "bound"}[b != nil],
map[string]any{"consumer": c.Node, "licence": l.Name, "from": from, "by": "a login there"})
if b != nil {
m.Log("%s moved from %s to %s: a login there", c.Node, from, l.Name)
}
}
for _, rep := range reports {
if rep.Identity == nil || rep.Identity.AccountUUID != c.Identity.AccountUUID {
continue
@@ -612,16 +642,29 @@ var licenceName = regexp.MustCompile(`^[A-Za-z0-9@._-]+$`)
// AdoptKey adopts an API key from a file on this node — never an argument (design 39 §6).
func (m *Manager) AdoptKey(ctx context.Context, name, file string) (map[string]any, error) {
if !licenceName.MatchString(name) {
return nil, fmt.Errorf("%q is not a licence name: letters, digits and @._-", name)
}
raw, err := os.ReadFile(file)
if err != nil {
return nil, err
}
key := strings.TrimSpace(string(raw))
return m.adoptKey(ctx, name, strings.TrimSpace(string(raw)), "a file on "+m.Holder)
}
// AdoptSealedKey adopts an API key sealed to this module's public key by a node's agent module (ADR 0209):
// the key crosses the bus only sealed, on request/reply.
func (m *Manager) AdoptSealedKey(ctx context.Context, name string, box SealedBox, from string) (map[string]any, error) {
key, err := Open(box, m.Keys.PrivateKey)
if err != nil {
return nil, err
}
return m.adoptKey(ctx, name, strings.TrimSpace(key), from)
}
func (m *Manager) adoptKey(ctx context.Context, name, key, from string) (map[string]any, error) {
if !licenceName.MatchString(name) {
return nil, fmt.Errorf("%q is not a licence name: letters, digits and @._-", name)
}
if key == "" {
return nil, fmt.Errorf("%s is empty", file)
return nil, errors.New("the key is empty")
}
held, err := m.Store.Licence(ctx, name)
if err != nil {
@@ -634,11 +677,11 @@ func (m *Manager) AdoptKey(ctx context.Context, name, file string) (map[string]a
if err := m.Store.SaveLicence(ctx, l); err != nil {
return nil, err
}
_ = m.Store.Audit(ctx, "adopted", map[string]any{"licence": name, "kind": "api-key", "from": "a file"})
_ = m.Store.Audit(ctx, "adopted", map[string]any{"licence": name, "kind": "api-key", "from": from})
if err := m.publishAdvance(ctx, l); err != nil {
return nil, err
}
_ = m.Emit("licence.adopted", map[string]any{"licence": name, "from": "a file", "replaced": held != nil})
_ = m.Emit("licence.adopted", map[string]any{"licence": name, "from": from, "replaced": held != nil})
return map[string]any{"licence": name, "kind": "api-key", "adopted": true, "fingerprint": Fingerprint(key)}, nil
}
@@ -357,3 +357,84 @@ func TestANodeReportingAnAdoptedAccountLaterIsBoundToIt(t *testing.T) {
t.Fatal("a node already bound was bound again")
}
}
// A login to another account on one node adopts it and moves that node, and only that node (ADR 0209).
func TestALoginToAnotherAccountMovesItsNodeAndNoOther(t *testing.T) {
mm := newMesh(t)
ctx := context.Background()
_, _ = mm.m.Consider(ctx, []Holdings{mm.login("laptop", "rt-a", true, t0), mm.report("server", "", t0, "acct-1")})
if mm.state["laptop"].Licence != licence1 || mm.state["server"].Licence != licence1 {
t.Fatalf("%v", mm.state)
}
mm.now = t0.Add(time.Hour)
mm.logins["laptop"] = FullGrant{AccessToken: "local", RefreshToken: "rt-b", ExpiresAt: 1}
mm.vendor.live["rt-b"] = true
second := mm.report("laptop", "rt-b", t0.Add(time.Hour), "acct-2")
adopted, _ := mm.m.Consider(ctx, []Holdings{second, mm.report("server", "", t0, "acct-1")})
if len(adopted) != 1 || adopted[0] != "acct-2@example.org" {
t.Fatalf("adopted %v", adopted)
}
if mm.state["laptop"].Licence != "acct-2@example.org" {
t.Fatalf("the node a login was made on stayed bound to %v", mm.state["laptop"])
}
if mm.state["server"].Licence != licence1 {
t.Fatalf("another node moved: %v", mm.state["server"])
}
ls, _ := mm.store.Licences(ctx)
if len(ls) != 2 {
t.Fatalf("%d licences", len(ls))
}
}
// A login that does not refresh moves nothing.
func TestALoginThatDoesNotRefreshMovesNothing(t *testing.T) {
mm := newMesh(t)
ctx := context.Background()
_, _ = mm.m.Consider(ctx, []Holdings{mm.login("laptop", "rt-a", true, t0)})
g := mm.state["laptop"]
mm.logins["laptop"] = FullGrant{AccessToken: "local", RefreshToken: "rt-dead", ExpiresAt: 1}
_, _ = mm.m.Consider(ctx, []Holdings{mm.report("laptop", "rt-dead", t0.Add(time.Hour), "acct-2")})
if mm.state["laptop"] != g {
t.Fatalf("a dead login moved its node: %v", mm.state["laptop"])
}
}
// An API key sealed to the manager by a node is adopted, and opens only for the manager.
func TestAnAPIKeySealedByANodeIsAdopted(t *testing.T) {
mm := newMesh(t)
ctx := context.Background()
box, _ := Seal("sk-ant-api03-test\n", mm.keys.PublicKey)
r, err := mm.m.AdoptSealedKey(ctx, "api", box, "laptop")
if err != nil || r["adopted"] != true {
t.Fatalf("%v %v", r, err)
}
l, _ := mm.store.Licence(ctx, "api")
if plain, _ := mm.m.Crypt.Open(l.Sealed); plain != "sk-ant-api03-test" {
t.Fatalf("stored %q", plain)
}
other, _ := GenerateKeyPair()
wrong, _ := Seal("sk", other.PublicKey)
if _, err := mm.m.AdoptSealedKey(ctx, "api2", wrong, "laptop"); err == nil {
t.Fatal("a key sealed to another key was adopted")
}
for _, e := range mm.events {
if strings.Contains(e, "sk-ant") {
t.Fatalf("the key was published: %s", e)
}
}
}
// A store rebuilt after a loss counts generations from one again (novox/hq issue 243): the first look at the
// reports moves the count past every generation a node says it applied, so no binding repeats one.
func TestARebuiltStoreNeverGivesAGenerationANodeHasPassed(t *testing.T) {
mm := newMesh(t)
ctx := context.Background()
laptop := mm.login("laptop", "rt-a", true, t0)
laptop.Generation = 16
if _, err := mm.m.Consider(ctx, []Holdings{laptop}); err != nil {
t.Fatal(err)
}
if g := mm.state["laptop"].Generation; g <= 16 {
t.Fatalf("the laptop applied generation 16 and was given %d", g)
}
}
@@ -225,6 +225,16 @@ func (s *PgStore) Unbind(ctx context.Context, consumer string) (bool, error) {
return err == nil && tag.RowsAffected() == 1, err
}
func (s *PgStore) GenerationsAbove(ctx context.Context, generation int64) error {
if generation <= 0 {
return nil
}
// setval with is_called, so the next nextval is generation+1; only ever forward.
_, err := s.pool.Exec(ctx, `select setval('binding_generation', $1, true) from binding_generation
where not is_called or last_value < $1`, generation)
return err
}
func (s *PgStore) Advance(ctx context.Context, licence string) ([]Binding, error) {
return s.bindingsWhere(ctx, `update binding set generation = nextval('binding_generation') where licence = $1
returning consumer, licence, generation`, licence)
@@ -70,6 +70,8 @@ type Store interface {
Unbind(ctx context.Context, consumer string) (bool, error)
// Advance gives every consumer of a licence a new generation: what a rotation is to them.
Advance(ctx context.Context, licence string) ([]Binding, error)
// GenerationsAbove makes every generation given from now on greater than this one.
GenerationsAbove(ctx context.Context, generation int64) error
Outcome(ctx context.Context, fingerprint string) (Outcome, error)
RecordOutcome(ctx context.Context, fingerprint, node, account string, o Outcome, why string) error
RecordUsage(ctx context.Context, licence string, at int64, r UsageReading, raw map[string]any) error
@@ -200,6 +202,13 @@ func (m *MemoryStore) Unbind(_ context.Context, consumer string) (bool, error) {
return ok, nil
}
func (m *MemoryStore) GenerationsAbove(_ context.Context, generation int64) error {
m.mu.Lock()
defer m.mu.Unlock()
m.generation = max(m.generation, generation)
return nil
}
func (m *MemoryStore) Advance(_ context.Context, licence string) ([]Binding, error) {
m.mu.Lock()
defer m.mu.Unlock()
+4 -2
View File
@@ -33,7 +33,8 @@
"refresh",
"usage",
"adopt",
"current"
"current",
"public-key"
]
}
],
@@ -50,7 +51,8 @@
"refresh",
"usage",
"adopt",
"current"
"current",
"public-key"
]
}
],
+89
View File
@@ -0,0 +1,89 @@
# clipmenu
The clipboard manager as a module (novox/hq ADR 0208, research 026/04).
- Installs `clipmenu` from the official repositories. It brings `clipnotify`, `xsel`, `xdotool` and
`dmenu` as its own dependencies.
- Claims the mesh's `node-clipboard` seat and serves its verbs `history` and `copy`. Requires
`x11-display` on its own machine.
- **Declares `rofi-greenclip` absent** (ADR 0180). This module replaces it.
- Starts `clipmenud` **once per session**, from the session's start (the `xinitrc` slot `normal`).
It is not a user unit as well. Its packaged unit needs user-scoped units (mesh-host #72, not
merged), and the session start alone is one starter.
- Binds `$mod+period` to `clipmenu`, as its own i3 drop-in (`50-clipmenu.conf`). clipmenu shows the
history through `dmenu`, the seat command of `node-launcher`, so it looks like every other menu.
- Its settings are environment contributions (ADR 0203), read by the daemon, the menu and the tools
alike:
- `CM_SELECTIONS=clipboard`: what was copied, not every highlighted word. The found greenclip did
the same.
- `CM_MAX_CLIPS=500`: how many clips are kept.
- `CM_HISTLENGTH=15`: how many lines the menu shows.
## Tools
| tool | does |
|---|---|
| `node-clipboard.history` | the history, newest first, each entry once with its id, first line, time, size and text (cut) |
| `node-clipboard.copy` | put text on the clipboard; it enters the history like any copy |
| `clipmenu_paste` | what the clipboard holds now |
| `clipmenu_clear` | forget the history; the daemon's locks stay |
| `clipmenu_delete` | forget one entry, by id or first line |
The history is read from clipmenu's own store, in the account's runtime directory, under clipmenu's
own lock, so a copy arriving meanwhile is neither lost nor half-written. `copy` hands the text to an
owner (`xsel`) under the account's service manager. As a child of the tools runtime, the clipboard
would empty whenever the runtime restarted.
## What it improves on what was found
- **No AUR package.** greenclip came from the user repository. clipmenu is in the official one.
- **Started once.** greenclip was started by the window manager on both workstations, and on the
desktop by an enabled user unit as well.
- **No absolute home path** in any configuration. greenclip's named one.
- **The history does not outlive a reboot.** greenclip kept it in `~/.cache`, so every password ever
copied stayed on disk. clipmenu keeps it in the runtime directory, which is memory.
- **One menu.** The history appears in the launcher's own menu, through the seat's `dmenu` command,
instead of a theme from a cloned theme repository.
## What it leaves as found
- `~/.config/greenclip.toml` and greenclip's history, `~/.cache/greenclip.history`.
- On the desktop: greenclip's enabled user unit link
(`~/.config/systemd/user/default.target.wants/greenclip.service`). It dangles once the package is
gone.
## Migration (ADR 0182)
1. After the first push, delete `~/.config/greenclip.toml` and `~/.cache/greenclip.history`.
2. On the desktop: `systemctl --user disable greenclip.service`, before the push if you can. The
package's removal takes the unit file with it.
3. Until the `i3` module carries the main configuration, the found `exec --no-startup-id greenclip
daemon` and `$mod+period` lines stay in `~/.config/i3/config`. i3 reports `$mod+period` as bound
twice. The `i3` module's configuration carries neither.
## Blockers
- `node-clipboard`, `x11-display` and the `xinitrc` slot are ADR 0208's. Until the controller knows
them, `mctl` reads them as unknown.
- `CM_*` reach the session through `node-env` (ADR 0203), so the account's environment module must
be assigned too. Without it clipmenu runs on its defaults: both selections, 1000 clips, 8 lines.
## Only text is read (added 2026-10-04)
clipmenud reads a selection with `timeout 1 xsel -o`. A selection holding an image, such as a
screenshot copied as `image/png`, arrives in pieces. One second is too short for megabytes, so
`timeout` kills xsel half way, and the program owning the image waits for ever for a reader that is
gone. From then on every paste hangs, and an app asking on its main thread (Electron: Slack) freezes.
The first screenshot after this module was assigned did exactly that.
So clipmenud runs with the module's own `xsel` first on its `PATH`
(`/usr/local/lib/mesh-clipmenu/xsel`). Before a read it asks the selection for its `TARGETS` and goes
ahead only when the selection offers text. It uses `xclip` for that question, which the `xclip`
module installs on every workstation.
## Its i3 lines are a contribution (changed 2026-10-05, novox/hq ADR 0212)
The module no longer writes a file into i3's `config.d`. Its window-manager lines (the source is still
under `files/i3/` where it had one) are a contribution to `node-display-session`. The i3 module places
them in its own configuration under a `# <module>` line, so this module depends on a window manager
being assigned beside it.
@@ -0,0 +1,97 @@
// Reading a tool's arguments: JSON numbers arrive as float64, and a missing argument is its default.
// The same in every desktop module that carries it.
package main
import (
"fmt"
"math"
"strings"
"time"
)
// text is a string argument, trimmed; required says an empty one is refused.
func text(args map[string]any, key string, required bool) (string, error) {
v, present := args[key]
if !present || v == nil {
if required {
return "", fmt.Errorf("%s is required", key)
}
return "", nil
}
s, ok := v.(string)
if !ok {
return "", fmt.Errorf("%s is a string, not %T", key, v)
}
s = strings.TrimSpace(s)
if s == "" && required {
return "", fmt.Errorf("%s is required", key)
}
return s, nil
}
// whole is a whole-number argument within [least, most], or def when absent.
func whole(args map[string]any, key string, def, least, most int) (int, error) {
v, present := args[key]
if !present || v == nil {
return def, nil
}
f, ok := v.(float64)
if !ok {
if i, isInt := v.(int); isInt {
f = float64(i)
} else {
return 0, fmt.Errorf("%s is a number, not %T", key, v)
}
}
if f != math.Trunc(f) {
return 0, fmt.Errorf("%s is a whole number, not %v", key, f)
}
n := int(f)
if n < least || n > most {
return 0, fmt.Errorf("%s is %d; it is between %d and %d", key, n, least, most)
}
return n, nil
}
// flag is a boolean argument, or def when absent.
func flag(args map[string]any, key string, def bool) (bool, error) {
v, present := args[key]
if !present || v == nil {
return def, nil
}
b, ok := v.(bool)
if !ok {
return false, fmt.Errorf("%s is true or false, not %T", key, v)
}
return b, nil
}
// texts is a list-of-strings argument.
func texts(args map[string]any, key string) ([]string, error) {
v, present := args[key]
if !present || v == nil {
return nil, nil
}
list, ok := v.([]any)
if !ok {
if ss, isStrings := v.([]string); isStrings {
return ss, nil
}
return nil, fmt.Errorf("%s is a list of strings, not %T", key, v)
}
out := make([]string, 0, len(list))
for i, item := range list {
s, ok := item.(string)
if !ok {
return nil, fmt.Errorf("%s[%d] is a string, not %T", key, i, item)
}
out = append(out, s)
}
return out, nil
}
// seconds is a timeout argument in seconds, defaulted and bounded below the runtime's call limit.
func seconds(args map[string]any, key string, def, most int) (time.Duration, error) {
n, err := whole(args, key, def, 1, most)
return time.Duration(n) * time.Second, err
}
@@ -0,0 +1,348 @@
package main
import (
"bufio"
"errors"
"fmt"
"os"
"os/user"
"path/filepath"
"sort"
"strconv"
"strings"
"syscall"
"time"
)
const (
mostCopy = 1 << 20
mostPaste = 64 << 10
// majorVersion is clipmenu's store layout: <dir>/clipmenu.<major>.<user>/.
majorVersion = 6
)
// account is the user clipmenu's store is named for.
func account() string {
for _, k := range []string{"USER", "MESH_OPERATOR_ACCOUNT", "LOGNAME"} {
if v := strings.TrimSpace(os.Getenv(k)); v != "" {
return v
}
}
if u, err := user.Current(); err == nil {
return u.Username
}
return ""
}
// storeDir is where clipmenud keeps the history: CM_DIR, else the account's runtime directory.
func storeDir(s Session) (string, error) {
base := os.Getenv("CM_DIR")
if base == "" {
base = s.RuntimeDir
}
if base == "" {
return "", fmt.Errorf("%w: the account's runtime directory, where the clipboard history lives, is missing (the account is not logged in)", ErrNoBus)
}
return filepath.Join(base, fmt.Sprintf("clipmenu.%d.%s", majorVersion, account())), nil
}
// cksum is POSIX cksum(1) of data: clipmenu names each entry's file by the cksum of its first line
// followed by a newline, as "<crc> <length>".
func cksum(data []byte) string {
var crc uint32
step := func(b byte) {
crc ^= uint32(b) << 24
for i := 0; i < 8; i++ {
if crc&0x80000000 != 0 {
crc = crc<<1 ^ 0x04C11DB7
} else {
crc <<= 1
}
}
}
for _, b := range data {
step(b)
}
for n := len(data); n != 0; n >>= 8 {
step(byte(n))
}
return fmt.Sprintf("%d %d", ^crc, len(data))
}
func entryID(line string) string { return cksum([]byte(line + "\n")) }
// Entry is one clip in the history.
type Entry struct {
ID string `json:"id"`
Line string `json:"line"`
At string `json:"at"`
Bytes int `json:"bytes"`
Text string `json:"text,omitempty"`
Truncated bool `json:"truncated,omitempty"`
at int64
}
// HistoryResult is what node-clipboard.history answers.
type HistoryResult struct {
Collecting bool `json:"collecting"`
Store string `json:"store"`
Total int `json:"total"`
Entries []Entry `json:"entries"`
}
// readStore reads clipmenu's line cache: one "<nanoseconds> <first line>" per copy, oldest first, a
// line repeated when the same thing was copied again. The newest copy of each line wins.
func readStore(dir string) ([]Entry, error) {
f, err := os.Open(filepath.Join(dir, "line_cache"))
if errors.Is(err, os.ErrNotExist) {
return []Entry{}, nil
}
if err != nil {
return nil, err
}
defer f.Close()
latest := map[string]int64{}
scan := bufio.NewScanner(f)
scan.Buffer(make([]byte, 64<<10), 1<<20)
for scan.Scan() {
stamp, line, ok := strings.Cut(scan.Text(), " ")
if !ok {
continue
}
ns, err := strconv.ParseInt(stamp, 10, 64)
if err != nil {
continue
}
if ns >= latest[line] {
latest[line] = ns
}
}
out := make([]Entry, 0, len(latest))
for line, ns := range latest {
out = append(out, Entry{ID: entryID(line), Line: line, at: ns, At: time.Unix(0, ns).Format(time.RFC3339)})
}
sort.Slice(out, func(i, j int) bool { return out[i].at > out[j].at })
return out, scan.Err()
}
// History is the clipboard's history, newest first.
func History(limit, maxBytes int) (HistoryResult, error) {
dir, err := storeDir(findEnvironment())
if err != nil {
return HistoryResult{}, err
}
entries, err := readStore(dir)
if err != nil {
return HistoryResult{}, err
}
out := HistoryResult{Collecting: len(processesOf("clipmenud")) > 0, Store: dir, Total: len(entries), Entries: []Entry{}}
for i, e := range entries {
if i == limit {
break
}
if info, err := os.Stat(filepath.Join(dir, e.ID)); err == nil {
e.Bytes = int(info.Size())
if maxBytes > 0 {
raw, _ := os.ReadFile(filepath.Join(dir, e.ID))
if len(raw) > maxBytes {
raw, e.Truncated = raw[:maxBytes], true
}
e.Text = string(raw)
}
}
out.Entries = append(out.Entries, e)
}
return out, nil
}
// CopyResult is what node-clipboard.copy answers.
type CopyResult struct {
Bytes int `json:"bytes"`
Unit string `json:"unit"`
}
// Copy puts text on the clipboard. The clipboard is owned by a process until another copies, so the
// owner (xsel) runs under the account's service manager, not as a child of this tool.
func Copy(text string) (CopyResult, error) {
if len(text) == 0 {
return CopyResult{}, errors.New("text is empty; to empty the clipboard's history, clipmenu_clear")
}
if len(text) > mostCopy {
return CopyResult{}, fmt.Errorf("%d bytes; the clipboard takes at most %d here", len(text), mostCopy)
}
s, err := findSession()
if err != nil {
return CopyResult{}, err
}
if s.RuntimeDir == "" {
return CopyResult{}, fmt.Errorf("%w: no runtime directory to hand the text over in", ErrNoBus)
}
// Handed over in a file only the account can read, which the owner reads and removes.
f, err := os.CreateTemp(s.RuntimeDir, "clipmenu-copy-")
if err != nil {
return CopyResult{}, err
}
if _, err := f.WriteString(text); err != nil {
f.Close()
os.Remove(f.Name())
return CopyResult{}, err
}
f.Close()
unit := uniqueUnit("clipmenu-copy")
script := `xsel --nodetach --input --clipboard < "$0" & sleep 1; rm -f "$0"; wait`
if err := s.detach(unit, "/bin/sh", "-c", script, f.Name()); err != nil {
os.Remove(f.Name())
return CopyResult{}, err
}
return CopyResult{Bytes: len(text), Unit: unit + ".service"}, nil
}
// PasteResult is what clipmenu_paste answers.
type PasteResult struct {
Text string `json:"text"`
Bytes int `json:"bytes"`
Truncated bool `json:"truncated,omitempty"`
}
// Paste reads the clipboard now.
func Paste() (PasteResult, error) {
s, err := findSession()
if err != nil {
return PasteResult{}, err
}
r, err := s.run(5*time.Second, "", "xsel", "--output", "--clipboard")
if err != nil {
return PasteResult{}, err
}
if r.Code != 0 {
return PasteResult{}, fmt.Errorf("xsel: %s", strings.TrimSpace(r.Stderr))
}
out := PasteResult{Text: r.Stdout, Bytes: len(r.Stdout), Truncated: r.Truncated}
if len(out.Text) > mostPaste {
out.Text, out.Truncated = out.Text[:mostPaste], true
}
return out, nil
}
// ChangeResult is what clear and delete answer.
type ChangeResult struct {
Removed int `json:"removed"`
Remaining int `json:"remaining"`
}
// withStoreLock holds clipmenu's own lock on its store, the one clipmenud and clipdel take, while
// change runs, so a copy arriving meanwhile is neither lost nor half-written.
func withStoreLock(dir string, change func() error) error {
lock, err := os.OpenFile(filepath.Join(dir, "lock"), os.O_CREATE|os.O_WRONLY, 0o600)
if err != nil {
return err
}
defer lock.Close()
deadline := time.Now().Add(2 * time.Second)
for {
err := syscall.Flock(int(lock.Fd()), syscall.LOCK_EX|syscall.LOCK_NB)
if err == nil {
break
}
if time.Now().After(deadline) {
return fmt.Errorf("the clipboard store is locked by clipmenud and did not come free within 2s")
}
time.Sleep(50 * time.Millisecond)
}
defer syscall.Flock(int(lock.Fd()), syscall.LOCK_UN)
return change()
}
func storeOf() (string, error) {
dir, err := storeDir(findEnvironment())
if err != nil {
return "", err
}
if _, err := os.Stat(dir); errors.Is(err, os.ErrNotExist) {
return "", fmt.Errorf("there is no clipboard history at %s: clipmenud has not run in this login", dir)
}
return dir, nil
}
// Clear forgets every entry and its text, keeping the store and its locks (clipdel's own clear
// removes the directory, the daemon's lock with it).
func Clear() (ChangeResult, error) {
dir, err := storeOf()
if err != nil {
return ChangeResult{}, err
}
var out ChangeResult
err = withStoreLock(dir, func() error {
entries, err := readStore(dir)
if err != nil {
return err
}
out.Removed = len(entries)
files, err := os.ReadDir(dir)
if err != nil {
return err
}
for _, f := range files {
switch f.Name() {
case "lock", "session_lock", "line_cache":
continue
}
if f.Type().IsRegular() {
if err := os.Remove(filepath.Join(dir, f.Name())); err != nil {
return err
}
}
}
return os.WriteFile(filepath.Join(dir, "line_cache"), nil, 0o600)
})
return out, err
}
// Delete forgets one entry, by id or by its first line.
func Delete(id, line string) (ChangeResult, error) {
if (id == "") == (line == "") {
return ChangeResult{}, errors.New("give the entry's id or its line, one of the two")
}
dir, err := storeOf()
if err != nil {
return ChangeResult{}, err
}
var out ChangeResult
err = withStoreLock(dir, func() error {
raw, err := os.ReadFile(filepath.Join(dir, "line_cache"))
if err != nil && !errors.Is(err, os.ErrNotExist) {
return err
}
var kept []string
for _, l := range strings.Split(strings.TrimRight(string(raw), "\n"), "\n") {
if l == "" {
continue
}
_, text, _ := strings.Cut(l, " ")
if text == line || (id != "" && entryID(text) == id) {
out.Removed++
_ = os.Remove(filepath.Join(dir, entryID(text)))
continue
}
kept = append(kept, l)
}
if out.Removed == 0 {
return fmt.Errorf("no entry %s%s in the clipboard history", id, line)
}
content := strings.Join(kept, "\n")
if content != "" {
content += "\n"
}
tmp := filepath.Join(dir, ".line_cache.mesh")
if err := os.WriteFile(tmp, []byte(content), 0o600); err != nil {
return err
}
return os.Rename(tmp, filepath.Join(dir, "line_cache"))
})
if err != nil {
return ChangeResult{}, err
}
entries, _ := readStore(dir)
out.Remaining = len(entries)
return out, nil
}
@@ -0,0 +1,163 @@
package main
import (
"errors"
"os"
"os/exec"
"path/filepath"
"strconv"
"strings"
"testing"
)
const nobody = 4194400
func TestEntryIdsAreWhatClipmenuNamesItsFiles(t *testing.T) {
for _, line := range []string{"hello", "", "two words (3 lines)", "ünïcode ✓", strings.Repeat("x", 300)} {
out, err := exec.Command("bash", "-c", `cksum <<< "$1"`, "_", line).Output()
if err != nil {
t.Skip("bash or cksum is missing here")
}
if got, want := entryID(line), strings.TrimSpace(string(out)); got != want {
t.Errorf("%q: %s, cksum says %s", line, got, want)
}
}
}
// store makes clipmenu's store as clipmenud leaves it, for the account the tools run as.
func store(t *testing.T, clips map[string]string, order ...string) string {
t.Helper()
fakeMachine(t)
runtime := filepath.Join(runUserDir, strconv.Itoa(os.Getuid()))
t.Setenv("USER", "op")
t.Setenv("CM_DIR", "")
dir := filepath.Join(runtime, "clipmenu.6.op")
if err := os.MkdirAll(dir, 0o700); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(runtime, "bus"), nil, 0o600); err != nil {
t.Fatal(err)
}
var cache strings.Builder
for i, line := range order {
cache.WriteString(strconv.FormatInt(1_700_000_000_000_000_000+int64(i)*1_000_000_000, 10) + " " + line + "\n")
if err := os.WriteFile(filepath.Join(dir, entryID(line)), []byte(clips[line]), 0o600); err != nil {
t.Fatal(err)
}
}
if err := os.WriteFile(filepath.Join(dir, "line_cache"), []byte(cache.String()), 0o600); err != nil {
t.Fatal(err)
}
return dir
}
func TestTheHistoryIsNewestFirstOnceEachWithItsText(t *testing.T) {
store(t, map[string]string{"first": "first", "second (2 lines)": "second\nline two"}, "first", "second (2 lines)", "first")
fakeProcess(t, nobody, "clipmenud")
h, err := History(10, 4096)
if err != nil {
t.Fatal(err)
}
if !h.Collecting || h.Total != 2 || h.Entries[0].Line != "first" || h.Entries[1].Text != "second\nline two" || h.Entries[1].Bytes != 15 {
t.Fatalf("%+v", h)
}
if h, _ := History(1, 3); len(h.Entries) != 1 || h.Entries[0].Text != "fir" || !h.Entries[0].Truncated {
t.Fatalf("limited and cut: %+v", h)
}
if h, _ := History(10, 0); h.Entries[0].Text != "" || h.Entries[0].Bytes != 5 {
t.Fatalf("without text: %+v", h)
}
}
func TestAnEntryIsDeletedByIdOrLineWithItsText(t *testing.T) {
dir := store(t, map[string]string{"a": "a", "b": "b", "c": "c"}, "a", "b", "c", "a")
r, err := Delete(entryID("a"), "")
if err != nil || r.Removed != 2 || r.Remaining != 2 {
t.Fatalf("by id, both copies: %+v, %v", r, err)
}
if _, err := os.Stat(filepath.Join(dir, entryID("a"))); !errors.Is(err, os.ErrNotExist) {
t.Fatal("the text stayed")
}
if r, err := Delete("", "b"); err != nil || r.Removed != 1 || r.Remaining != 1 {
t.Fatalf("by line: %+v, %v", r, err)
}
if _, err := Delete("", "zzz"); err == nil {
t.Fatal("a missing entry was reported deleted")
}
if _, err := Delete("x", "y"); err == nil {
t.Fatal("both an id and a line were accepted")
}
cache, _ := os.ReadFile(filepath.Join(dir, "line_cache"))
if !strings.HasSuffix(string(cache), " c\n") || strings.Count(string(cache), "\n") != 1 {
t.Fatalf("line cache: %q", cache)
}
}
func TestClearForgetsEverythingButKeepsTheDaemonsLocks(t *testing.T) {
dir := store(t, map[string]string{"a": "a", "b": "b"}, "a", "b")
if err := os.WriteFile(filepath.Join(dir, "session_lock"), nil, 0o600); err != nil {
t.Fatal(err)
}
r, err := Clear()
if err != nil || r.Removed != 2 {
t.Fatalf("%+v, %v", r, err)
}
left, _ := os.ReadDir(dir)
var names []string
for _, f := range left {
names = append(names, f.Name())
}
if strings.Join(names, ",") != "line_cache,lock,session_lock" {
t.Fatalf("left: %v", names)
}
}
func TestCopyHandsTheTextToAnOwnerUnderTheAccountsServiceManager(t *testing.T) {
store(t, nil)
fakeProcess(t, nobody, "i3", "DISPLAY=:1")
bin := fakeBinaries(t, map[string]string{"systemctl": "true", "systemd-run": `echo "$*" > "$LOG"; for last; do :; done; cat "$last" > "$LOG.text"`})
t.Setenv("LOG", filepath.Join(bin, "log"))
r, err := Copy("secret-free text")
if err != nil || r.Bytes != 16 || !strings.HasPrefix(r.Unit, "clipmenu-copy-") {
t.Fatalf("%+v, %v", r, err)
}
asked, _ := os.ReadFile(filepath.Join(bin, "log"))
if !strings.Contains(string(asked), "--setenv=DISPLAY=:1 -- /bin/sh -c xsel --nodetach --input --clipboard") {
t.Fatalf("asked: %s", asked)
}
handed, _ := os.ReadFile(filepath.Join(bin, "log.text"))
if string(handed) != "secret-free text" {
t.Fatalf("handed over: %q", handed)
}
if _, err := Copy(""); err == nil {
t.Fatal("empty text was accepted")
}
}
func TestPasteReadsTheClipboardOrSaysThereIsNoSession(t *testing.T) {
fakeMachine(t)
if _, err := Paste(); !errors.Is(err, ErrNoSession) {
t.Fatal(err)
}
fakeProcess(t, nobody, "i3", "DISPLAY=:1")
fakeBinaries(t, map[string]string{"xsel": `[ "$*" = "--output --clipboard" ] && printf 'on the clipboard'`})
p, err := Paste()
if err != nil || p.Text != "on the clipboard" || p.Bytes != 16 {
t.Fatalf("%+v, %v", p, err)
}
}
func TestWithoutAStoreTheChangesSayWhy(t *testing.T) {
fakeMachine(t)
runtime := filepath.Join(runUserDir, strconv.Itoa(os.Getuid()))
if err := os.MkdirAll(runtime, 0o700); err != nil {
t.Fatal(err)
}
t.Setenv("USER", "op")
if _, err := Clear(); err == nil || !strings.Contains(err.Error(), "clipmenud has not run") {
t.Fatal(err)
}
if h, err := History(5, 0); err != nil || h.Total != 0 || h.Collecting {
t.Fatalf("an empty history: %+v, %v", h, err)
}
}
@@ -0,0 +1,90 @@
// clipmenu's Go tools bundle (novox/hq ADR 0188, ADR 0193, ADR 0208): its implementation of
// node-clipboard's verbs `history` and `copy`, and its own tools, served by the node's runtime as the
// operator account. The history is read from clipmenu's own store in the account's runtime directory;
// the clipboard itself is the X session's.
package main
import (
"fmt"
"os"
stdio "git.novox.be/novox/mesh-sdk/go"
)
func main() {
if err := stdio.Serve("", tools()); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
}
func tools() []stdio.Tool {
return []stdio.Tool{
{
Name: "node-clipboard.history",
Description: "What the operator copied, newest first: each entry's id, its first line, when, its size " +
"and its text (each cut at max_bytes). Whether the clipboard daemon is collecting.",
Input: map[string]any{
"limit": map[string]any{"type": "integer", "description": "at most this many entries (default 20, at most 500)"},
"max_bytes": map[string]any{"type": "integer", "description": "cut each entry's text at this many bytes; 0 leaves the text out (default 4096, at most 65536)"},
},
Run: func(args map[string]any) (any, error) {
limit, err := whole(args, "limit", 20, 1, 500)
if err != nil {
return nil, err
}
most, err := whole(args, "max_bytes", 4096, 0, 65536)
if err != nil {
return nil, err
}
return History(limit, most)
},
},
{
Name: "node-clipboard.copy",
Description: "Put text on the operator's clipboard, as if they had copied it; it enters the history " +
"like any copy. Answers how many bytes.",
Input: map[string]any{
"type": "object",
"properties": map[string]any{
"text": map[string]any{"type": "string", "description": fmt.Sprintf("the text (at most %d bytes)", mostCopy)},
},
"required": []string{"text"},
},
Run: func(args map[string]any) (any, error) {
t, ok := args["text"].(string)
if !ok {
return nil, fmt.Errorf("text is required, as a string")
}
return Copy(t)
},
},
{
Name: "clipmenu_paste",
Description: "What the operator's clipboard holds right now, as text (cut at 64 KiB, said in truncated).",
Run: func(map[string]any) (any, error) { return Paste() },
},
{
Name: "clipmenu_clear",
Description: "Forget the whole clipboard history. What is on the clipboard now stays there.",
Run: func(map[string]any) (any, error) { return Clear() },
},
{
Name: "clipmenu_delete",
Description: "Forget one entry of the clipboard history, by its id as the history answers it, or by " +
"its first line exactly.",
Input: map[string]any{
"id": map[string]any{"type": "string", "description": "the entry's id"},
"line": map[string]any{"type": "string", "description": "the entry's first line, exactly"},
},
Run: func(args map[string]any) (any, error) {
id, err := text(args, "id", false)
if err != nil {
return nil, err
}
line, _ := args["line"].(string)
return Delete(id, line)
},
},
}
}
@@ -0,0 +1,206 @@
package main
import (
"encoding/json"
"os"
"path/filepath"
"strings"
"testing"
)
// The module's manifest, read the way the catalogue reads it, for the manifest tests. The same in
// every desktop module that carries it.
type manifest struct {
Module string `json:"module"`
Version string `json:"version"`
Capabilities []string `json:"capabilities"`
Requires []string `json:"requires"`
Claims []claim `json:"claims"`
Seats []any `json:"seats"`
Tools []string `json:"tools"`
Environment *environment `json:"environment"`
Shell []shellCode `json:"shell"`
Resources []map[string]any `json:"resources"`
// Lines for other modules' seats (novox/hq ADR 0212): the window manager's, here.
Contributions []contribution `json:"contributions"`
Build struct {
Artifacts []map[string]any `json:"artifacts"`
} `json:"build"`
}
type claim struct {
Name string `json:"name"`
Scope string `json:"scope"`
Serves []string `json:"serves"`
}
type environment struct {
Variables map[string]string `json:"variables"`
Path []map[string]any `json:"path"`
}
type shellCode struct {
For string `json:"for"`
Slot string `json:"slot"`
Code string `json:"code"`
}
func readManifest(t *testing.T) manifest {
t.Helper()
raw, err := os.ReadFile(filepath.Join("..", "..", "module.json"))
if err != nil {
t.Fatal(err)
}
dec := json.NewDecoder(strings.NewReader(string(raw)))
dec.DisallowUnknownFields()
var m manifest
if err := dec.Decode(&m); err != nil {
t.Fatalf("module.json: %v", err)
}
return m
}
func (m manifest) resource(t *testing.T, id string) map[string]any {
t.Helper()
for _, r := range m.Resources {
if r["id"] == id {
return r
}
}
t.Fatalf("no resource %q", id)
return nil
}
func (m manifest) packages() (present, absent []string) {
for _, r := range m.Resources {
if r["type"] == "package" {
if r["absent"] == true {
absent = append(absent, r["package"].(string))
} else {
present = append(present, r["package"].(string))
}
}
}
return present, absent
}
// sameAsSource checks that a file resource's content is byte for byte the module's source file, so
// the readable file in the repository is what the machine gets.
func (m manifest) sameAsSource(t *testing.T, id, source string) {
t.Helper()
want, err := os.ReadFile(filepath.Join("..", "..", source))
if err != nil {
t.Fatal(err)
}
r := m.resource(t, id)
if r["type"] != "file" {
t.Fatalf("%s is a %v, not a file", id, r["type"])
}
if got, _ := r["content"].(string); got != string(want) {
t.Fatalf("resource %s's content is not %s: edit the source and copy it into module.json", id, source)
}
if r["owner"] != "${machine:account}" && !strings.HasPrefix(r["path"].(string), "/etc/") {
t.Fatalf("%s under the home is the account's", id)
}
}
// checkTheToolsAgree checks that the manifest lists the module's own tools exactly, that the bundle
// serves each seat verb the claims promise as <seat>.<verb>, and that the Go bundle is declared.
func checkTheToolsAgree(t *testing.T, m manifest) {
t.Helper()
own, seat := map[string]bool{}, map[string]bool{}
for _, tool := range tools() {
if strings.Contains(tool.Name, ".") {
seat[tool.Name] = true
} else {
own[tool.Name] = true
}
if strings.TrimSpace(tool.Description) == "" {
t.Errorf("%s has no description", tool.Name)
}
}
listed := map[string]bool{}
for _, name := range m.Tools {
listed[name] = true
if !own[name] {
t.Errorf("module.json lists %s, which the bundle does not serve", name)
}
}
for name := range own {
if !listed[name] {
t.Errorf("the bundle serves %s, which module.json does not list", name)
}
if !strings.HasPrefix(name, strings.ReplaceAll(m.Module, "-", "_")+"_") {
t.Errorf("%s is not prefixed with the module's name", name)
}
}
promised := map[string]bool{}
for _, c := range m.Claims {
for _, verb := range c.Serves {
promised[c.Name+"."+verb] = true
if !seat[c.Name+"."+verb] {
t.Errorf("the claim on %s promises %s, which the bundle does not serve", c.Name, verb)
}
}
}
for name := range seat {
if !promised[name] {
t.Errorf("the bundle serves %s, which no claim promises", name)
}
}
var bundle map[string]any
for _, a := range m.Build.Artifacts {
if a["kind"] == "bundle" {
bundle = a
}
}
if bundle == nil || bundle["language"] != "go" || bundle["system"] != "arch" ||
bundle["from"] != "cmd/"+m.Module+"-tools" || bundle["binary"] != m.Module+"-tools" {
t.Errorf("the Go tools bundle: %v", bundle)
}
}
// checkNoSecretsOrInstallationNames refuses what a catalogue manifest must never carry.
func checkNoSecretsOrInstallationNames(t *testing.T) {
t.Helper()
raw, err := os.ReadFile(filepath.Join("..", "..", "module.json"))
if err != nil {
t.Fatal(err)
}
s := strings.ToLower(string(raw))
for _, never := range []string{"/home/", "jochen", "g14", "shanks", "novox.be", "api_key", ".hal/", "greenclip daemon"} {
if strings.Contains(s, never) {
t.Errorf("module.json names %q", never)
}
}
}
type contribution struct {
Seat string `json:"seat"`
Kind string `json:"kind"`
Content string `json:"content"`
}
// i3Lines is what the module contributes to the window manager.
func (m manifest) i3Lines() string {
var out string
for _, c := range m.Contributions {
if c.Seat == "node-display-session" && c.Kind == "config" {
out += c.Content
}
}
return out
}
// i3LinesAreSource checks the window-manager contribution is the source file it is written from.
func (m manifest) i3LinesAreSource(t *testing.T, source string) {
t.Helper()
want, err := os.ReadFile(filepath.Join("..", "..", source))
if err != nil {
t.Fatal(err)
}
if m.i3Lines() != string(want) {
t.Fatalf("the contribution to node-display-session is not %s: edit the source and copy it into module.json", source)
}
}
@@ -0,0 +1,66 @@
package main
import (
"reflect"
"strings"
"testing"
)
// clipmenu's shape (novox/hq ADR 0208, research 026/04): it claims node-clipboard serving history
// and copy, requires the X display on its own machine, replaces greenclip, starts its daemon once
// from the session's start, and binds its menu as an i3 drop-in through the launcher's dmenu command.
func TestItClaimsTheClipboardSeatServingHistoryAndCopy(t *testing.T) {
m := readManifest(t)
if m.Module != "clipmenu" || m.Seats != nil {
t.Fatalf("module %q declares seats %v", m.Module, m.Seats)
}
if !reflect.DeepEqual(m.Claims, []claim{{Name: "node-clipboard", Scope: "node", Serves: []string{"history", "copy"}}}) {
t.Fatalf("claims: %+v", m.Claims)
}
if !reflect.DeepEqual(m.Requires, []string{"x11-display"}) {
t.Fatalf("requires: %v", m.Requires)
}
present, absent := m.packages()
if !reflect.DeepEqual(present, []string{"clipmenu"}) || !reflect.DeepEqual(absent, []string{"rofi-greenclip"}) {
t.Fatalf("packages: %v, absent %v", present, absent)
}
}
func TestTheDaemonStartsOnceFromTheSessionsStart(t *testing.T) {
m := readManifest(t)
if len(m.Shell) != 1 || m.Shell[0].For != "xinitrc" || m.Shell[0].Slot != "normal" {
t.Fatalf("%+v", m.Shell)
}
code := m.Shell[0].Code
// Started once, through the module's text-only xsel (a selection holding an image is never read).
if strings.Count(code, "clipmenud &\n") != 1 || !strings.Contains(code, `PATH="/usr/local/lib/mesh-clipmenu:$PATH" clipmenud &`) ||
strings.Contains(code, "greenclip") {
t.Fatalf("%q", code)
}
for _, r := range m.Resources {
if r["type"] == "service" || r["type"] == "process" {
t.Fatalf("a second start: %v", r)
}
}
}
func TestItsSettingsAreEnvironmentAndItsMenuIsTheLaunchersDmenu(t *testing.T) {
m := readManifest(t)
if !reflect.DeepEqual(m.Environment.Variables, map[string]string{"CM_SELECTIONS": "clipboard", "CM_MAX_CLIPS": "500", "CM_HISTLENGTH": "15"}) {
t.Fatalf("%v", m.Environment.Variables)
}
if _, set := m.Environment.Variables["CM_LAUNCHER"]; set {
t.Fatal("the launcher is clipmenu's default, dmenu: the seat's command")
}
m.i3LinesAreSource(t, "files/i3/50-clipmenu.conf")
if c := m.i3Lines(); !strings.Contains(c, "bindsym $mod+period exec --no-startup-id clipmenu") {
t.Fatalf("%s", c)
}
}
func TestTheToolsAgreeWithTheManifest(t *testing.T) {
m := readManifest(t)
checkTheToolsAgree(t, m)
checkNoSecretsOrInstallationNames(t)
}
@@ -0,0 +1,423 @@
// The operator's graphical session, as a tool the node's runtime runs finds it (novox/hq ADR 0208).
//
// The runtime is a system service running as the operator account (ADR 0175): it has the account's
// uid and none of the session's environment — no DISPLAY, no XAUTHORITY, no session bus. A tool that
// draws on the screen or talks to the desktop's D-Bus must find them. It reads them from a process of
// the account that is part of the session (the window manager first), the same thing `loginctl` and
// a person's own shell would point at, and says where it found them.
//
// Long-lived programs a tool starts go to the account's own service manager through `systemd-run
// --user`, never as children of the tool: the runtime's unit is a cgroup the service manager empties
// whenever the runtime restarts, and a compositor or a clipboard owner started from inside it would
// die with it.
//
// This file is the same in every desktop module that carries it; it moves into the Go SDK once a
// second consumer outside the desktop wants it.
package main
import (
"bytes"
"errors"
"fmt"
"os"
"os/exec"
"path/filepath"
"sort"
"strconv"
"strings"
"syscall"
"time"
)
// Where the session is looked for. Variables so a test can point them at a fake tree.
var (
procRoot = "/proc"
runUserDir = "/run/user"
x11Sockets = "/tmp/.X11-unix"
)
// sessionHolders are the processes whose environment is the session's, best first: the window
// manager is the session, the rest are its children. Anything else carrying DISPLAY ranks after them.
var sessionHolders = []string{"i3", "sway", "i3bar", "picom", "xss-lock", "dunst", "clipmenud", "xterm"}
// sessionKeys are the variables a session carries that a tool hands on to what it runs.
var sessionKeys = []string{"DISPLAY", "XAUTHORITY", "WAYLAND_DISPLAY", "DBUS_SESSION_BUS_ADDRESS",
"XDG_RUNTIME_DIR", "XDG_SESSION_ID", "I3SOCK"}
// Session is what a tool needs to reach the operator's desktop.
type Session struct {
UID int `json:"uid"`
Display string `json:"display,omitempty"`
XAuthority string `json:"xauthority,omitempty"`
Wayland string `json:"wayland_display,omitempty"`
Bus string `json:"bus,omitempty"`
RuntimeDir string `json:"runtime_dir,omitempty"`
SessionID string `json:"session_id,omitempty"`
I3Sock string `json:"i3sock,omitempty"`
// From says where the values were found: the tool's own environment, a process, or the socket.
From string `json:"from"`
}
// ErrNoSession is answered by a tool that needs the desktop when nobody is logged in to it.
var ErrNoSession = errors.New("no graphical session")
// ErrTimedOut is what run answers for a command ended because it ran past its time.
var ErrTimedOut = errors.New("timed out")
// ErrNoBus is answered by a tool that needs the session bus when the account has none.
var ErrNoBus = errors.New("no session bus")
// operatorHome is the account's home: what the runtime was told, else the process's own.
func operatorHome() string {
if h := strings.TrimSpace(os.Getenv("MESH_OPERATOR_HOME")); h != "" {
return h
}
h, _ := os.UserHomeDir()
return h
}
// findSession finds the graphical session of the account this tool runs as, or answers
// ErrNoSession with what it looked at.
func findSession() (Session, error) {
s := findEnvironment()
if s.Display == "" && s.Wayland == "" {
return s, fmt.Errorf("%w for uid %d on this machine: no process of the account carries DISPLAY "+
"or WAYLAND_DISPLAY, and no X server socket in %s has an authority file to go with it. "+
"Is anyone logged in to the desktop?", ErrNoSession, s.UID, x11Sockets)
}
return s, nil
}
// findBus finds the account's session bus, which a logged-in account has whether or not a desktop
// is running.
func findBus() (Session, error) {
s := findEnvironment()
if s.Bus == "" {
return s, fmt.Errorf("%w for uid %d: DBUS_SESSION_BUS_ADDRESS is not set and %s does not exist "+
"(the account is not logged in)", ErrNoBus, s.UID, filepath.Join(runUserDir, strconv.Itoa(s.UID), "bus"))
}
return s, nil
}
func findEnvironment() Session {
uid := os.Getuid()
s := Session{UID: uid}
own := map[string]string{}
for _, k := range sessionKeys {
own[k] = os.Getenv(k)
}
if own["DISPLAY"] != "" || own["WAYLAND_DISPLAY"] != "" {
s.fill(own)
s.From = "the tool's own environment"
} else if pid, comm, env, ok := sessionProcess(uid); ok {
s.fill(env)
s.From = fmt.Sprintf("process %s (pid %d)", comm, pid)
} else if display, ok := lonelyX11Socket(); ok {
if a := filepath.Join(operatorHome(), ".Xauthority"); exists(a) {
s.Display, s.XAuthority = display, a
s.From = "the X server socket and the account's ~/.Xauthority"
}
s.fill(own)
} else {
s.fill(own)
s.From = "nothing: no session found"
}
// The bus and the runtime directory are the account's, whether or not the process named them.
runtime := filepath.Join(runUserDir, strconv.Itoa(uid))
if s.RuntimeDir == "" && exists(runtime) {
s.RuntimeDir = runtime
}
if s.Bus == "" && s.RuntimeDir != "" && exists(filepath.Join(s.RuntimeDir, "bus")) {
s.Bus = "unix:path=" + filepath.Join(s.RuntimeDir, "bus")
}
return s
}
func (s *Session) fill(env map[string]string) {
set := func(dst *string, key string) {
if *dst == "" {
*dst = env[key]
}
}
set(&s.Display, "DISPLAY")
set(&s.XAuthority, "XAUTHORITY")
set(&s.Wayland, "WAYLAND_DISPLAY")
set(&s.Bus, "DBUS_SESSION_BUS_ADDRESS")
set(&s.RuntimeDir, "XDG_RUNTIME_DIR")
set(&s.SessionID, "XDG_SESSION_ID")
set(&s.I3Sock, "I3SOCK")
}
// sessionProcess is the best process of this uid whose environment names a display.
func sessionProcess(uid int) (int, string, map[string]string, bool) {
entries, err := os.ReadDir(procRoot)
if err != nil {
return 0, "", nil, false
}
type candidate struct {
pid int
comm string
env map[string]string
rank int
}
var found []candidate
for _, e := range entries {
pid, err := strconv.Atoi(e.Name())
if err != nil {
continue
}
dir := filepath.Join(procRoot, e.Name())
if owner, ok := ownerOf(dir); !ok || owner != uid {
continue
}
raw, err := os.ReadFile(filepath.Join(dir, "environ"))
if err != nil {
continue
}
env := parseEnviron(raw)
if env["DISPLAY"] == "" && env["WAYLAND_DISPLAY"] == "" {
continue
}
comm := readTrimmed(filepath.Join(dir, "comm"))
rank := len(sessionHolders)
for i, h := range sessionHolders {
if h == comm {
rank = i
break
}
}
found = append(found, candidate{pid, comm, env, rank})
}
if len(found) == 0 {
return 0, "", nil, false
}
sort.Slice(found, func(i, j int) bool {
if found[i].rank != found[j].rank {
return found[i].rank < found[j].rank
}
return found[i].pid > found[j].pid // the newer of two equals
})
best := found[0]
return best.pid, best.comm, best.env, true
}
func parseEnviron(raw []byte) map[string]string {
env := map[string]string{}
for _, kv := range bytes.Split(raw, []byte{0}) {
if i := bytes.IndexByte(kv, '='); i > 0 {
env[string(kv[:i])] = string(kv[i+1:])
}
}
return env
}
func ownerOf(path string) (int, bool) {
info, err := os.Stat(path)
if err != nil {
return 0, false
}
st, ok := info.Sys().(*syscall.Stat_t)
if !ok {
return 0, false
}
return int(st.Uid), true
}
// lonelyX11Socket is the display of the one X server socket there is, when there is exactly one.
func lonelyX11Socket() (string, bool) {
entries, err := os.ReadDir(x11Sockets)
if err != nil {
return "", false
}
var displays []string
for _, e := range entries {
if n := strings.TrimPrefix(e.Name(), "X"); n != e.Name() {
if _, err := strconv.Atoi(n); err == nil {
displays = append(displays, ":"+n)
}
}
}
if len(displays) != 1 {
return "", false
}
return displays[0], true
}
func readTrimmed(path string) string {
b, err := os.ReadFile(path)
if err != nil {
return ""
}
return strings.TrimSpace(string(b))
}
func exists(path string) bool {
_, err := os.Stat(path)
return err == nil
}
// Env is this process's environment with the session's variables in place of its own.
func (s Session) Env() []string {
drop := map[string]bool{}
for _, k := range sessionKeys {
drop[k] = true
}
var env []string
for _, kv := range os.Environ() {
if i := strings.IndexByte(kv, '='); i > 0 && drop[kv[:i]] {
continue
}
env = append(env, kv)
}
add := func(k, v string) {
if v != "" {
env = append(env, k+"="+v)
}
}
add("DISPLAY", s.Display)
add("XAUTHORITY", s.XAuthority)
add("WAYLAND_DISPLAY", s.Wayland)
add("DBUS_SESSION_BUS_ADDRESS", s.Bus)
add("XDG_RUNTIME_DIR", s.RuntimeDir)
add("XDG_SESSION_ID", s.SessionID)
add("I3SOCK", s.I3Sock)
return env
}
// mostOutput bounds what a command may answer with, per stream.
const mostOutput = 256 << 10
// Result is what a command did.
type Result struct {
Stdout string `json:"stdout"`
Stderr string `json:"stderr,omitempty"`
Code int `json:"code"`
Truncated bool `json:"truncated,omitempty"`
}
// run runs a command in the session's environment, its input given, ended with everything it
// started after timeout. A command that is not installed is an error naming it; one that exits
// non-zero is a Result with its code, for the caller to judge.
func (s Session) run(timeout time.Duration, stdin string, name string, args ...string) (Result, error) {
path, err := exec.LookPath(name)
if err != nil {
return Result{}, fmt.Errorf("%s is not installed on this machine", name)
}
cmd := exec.Command(path, args...)
cmd.Env = s.Env()
if home := operatorHome(); exists(home) {
cmd.Dir = home
}
if stdin != "" {
cmd.Stdin = strings.NewReader(stdin)
}
var out, errOut capped
cmd.Stdout, cmd.Stderr = &out, &errOut
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
if err := cmd.Start(); err != nil {
return Result{}, fmt.Errorf("%s: %w", name, err)
}
done := make(chan error, 1)
go func() { done <- cmd.Wait() }()
select {
case err = <-done:
case <-time.After(timeout):
_ = syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL)
<-done
return Result{Stdout: out.String(), Stderr: errOut.String()},
fmt.Errorf("%s did not finish within %s and was ended: %w", name, timeout, ErrTimedOut)
}
r := Result{Stdout: out.String(), Stderr: errOut.String(), Truncated: out.cut || errOut.cut}
var exit *exec.ExitError
if errors.As(err, &exit) {
r.Code = exit.ExitCode()
} else if err != nil {
return r, fmt.Errorf("%s: %w", name, err)
}
return r, nil
}
// detach starts a long-lived program under the account's own service manager, as a transient unit
// that carries the session's display, so it outlives the runtime that asked for it. A unit already
// running under the same name is stopped first, so a fixed name means "at most one".
func (s Session) detach(unit string, args ...string) error {
if s.RuntimeDir == "" {
return fmt.Errorf("%w: the account's runtime directory is missing, so its service manager "+
"cannot be reached", ErrNoBus)
}
_, _ = s.run(5*time.Second, "", "systemctl", "--user", "stop", unit+".service")
call := []string{"--user", "--collect", "--quiet", "--unit=" + unit}
for _, kv := range [][2]string{{"DISPLAY", s.Display}, {"XAUTHORITY", s.XAuthority},
{"WAYLAND_DISPLAY", s.Wayland}, {"XDG_SESSION_ID", s.SessionID}, {"I3SOCK", s.I3Sock}} {
if kv[1] != "" {
call = append(call, "--setenv="+kv[0]+"="+kv[1])
}
}
call = append(call, "--")
call = append(call, args...)
r, err := s.run(10*time.Second, "", "systemd-run", call...)
if err != nil {
return err
}
if r.Code != 0 {
return fmt.Errorf("systemd-run %s: %s", unit, strings.TrimSpace(r.Stderr))
}
return nil
}
// uniqueUnit is a transient unit name that will not collide with an earlier one.
func uniqueUnit(prefix string) string {
return fmt.Sprintf("%s-%d", prefix, time.Now().UnixNano())
}
type capped struct {
bytes.Buffer
cut bool
}
func (c *capped) Write(p []byte) (int, error) {
if room := mostOutput - c.Len(); room < len(p) {
if room > 0 {
c.Buffer.Write(p[:room])
}
c.cut = true
return len(p), nil
}
return c.Buffer.Write(p)
}
// processesOf are the pids of this uid's processes whose command name is comm, oldest first.
func processesOf(comm string) []int {
entries, err := os.ReadDir(procRoot)
if err != nil {
return nil
}
uid := os.Getuid()
var pids []int
for _, e := range entries {
pid, err := strconv.Atoi(e.Name())
if err != nil {
continue
}
dir := filepath.Join(procRoot, e.Name())
if owner, ok := ownerOf(dir); !ok || owner != uid {
continue
}
if readTrimmed(filepath.Join(dir, "comm")) == comm {
pids = append(pids, pid)
}
}
sort.Ints(pids)
return pids
}
// signalAll sends sig to every process of this uid named comm, and answers the pids it reached.
func signalAll(comm string, sig syscall.Signal) []int {
var reached []int
for _, pid := range processesOf(comm) {
if syscall.Kill(pid, sig) == nil {
reached = append(reached, pid)
}
}
return reached
}
@@ -0,0 +1,174 @@
package main
import (
"errors"
"os"
"path/filepath"
"strconv"
"strings"
"testing"
"time"
)
// fakeMachine points the session finder at a temporary /proc, /run/user and X socket directory, with
// none of the test process's own session variables, and gives back the root.
func fakeMachine(t *testing.T) string {
t.Helper()
root := t.TempDir()
procRoot, runUserDir, x11Sockets = filepath.Join(root, "proc"), filepath.Join(root, "run-user"), filepath.Join(root, "x11")
for _, d := range []string{procRoot, runUserDir, x11Sockets} {
if err := os.MkdirAll(d, 0o755); err != nil {
t.Fatal(err)
}
}
for _, k := range sessionKeys {
t.Setenv(k, "")
}
t.Setenv("MESH_OPERATOR_HOME", filepath.Join(root, "home"))
t.Cleanup(func() { procRoot, runUserDir, x11Sockets = "/proc", "/run/user", "/tmp/.X11-unix" })
return root
}
func fakeProcess(t *testing.T, pid int, comm string, env ...string) {
t.Helper()
dir := filepath.Join(procRoot, strconv.Itoa(pid))
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(dir, "comm"), []byte(comm+"\n"), 0o644); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(dir, "environ"), []byte(strings.Join(env, "\x00")+"\x00"), 0o600); err != nil {
t.Fatal(err)
}
}
func TestTheSessionIsReadFromTheWindowManagerBeforeAnyOtherProcess(t *testing.T) {
fakeMachine(t)
fakeProcess(t, 900, "xterm", "DISPLAY=:9", "XAUTHORITY=/elsewhere")
fakeProcess(t, 100, "i3", "DISPLAY=:1", "XAUTHORITY=/home/op/.Xauthority",
"DBUS_SESSION_BUS_ADDRESS=unix:path=/run/user/1000/bus", "XDG_SESSION_ID=3", "SECRET_TOKEN=never-copied")
fakeProcess(t, 50, "bash", "PATH=/usr/bin")
s, err := findSession()
if err != nil {
t.Fatal(err)
}
if s.Display != ":1" || s.XAuthority != "/home/op/.Xauthority" || s.SessionID != "3" || !strings.Contains(s.From, "i3 (pid 100)") {
t.Fatalf("the window manager's environment: %+v", s)
}
for _, kv := range s.Env() {
if strings.HasPrefix(kv, "SECRET_TOKEN=") {
t.Fatal("a variable of the session process that is not a session variable was handed on")
}
}
}
func TestAnyProcessCarryingADisplayServesWhenTheWindowManagerIsNotFound(t *testing.T) {
fakeMachine(t)
fakeProcess(t, 10, "firefox", "DISPLAY=:0")
fakeProcess(t, 20, "firefox", "DISPLAY=:2")
s, err := findSession()
if err != nil || s.Display != ":2" {
t.Fatalf("the newest of two equals: %+v, %v", s, err)
}
}
func TestNoSessionIsAClearAnswerNotAGuess(t *testing.T) {
fakeMachine(t)
fakeProcess(t, 10, "sshd", "PATH=/usr/bin")
_, err := findSession()
if !errors.Is(err, ErrNoSession) || !strings.Contains(err.Error(), "logged in to the desktop") {
t.Fatalf("no session: %v", err)
}
}
func TestOneXSocketAndTheAccountsAuthorityFileAreASession(t *testing.T) {
root := fakeMachine(t)
if err := os.WriteFile(filepath.Join(x11Sockets, "X0"), nil, 0o644); err != nil {
t.Fatal(err)
}
if err := os.MkdirAll(filepath.Join(root, "home"), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(root, "home", ".Xauthority"), nil, 0o600); err != nil {
t.Fatal(err)
}
s, err := findSession()
if err != nil || s.Display != ":0" || !strings.HasSuffix(s.XAuthority, "/home/.Xauthority") {
t.Fatalf("socket and authority: %+v, %v", s, err)
}
}
func TestTheBusIsTheAccountsRuntimeDirectoryWhenNoProcessNamesIt(t *testing.T) {
fakeMachine(t)
runtime := filepath.Join(runUserDir, strconv.Itoa(os.Getuid()))
if _, err := findBus(); !errors.Is(err, ErrNoBus) {
t.Fatalf("no runtime directory is no bus: %v", err)
}
if err := os.MkdirAll(runtime, 0o700); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(runtime, "bus"), nil, 0o600); err != nil {
t.Fatal(err)
}
s, err := findBus()
if err != nil || s.Bus != "unix:path="+filepath.Join(runtime, "bus") || s.RuntimeDir != runtime {
t.Fatalf("bus: %+v, %v", s, err)
}
env := strings.Join(s.Env(), "\n")
if !strings.Contains(env, "XDG_RUNTIME_DIR="+runtime) || !strings.Contains(env, "DBUS_SESSION_BUS_ADDRESS=unix:path=") {
t.Fatalf("the bus is handed on: %s", env)
}
}
func TestACommandIsBoundedAndANonZeroExitIsAResult(t *testing.T) {
fakeMachine(t)
s := Session{}
r, err := s.run(5*time.Second, "in", "sh", "-c", "cat; echo err >&2; exit 3")
if err != nil || r.Stdout != "in" || r.Code != 3 || strings.TrimSpace(r.Stderr) != "err" {
t.Fatalf("result: %+v, %v", r, err)
}
start := time.Now()
if _, err := s.run(200*time.Millisecond, "", "sh", "-c", "sleep 30 & sleep 30"); err == nil || time.Since(start) > 5*time.Second {
t.Fatalf("a command past its time is ended with what it started: %v after %s", err, time.Since(start))
}
if _, err := s.run(time.Second, "", "no-such-program-here"); err == nil || !strings.Contains(err.Error(), "not installed") {
t.Fatalf("a missing program: %v", err)
}
}
func TestDetachAsksTheAccountsServiceManagerWithTheSessionsDisplay(t *testing.T) {
fakeMachine(t)
bin := fakeBinaries(t, map[string]string{
"systemctl": `echo "systemctl $*" >> "$LOG"`,
"systemd-run": `echo "systemd-run $*" >> "$LOG"`,
})
log := filepath.Join(bin, "log")
t.Setenv("LOG", log)
s := Session{Display: ":1", XAuthority: "/x", RuntimeDir: "/run/user/1"}
if err := s.detach("picom-session", "picom", "--config", "/c"); err != nil {
t.Fatal(err)
}
got, _ := os.ReadFile(log)
want := "systemctl --user stop picom-session.service\n" +
"systemd-run --user --collect --quiet --unit=picom-session --setenv=DISPLAY=:1 --setenv=XAUTHORITY=/x -- picom --config /c\n"
if string(got) != want {
t.Fatalf("detach ran:\n%s\nwant:\n%s", got, want)
}
if err := (Session{}).detach("x", "y"); !errors.Is(err, ErrNoBus) {
t.Fatalf("no runtime directory: %v", err)
}
}
// fakeBinaries puts shell scripts named for programs first on PATH, and answers their directory.
func fakeBinaries(t *testing.T, scripts map[string]string) string {
t.Helper()
dir := t.TempDir()
for name, body := range scripts {
if err := os.WriteFile(filepath.Join(dir, name), []byte("#!/bin/sh\n"+body+"\n"), 0o755); err != nil {
t.Fatal(err)
}
}
t.Setenv("PATH", dir+string(os.PathListSeparator)+os.Getenv("PATH"))
return dir
}
@@ -0,0 +1,5 @@
# The clipboard's history key (module clipmenu, novox/hq ADR 0208). Owned by the mesh: replaced at
# every push. clipmenu shows the history through `dmenu`, the node's dmenu-compatible command, which
# the holder of node-launcher answers (rofi on the workstations); the chosen entry is put back on the
# clipboard.
bindsym $mod+period exec --no-startup-id clipmenu -p Clipboard
+5
View File
@@ -0,0 +1,5 @@
module clipmenu
go 1.22
require git.novox.be/novox/mesh-sdk/go v0.1.7
+2
View File
@@ -0,0 +1,2 @@
git.novox.be/novox/mesh-sdk/go v0.1.7 h1:C0sTQmtTiyYH7bnqZb7PusXnqA37gKuT7Nqjn9gG47w=
git.novox.be/novox/mesh-sdk/go v0.1.7/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY=
+81
View File
@@ -0,0 +1,81 @@
{
"module": "clipmenu",
"version": "1",
"capabilities": [
"package-manager"
],
"requires": [
"x11-display"
],
"claims": [
{
"name": "node-clipboard",
"scope": "node",
"serves": [
"history",
"copy"
]
}
],
"tools": [
"clipmenu_paste",
"clipmenu_clear",
"clipmenu_delete"
],
"environment": {
"variables": {
"CM_SELECTIONS": "clipboard",
"CM_MAX_CLIPS": "500",
"CM_HISTLENGTH": "15"
}
},
"shell": [
{
"for": "xinitrc",
"slot": "normal",
"code": "# The clipboard's history (module clipmenu, novox/hq ADR 0208): clipmenud collects every copy from\n# here on, once per session. It keeps the history in the account's runtime directory, so a reboot\n# forgets it, and with it every password that was ever copied.\n# Through the module's xsel, which reads only a selection offering text (see the README).\nPATH=\"/usr/local/lib/mesh-clipmenu:$PATH\" clipmenud &\n"
}
],
"resources": [
{
"id": "package",
"type": "package",
"package": "clipmenu"
},
{
"id": "greenclip",
"type": "package",
"package": "rofi-greenclip",
"absent": true
},
{
"id": "text-only",
"type": "file",
"path": "/usr/local/lib/mesh-clipmenu/xsel",
"mode": "0755",
"content": "#!/bin/sh\n# xsel as clipmenud sees it, written by the mesh (module clipmenu). Replaced at every push.\n#\n# clipmenud records text, and reads a selection with `timeout 1 xsel -o`. A selection holding an\n# image (a screenshot copied as image/png) is sent in pieces; one second is too short for megabytes,\n# timeout kills xsel half way, and the program owning the image waits for ever for a reader that is\n# gone. From then on nothing can ask the clipboard anything: pastes hang, and an Electron app that\n# asks on its main thread freezes. So a read goes ahead only when the selection offers text.\ncase \" $* \" in\n*\" -o \"* | *\" --output \"*)\n\tselection=clipboard\n\tcase \" $* \" in\n\t*\" --primary \"* | *\" -p \"*) selection=primary ;;\n\t*\" --secondary \"* | *\" -s \"*) selection=secondary ;;\n\tesac\n\ttargets=$(timeout 1 xclip -selection \"$selection\" -t TARGETS -o 2>/dev/null) || exit 1\n\tprintf '%s\\n' \"$targets\" | grep -qxE 'UTF8_STRING|STRING|TEXT|text/plain(;charset=utf-8)?' || exit 1\n\t;;\nesac\nexec /usr/bin/xsel \"$@\"\n"
}
],
"build": {
"artifacts": [
{
"name": "tools",
"kind": "bundle",
"language": "go",
"system": "arch",
"from": "cmd/clipmenu-tools",
"binary": "clipmenu-tools",
"loads": [
"clipmenu-tools"
]
}
]
},
"contributions": [
{
"seat": "node-display-session",
"kind": "config",
"content": "# The clipboard's history key (module clipmenu, novox/hq ADR 0208). Owned by the mesh: replaced at\n# every push. clipmenu shows the history through `dmenu`, the node's dmenu-compatible command, which\n# the holder of node-launcher answers (rofi on the workstations); the chosen entry is put back on the\n# clipboard.\nbindsym $mod+period exec --no-startup-id clipmenu -p Clipboard\n"
}
]
}
+111
View File
@@ -0,0 +1,111 @@
# dbus
A machine's message bus (novox/hq ADR 0215). This module holds `node-message-bus` on every machine,
servers included, because every machine runs a D-Bus system bus. It owns the bus implementation's
packages and declares its system service running. It publishes what matters about the bus on the
mesh's bus, and serves tools to look at the system bus and at the operator's session bus.
## What it owns
| | |
|---|---|
| package `dbus-broker` | the bus every machine runs |
| package `dbus-broker-units` | `dbus.service` as an alias of `dbus-broker.service`, for the system and for each user |
| package `dbus` | the bus's configuration (`system.conf`, `session.conf`), `dbus.socket`, which starts the bus at boot, and libdbus |
| service `dbus-broker.service` | declared running, with no boot state and no restart or reload trigger (below) |
All four machines were found with dbus-broker 37 behind `dbus.service` and dbus 1.16.2, from the
distribution. One server also has an old `dbus-units` package, an empty package that depends on
`dbus-broker-units`. It is not declared: nothing needs it, and removing it is a choice for its
operator.
## The bus is never restarted live
On 2026-10-04 a full upgrade on a workstation restarted the system bus while the upgrade was still
running. From then on every login hung, sshd answered nothing and the machine's host stopped
reporting, until someone rebooted it at its keyboard. Every program that speaks on the bus (the
service manager, logind, the network manager, the keyring, the power module's sleep lock) holds a
connection to it. A restart takes all of them away at once, and not all of them come back.
So the module never restarts or reloads the bus, for any change. Its service has no `restart-on` and
no `reload-on`, and the host never restarts a service that has neither. A new bus takes effect at
the next boot. `dbus_check` says when the running bus is older than an installed package, which is
the sign that a reboot is due. The distribution's own upgrade can still restart the bus. This rule
covers what the mesh does, not what pacman does.
**Why `state: running` and no `boot`.** `dbus.service` is an alias (`systemctl is-enabled` says
`alias`), so it cannot be declared enabled: the host would run `systemctl enable` and read back
something other than `enabled`. The real unit, `dbus-broker.service`, reads `disabled` on three
machines and `enabled` on one. It needs no enabling. At boot `dbus.socket`, which the `dbus` package
links into `sockets.target`, pulls in `dbus.service`, and `dbus-broker-units` ships that name as a
link to `dbus-broker.service`. On the three machines where it reads `disabled`, enabling it would
only write a second alias link into `/etc`, and a module's apply would change something on a running
machine for no gain. The service is declared so that the mesh knows the bus is the module's and says
so when it is not running. A host finding it stopped would start it, which can only help a machine
whose bus is down. ADR 0215 §1 says "running and enabled". On these machines the package's own
socket link is what makes it start at boot.
**A policy change** a module needs in the future uses the bus's own reload (`dbus-broker.service` is
`Type=notify-reload`), which keeps every connection. No module needs one today (below).
## Events
The watcher runs beside the tools in the same process, on its own connection to the system bus as the
operator's account. It reads only the bus driver's answers, the driver's `NameOwnerChanged` signal and
the bus unit's journal.
| event | when | carries |
|---|---|---|
| `bus.stalled` | the bus does not answer the driver's `GetId` within 3 s, or cannot be reached | the reason |
| `bus.recovered` | a stalled bus answers again | since when it was stalled, and for how long |
| `bus.restarted` | after reconnecting, the bus's id or the driver's pid has changed within the same boot (after a boot both change, and `power` says `booted`) | the old and new id and pid, the unit |
| `service.appeared` | a well-known name appears and is still there 10 s later | the name, the owner's pid, process and unit, whether it is activatable |
| `service.left` | a well-known name is gone and still gone 10 s later | the name and what held it |
| `policy.denied` | the bus logged a policy denial, at most once per 5 minutes | how many since the last one, and up to 5 distinct examples: the refused message's type, sender, destination, path, interface and member |
Unique names (`:1.42`) come and go with every client and are never published. A service restarted
within the 10 s, or one that came and went, publishes nothing. Activatable services that exit when
idle, such as hostnamed, do appear and leave, and their events say `activatable: true`.
**Why traffic never leaves the machine.** The bus carries secrets from the keyring, notification text
and the clipboard. The watcher never becomes a monitor, so it never sees another peer's messages. Its
events are built from names, pids, units and the header fields the broker logs with a denial. The log
line itself is not passed on. The tests hold every event's body to those fields.
Events the mesh's bus does not take wait in order and go out when it answers again, as `power`'s do.
At most 1000 events wait. When there are more, the oldest are dropped and counted.
The watcher's ping is `GetId`, not `org.freedesktop.DBus.Peer.Ping`. The system policy refuses the
peer ping to an account that is not root and logs a denial each time, which the watcher would then
publish.
## Tools
| tool | |
|---|---|
| `dbus_names` | `bus`: system or session. Every well-known name with its owner's pid, process, user and unit, the activatable names that are not running, and the number of connections |
| `dbus_introspect` | `bus`, `service`, `path`. The object's interfaces with their methods, properties (type and access, never values) and signals, and its children. Uses `--auto-start=no`, so looking never starts a service |
| `dbus_monitor` | `bus`, `seconds` (at most 15), optional `match` rule and `names`. The headers of what passed (type, sender, destination, path, interface, member, error name), at most 500. Never a body: each line is decoded into the header alone. On the system bus only root may monitor, so it runs through `sudo -n` |
| `dbus_check` | the packages are installed. `dbus.service` is `dbus-broker.service` and active. The running bus is not older than the installed `dbus-broker` or `dbus` (otherwise a reboot is due). Every activatable service file's `SystemdService` exists. Policy denials in the last hour. The watcher is connected, not stalled, and has nothing stuck |
| `dbus_health` | the round trip of a ping on the watcher's connection, the connection count (from `Debug.Stats` through `sudo -n`, else the unique names), and the bus's unit, pid, start and uptime |
Every command is bounded at 20 s and its output read up to 1 MiB. Lists stop at 500 entries.
`Debug.Stats` is asked only as root: asked as the account it is refused and logged as a denial.
The session bus is the runtime's `DBUS_SESSION_BUS_ADDRESS`, else `$XDG_RUNTIME_DIR/bus`, else
`/run/user/<uid>/bus`. On a machine where the account has no session, the tools say so.
The check notes, without failing, systemd's own services whose `dbus-org.*` alias is missing because
they are not enabled, such as resolved, networkd and homed on the workstations. Activating them fails
by design.
## Assignment
On every machine, as the first holder of `node-message-bus`. The module needs nothing set: no
settings, no secrets, no ports.
## What comes later
ADR 0215 §4: the seat receives nothing yet. Packages ship their own D-Bus policy and service files,
and no module writes one of its own. When one does, that file will be a contribution to this seat,
and this module will list the kind and reload the bus's policy for it, without a restart.
+103
View File
@@ -0,0 +1,103 @@
package main
import (
"context"
"github.com/godbus/dbus/v5"
)
const (
busName = "org.freedesktop.DBus"
busPath = "/org/freedesktop/DBus"
)
// systemBus is the machine's system bus as the watcher uses it, over its own private connection: the
// bus driver's answers and its NameOwnerChanged signal, never another peer's messages.
type systemBus struct {
conn *dbus.Conn
out chan NameChange
}
// DialSystemBus connects to the system bus as this account and listens for names changing owner.
func DialSystemBus() (Bus, error) {
conn, err := dbus.SystemBusPrivate()
if err != nil {
return nil, err
}
if err := conn.Auth(nil); err != nil {
conn.Close()
return nil, err
}
if err := conn.Hello(); err != nil {
conn.Close()
return nil, err
}
if err := conn.AddMatchSignal(dbus.WithMatchSender(busName), dbus.WithMatchInterface(busName),
dbus.WithMatchMember("NameOwnerChanged")); err != nil {
conn.Close()
return nil, err
}
raw := make(chan *dbus.Signal, 256)
conn.Signal(raw)
b := &systemBus{conn: conn, out: make(chan NameChange, 256)}
go func() {
// The signal channel closes when the connection is lost; so does this one, which is how the
// watcher learns the bus went away.
defer close(b.out)
for {
select {
case s, open := <-raw:
if !open {
return
}
if s.Name != busName+".NameOwnerChanged" || len(s.Body) != 3 {
continue
}
name, _ := s.Body[0].(string)
old, _ := s.Body[1].(string)
nw, _ := s.Body[2].(string)
b.out <- NameChange{Name: name, Old: old, New: nw}
case <-conn.Context().Done():
return
}
}
}()
return b, nil
}
func (b *systemBus) driver() dbus.BusObject { return b.conn.Object(busName, busPath) }
// Ping asks the bus driver its id. Not org.freedesktop.DBus.Peer.Ping: dbus-broker's system policy
// refuses that to an account that is not root, and logs the refusal as a denial.
func (b *systemBus) Ping(ctx context.Context) error {
var id string
return b.driver().CallWithContext(ctx, busName+".GetId", 0).Store(&id)
}
func (b *systemBus) ID(ctx context.Context) (string, error) {
var id string
err := b.driver().CallWithContext(ctx, busName+".GetId", 0).Store(&id)
return id, err
}
func (b *systemBus) PID(ctx context.Context, name string) (uint32, error) {
var pid uint32
err := b.driver().CallWithContext(ctx, busName+".GetConnectionUnixProcessID", 0, name).Store(&pid)
return pid, err
}
func (b *systemBus) Names(ctx context.Context) ([]string, error) {
var names []string
err := b.driver().CallWithContext(ctx, busName+".ListNames", 0).Store(&names)
return names, err
}
func (b *systemBus) Activatable(ctx context.Context) ([]string, error) {
var names []string
err := b.driver().CallWithContext(ctx, busName+".ListActivatableNames", 0).Store(&names)
return names, err
}
func (b *systemBus) Changes() <-chan NameChange { return b.out }
func (b *systemBus) Close() { b.conn.Close() }
+290
View File
@@ -0,0 +1,290 @@
package main
import (
"context"
"fmt"
"sort"
"strconv"
"strings"
"time"
)
// Packages are the bus implementation's packages, as the manifest declares them: dbus-broker (the
// bus), its units (the dbus.service alias), and the reference package, which ships the bus's
// configuration, the socket that starts it at boot, and libdbus.
var Packages = []string{"dbus", "dbus-broker", "dbus-broker-units"}
// RunningPackages are the packages whose files the running bus loaded when it started: a newer one
// installed since takes effect only at the next boot (novox/hq ADR 0215 §2).
var RunningPackages = []string{"dbus-broker", "dbus"}
// SystemUnit is the bus's real unit; dbus.service is its alias.
const SystemUnit = "dbus-broker.service"
// ServiceDirs are where activatable system services are described.
var ServiceDirs = []string{"/usr/share/dbus-1/system-services", "/usr/local/share/dbus-1/system-services",
"/usr/lib/dbus-1/system-services"}
// Package is one installed package as the package manager's local database records it.
type Package struct {
Name string
Version string
Installed time.Time
}
// ParseDesc reads one package's desc file in the local database.
func ParseDesc(desc string) Package {
var p Package
lines := strings.Split(desc, "\n")
for i := 0; i+1 < len(lines); i++ {
v := strings.TrimSpace(lines[i+1])
switch strings.TrimSpace(lines[i]) {
case "%NAME%":
p.Name = v
case "%VERSION%":
p.Version = v
case "%INSTALLDATE%":
if n, err := strconv.ParseInt(v, 10, 64); err == nil {
p.Installed = time.Unix(n, 0)
}
}
}
return p
}
// InstalledPackage reads a package from the local database, without running the package manager.
func (m *Machine) InstalledPackage(name string) (Package, bool) {
for _, d := range m.glob("/var/lib/pacman/local/" + name + "-*/desc") {
if p := ParseDesc(m.read(d) + "\n"); p.Name == name {
return p, true
}
}
return Package{}, false
}
// ParseShow reads `systemctl show` blocks: one map per unit, in the order asked.
func ParseShow(out string) []map[string]string {
var blocks []map[string]string
cur := map[string]string{}
for _, line := range strings.Split(out, "\n") {
line = strings.TrimSpace(line)
if line == "" {
if len(cur) > 0 {
blocks = append(blocks, cur)
cur = map[string]string{}
}
continue
}
if k, v, ok := strings.Cut(line, "="); ok {
cur[k] = v
}
}
if len(cur) > 0 {
blocks = append(blocks, cur)
}
return blocks
}
// unixStamp reads systemd's "@<seconds>" timestamp.
func unixStamp(s string) (time.Time, bool) {
n, err := strconv.ParseInt(strings.TrimPrefix(s, "@"), 10, 64)
if err != nil || n == 0 {
return time.Time{}, false
}
return time.Unix(n, 0), true
}
// BusUnit is the system bus's unit as the service manager has it.
type BusUnit struct {
Unit string `json:"unit"`
Active string `json:"active"`
PID uint32 `json:"pid,omitempty"`
Since time.Time `json:"-"`
Started string `json:"started,omitempty"`
}
// SystemBusUnit asks the service manager about the system bus through its alias, so the answer is
// the implementation's unit whichever it is.
func (m *Machine) SystemBusUnit(ctx context.Context) (BusUnit, error) {
out, err := m.Run(ctx, "systemctl", "show", "dbus.service", "-p", "Id,ActiveState,MainPID,ActiveEnterTimestamp",
"--timestamp=unix")
if err != nil {
return BusUnit{}, err
}
b := ParseShow(out)
if len(b) == 0 {
return BusUnit{}, fmt.Errorf("systemctl show answered nothing for dbus.service")
}
u := BusUnit{Unit: b[0]["Id"], Active: b[0]["ActiveState"], PID: parsePID(b[0]["MainPID"])}
if t, ok := unixStamp(b[0]["ActiveEnterTimestamp"]); ok {
u.Since, u.Started = t, t.UTC().Format(time.RFC3339)
}
return u, nil
}
// ServiceFile is one activatable system service's description.
type ServiceFile struct {
File string
Name string
Unit string
}
// ParseServiceFile reads the Name and SystemdService of a D-Bus service file.
func ParseServiceFile(file, content string) ServiceFile {
s := ServiceFile{File: file}
for _, line := range strings.Split(content, "\n") {
k, v, ok := strings.Cut(strings.TrimSpace(line), "=")
if !ok {
continue
}
switch strings.TrimSpace(k) {
case "Name":
s.Name = strings.TrimSpace(v)
case "SystemdService":
s.Unit = strings.TrimSpace(v)
}
}
return s
}
// Check is one thing the module expects of the machine.
type Check struct {
Name string `json:"name"`
OK bool `json:"ok"`
Detail string `json:"detail"`
}
// RebootDue says, per package the running bus loaded, whether a newer one was installed after the bus
// started: the bus is never restarted live, so that package waits for a boot.
func RebootDue(started time.Time, pkgs []Package) (bool, string) {
var newer []string
for _, p := range pkgs {
if !p.Installed.IsZero() && p.Installed.After(started) {
newer = append(newer, fmt.Sprintf("%s %s installed %s", p.Name, p.Version, p.Installed.UTC().Format(time.RFC3339)))
}
}
if len(newer) == 0 {
return false, "the running bus started " + started.UTC().Format(time.RFC3339) + ", after every package it loaded was installed"
}
return true, "the running bus started " + started.UTC().Format(time.RFC3339) + " and is older than " +
strings.Join(newer, ", ") + ": a reboot is due (the bus is never restarted live, novox/hq ADR 0215)"
}
// Check says what this module expects and whether the machine meets it.
func (m *Machine) Check(ctx context.Context, w *Watcher) map[string]any {
var checks []Check
var notes []string
add := func(name string, ok bool, format string, args ...any) {
checks = append(checks, Check{Name: name, OK: ok, Detail: fmt.Sprintf(format, args...)})
}
var running []Package
for _, name := range Packages {
p, ok := m.InstalledPackage(name)
add("package "+name, ok, "installed: %v %s", ok, p.Version)
for _, r := range RunningPackages {
if ok && r == name {
running = append(running, p)
}
}
}
unit, err := m.SystemBusUnit(ctx)
if err != nil {
add("system bus", false, "%v", err)
} else {
add("system bus", unit.Unit == SystemUnit && unit.Active == "active",
"dbus.service is %s, %s, pid %d, since %s (want %s active)", orWord(unit.Unit, "unknown"),
orWord(unit.Active, "unknown"), unit.PID, orWord(unit.Started, "unknown"), SystemUnit)
if !unit.Since.IsZero() {
due, detail := RebootDue(unit.Since, running)
add("running bus is the installed one", !due, "%s", detail)
}
}
var files []ServiceFile
for _, dir := range ServiceDirs {
for _, f := range m.glob(dir + "/*.service") {
if s := ParseServiceFile(f, m.read(f)); s.Unit != "" {
files = append(files, s)
}
}
}
sort.Slice(files, func(i, j int) bool { return files[i].Name < files[j].Name })
if len(files) > 0 {
args := []string{"show", "-p", "Id,LoadState"}
for _, f := range files {
args = append(args, f.Unit)
}
out, err := m.Run(ctx, "systemctl", args...)
blocks := ParseShow(out)
switch {
case err != nil:
add("activatable services", false, "systemctl show: %v", err)
case len(blocks) != len(files):
add("activatable services", false, "systemctl show answered %d units for %d service files", len(blocks), len(files))
default:
var broken, disabled []string
for i, f := range files {
if blocks[i]["LoadState"] != "not-found" {
continue
}
// systemd's own bus services are reached through a dbus-org.* alias that exists only
// while the service is enabled: a disabled one is a choice, not a fault.
if strings.HasPrefix(f.Unit, "dbus-org.") {
disabled = append(disabled, f.Name+" → "+f.Unit)
} else {
broken = append(broken, f.Name+" → "+f.Unit+" ("+f.File+")")
}
}
add("activatable services", len(broken) == 0, "%d service files; whose unit does not exist: %s",
len(files), orWord(strings.Join(broken, ", "), "none"))
if len(disabled) > 0 {
notes = append(notes, "activation fails for "+strings.Join(disabled, ", ")+
": the service is not enabled, so its dbus-org alias does not exist")
}
}
}
denials, _, err := m.Denials(ctx, "", m.Now().Add(-time.Hour))
if err != nil {
add("policy denials in the last hour", false, "reading the journal: %v", err)
} else {
seen := map[string]bool{}
var ex []string
for _, d := range denials {
k := strings.TrimSpace(d.Type + " " + d.Interface + "." + d.Member + " to " + d.Destination)
if !seen[k] && len(ex) < DenialExamples {
seen[k] = true
ex = append(ex, k)
}
}
add("policy denials in the last hour", len(denials) == 0, "%d: %s", len(denials), orWord(strings.Join(ex, "; "), "none"))
}
if a, err := m.SessionAddress(); err == nil {
notes = append(notes, "this account's session bus: "+a)
} else {
notes = append(notes, err.Error())
}
if w != nil {
s := w.Snapshot()
add("watcher", s.Connected && !s.Stalled, "connected %v, stalled %v%s, last ping %.1f ms, %d well-known names",
s.Connected, s.Stalled, orNote(s.StallReason), s.LastPingMS, s.Services)
add("events reach the mesh's bus", s.Pending == 0 && s.Problem == "", "%d event(s) waiting, %d dropped%s",
s.Pending, s.Dropped, orNote(s.Problem))
}
failing := 0
for _, c := range checks {
if !c.OK {
failing++
}
}
return map[string]any{"checks": checks, "failing": failing, "notes": notes}
}
func orNote(s string) string {
if s == "" {
return ""
}
return " (" + s + ")"
}
+91
View File
@@ -0,0 +1,91 @@
package main
import (
"context"
"encoding/json"
"strings"
"time"
)
// CountConnections reads the bus driver's Debug.Stats answer (`busctl call … GetStats --json=short`):
// dbus-broker lists one accounting entry per peer, the reference daemon says ActiveConnections.
func CountConnections(out string) (int, bool) {
var reply struct {
Data []map[string]struct {
Data json.RawMessage `json:"data"`
} `json:"data"`
}
if json.Unmarshal([]byte(strings.TrimSpace(out)), &reply) != nil || len(reply.Data) != 1 {
return 0, false
}
if v, ok := reply.Data[0]["org.bus1.DBus.Debug.Stats.PeerAccounting"]; ok {
var peers []json.RawMessage
if json.Unmarshal(v.Data, &peers) == nil {
return len(peers), true
}
}
if v, ok := reply.Data[0]["ActiveConnections"]; ok {
var n int
if json.Unmarshal(v.Data, &n) == nil {
return n, true
}
}
return 0, false
}
// CountUniqueNames counts the connections among ListNames' answer (`busctl call … ListNames`).
func CountUniqueNames(out string) (int, bool) {
var reply struct {
Data [][]string `json:"data"`
}
if json.Unmarshal([]byte(strings.TrimSpace(out)), &reply) != nil || len(reply.Data) != 1 {
return 0, false
}
n := 0
for _, name := range reply.Data[0] {
if strings.HasPrefix(name, ":") {
n++
}
}
return n, true
}
// Health is the system bus's health now: a ping through the watcher's connection, how many
// connections the bus has, and how long it has run.
func (m *Machine) Health(ctx context.Context, w *Watcher) map[string]any {
h := map[string]any{"bus": "system"}
if w != nil {
took, err := w.PingNow()
if err != nil {
h["ping"] = "no answer: " + err.Error()
} else {
h["ping_ms"] = float64(took.Microseconds()) / 1000
}
h["watcher"] = w.Snapshot()
}
// Debug.Stats is root's on the system bus; asked as the account it is refused and logged as a
// denial, which the watcher would then publish. So it is asked through sudo -n or not at all.
call := []string{"busctl", "--system", "--json=short", "--no-pager", "call", busName, busPath}
if out, err := m.privileged(ctx, call[0], append(call[1:], busName+".Debug.Stats", "GetStats")...); err == nil {
if n, ok := CountConnections(out); ok {
h["connections"], h["connections_from"] = n, "the bus's Debug.Stats"
}
}
if _, ok := h["connections"]; !ok {
if out, err := m.Run(ctx, call[0], append(call[1:], busName, "ListNames")...); err == nil {
if n, ok := CountUniqueNames(out); ok {
h["connections"], h["connections_from"] = n, "the unique names on the bus (Debug.Stats needs sudo -n)"
}
}
}
if u, err := m.SystemBusUnit(ctx); err != nil {
h["unit"] = err.Error()
} else {
h["unit"], h["active"], h["pid"] = u.Unit, u.Active, u.PID
if !u.Since.IsZero() {
h["started"] = u.Started
h["uptime"] = m.Now().Sub(u.Since).Round(time.Second).String()
}
}
return h
}
+133
View File
@@ -0,0 +1,133 @@
package main
import (
"context"
"encoding/json"
"encoding/xml"
"fmt"
"regexp"
"strings"
"github.com/godbus/dbus/v5/introspect"
)
// A bus name and an object path as the specification allows them; anything else is refused before
// it reaches busctl, so no argument is ever taken for an option.
var (
busNameRE = regexp.MustCompile(`^(:[A-Za-z0-9_-]+(\.[A-Za-z0-9_-]+)+|[A-Za-z_-][A-Za-z0-9_-]*(\.[A-Za-z_-][A-Za-z0-9_-]*)+)$`)
objectPathRE = regexp.MustCompile(`^/([A-Za-z0-9_]+(/[A-Za-z0-9_]+)*)?$`)
)
// Member is one method or signal, with its arguments as "name type".
type Member struct {
Name string `json:"name"`
In []string `json:"in,omitempty"`
Out []string `json:"out,omitempty"`
Args []string `json:"args,omitempty"`
}
// Property is one property's name, type and access, never its value: a value can be anything a
// service holds, and reading it is a call of its own.
type Property struct {
Name string `json:"name"`
Type string `json:"type"`
Access string `json:"access"`
}
// Interface is one interface of an object.
type Interface struct {
Name string `json:"name"`
Methods []Member `json:"methods,omitempty"`
Properties []Property `json:"properties,omitempty"`
Signals []Member `json:"signals,omitempty"`
}
// Object is dbus_introspect's answer.
type Object struct {
Bus string `json:"bus"`
Service string `json:"service"`
Path string `json:"path"`
Interfaces []Interface `json:"interfaces"`
Children []string `json:"children,omitempty"`
}
// ParseIntrospection reads `busctl call … Introspect --json=short` ({"type":"s","data":["<xml>"]}).
func ParseIntrospection(out string) (introspect.Node, error) {
var reply struct {
Data []string `json:"data"`
}
var node introspect.Node
if err := json.Unmarshal([]byte(strings.TrimSpace(out)), &reply); err != nil || len(reply.Data) != 1 {
return node, fmt.Errorf("the introspection answer is not a single string")
}
if err := xml.Unmarshal([]byte(reply.Data[0]), &node); err != nil {
return node, fmt.Errorf("the introspection document does not parse: %w", err)
}
return node, nil
}
// Shape turns an introspection document into the tool's answer.
func Shape(bus, service, path string, node introspect.Node) Object {
o := Object{Bus: bus, Service: service, Path: path, Interfaces: []Interface{}}
for _, i := range node.Interfaces {
iface := Interface{Name: i.Name}
for _, m := range i.Methods {
mem := Member{Name: m.Name}
for _, a := range m.Args {
s := strings.TrimSpace(a.Name + " " + a.Type)
if a.Direction == "out" {
mem.Out = append(mem.Out, s)
} else {
mem.In = append(mem.In, s)
}
}
iface.Methods = append(iface.Methods, mem)
}
for _, p := range i.Properties {
iface.Properties = append(iface.Properties, Property{Name: p.Name, Type: p.Type, Access: p.Access})
}
for _, s := range i.Signals {
mem := Member{Name: s.Name}
for _, a := range s.Args {
mem.Args = append(mem.Args, strings.TrimSpace(a.Name+" "+a.Type))
}
iface.Signals = append(iface.Signals, mem)
}
o.Interfaces = append(o.Interfaces, iface)
}
for _, c := range node.Children {
if len(o.Children) >= AnswerCap {
break
}
o.Children = append(o.Children, c.Name)
}
return o
}
// Introspect asks one object what it offers, as this account, without starting a service that is not
// running (--auto-start=no): looking must not change what runs.
func (m *Machine) Introspect(ctx context.Context, bus, service, path string) (Object, error) {
if !busNameRE.MatchString(service) {
return Object{}, fmt.Errorf("%q is not a bus name", service)
}
if path == "" {
path = "/"
}
if !objectPathRE.MatchString(path) {
return Object{}, fmt.Errorf("%q is not an object path", path)
}
args, err := m.busArgs(bus)
if err != nil {
return Object{}, err
}
out, err := m.Run(ctx, "busctl", append(args, "--json=short", "--no-pager", "--auto-start=no", "call",
service, path, "org.freedesktop.DBus.Introspectable", "Introspect")...)
if err != nil {
return Object{}, err
}
node, err := ParseIntrospection(out)
if err != nil {
return Object{}, err
}
return Shape(orWord(bus, "system"), service, path, node), nil
}
+114
View File
@@ -0,0 +1,114 @@
package main
import (
"context"
"encoding/json"
"strconv"
"strings"
"time"
)
// BusUnits are the system bus's units as the journal knows them: dbus-broker's own name, and the
// alias every implementation answers to.
var BusUnits = []string{"dbus-broker.service", "dbus.service"}
// JournalLines is the most journal entries one read takes.
const JournalLines = 2000
// Denial is one policy denial as the bus logged it: the header of the message it refused, never its
// body, which the bus does not log either.
type Denial struct {
At string `json:"at"`
Action string `json:"action,omitempty"`
Type string `json:"type,omitempty"`
Sender string `json:"sender,omitempty"`
Destination string `json:"destination,omitempty"`
Path string `json:"path,omitempty"`
Interface string `json:"interface,omitempty"`
Member string `json:"member,omitempty"`
Policy string `json:"policy,omitempty"`
}
// key is what makes two denials the same example: who was refused what, ignoring the sender's unique
// name, which differs at every connection.
func (d Denial) key() string {
return d.Action + "|" + d.Type + "|" + d.Destination + "|" + d.Interface + "|" + d.Member
}
// journalEntry is the part of a journal entry the module reads. MESSAGE is read only to recognise a
// denial; it is never answered or published.
type journalEntry struct {
Cursor string `json:"__CURSOR"`
Realtime string `json:"__REALTIME_TIMESTAMP"`
Message any `json:"MESSAGE"`
Action string `json:"DBUS_BROKER_TRANSMIT_ACTION"`
Type string `json:"DBUS_BROKER_MESSAGE_TYPE"`
Sender string `json:"DBUS_BROKER_SENDER_UNIQUE_NAME"`
Destination string `json:"DBUS_BROKER_MESSAGE_DESTINATION"`
Path string `json:"DBUS_BROKER_MESSAGE_PATH"`
Interface string `json:"DBUS_BROKER_MESSAGE_INTERFACE"`
Member string `json:"DBUS_BROKER_MESSAGE_MEMBER"`
Policy string `json:"DBUS_BROKER_POLICY_TYPE"`
}
// IsDenial is whether a bus's log line is a policy denial: dbus-broker's "A security policy denied",
// or the reference daemon's "Rejected send message".
func IsDenial(message string) bool {
return strings.Contains(message, "security policy denied") || strings.Contains(message, "Rejected send message") ||
strings.Contains(message, "Rejected receive message")
}
// ParseDenials reads journalctl's JSON lines and answers the denials among them and the last cursor.
func ParseDenials(out string) ([]Denial, string) {
var got []Denial
cursor := ""
for _, line := range strings.Split(out, "\n") {
line = strings.TrimSpace(line)
if line == "" || line[0] != '{' {
continue
}
var e journalEntry
if json.Unmarshal([]byte(line), &e) != nil {
continue
}
if e.Cursor != "" {
cursor = e.Cursor
}
msg, _ := e.Message.(string) // a binary MESSAGE comes as an array of bytes and is no denial
if !IsDenial(msg) {
continue
}
at := ""
if us, err := strconv.ParseInt(e.Realtime, 10, 64); err == nil {
at = time.UnixMicro(us).UTC().Format(time.RFC3339)
}
got = append(got, Denial{At: at, Action: e.Action, Type: e.Type, Sender: e.Sender,
Destination: e.Destination, Path: e.Path, Interface: e.Interface, Member: e.Member, Policy: e.Policy})
}
return got, cursor
}
func journalArgs() []string {
args := []string{"--no-pager", "-o", "json", "-n", strconv.Itoa(JournalLines)}
for _, u := range BusUnits {
args = append(args, "-u", u)
}
return args
}
// Denials is the watcher's Journal on this machine: journalctl as the operator's account, which
// reads the system journal through its group.
func (m *Machine) Denials(ctx context.Context, after string, since time.Time) ([]Denial, string, error) {
args := journalArgs()
if after != "" {
args = append(args, "--after-cursor", after)
} else {
args = append(args, "--since", "@"+strconv.FormatInt(since.Unix(), 10))
}
out, err := m.Run(ctx, "journalctl", args...)
if err != nil {
return nil, "", err
}
d, cursor := ParseDenials(out)
return d, cursor, nil
}
+67
View File
@@ -0,0 +1,67 @@
package main
import (
"context"
"encoding/json"
"os"
"path/filepath"
"testing"
"time"
)
// Run on a real machine with MESH_LIVE=1: every tool reads this machine's buses, and the watcher's
// connection answers. Nothing is changed.
func TestLiveTools(t *testing.T) {
if os.Getenv("MESH_LIVE") == "" {
t.Skip("set MESH_LIVE=1 on a machine with a system bus")
}
ctx := context.Background()
m := Here()
b, err := DialSystemBus()
if err != nil {
t.Fatal(err)
}
defer b.Close()
if err := b.Ping(ctx); err != nil {
t.Fatal(err)
}
id, _ := b.ID(ctx)
pid, _ := b.PID(ctx, busName)
t.Logf("bus %s, driver pid %d in %s", id, pid, m.UnitOf(pid))
show := func(name string, v any, err error) {
raw, _ := json.Marshal(v)
if len(raw) > 1500 {
raw = append(raw[:1500], "…"...)
}
t.Logf("%s: %v\n%s", name, err, raw)
}
n, err := m.Names(ctx, "system")
show("names system", n, err)
n, err = m.Names(ctx, "session")
show("names session", n, err)
o, err := m.Introspect(ctx, "system", "org.freedesktop.login1", "/org/freedesktop/login1")
show("introspect", o, err)
w, err := m.Monitor(ctx, "system", 2, "", nil)
show("monitor", w, err)
show("health", m.Health(ctx, nil), nil)
show("check", m.Check(ctx, nil), nil)
}
// Run with MESH_LIVE=1: the watcher connects, pings and baselines the names, and says nothing.
func TestLiveWatcher(t *testing.T) {
if os.Getenv("MESH_LIVE") == "" {
t.Skip("set MESH_LIVE=1 on a machine with a system bus")
}
m := Here()
var said []string
w := NewWatcher(m, func(e string, b any) error { said = append(said, e); return nil }, DialSystemBus, m.Denials)
w.state = filepath.Join(t.TempDir(), "bus")
ctx, cancel := context.WithTimeout(context.Background(), PingEvery+2*time.Second)
defer cancel()
w.Run(ctx)
s := w.Snapshot()
t.Logf("%+v said %v", s, said)
if s.BusID == "" || s.LastPingAt == "" || s.Stalled || s.Services == 0 {
t.Fatalf("%+v", s)
}
}
+247
View File
@@ -0,0 +1,247 @@
package main
import (
"bufio"
"bytes"
"context"
"errors"
"fmt"
"os"
"os/exec"
"path/filepath"
"strconv"
"strings"
"syscall"
"time"
)
// CommandTimeout bounds every command a tool runs: a bus that hangs must cost a tool call twenty
// seconds, never the runtime's thirty.
const CommandTimeout = 20 * time.Second
// ReadCap is the most of one command's output the module reads. An introspection document of the
// service manager is about 100 KiB; nothing the module asks is near a mebibyte.
const ReadCap = 1024 * 1024
// AnswerCap is the most entries a tool answers in one list (names, messages, denials).
const AnswerCap = 500
// Runner runs one command and answers its standard output. Injected, so every tool is tested against
// recorded answers rather than this machine's bus.
type Runner func(ctx context.Context, name string, args ...string) (string, error)
// Streamer runs one command for at most the context's time and hands each line of its output to
// line, which says whether it wants more. The end of the time is the normal end, not an error.
// Injected, so dbus_monitor is tested without a bus.
type Streamer func(ctx context.Context, line func(string) bool, name string, args ...string) error
// ExecRunner runs a command, bounded by CommandTimeout. A failure carries what it said on stderr.
func ExecRunner(ctx context.Context, name string, args ...string) (string, error) {
ctx, cancel := context.WithTimeout(ctx, CommandTimeout)
defer cancel()
cmd := exec.CommandContext(ctx, name, args...)
cmd.Env = append(os.Environ(), "LC_ALL=C", "SYSTEMD_PAGER=", "SYSTEMD_COLORS=0")
var stdout, stderr bytes.Buffer
cmd.Stdout, cmd.Stderr = &stdout, &stderr
err := cmd.Run()
out := capped(stdout.String(), ReadCap)
if ctx.Err() == context.DeadlineExceeded {
return out, fmt.Errorf("%s did not answer within %s", name, CommandTimeout)
}
if err != nil {
said := strings.TrimSpace(stderr.String())
if said == "" {
said = strings.TrimSpace(stdout.String())
}
return out, fmt.Errorf("%s %s: %w: %s", name, strings.Join(args, " "), err, capped(said, 2048))
}
return out, nil
}
// ExecStreamer runs a command until the context ends, line by line. A line longer than ReadCap is
// skipped, never held: a monitored message's line carries its body, which the module never keeps.
func ExecStreamer(ctx context.Context, line func(string) bool, name string, args ...string) error {
cmd := exec.CommandContext(ctx, name, args...)
cmd.Env = append(os.Environ(), "LC_ALL=C", "SYSTEMD_PAGER=", "SYSTEMD_COLORS=0")
// A terminate, which sudo passes on to what it runs; a kill would leave a root monitor behind.
cmd.Cancel = func() error { return cmd.Process.Signal(syscall.SIGTERM) }
cmd.WaitDelay = 2 * time.Second
var stderr bytes.Buffer
cmd.Stderr = &stderr
out, err := cmd.StdoutPipe()
if err != nil {
return err
}
if err := cmd.Start(); err != nil {
return err
}
r := bufio.NewReaderSize(out, 64*1024)
var cur []byte
tooLong := false
for {
chunk, isPrefix, err := r.ReadLine()
if err != nil {
break
}
if !tooLong {
cur = append(cur, chunk...)
if len(cur) > ReadCap {
cur, tooLong = cur[:0], true
}
}
if isPrefix {
continue
}
if !tooLong && !line(string(cur)) {
break
}
cur, tooLong = cur[:0], false
}
_ = cmd.Process.Signal(syscall.SIGTERM)
werr := cmd.Wait()
if ctx.Err() != nil {
return nil // the time ran out: the normal end of a bounded watch
}
if werr != nil && stderr.Len() > 0 {
return fmt.Errorf("%s: %w: %s", name, werr, capped(strings.TrimSpace(stderr.String()), 2048))
}
return nil
}
func capped(s string, n int) string {
if len(s) <= n {
return s
}
return s[:n] + "\n… (cut)"
}
// Machine is what the module reads and acts on: a filesystem root (the real one, or a test's tree),
// a way to run commands, a way to stream one, and the environment the runtime gave it.
type Machine struct {
Root string
Run Runner
Stream Streamer
Env func(string) string
UID int
Now func() time.Time
}
// Here is the machine this process runs on.
func Here() *Machine {
return &Machine{Root: "/", Run: ExecRunner, Stream: ExecStreamer, Env: os.Getenv, UID: os.Getuid(), Now: time.Now}
}
func (m *Machine) path(p string) string { return filepath.Join(m.Root, p) }
func (m *Machine) read(p string) string {
b, err := os.ReadFile(m.path(p))
if err != nil {
return ""
}
return strings.TrimSpace(string(b))
}
func (m *Machine) exists(p string) bool {
_, err := os.Stat(m.path(p))
return err == nil
}
func (m *Machine) glob(pattern string) []string {
got, _ := filepath.Glob(m.path(pattern))
out := make([]string, 0, len(got))
for _, g := range got {
rel, err := filepath.Rel(m.Root, g)
if err != nil {
continue
}
out = append(out, "/"+rel)
}
return out
}
// privileged runs a command as root without asking for a password (sudo -n), as the other modules'
// tools do: the runtime runs as the operator's account, and watching the system bus is root's.
func (m *Machine) privileged(ctx context.Context, name string, args ...string) (string, error) {
return m.Run(ctx, "sudo", append([]string{"-n", name}, args...)...)
}
// Buses are the two buses a tool can be pointed at.
var Buses = []string{"system", "session"}
// ErrNoSession is the answer on a machine where this account has no session bus: the servers.
var ErrNoSession = errors.New("no session bus for this account on this machine")
// SessionAddress is the account's session bus: the runtime's DBUS_SESSION_BUS_ADDRESS, else the
// user manager's socket under XDG_RUNTIME_DIR or /run/user/<uid>. Only a socket that exists counts.
func (m *Machine) SessionAddress() (string, error) {
if a := m.Env("DBUS_SESSION_BUS_ADDRESS"); strings.HasPrefix(a, "unix:path=") {
p := strings.TrimPrefix(a, "unix:path=")
if i := strings.IndexByte(p, ','); i >= 0 {
p = p[:i]
}
if m.exists(p) {
return a, nil
}
} else if a != "" {
return a, nil // an abstract or other address: taken as given
}
dir := m.Env("XDG_RUNTIME_DIR")
if dir == "" {
dir = "/run/user/" + strconv.Itoa(m.UID)
}
if p := dir + "/bus"; m.exists(p) {
return "unix:path=" + p, nil
}
return "", fmt.Errorf("%w (no socket at %s/bus)", ErrNoSession, dir)
}
// busArgs are busctl's words for one bus.
func (m *Machine) busArgs(bus string) ([]string, error) {
switch bus {
case "", "system":
return []string{"--system"}, nil
case "session":
a, err := m.SessionAddress()
if err != nil {
return nil, err
}
return []string{"--address=" + a}, nil
}
return nil, fmt.Errorf("bus is system or session, not %q", bus)
}
// UnitOf is the systemd unit a process runs in, from its cgroup: readable for any process.
func (m *Machine) UnitOf(pid uint32) string {
if pid == 0 {
return ""
}
return UnitFromCgroup(m.read(fmt.Sprintf("/proc/%d/cgroup", pid)))
}
// ProcessName is a process's command name.
func (m *Machine) ProcessName(pid uint32) string {
if pid == 0 {
return ""
}
return m.read(fmt.Sprintf("/proc/%d/comm", pid))
}
// UnitFromCgroup is the innermost service, socket or scope in a cgroup v2 path.
func UnitFromCgroup(cgroup string) string {
for _, line := range strings.Split(cgroup, "\n") {
if !strings.HasPrefix(line, "0::") {
continue
}
parts := strings.Split(strings.TrimPrefix(line, "0::"), "/")
for i := len(parts) - 1; i >= 0; i-- {
p := parts[i]
if strings.HasSuffix(p, ".service") || strings.HasSuffix(p, ".scope") {
return p
}
}
}
return ""
}
// BootID is this boot's id: a bus that changed across a boot did not restart, the machine did.
func (m *Machine) BootID() string { return m.read("/proc/sys/kernel/random/boot_id") }
+26
View File
@@ -0,0 +1,26 @@
// The dbus module's Go bundle (novox/hq ADR 0215): the holder of node-message-bus. One process the
// node's runtime launches as the operator's account, serving the module's tools over MCP on stdio and
// running its watcher beside them, which publishes what matters about the system bus, never its
// traffic.
package main
import (
"context"
"fmt"
"os"
stdio "git.novox.be/novox/mesh-sdk/go"
)
func main() {
m := Here()
w := NewWatcher(m, func(eventType string, body any) error { return stdio.Emit(eventType, body) },
DialSystemBus, m.Denials)
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
go w.Run(ctx)
if err := stdio.Serve("", Tools(m, w)); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
}
@@ -0,0 +1,159 @@
package main
// The dbus module's shape (novox/hq ADR 0215): it holds node-message-bus, owns the bus's packages,
// declares the bus running and never restarts or reloads it, and publishes only its curated events.
import (
"encoding/json"
"os"
"reflect"
"regexp"
"sort"
"strings"
"testing"
)
type manifestShape struct {
Module string `json:"module"`
Capabilities []string `json:"capabilities"`
Claims []map[string]any `json:"claims"`
Emits []string `json:"emits"`
Consumes []string `json:"consumes"`
Tools []string `json:"tools"`
Resources []map[string]any `json:"resources"`
Build struct {
Artifacts []map[string]any `json:"artifacts"`
} `json:"build"`
}
func manifest(t *testing.T) (manifestShape, string) {
t.Helper()
raw, err := os.ReadFile("../../module.json")
if err != nil {
t.Fatal(err)
}
var m manifestShape
if err := json.Unmarshal(raw, &m); err != nil {
t.Fatal(err)
}
return m, string(raw)
}
func TestItHoldsTheMessageBusSeatWithOnlyWhatItNeeds(t *testing.T) {
m, _ := manifest(t)
if m.Module != "dbus" || len(m.Claims) != 1 || m.Claims[0]["name"] != "node-message-bus" || m.Claims[0]["scope"] != "node" {
t.Fatalf("claims: %+v", m.Claims)
}
if _, serves := m.Claims[0]["serves"]; serves {
t.Error("the seat receives nothing yet (ADR 0215 §4) and serves no verbs")
}
if !reflect.DeepEqual(m.Capabilities, []string{"package-manager", "service-manager"}) {
t.Fatalf("capabilities: %v", m.Capabilities)
}
}
func TestItOwnsTheBusImplementationsPackages(t *testing.T) {
m, _ := manifest(t)
var pkgs []string
for _, r := range m.Resources {
if r["type"] == "package" {
pkgs = append(pkgs, r["package"].(string))
if r["absent"] == true {
t.Errorf("%v is declared absent", r["package"])
}
}
}
sort.Strings(pkgs)
if !reflect.DeepEqual(pkgs, Packages) {
t.Fatalf("packages %v, the check reads %v", pkgs, Packages)
}
}
// TestTheBusIsNeverRestartedOrReloaded is ADR 0215 §2: the bus is declared running, with no trigger
// that would restart or reload it, and no boot state: dbus.service is an alias and dbus-broker.service
// is started at boot through dbus.socket, so enabling would write an alias link the machines do not
// have today.
func TestTheBusIsNeverRestartedOrReloaded(t *testing.T) {
m, raw := manifest(t)
if strings.Contains(raw, "restart-on") || strings.Contains(raw, "reload-on") {
t.Fatal("the manifest carries a restart or reload trigger")
}
var services []map[string]any
for _, r := range m.Resources {
if r["type"] == "service" {
services = append(services, r)
}
}
if len(services) != 1 {
t.Fatalf("services: %v", services)
}
s := services[0]
if s["unit"] != SystemUnit || s["state"] != "running" {
t.Fatalf("%v", s)
}
if _, ok := s["boot"]; ok {
t.Fatal("a boot state on the bus would have the host enable it")
}
for k := range s {
if k != "id" && k != "type" && k != "unit" && k != "state" {
t.Errorf("the bus's service carries %q", k)
}
}
}
func TestItEmitsTheCuratedEventsAndConsumesNothing(t *testing.T) {
m, _ := manifest(t)
if !reflect.DeepEqual(m.Emits, Emits) {
t.Fatalf("emits %v, the watcher publishes %v", m.Emits, Emits)
}
if len(m.Consumes) != 0 {
t.Fatalf("consumes %v", m.Consumes)
}
}
func TestToolsAreTheManifests(t *testing.T) {
m, _ := manifest(t)
served := map[string]bool{}
for _, tool := range Tools(testMachine(t, nil), nil) {
if served[tool.Name] {
t.Errorf("%s is served twice", tool.Name)
}
served[tool.Name] = true
if !strings.HasPrefix(tool.Name, "dbus_") {
t.Errorf("%s is not named dbus_<verb>", tool.Name)
}
}
for _, want := range m.Tools {
if !served[want] {
t.Errorf("the manifest lists %s and the bundle does not serve it", want)
}
delete(served, want)
}
for extra := range served {
t.Errorf("the bundle serves %s, which the manifest does not list", extra)
}
}
func TestTheBundleIsTheBuildersShape(t *testing.T) {
m, _ := manifest(t)
if len(m.Build.Artifacts) != 1 {
t.Fatalf("%v", m.Build.Artifacts)
}
a := m.Build.Artifacts[0]
for k, v := range map[string]string{"kind": "bundle", "language": "go", "system": "arch", "from": "cmd/dbus-tools", "binary": "dbus-tools"} {
if a[k] != v {
t.Errorf("%s is %v, want %s", k, a[k], v)
}
}
}
// TestNothingInstallationSpecific holds the manifest to the catalogue's rule: no node names, domains
// or home paths.
func TestNothingInstallationSpecific(t *testing.T) {
_, raw := manifest(t)
for _, re := range []*regexp.Regexp{regexp.MustCompile(`/home/`), regexp.MustCompile(`\.(be|internal|com)\b`)} {
if loc := re.FindString(raw); loc != "" {
t.Errorf("the manifest carries %q", loc)
}
}
}
+115
View File
@@ -0,0 +1,115 @@
package main
import (
"context"
"encoding/json"
"fmt"
"strconv"
"strings"
"time"
)
// MaxMonitorSeconds bounds a watch of the bus: long enough to catch an exchange, short enough that
// nobody leaves a monitor running.
const MaxMonitorSeconds = 15
// MonitorLimit is the most messages busctl reads in one watch before it stops by itself.
const MonitorLimit = 20000
// Header is what dbus_monitor answers of one message: who sent what to whom. The body is never read
// into it: the struct has no field for it, so a payload is dropped as each line is decoded.
type Header struct {
Time string `json:"time,omitempty"`
Type string `json:"type"`
Sender string `json:"sender,omitempty"`
Destination string `json:"destination,omitempty"`
Path string `json:"path,omitempty"`
Interface string `json:"interface,omitempty"`
Member string `json:"member,omitempty"`
ErrorName string `json:"error_name,omitempty"`
}
type monitorLine struct {
Type string `json:"type"`
Sender string `json:"sender"`
Destination string `json:"destination"`
Path string `json:"path"`
Interface string `json:"interface"`
Member string `json:"member"`
ErrorName string `json:"error_name"`
Realtime int64 `json:"timestamp-realtime"`
}
// ParseHeader reads one line of `busctl monitor --json=short` into its header alone.
func ParseHeader(line string) (Header, bool) {
var l monitorLine
if json.Unmarshal([]byte(line), &l) != nil || l.Type == "" {
return Header{}, false
}
h := Header{Type: l.Type, Sender: l.Sender, Destination: l.Destination, Path: l.Path,
Interface: l.Interface, Member: l.Member, ErrorName: l.ErrorName}
if l.Realtime > 0 {
h.Time = time.UnixMicro(l.Realtime).UTC().Format("15:04:05.000000")
}
return h, true
}
// Watch is dbus_monitor's answer.
type Watch struct {
Bus string `json:"bus"`
Seconds int `json:"seconds"`
Match string `json:"match,omitempty"`
Names []string `json:"names,omitempty"`
Total int `json:"total"`
Messages []Header `json:"messages"`
Cut bool `json:"cut,omitempty"`
Note string `json:"note"`
}
// Monitor watches one bus for a few seconds and answers the headers of what passed. The system bus is
// watched as root (sudo -n): only root may become a monitor there. The session bus is the account's
// own. The bodies, which carry secrets, notification text and the clipboard, are never kept.
func (m *Machine) Monitor(ctx context.Context, bus string, seconds int, match string, names []string) (Watch, error) {
if seconds < 1 || seconds > MaxMonitorSeconds {
return Watch{}, fmt.Errorf("seconds is 1 to %d", MaxMonitorSeconds)
}
if len(match) > 512 || strings.ContainsAny(match, "\n\x00") {
return Watch{}, fmt.Errorf("the match rule is not one line of at most 512 characters")
}
for _, n := range names {
if !busNameRE.MatchString(n) {
return Watch{}, fmt.Errorf("%q is not a bus name", n)
}
}
args, err := m.busArgs(bus)
if err != nil {
return Watch{}, err
}
cmd := append([]string{"timeout", strconv.Itoa(seconds) + "s", "busctl"}, args...)
cmd = append(cmd, "--json=short", "--no-pager", "--limit-messages="+strconv.Itoa(MonitorLimit), "monitor")
if match != "" {
cmd = append(cmd, "--match="+match)
}
cmd = append(cmd, names...)
if orWord(bus, "system") == "system" {
cmd = append([]string{"sudo", "-n"}, cmd...)
}
w := Watch{Bus: orWord(bus, "system"), Seconds: seconds, Match: match, Names: names, Messages: []Header{},
Note: "headers only; message bodies are never read into the answer"}
ctx, cancel := context.WithTimeout(ctx, time.Duration(seconds)*time.Second+5*time.Second)
defer cancel()
err = m.Stream(ctx, func(line string) bool {
h, ok := ParseHeader(line)
if !ok {
return true
}
w.Total++
if len(w.Messages) < AnswerCap {
w.Messages = append(w.Messages, h)
} else {
w.Cut = true
}
return true
}, cmd[0], cmd[1:]...)
return w, err
}
+103
View File
@@ -0,0 +1,103 @@
package main
import (
"context"
"encoding/json"
"fmt"
"sort"
"strings"
)
// busctlName is one line of `busctl list --json`.
type busctlName struct {
Name string `json:"name"`
PID *uint32 `json:"pid"`
Process *string `json:"process"`
User *string `json:"user"`
Connection string `json:"connection"`
Unit *string `json:"unit"`
}
// Name is one well-known name on a bus, with what holds it.
type Name struct {
Name string `json:"name"`
PID uint32 `json:"pid,omitempty"`
Process string `json:"process,omitempty"`
User string `json:"user,omitempty"`
Unit string `json:"unit,omitempty"`
Connection string `json:"connection,omitempty"`
}
// Names is dbus_names's answer.
type Names struct {
Bus string `json:"bus"`
Running []Name `json:"running"`
ActivatableNotRunning []string `json:"activatable_not_running"`
Connections int `json:"connections"`
Cut bool `json:"cut,omitempty"`
}
// ParseNames reads `busctl list --json=short`: the well-known names running, the activatable names
// that are not, and how many connections (unique names) the bus has.
func ParseNames(bus, out string) (Names, error) {
var raw []busctlName
if err := json.Unmarshal([]byte(strings.TrimSpace(out)), &raw); err != nil {
return Names{}, fmt.Errorf("busctl list answered what is not its JSON: %w", err)
}
n := Names{Bus: bus, Running: []Name{}, ActivatableNotRunning: []string{}}
for _, r := range raw {
switch {
case r.Name == busName: // the bus itself, which busctl attributes to the service manager
case strings.HasPrefix(r.Name, ":"):
n.Connections++
case r.Connection == "(activatable)" || r.PID == nil:
n.ActivatableNotRunning = append(n.ActivatableNotRunning, r.Name)
default:
n.Running = append(n.Running, Name{Name: r.Name, PID: deref(r.PID), Process: derefS(r.Process),
User: derefS(r.User), Unit: derefS(r.Unit), Connection: r.Connection})
}
}
sort.Slice(n.Running, func(i, j int) bool { return n.Running[i].Name < n.Running[j].Name })
sort.Strings(n.ActivatableNotRunning)
if len(n.Running) > AnswerCap {
n.Running, n.Cut = n.Running[:AnswerCap], true
}
if len(n.ActivatableNotRunning) > AnswerCap {
n.ActivatableNotRunning, n.Cut = n.ActivatableNotRunning[:AnswerCap], true
}
return n, nil
}
// Names lists one bus's names as this account sees them.
func (m *Machine) Names(ctx context.Context, bus string) (Names, error) {
args, err := m.busArgs(bus)
if err != nil {
return Names{}, err
}
out, err := m.Run(ctx, "busctl", append(args, "--json=short", "--no-pager", "list")...)
if err != nil {
return Names{}, err
}
return ParseNames(orWord(bus, "system"), out)
}
func deref(p *uint32) uint32 {
if p == nil {
return 0
}
return *p
}
func derefS(p *string) string {
if p == nil {
return ""
}
return *p
}
func orWord(s, word string) string {
if strings.TrimSpace(s) == "" {
return word
}
return s
}
+304
View File
@@ -0,0 +1,304 @@
package main
import (
"context"
"encoding/json"
"errors"
"reflect"
"strings"
"testing"
"time"
)
// Recorded answers, trimmed, from a workstation of 2026-10-05.
const busctlList = `[{"name":":1.0","pid":456,"process":"systemd-timesyn","user":"systemd-timesync","connection":":1.0","unit":"systemd-timesyncd.service","session":null,"description":null},` +
`{"name":"fi.w1.wpa_supplicant1","pid":1442,"process":"wpa_supplicant","user":"root","connection":":1.23","unit":"wpa_supplicant.service","session":null,"description":null},` +
`{"name":"org.blueman.Mechanism","pid":null,"process":null,"user":null,"connection":"(activatable)","unit":null,"session":null,"description":null},` +
`{"name":":1.11","pid":975,"process":"polkitd","user":"polkitd","connection":":1.11","unit":"polkit.service","session":null,"description":null},` +
`{"name":"org.freedesktop.DBus","pid":807,"process":"dbus-broker-lau","user":"root","connection":"org.freedesktop.DBus","unit":"dbus-broker.service","session":null,"description":null}]`
func TestNamesAreSplitIntoRunningActivatableAndConnections(t *testing.T) {
n, err := ParseNames("system", busctlList)
if err != nil {
t.Fatal(err)
}
if n.Connections != 2 || len(n.Running) != 1 || n.Running[0].Name != "fi.w1.wpa_supplicant1" ||
n.Running[0].Unit != "wpa_supplicant.service" || n.Running[0].PID != 1442 {
t.Fatalf("%+v", n)
}
if !reflect.DeepEqual(n.ActivatableNotRunning, []string{"org.blueman.Mechanism"}) {
t.Fatalf("%v", n.ActivatableNotRunning)
}
}
const introspection = `{"type":"s","data":["<!DOCTYPE node PUBLIC \"-//freedesktop//DTD D-BUS Object Introspection 1.0//EN\" \"http://www.freedesktop.org/standards/dbus/1.0/introspect.dtd\">\n<node>\n <interface name=\"org.freedesktop.login1.Manager\">\n <property name=\"IdleHint\" type=\"b\" access=\"read\"></property>\n <method name=\"Inhibit\">\n <arg type=\"s\" name=\"what\" direction=\"in\"/>\n <arg type=\"h\" name=\"pipe_fd\" direction=\"out\"/>\n </method>\n <signal name=\"PrepareForSleep\">\n <arg type=\"b\" name=\"start\"/>\n </signal>\n </interface>\n <node name=\"session\"/>\n <node name=\"seat\"/>\n</node>\n"]}`
func TestAnIntrospectionIsShapedWithoutValues(t *testing.T) {
node, err := ParseIntrospection(introspection)
if err != nil {
t.Fatal(err)
}
o := Shape("system", "org.freedesktop.login1", "/org/freedesktop/login1", node)
if len(o.Interfaces) != 1 || !reflect.DeepEqual(o.Children, []string{"session", "seat"}) {
t.Fatalf("%+v", o)
}
i := o.Interfaces[0]
if !reflect.DeepEqual(i.Methods[0], Member{Name: "Inhibit", In: []string{"what s"}, Out: []string{"pipe_fd h"}}) ||
!reflect.DeepEqual(i.Properties[0], Property{Name: "IdleHint", Type: "b", Access: "read"}) ||
!reflect.DeepEqual(i.Signals[0], Member{Name: "PrepareForSleep", Args: []string{"start b"}}) {
t.Fatalf("%+v", i)
}
}
func TestIntrospectRefusesWhatIsNotANameOrAPathAndNeverStartsAService(t *testing.T) {
var asked []string
m := testMachine(t, nil)
m.Run = func(_ context.Context, name string, args ...string) (string, error) {
asked = append([]string{name}, args...)
return introspection, nil
}
if _, err := m.Introspect(context.Background(), "system", "--address=x", "/"); err == nil {
t.Fatal("an option was taken for a bus name")
}
if _, err := m.Introspect(context.Background(), "system", "org.freedesktop.login1", "relative"); err == nil {
t.Fatal("a relative path was taken")
}
if _, err := m.Introspect(context.Background(), "system", "org.freedesktop.login1", ""); err != nil {
t.Fatal(err)
}
if !strings.Contains(strings.Join(asked, " "), "--auto-start=no") || asked[len(asked)-3] != "/" {
t.Fatalf("%v", asked)
}
}
const monitorLines = `{"type":"method_call","endian":"l","flags":0,"version":1,"cookie":186058,"timestamp-realtime":1791193058632202,"sender":":1.797","destination":"org.freedesktop.UPower","path":"/org/freedesktop/UPower","interface":"org.freedesktop.UPower","member":"GetDisplayDevice","payload":{"type":"","data":[]}}
{"type":"signal","endian":"l","flags":1,"version":1,"cookie":7,"timestamp-realtime":1791193058632725,"sender":":1.40","path":"/org/freedesktop/Notifications","interface":"org.freedesktop.Notifications","member":"Notify","payload":{"type":"susssasa{sv}i","data":["app",0,"","the secret notification text","the clipboard's password",[],{},5000]}}
not json at all
{"type":"error","endian":"l","flags":1,"version":1,"cookie":9,"sender":":1.9","destination":":1.797","error_name":"org.freedesktop.DBus.Error.AccessDenied","payload":{"type":"s","data":["the reason, with a token"]}}`
func TestAMonitoredMessageIsItsHeaderOnly(t *testing.T) {
var asked []string
m := testMachine(t, nil)
m.Stream = func(ctx context.Context, line func(string) bool, name string, args ...string) error {
asked = append([]string{name}, args...)
if _, ok := ctx.Deadline(); !ok {
t.Error("the watch has no deadline")
}
for _, l := range strings.Split(monitorLines, "\n") {
if !line(l) {
break
}
}
return nil
}
w, err := m.Monitor(context.Background(), "system", 3, "type='signal'", []string{"org.freedesktop.Notifications"})
if err != nil {
t.Fatal(err)
}
raw, _ := json.Marshal(w)
for _, secret := range []string{"secret notification", "password", "token", "payload", "data"} {
if strings.Contains(string(raw), secret) {
t.Errorf("the answer carries %q: %s", secret, raw)
}
}
if w.Total != 3 || w.Messages[1].Member != "Notify" || w.Messages[2].ErrorName != "org.freedesktop.DBus.Error.AccessDenied" {
t.Fatalf("%+v", w)
}
cmd := strings.Join(asked, " ")
if !strings.HasPrefix(cmd, "sudo -n timeout 3s busctl --system") || !strings.Contains(cmd, "--match=type='signal'") ||
!strings.HasSuffix(cmd, "org.freedesktop.Notifications") {
t.Fatalf("%s", cmd)
}
}
func TestAMonitorIsBoundedAndTheSessionBusIsTheAccounts(t *testing.T) {
m := testMachine(t, map[string]string{"/run/user/1000/bus": ""})
var asked string
m.Stream = func(_ context.Context, _ func(string) bool, name string, args ...string) error {
asked = name + " " + strings.Join(args, " ")
return nil
}
for _, s := range []int{0, MaxMonitorSeconds + 1} {
if _, err := m.Monitor(context.Background(), "system", s, "", nil); err == nil {
t.Errorf("%d seconds were accepted", s)
}
}
if _, err := m.Monitor(context.Background(), "session", 2, "", []string{"-x"}); err == nil {
t.Error("an option was taken for a name")
}
if _, err := m.Monitor(context.Background(), "session", 2, "", nil); err != nil {
t.Fatal(err)
}
if strings.HasPrefix(asked, "sudo") || !strings.Contains(asked, "--address=unix:path=/run/user/1000/bus") {
t.Fatalf("%s", asked)
}
}
func TestASessionBusIsFoundOrSaidMissing(t *testing.T) {
m := testMachine(t, nil)
if _, err := m.SessionAddress(); !errors.Is(err, ErrNoSession) {
t.Fatalf("a server without a session said %v", err)
}
m = testMachine(t, map[string]string{"/run/user/1000/bus": ""})
if a, err := m.SessionAddress(); err != nil || a != "unix:path=/run/user/1000/bus" {
t.Fatalf("%q %v", a, err)
}
m.Env = func(k string) string {
if k == "DBUS_SESSION_BUS_ADDRESS" {
return "unix:path=/run/user/1000/bus,guid=abc"
}
return ""
}
if a, _ := m.SessionAddress(); a != "unix:path=/run/user/1000/bus,guid=abc" {
t.Fatalf("%q", a)
}
}
const journalLines = `{"__CURSOR":"s=1;i=1","__REALTIME_TIMESTAMP":"1791192960341045","MESSAGE":"Ready","_PID":"807"}
{"__CURSOR":"s=1;i=2","__REALTIME_TIMESTAMP":"1791192960341045","MESSAGE":"A security policy denied :1.1199 to send method call /org/freedesktop/DBus:org.freedesktop.DBus.Debug.Stats.GetStats to org.freedesktop.DBus.","DBUS_BROKER_TRANSMIT_ACTION":"send","DBUS_BROKER_MESSAGE_TYPE":"method_call","DBUS_BROKER_SENDER_UNIQUE_NAME":":1.1199","DBUS_BROKER_MESSAGE_DESTINATION":"org.freedesktop.DBus","DBUS_BROKER_MESSAGE_PATH":"/org/freedesktop/DBus","DBUS_BROKER_MESSAGE_INTERFACE":"org.freedesktop.DBus.Debug.Stats","DBUS_BROKER_MESSAGE_MEMBER":"GetStats","DBUS_BROKER_POLICY_TYPE":"internal"}
{"__CURSOR":"s=1;i=3","MESSAGE":[65,66]}`
func TestDenialsAreReadFromTheJournalByTheirFields(t *testing.T) {
d, cursor := ParseDenials(journalLines)
if cursor != "s=1;i=3" || len(d) != 1 {
t.Fatalf("%q %+v", cursor, d)
}
want := Denial{At: "2026-10-05T09:36:00Z", Action: "send", Type: "method_call", Sender: ":1.1199",
Destination: "org.freedesktop.DBus", Path: "/org/freedesktop/DBus", Interface: "org.freedesktop.DBus.Debug.Stats",
Member: "GetStats", Policy: "internal"}
if d[0] != want {
t.Fatalf("%+v", d[0])
}
}
func TestTheJournalIsReadFromTheCursorOn(t *testing.T) {
m := testMachine(t, nil)
var asked []string
m.Run = func(_ context.Context, name string, args ...string) (string, error) {
asked = append([]string{name}, args...)
return journalLines, nil
}
_, next, _ := m.Denials(context.Background(), "", time.Unix(100, 0))
if !strings.Contains(strings.Join(asked, " "), "--since @100") {
t.Fatalf("%v", asked)
}
m.Denials(context.Background(), next, time.Time{})
if !strings.Contains(strings.Join(asked, " "), "--after-cursor s=1;i=3") || !strings.Contains(strings.Join(asked, " "), "-u dbus-broker.service") {
t.Fatalf("%v", asked)
}
}
func TestAUnitIsReadFromACgroup(t *testing.T) {
for in, want := range map[string]string{
"0::/system.slice/bluetooth.service\n": "bluetooth.service",
"0::/user.slice/user-1000.slice/user@1000.service/app.slice/dunst.service": "dunst.service",
"0::/user.slice/user-1000.slice/session-2.scope": "session-2.scope",
"0::/init.scope": "init.scope",
"": "",
} {
if got := UnitFromCgroup(in); got != want {
t.Errorf("%q: %q, want %q", in, got, want)
}
}
}
func TestShowBlocksAreReadInOrder(t *testing.T) {
b := ParseShow("Id=dbus-org.freedesktop.resolve1.service\nLoadState=not-found\n\nId=systemd-hostnamed.service\nLoadState=loaded\n")
if len(b) != 2 || b[0]["LoadState"] != "not-found" || b[1]["Id"] != "systemd-hostnamed.service" {
t.Fatalf("%v", b)
}
}
func TestARunningBusOlderThanItsPackageIsSaid(t *testing.T) {
p := ParseDesc("%NAME%\ndbus-broker\n\n%VERSION%\n37-3\n\n%INSTALLDATE%\n1772097880\n")
if p.Name != "dbus-broker" || p.Version != "37-3" || p.Installed.Unix() != 1772097880 {
t.Fatalf("%+v", p)
}
if due, _ := RebootDue(time.Unix(1791123478, 0), []Package{p}); due {
t.Fatal("a bus started after its package was said to be older")
}
due, detail := RebootDue(time.Unix(1772000000, 0), []Package{p})
if !due || !strings.Contains(detail, "reboot is due") || !strings.Contains(detail, "dbus-broker 37-3") {
t.Fatalf("%v %s", due, detail)
}
}
func TestConnectionsAreCountedFromStatsOrNames(t *testing.T) {
stats := `{"type":"a{sv}","data":[{"org.bus1.DBus.Debug.Stats.PeerAccounting":{"type":"a(sa{sv}a{su})","data":[[":1.0",{},{}],[":1.1",{},{}],[":1.7",{},{}]]}}]}`
if n, ok := CountConnections(stats); !ok || n != 3 {
t.Fatalf("%d %v", n, ok)
}
if n, ok := CountConnections(`{"type":"a{sv}","data":[{"ActiveConnections":{"type":"u","data":12}}]}`); !ok || n != 12 {
t.Fatalf("%d %v", n, ok)
}
if n, ok := CountUniqueNames(`{"type":"as","data":[["org.freedesktop.DBus",":1.0","org.bluez",":1.5"]]}`); !ok || n != 2 {
t.Fatalf("%d %v", n, ok)
}
}
func TestStatsAreNeverAskedAsTheAccount(t *testing.T) {
m := testMachine(t, nil)
var calls []string
m.Run = func(_ context.Context, name string, args ...string) (string, error) {
line := name + " " + strings.Join(args, " ")
calls = append(calls, line)
if name == "sudo" {
return "", errors.New("sudo: a password is required")
}
if strings.Contains(line, "ListNames") {
return `{"type":"as","data":[[":1.0",":1.1"]]}`, nil
}
return "Id=dbus-broker.service\nActiveState=active\nMainPID=807\nActiveEnterTimestamp=@1791123478\n", nil
}
h := m.Health(context.Background(), nil)
for _, c := range calls {
if strings.Contains(c, "GetStats") && !strings.HasPrefix(c, "sudo -n ") {
t.Fatalf("Debug.Stats asked as the account, which the bus logs as a denial: %s", c)
}
}
if h["connections"] != 2 || h["unit"] != "dbus-broker.service" {
t.Fatalf("%v", h)
}
}
func TestTheCheckReadsPackagesUnitsServiceFilesAndDenials(t *testing.T) {
m := testMachine(t, map[string]string{
"/var/lib/pacman/local/dbus-1.16.2-1/desc": "%NAME%\ndbus\n\n%VERSION%\n1.16.2-1\n\n%INSTALLDATE%\n1741340000\n",
"/var/lib/pacman/local/dbus-broker-37-3/desc": "%NAME%\ndbus-broker\n\n%VERSION%\n37-3\n\n%INSTALLDATE%\n1800000000\n",
"/var/lib/pacman/local/dbus-broker-units-37-3/desc": "%NAME%\ndbus-broker-units\n\n%VERSION%\n37-3\n\n%INSTALLDATE%\n1772097880\n",
"/usr/share/dbus-1/system-services/org.bluez.service": "[D-BUS Service]\nName=org.bluez\nExec=/bin/false\nUser=root\nSystemdService=dbus-org.bluez.service\n",
"/usr/share/dbus-1/system-services/org.example.service": "[D-BUS Service]\nName=org.example\nSystemdService=example.service\n",
"/usr/share/dbus-1/system-services/org.freedesktop.systemd1.service": "[D-BUS Service]\nName=org.freedesktop.systemd1\nExec=/bin/false\n",
})
m.Run = func(_ context.Context, name string, args ...string) (string, error) {
line := strings.Join(args, " ")
switch {
case name == "journalctl":
return journalLines, nil
case strings.Contains(line, "Id,LoadState"):
return "Id=dbus-org.bluez.service\nLoadState=not-found\n\nId=example.service\nLoadState=not-found\n", nil
default:
return "Id=dbus-broker.service\nActiveState=active\nMainPID=807\nActiveEnterTimestamp=@1791123478\n", nil
}
}
got := m.Check(context.Background(), nil)
byName := map[string]Check{}
for _, c := range got["checks"].([]Check) {
byName[c.Name] = c
}
if !byName["package dbus-broker-units"].OK || !byName["system bus"].OK {
t.Fatalf("%+v", got)
}
if c := byName["running bus is the installed one"]; c.OK || !strings.Contains(c.Detail, "dbus-broker 37-3") {
t.Fatalf("an upgraded broker was not said: %+v", c)
}
if c := byName["activatable services"]; c.OK || !strings.Contains(c.Detail, "org.example → example.service") || strings.Contains(c.Detail, "bluez") {
t.Fatalf("%+v", c)
}
if c := byName["policy denials in the last hour"]; c.OK || !strings.Contains(c.Detail, "GetStats") {
t.Fatalf("%+v", c)
}
if !strings.Contains(strings.Join(got["notes"].([]string), " "), "org.bluez → dbus-org.bluez.service") {
t.Fatalf("%v", got["notes"])
}
}
+99
View File
@@ -0,0 +1,99 @@
package main
import (
"context"
"fmt"
stdio "git.novox.be/novox/mesh-sdk/go"
)
var busArg = map[string]any{"type": "string", "enum": Buses,
"description": "system (the default) or session (this account's login session bus, where one exists)"}
func busOf(args map[string]any) string {
b, _ := args["bus"].(string)
return b
}
// Tools are the module's tools over MCP (novox/hq ADR 0215): the names on either bus, what an object
// offers, a bounded watch of message headers, the module's check, and the bus's health.
func Tools(m *Machine, w *Watcher) []stdio.Tool {
return []stdio.Tool{
{
Name: "dbus_names",
Description: "Every well-known name on the system or session bus: its owner's pid, process, user and " +
"systemd unit, the activatable names that are not running, and how many connections the bus has. " +
"Read as the operator's account.",
Input: map[string]any{"type": "object", "properties": map[string]any{"bus": busArg}},
Run: func(args map[string]any) (any, error) {
return m.Names(context.Background(), busOf(args))
},
},
{
Name: "dbus_introspect",
Description: "What one object on a bus offers: its interfaces with their methods (arguments in and out), " +
"properties (type and access, never their values) and signals, and its child objects. A service " +
"that is not running is not started for it.",
Input: map[string]any{
"type": "object",
"properties": map[string]any{
"bus": busArg,
"service": map[string]any{"type": "string", "description": "the bus name, e.g. org.freedesktop.login1"},
"path": map[string]any{"type": "string", "description": "the object path (default /)"},
},
"required": []string{"service"},
},
Run: func(args map[string]any) (any, error) {
service, _ := args["service"].(string)
path, _ := args["path"].(string)
return m.Introspect(context.Background(), busOf(args), service, path)
},
},
{
Name: "dbus_monitor",
Description: fmt.Sprintf("Watch a bus for a few seconds (at most %d) and answer the headers of the "+
"messages that passed: type, sender, destination, path, interface, member. Never a message's body, "+
"which carries secrets, notification text and the clipboard. Optional match rule and names to "+
"narrow it. The system bus is watched as root through sudo -n.", MaxMonitorSeconds),
Input: map[string]any{
"type": "object",
"properties": map[string]any{
"bus": busArg,
"seconds": map[string]any{"type": "integer", "description": fmt.Sprintf("how long (default 5, at most %d)", MaxMonitorSeconds)},
"match": map[string]any{"type": "string", "description": "a D-Bus match rule, e.g. type='signal',interface='org.freedesktop.login1.Manager'"},
"names": map[string]any{"type": "array", "items": map[string]any{"type": "string"}, "description": "only messages to or from these bus names"},
},
},
Run: func(args map[string]any) (any, error) {
s := 5
if v, ok := args["seconds"].(float64); ok {
s = int(v)
}
match, _ := args["match"].(string)
var names []string
if list, ok := args["names"].([]any); ok {
for _, n := range list {
if str, ok := n.(string); ok {
names = append(names, str)
}
}
}
return m.Monitor(context.Background(), busOf(args), s, match, names)
},
},
{
Name: "dbus_check",
Description: "Whether this machine's message bus is as the mesh expects: the bus's packages installed, " +
"dbus-broker running behind dbus.service, whether the running bus is older than its installed " +
"package (then a reboot is due: the bus is never restarted live), activatable services whose " +
"unit does not exist, policy denials in the last hour, and the watcher's state.",
Run: func(map[string]any) (any, error) { return m.Check(context.Background(), w), nil },
},
{
Name: "dbus_health",
Description: "The system bus's health now: a ping's round trip, how many connections it has, its unit, " +
"pid and uptime, and what the watcher last saw.",
Run: func(map[string]any) (any, error) { return m.Health(context.Background(), w), nil },
},
}
}
+619
View File
@@ -0,0 +1,619 @@
package main
import (
"context"
"os"
"path/filepath"
"sort"
"strings"
"sync"
"time"
)
// The watcher publishes what matters about the machine's system bus as this module's events
// (novox/hq ADR 0215 §3): its health, a well-known service appearing or leaving, and a policy
// denial. It reads only the bus driver's own answers and signals and the bus unit's journal. It never
// becomes a monitor and never sees another peer's messages, so traffic cannot leave the machine
// through it: the event bodies are built from names, pids, units and the denial's header fields.
// Event types, as the module's manifest declares them in `emits`.
const (
BusStalled = "bus.stalled"
BusRecovered = "bus.recovered"
BusRestarted = "bus.restarted"
ServiceAppeared = "service.appeared"
ServiceLeft = "service.left"
PolicyDenied = "policy.denied"
)
// Emits is every event the watcher publishes, in the manifest's order.
var Emits = []string{BusStalled, BusRecovered, BusRestarted, ServiceAppeared, ServiceLeft, PolicyDenied}
const (
// PingEvery is how often the bus is pinged.
PingEvery = 10 * time.Second
// StallAfter is how long a ping may take before the bus counts as stalled.
StallAfter = 3 * time.Second
// Debounce is how long a name must stay as it is before its change is said: a service restarted
// within it, or one that flaps, says nothing.
Debounce = 10 * time.Second
// DenialsEvery is how often the journal is read for policy denials.
DenialsEvery = 30 * time.Second
// DenialEventEvery is the least time between two policy.denied events; denials in between are
// counted into the next one.
DenialEventEvery = 5 * time.Minute
// MaxQueue is the most events kept while the mesh's bus does not take them; the oldest go first.
MaxQueue = 1000
// DenialExamples is the most distinct denials one policy.denied names.
DenialExamples = 5
)
// NameChange is the bus driver's NameOwnerChanged: a name, its old owner and its new one.
type NameChange struct{ Name, Old, New string }
// Bus is the system bus as the watcher uses it, behind an interface so it is tested without one.
type Bus interface {
Ping(ctx context.Context) error
ID(ctx context.Context) (string, error)
PID(ctx context.Context, name string) (uint32, error)
Names(ctx context.Context) ([]string, error)
Activatable(ctx context.Context) ([]string, error)
// Changes closes when the connection is lost.
Changes() <-chan NameChange
Close()
}
// Emitter publishes one event and returns once the mesh's bus has it.
type Emitter func(eventType string, body any) error
// Journal answers the policy denials in the bus unit's journal after a cursor (or, with none, since
// a time), and the cursor to continue from.
type Journal func(ctx context.Context, after string, since time.Time) ([]Denial, string, error)
type queued struct {
Type string
Body map[string]any
}
type owner struct {
PID uint32
Process string
Unit string
}
// Watcher is the long-running half of the module.
type Watcher struct {
m *Machine
emit Emitter
dial func() (Bus, error)
journal Journal
now func() time.Time
state string // where the last seen bus identity is kept, across restarts of the runtime
retry time.Duration
mu sync.Mutex
bus Bus
queue []queued
dropped int
issue string
connected bool
stalled bool
stallSince time.Time
stallReason string
lastPing time.Duration
lastPingAt time.Time
busID string
brokerPID uint32
baselined bool
current map[string]bool
published map[string]owner
dirty map[string]time.Time
activatable map[string]bool
cursor string
denials int
denialSince time.Time
examples []Denial
lastDenial time.Time
kick chan struct{}
}
// NewWatcher is a watcher for this machine, emitting through emit, reaching the bus through dial and
// the journal through journal.
func NewWatcher(m *Machine, emit Emitter, dial func() (Bus, error), journal Journal) *Watcher {
home, _ := os.UserHomeDir()
now := time.Now
if m != nil && m.Now != nil {
now = m.Now
}
return &Watcher{m: m, emit: emit, dial: dial, journal: journal, now: now,
state: filepath.Join(home, ".local", "state", "mesh-dbus", "bus"), retry: 5 * time.Second,
current: map[string]bool{}, published: map[string]owner{}, dirty: map[string]time.Time{},
activatable: map[string]bool{}, kick: make(chan struct{}, 1)}
}
// Snapshot is what dbus_check and dbus_health show of the watcher.
type Snapshot struct {
Connected bool `json:"connected"`
Stalled bool `json:"stalled"`
StalledSince string `json:"stalled_since,omitempty"`
StallReason string `json:"stall_reason,omitempty"`
LastPingMS float64 `json:"last_ping_ms"`
LastPingAt string `json:"last_ping_at,omitempty"`
BusID string `json:"bus_id,omitempty"`
BrokerPID uint32 `json:"broker_pid,omitempty"`
Services int `json:"well_known_names"`
Pending int `json:"pending_events"`
Dropped int `json:"dropped_events,omitempty"`
Problem string `json:"problem,omitempty"`
}
func (w *Watcher) Snapshot() Snapshot {
w.mu.Lock()
defer w.mu.Unlock()
s := Snapshot{Connected: w.connected, Stalled: w.stalled, StallReason: w.stallReason,
LastPingMS: float64(w.lastPing.Microseconds()) / 1000, BusID: w.busID, BrokerPID: w.brokerPID,
Services: len(w.published), Pending: len(w.queue), Dropped: w.dropped, Problem: w.issue}
if w.stalled {
s.StalledSince = w.stallSince.UTC().Format(time.RFC3339)
}
if !w.lastPingAt.IsZero() {
s.LastPingAt = w.lastPingAt.UTC().Format(time.RFC3339)
}
return s
}
func (w *Watcher) problem(s string) {
w.mu.Lock()
w.issue = s
w.mu.Unlock()
}
// enqueue adds an event in order, stamped with when it happened. A full queue lets the oldest go and
// counts it: a mesh bus gone for a day must not grow the process without bound.
func (w *Watcher) enqueue(eventType string, body map[string]any) {
if body == nil {
body = map[string]any{}
}
body["at"] = w.now().UTC().Format(time.RFC3339)
w.mu.Lock()
w.queue = append(w.queue, queued{eventType, body})
if len(w.queue) > MaxQueue {
w.dropped += len(w.queue) - MaxQueue
w.queue = w.queue[len(w.queue)-MaxQueue:]
}
w.mu.Unlock()
select {
case w.kick <- struct{}{}:
default:
}
}
// flush publishes what waits, in order, and stops at the first the mesh's bus does not take.
func (w *Watcher) flush() {
for {
w.mu.Lock()
if len(w.queue) == 0 {
w.mu.Unlock()
return
}
next := w.queue[0]
w.mu.Unlock()
if err := w.emit(next.Type, next.Body); err != nil {
w.problem("the mesh's bus did not take " + next.Type + ": " + err.Error())
return
}
w.mu.Lock()
if len(w.queue) > 0 {
w.queue = w.queue[1:]
}
if len(w.queue) == 0 && strings.HasPrefix(w.issue, "the mesh's bus") {
w.issue = ""
}
w.mu.Unlock()
}
}
// flusher publishes on its own, so an emit waiting on the runtime never delays a ping.
func (w *Watcher) flusher(ctx context.Context) {
tick := time.NewTicker(5 * time.Second)
defer tick.Stop()
for {
select {
case <-ctx.Done():
return
case <-w.kick:
case <-tick.C:
}
w.flush()
}
}
// markStalled says bus.stalled once, until the bus answers again.
func (w *Watcher) markStalled(reason string) {
w.mu.Lock()
if w.stalled {
w.stallReason = reason
w.mu.Unlock()
return
}
w.stalled, w.stallSince, w.stallReason = true, w.now(), reason
w.mu.Unlock()
w.enqueue(BusStalled, map[string]any{"reason": reason})
}
// answered says bus.recovered when a stalled bus answers again.
func (w *Watcher) answered() {
w.mu.Lock()
if !w.stalled {
w.mu.Unlock()
return
}
since := w.stallSince
w.stalled, w.stallReason = false, ""
w.mu.Unlock()
w.enqueue(BusRecovered, map[string]any{"stalled_since": since.UTC().Format(time.RFC3339),
"stalled_seconds": int(w.now().Sub(since).Seconds())})
}
// ping asks the bus driver to answer within StallAfter.
func (w *Watcher) ping(b Bus) {
ctx, cancel := context.WithTimeout(context.Background(), StallAfter)
defer cancel()
start := time.Now()
err := b.Ping(ctx)
took := time.Since(start)
if err != nil {
if ctx.Err() != nil {
w.markStalled("the bus did not answer a ping within " + StallAfter.String())
} else {
w.markStalled("the bus answered a ping with an error: " + err.Error())
}
return
}
w.mu.Lock()
w.lastPing, w.lastPingAt = took, w.now()
w.mu.Unlock()
w.answered()
}
// PingNow pings the bus on the watcher's connection, for dbus_health.
func (w *Watcher) PingNow() (time.Duration, error) {
w.mu.Lock()
b := w.bus
w.mu.Unlock()
if b == nil {
return 0, errNotConnected
}
ctx, cancel := context.WithTimeout(context.Background(), StallAfter)
defer cancel()
start := time.Now()
err := b.Ping(ctx)
return time.Since(start), err
}
type watcherError string
func (e watcherError) Error() string { return string(e) }
const errNotConnected = watcherError("the watcher is not connected to the system bus")
// identity notes the bus's id and the bus driver's pid, and says bus.restarted when either changed
// within one boot: after a boot both change, and that is the machine's news, not the bus's.
func (w *Watcher) identity(id string, pid uint32) {
boot := w.m.BootID()
w.mu.Lock()
prevID, prevPID := w.busID, w.brokerPID
w.busID, w.brokerPID = id, pid
w.mu.Unlock()
prevBoot := boot
if prevID == "" {
if b, err := os.ReadFile(w.state); err == nil {
f := strings.Fields(string(b))
if len(f) == 3 {
prevBoot, prevID = f[0], f[1]
prevPID = parsePID(f[2])
}
}
}
if prevID != "" && prevBoot == boot && (prevID != id || prevPID != pid) {
w.enqueue(BusRestarted, map[string]any{"previous_bus_id": prevID, "bus_id": id,
"previous_pid": prevPID, "pid": pid, "unit": w.m.UnitOf(pid)})
}
if err := os.MkdirAll(filepath.Dir(w.state), 0o755); err == nil {
_ = os.WriteFile(w.state, []byte(boot+" "+id+" "+itoa(pid)+"\n"), 0o644)
}
}
// IsWellKnown is whether a name is a service's name rather than a connection's: unique names (":1.42")
// come and go with every client and are never said, nor is the bus driver's own.
func IsWellKnown(name string) bool {
return name != "" && !strings.HasPrefix(name, ":") && name != busName
}
// connected baselines the names after a (re)connect. The first time it says nothing; after a lost
// connection the difference with what was said is debounced like any other change, so a service that
// did not come back with a restarted bus is said to have left.
func (w *Watcher) connectedTo(b Bus) {
ctx, cancel := context.WithTimeout(context.Background(), StallAfter)
defer cancel()
names, err := b.Names(ctx)
if err != nil {
w.problem("listing the bus's names: " + err.Error())
return
}
act, _ := b.Activatable(ctx)
now := w.now()
w.mu.Lock()
defer w.mu.Unlock()
w.activatable = map[string]bool{}
for _, n := range act {
w.activatable[n] = true
}
cur := map[string]bool{}
for _, n := range names {
if IsWellKnown(n) {
cur[n] = true
}
}
if !w.baselined {
w.baselined = true
w.current = cur
for n := range cur {
w.published[n] = owner{}
w.dirty[n] = time.Time{} // resolved silently at the next settle
}
return
}
for n := range cur {
if _, said := w.published[n]; !said {
w.dirty[n] = now
}
}
for n := range w.published {
if !cur[n] {
w.dirty[n] = now
}
}
w.current = cur
}
// observe takes one NameOwnerChanged. Only well-known names count.
func (w *Watcher) observe(c NameChange) {
if !IsWellKnown(c.Name) {
return
}
w.mu.Lock()
defer w.mu.Unlock()
if c.New != "" {
w.current[c.Name] = true
} else {
delete(w.current, c.Name)
}
w.dirty[c.Name] = w.now()
}
// settle says what changed and stayed changed for Debounce. A name's owner is resolved when it is
// said, so the event names the process and the unit that holds it.
func (w *Watcher) settle(b Bus) {
now := w.now()
w.mu.Lock()
var due []string
for n, at := range w.dirty {
if now.Sub(at) >= Debounce {
due = append(due, n)
}
}
sort.Strings(due)
w.mu.Unlock()
for _, n := range due {
w.mu.Lock()
present := w.current[n]
was, said := w.published[n]
silent := w.dirty[n].IsZero()
delete(w.dirty, n)
activatable := w.activatable[n]
w.mu.Unlock()
switch {
case present && (!said || silent):
o := w.resolve(b, n)
w.mu.Lock()
w.published[n] = o
w.mu.Unlock()
if !silent {
w.enqueue(ServiceAppeared, o.body(n, activatable))
}
case !present && said:
w.mu.Lock()
delete(w.published, n)
w.mu.Unlock()
w.enqueue(ServiceLeft, was.body(n, activatable))
}
}
}
func (w *Watcher) resolve(b Bus, name string) owner {
if b == nil {
return owner{}
}
ctx, cancel := context.WithTimeout(context.Background(), StallAfter)
defer cancel()
pid, err := b.PID(ctx, name)
if err != nil {
return owner{}
}
return owner{PID: pid, Process: w.m.ProcessName(pid), Unit: w.m.UnitOf(pid)}
}
func (o owner) body(name string, activatable bool) map[string]any {
body := map[string]any{"name": name, "activatable": activatable}
if o.PID != 0 {
body["pid"] = o.PID
}
if o.Process != "" {
body["process"] = o.Process
}
if o.Unit != "" {
body["unit"] = o.Unit
}
return body
}
// readDenials takes the denials logged since the last read, and says policy.denied at most once per
// DenialEventEvery, with the count and a few distinct examples: a client denied in a loop must not
// flood the mesh's bus.
func (w *Watcher) readDenials(ctx context.Context, start time.Time) {
if w.journal == nil {
return
}
w.mu.Lock()
cursor := w.cursor
w.mu.Unlock()
got, next, err := w.journal(ctx, cursor, start)
if err != nil {
w.problem("reading the bus's journal: " + err.Error())
return
}
now := w.now()
w.mu.Lock()
if next != "" {
w.cursor = next
}
if strings.HasPrefix(w.issue, "reading the bus's journal") {
w.issue = ""
}
for _, d := range got {
if w.denials == 0 {
w.denialSince = now
}
w.denials++
if len(w.examples) < DenialExamples && !containsDenial(w.examples, d) {
w.examples = append(w.examples, d)
}
}
due := w.denials > 0 && (w.lastDenial.IsZero() || now.Sub(w.lastDenial) >= DenialEventEvery)
var body map[string]any
if due {
body = map[string]any{"count": w.denials, "since": w.denialSince.UTC().Format(time.RFC3339),
"examples": w.examples}
w.denials, w.examples, w.lastDenial = 0, nil, now
}
w.mu.Unlock()
if due {
w.enqueue(PolicyDenied, body)
}
}
// Run watches until ctx ends. Without the system bus it says the bus stalled, and tries again every
// few seconds; a lost connection is followed at once by a new one.
func (w *Watcher) Run(ctx context.Context) {
go w.flusher(ctx)
start := w.now()
tick := time.NewTicker(PingEvery)
defer tick.Stop()
denials := time.NewTicker(DenialsEvery)
defer denials.Stop()
retry := time.NewTimer(0)
defer retry.Stop()
var changes <-chan NameChange
for {
select {
case <-ctx.Done():
w.mu.Lock()
b := w.bus
w.bus, w.connected = nil, false
w.mu.Unlock()
if b != nil {
b.Close()
}
w.flush()
return
case <-retry.C:
b, err := w.dial()
if err != nil {
w.markStalled("the system bus is not reachable: " + err.Error())
retry.Reset(w.retry)
continue
}
idCtx, cancel := context.WithTimeout(ctx, StallAfter)
id, idErr := b.ID(idCtx)
pid, _ := b.PID(idCtx, busName)
cancel()
if idErr != nil {
b.Close()
w.markStalled("the system bus did not say its id: " + idErr.Error())
retry.Reset(w.retry)
continue
}
w.mu.Lock()
w.bus, w.connected = b, true
w.mu.Unlock()
changes = b.Changes()
w.identity(id, pid)
w.connectedTo(b)
w.answered()
w.settle(b)
case c, open := <-changes:
if !open {
w.mu.Lock()
b := w.bus
w.bus, w.connected = nil, false
w.mu.Unlock()
if b != nil {
b.Close()
}
changes = nil
retry.Reset(time.Second)
continue
}
w.observe(c)
case <-tick.C:
w.mu.Lock()
b := w.bus
w.mu.Unlock()
if b != nil {
w.ping(b)
w.settle(b)
}
case <-denials.C:
w.readDenials(ctx, start)
}
}
}
func containsDenial(list []Denial, d Denial) bool {
for _, x := range list {
if x.key() == d.key() {
return true
}
}
return false
}
func parsePID(s string) uint32 {
var n uint32
for _, c := range s {
if c < '0' || c > '9' {
return 0
}
n = n*10 + uint32(c-'0')
}
return n
}
func itoa(n uint32) string {
if n == 0 {
return "0"
}
var b [10]byte
i := len(b)
for n > 0 {
i--
b[i] = byte('0' + n%10)
n /= 10
}
return string(b[i:])
}
+446
View File
@@ -0,0 +1,446 @@
package main
import (
"context"
"encoding/json"
"errors"
"os"
"path/filepath"
"reflect"
"strings"
"sync"
"testing"
"time"
)
// fakeBus is a system bus in memory: names with their owners' pids, a ping that can hang, and the
// NameOwnerChanged stream.
type fakeBus struct {
mu sync.Mutex
id string
pid uint32
names map[string]uint32
hang bool
changes chan NameChange
}
func newFakeBus(id string, pid uint32, names map[string]uint32) *fakeBus {
return &fakeBus{id: id, pid: pid, names: names, changes: make(chan NameChange, 64)}
}
func (f *fakeBus) Ping(ctx context.Context) error {
f.mu.Lock()
hang := f.hang
f.mu.Unlock()
if hang {
<-ctx.Done()
return ctx.Err()
}
return nil
}
func (f *fakeBus) ID(context.Context) (string, error) { return f.id, nil }
func (f *fakeBus) PID(_ context.Context, name string) (uint32, error) {
if name == busName {
return f.pid, nil
}
f.mu.Lock()
defer f.mu.Unlock()
if p, ok := f.names[name]; ok {
return p, nil
}
return 0, errors.New("no such name")
}
func (f *fakeBus) Names(context.Context) ([]string, error) {
f.mu.Lock()
defer f.mu.Unlock()
out := []string{busName, ":1.0", ":1.1"}
for n := range f.names {
out = append(out, n)
}
return out, nil
}
func (f *fakeBus) Activatable(context.Context) ([]string, error) {
return []string{"org.freedesktop.hostname1"}, nil
}
func (f *fakeBus) Changes() <-chan NameChange { return f.changes }
func (f *fakeBus) Close() {}
// meshBus records what was published, and can refuse.
type meshBus struct {
mu sync.Mutex
down bool
types []string
bodies []map[string]any
}
func (b *meshBus) emit(t string, body any) error {
b.mu.Lock()
defer b.mu.Unlock()
if b.down {
return errors.New("no bus")
}
b.types = append(b.types, t)
m, _ := body.(map[string]any)
b.bodies = append(b.bodies, m)
return nil
}
func (b *meshBus) seen() []string {
b.mu.Lock()
defer b.mu.Unlock()
return append([]string(nil), b.types...)
}
// clock is a time the test moves.
type clock struct{ t time.Time }
func (c *clock) now() time.Time { return c.t }
func (c *clock) advance(d time.Duration) { c.t = c.t.Add(d) }
func testMachine(t *testing.T, files map[string]string) *Machine {
t.Helper()
root := t.TempDir()
for p, c := range files {
full := filepath.Join(root, p)
os.MkdirAll(filepath.Dir(full), 0o755)
os.WriteFile(full, []byte(c), 0o644)
}
return &Machine{Root: root, Env: func(string) string { return "" }, UID: 1000, Now: time.Now}
}
func testWatcher(t *testing.T, b *meshBus, c *clock) *Watcher {
m := testMachine(t, map[string]string{
"/proc/sys/kernel/random/boot_id": "boot-1\n",
"/proc/700/comm": "systemd-logind\n",
"/proc/700/cgroup": "0::/system.slice/systemd-logind.service\n",
"/proc/900/comm": "bluetoothd\n",
"/proc/900/cgroup": "0::/system.slice/bluetooth.service\n",
})
w := NewWatcher(m, b.emit, nil, nil)
w.now = c.now
w.state = filepath.Join(t.TempDir(), "bus")
return w
}
func start() *clock { return &clock{t: time.Date(2026, 10, 5, 12, 0, 0, 0, time.UTC)} }
func TestTheFirstConnectionSaysNothingAboutNames(t *testing.T) {
mb, c := &meshBus{}, start()
w := testWatcher(t, mb, c)
fb := newFakeBus("id-1", 500, map[string]uint32{"org.freedesktop.login1": 700})
w.identity("id-1", 500)
w.connectedTo(fb)
c.advance(Debounce)
w.settle(fb)
w.flush()
if got := mb.seen(); len(got) != 0 {
t.Fatalf("the baseline was announced: %v", got)
}
if w.Snapshot().Services != 1 {
t.Fatalf("%+v", w.Snapshot())
}
}
func TestAServiceAppearingIsSaidOnceItStaysWithItsUnit(t *testing.T) {
mb, c := &meshBus{}, start()
w := testWatcher(t, mb, c)
fb := newFakeBus("id-1", 500, map[string]uint32{})
w.connectedTo(fb)
fb.names["org.bluez"] = 900
w.observe(NameChange{Name: "org.bluez", New: ":1.9"})
c.advance(Debounce / 2)
w.settle(fb)
w.flush()
if got := mb.seen(); len(got) != 0 {
t.Fatalf("said before the debounce: %v", got)
}
c.advance(Debounce)
w.settle(fb)
w.flush()
if got := mb.seen(); !reflect.DeepEqual(got, []string{ServiceAppeared}) {
t.Fatalf("%v", got)
}
body := mb.bodies[0]
if body["name"] != "org.bluez" || body["unit"] != "bluetooth.service" || body["process"] != "bluetoothd" || body["pid"] != uint32(900) {
t.Fatalf("%v", body)
}
}
func TestAFlapIsDebouncedAway(t *testing.T) {
mb, c := &meshBus{}, start()
w := testWatcher(t, mb, c)
fb := newFakeBus("id-1", 500, map[string]uint32{"org.freedesktop.login1": 700})
w.connectedTo(fb)
c.advance(Debounce)
w.settle(fb)
// logind restarted: left and back within the debounce, and a newcomer that came and went.
w.observe(NameChange{Name: "org.freedesktop.login1", Old: ":1.5"})
c.advance(time.Second)
w.observe(NameChange{Name: "org.freedesktop.login1", New: ":1.80"})
w.observe(NameChange{Name: "org.example.Brief", New: ":1.81"})
w.observe(NameChange{Name: "org.example.Brief", Old: ":1.81"})
c.advance(Debounce)
w.settle(fb)
w.flush()
if got := mb.seen(); len(got) != 0 {
t.Fatalf("a flap was said: %v", got)
}
}
func TestAServiceLeavingIsSaidWithTheUnitItHad(t *testing.T) {
mb, c := &meshBus{}, start()
w := testWatcher(t, mb, c)
fb := newFakeBus("id-1", 500, map[string]uint32{"org.freedesktop.login1": 700})
w.connectedTo(fb)
c.advance(Debounce)
w.settle(fb)
delete(fb.names, "org.freedesktop.login1")
w.observe(NameChange{Name: "org.freedesktop.login1", Old: ":1.5"})
c.advance(Debounce)
w.settle(fb)
w.flush()
if got := mb.seen(); !reflect.DeepEqual(got, []string{ServiceLeft}) {
t.Fatalf("%v", got)
}
if mb.bodies[0]["unit"] != "systemd-logind.service" {
t.Fatalf("%v", mb.bodies[0])
}
}
func TestUniqueNamesAndTheDriverAreNeverSaid(t *testing.T) {
mb, c := &meshBus{}, start()
w := testWatcher(t, mb, c)
fb := newFakeBus("id-1", 500, map[string]uint32{})
w.connectedTo(fb)
w.observe(NameChange{Name: ":1.42", New: ":1.42"})
w.observe(NameChange{Name: ":1.42", Old: ":1.42"})
w.observe(NameChange{Name: busName, New: busName})
c.advance(Debounce)
w.settle(fb)
w.flush()
if got := mb.seen(); len(got) != 0 {
t.Fatalf("%v", got)
}
for _, n := range []string{":1.1", busName, ""} {
if IsWellKnown(n) {
t.Errorf("%q counted as a service", n)
}
}
}
func TestAPingThatHangsIsAStallAndAnAnswerARecovery(t *testing.T) {
mb, c := &meshBus{}, start()
w := testWatcher(t, mb, c)
fb := newFakeBus("id-1", 500, nil)
fb.hang = true
begun := time.Now()
w.ping(fb)
w.ping(fb)
if time.Since(begun) > 2*StallAfter+time.Second {
t.Fatal("a ping waited longer than its bound")
}
c.advance(42 * time.Second)
fb.hang = false
w.ping(fb)
w.ping(fb)
w.flush()
if got := mb.seen(); !reflect.DeepEqual(got, []string{BusStalled, BusRecovered}) {
t.Fatalf("%v", got)
}
if mb.bodies[1]["stalled_seconds"] != 42 || w.Snapshot().Stalled {
t.Fatalf("%v %+v", mb.bodies[1], w.Snapshot())
}
}
func TestARestartWithinABootIsSaidAndABootIsNot(t *testing.T) {
mb, c := &meshBus{}, start()
w := testWatcher(t, mb, c)
w.identity("id-1", 500)
w.identity("id-1", 500) // a reconnect to the same bus
w.identity("id-2", 501) // the bus came back as another
w.flush()
if got := mb.seen(); !reflect.DeepEqual(got, []string{BusRestarted}) {
t.Fatalf("%v", got)
}
if mb.bodies[0]["previous_bus_id"] != "id-1" || mb.bodies[0]["pid"] != uint32(501) {
t.Fatalf("%v", mb.bodies[0])
}
// The runtime restarts: the bus it remembers is the one still running, so nothing is said.
again := NewWatcher(w.m, mb.emit, nil, nil)
again.state, again.now = w.state, c.now
again.identity("id-2", 501)
// After a boot both change, and that is not the bus's restart.
os.WriteFile(filepath.Join(w.m.Root, "/proc/sys/kernel/random/boot_id"), []byte("boot-2\n"), 0o644)
third := NewWatcher(w.m, mb.emit, nil, nil)
third.state, third.now = w.state, c.now
third.identity("id-3", 400)
again.flush()
third.flush()
if got := mb.seen(); len(got) != 1 {
t.Fatalf("a runtime restart or a boot was taken for the bus's restart: %v", got)
}
}
func TestAServiceThatDidNotComeBackAfterAReconnectHasLeft(t *testing.T) {
mb, c := &meshBus{}, start()
w := testWatcher(t, mb, c)
fb := newFakeBus("id-1", 500, map[string]uint32{"org.freedesktop.login1": 700, "org.bluez": 900})
w.connectedTo(fb)
c.advance(Debounce)
w.settle(fb)
back := newFakeBus("id-2", 501, map[string]uint32{"org.freedesktop.login1": 700})
w.connectedTo(back)
c.advance(Debounce)
w.settle(back)
w.flush()
if got := mb.seen(); !reflect.DeepEqual(got, []string{ServiceLeft}) || mb.bodies[0]["name"] != "org.bluez" {
t.Fatalf("%v %v", got, mb.bodies)
}
}
func TestEventsWaitInOrderWhileTheMeshBusIsGone(t *testing.T) {
mb, c := &meshBus{down: true}, start()
w := testWatcher(t, mb, c)
w.markStalled("test")
c.advance(time.Minute)
w.answered()
w.flush()
if s := w.Snapshot(); s.Pending != 2 || s.Problem == "" {
t.Fatalf("what the mesh's bus did not take is not kept and said: %+v", s)
}
mb.mu.Lock()
mb.down = false
mb.mu.Unlock()
w.flush()
if got := mb.seen(); !reflect.DeepEqual(got, []string{BusStalled, BusRecovered}) {
t.Fatalf("%v", got)
}
if s := w.Snapshot(); s.Pending != 0 || s.Problem != "" {
t.Fatalf("%+v", s)
}
}
func TestAFullQueueLetsTheOldestGo(t *testing.T) {
mb, c := &meshBus{down: true}, start()
w := testWatcher(t, mb, c)
for i := 0; i < MaxQueue+5; i++ {
w.enqueue(ServiceAppeared, map[string]any{"i": i})
}
s := w.Snapshot()
if s.Pending != MaxQueue || s.Dropped != 5 || w.queue[0].Body["i"] != 5 {
t.Fatalf("%+v first %v", s, w.queue[0].Body)
}
}
func TestDenialsAreSaidAtMostOncePerWindowWithACount(t *testing.T) {
mb, c := &meshBus{}, start()
w := testWatcher(t, mb, c)
batches := [][]Denial{
{{Type: "method_call", Interface: "org.example.A", Member: "Do", Destination: "org.example"}},
{{Type: "method_call", Interface: "org.example.A", Member: "Do", Destination: "org.example"},
{Type: "method_call", Interface: "org.example.B", Member: "Other", Destination: "org.example"}},
{{Type: "method_call", Interface: "org.example.A", Member: "Do", Destination: "org.example"}},
}
n := 0
w.journal = func(ctx context.Context, after string, since time.Time) ([]Denial, string, error) {
if n > 0 && after != "c"+string(rune('0'+n-1)) {
t.Errorf("read %d did not continue from the cursor: %q", n, after)
}
d := batches[n]
n++
return d, "c" + string(rune('0'+n-1)), nil
}
w.readDenials(context.Background(), c.t)
c.advance(DenialsEvery)
w.readDenials(context.Background(), c.t)
c.advance(DenialEventEvery)
w.readDenials(context.Background(), c.t)
w.flush()
if got := mb.seen(); !reflect.DeepEqual(got, []string{PolicyDenied, PolicyDenied}) {
t.Fatalf("%v", got)
}
if mb.bodies[0]["count"] != 1 || mb.bodies[1]["count"] != 3 {
t.Fatalf("%v", mb.bodies)
}
if ex := mb.bodies[1]["examples"].([]Denial); len(ex) != 2 {
t.Fatalf("the same denial is one example: %v", ex)
}
}
// TestNoEventCarriesTraffic holds every event body to names, pids, units, times, counts and the
// header fields of a denial: nothing in the watcher can carry a message's body.
func TestNoEventCarriesTraffic(t *testing.T) {
mb, c := &meshBus{}, start()
w := testWatcher(t, mb, c)
fb := newFakeBus("id-1", 500, map[string]uint32{})
w.connectedTo(fb)
fb.names["org.bluez"] = 900
w.observe(NameChange{Name: "org.bluez", New: ":1.9"})
c.advance(Debounce)
w.settle(fb)
w.markStalled("x")
w.answered()
w.identity("a", 1)
w.identity("b", 2)
w.journal = func(context.Context, string, time.Time) ([]Denial, string, error) {
d, cur := ParseDenials(`{"__CURSOR":"c","MESSAGE":"A security policy denied :1.9 to send method call /p:i.m to d.","DBUS_BROKER_MESSAGE_MEMBER":"m","SECRET_BODY":"hunter2"}`)
return d, cur, nil
}
w.readDenials(context.Background(), c.t)
w.flush()
allowed := map[string]bool{"at": true, "name": true, "pid": true, "process": true, "unit": true, "activatable": true,
"reason": true, "stalled_since": true, "stalled_seconds": true, "previous_bus_id": true, "bus_id": true,
"previous_pid": true, "count": true, "since": true, "examples": true}
if len(mb.types) != 5 {
t.Fatalf("%v", mb.types)
}
for i, b := range mb.bodies {
for k := range b {
if !allowed[k] {
t.Errorf("%s carries %q", mb.types[i], k)
}
}
raw, _ := json.Marshal(b)
if strings.Contains(string(raw), "hunter2") || strings.Contains(string(raw), "security policy") {
t.Errorf("%s carries what the bus logged verbatim: %s", mb.types[i], raw)
}
}
}
func TestRunWithoutABusSaysItStalledAndReconnects(t *testing.T) {
mb := &meshBus{}
m := testMachine(t, map[string]string{"/proc/sys/kernel/random/boot_id": "b\n"})
fb := newFakeBus("id-1", 500, map[string]uint32{})
var dials int
w := NewWatcher(m, mb.emit, func() (Bus, error) {
dials++
if dials == 1 {
return nil, errors.New("no socket")
}
return fb, nil
}, nil)
w.state = filepath.Join(t.TempDir(), "bus")
w.retry = 20 * time.Millisecond
ctx, cancel := context.WithTimeout(context.Background(), 6*time.Second)
defer cancel()
done := make(chan struct{})
go func() { w.Run(ctx); close(done) }()
deadline := time.Now().Add(6 * time.Second)
for time.Now().Before(deadline) && !w.Snapshot().Connected {
time.Sleep(50 * time.Millisecond)
}
if !w.Snapshot().Connected {
t.Fatal("the watcher did not reconnect")
}
close(fb.changes) // the bus goes away
for time.Now().Before(deadline) && w.Snapshot().Connected {
time.Sleep(10 * time.Millisecond)
}
cancel()
<-done
got := mb.seen()
if len(got) < 2 || got[0] != BusStalled || got[1] != BusRecovered {
t.Fatalf("%v", got)
}
}
+8
View File
@@ -0,0 +1,8 @@
module dbus
go 1.22
require (
git.novox.be/novox/mesh-sdk/go v0.1.7
github.com/godbus/dbus/v5 v5.1.0
)
+4
View File
@@ -0,0 +1,4 @@
git.novox.be/novox/mesh-sdk/go v0.1.7 h1:C0sTQmtTiyYH7bnqZb7PusXnqA37gKuT7Nqjn9gG47w=
git.novox.be/novox/mesh-sdk/go v0.1.7/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY=
github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk=
github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
+67
View File
@@ -0,0 +1,67 @@
{
"module": "dbus",
"version": "1",
"capabilities": [
"package-manager",
"service-manager"
],
"claims": [
{
"name": "node-message-bus",
"scope": "node"
}
],
"emits": [
"bus.stalled",
"bus.recovered",
"bus.restarted",
"service.appeared",
"service.left",
"policy.denied"
],
"tools": [
"dbus_names",
"dbus_introspect",
"dbus_monitor",
"dbus_check",
"dbus_health"
],
"resources": [
{
"id": "dbus",
"type": "package",
"package": "dbus"
},
{
"id": "broker",
"type": "package",
"package": "dbus-broker"
},
{
"id": "broker-units",
"type": "package",
"package": "dbus-broker-units"
},
{
"id": "system-bus",
"type": "service",
"unit": "dbus-broker.service",
"state": "running"
}
],
"build": {
"artifacts": [
{
"name": "tools-go",
"kind": "bundle",
"language": "go",
"system": "arch",
"from": "cmd/dbus-tools",
"binary": "dbus-tools",
"loads": [
"dbus-tools"
]
}
]
}
}
+60
View File
@@ -0,0 +1,60 @@
# dunst
The notifier as a module (novox/hq ADR 0208, research 026/05).
- Installs `dunst`, and `libnotify` for `notify-send`, the client every program and these tools use.
- Claims the mesh's `node-notifier` seat and serves its verbs `send` and `history`.
- Owns `~/.config/dunst/dunstrc` and the directory `~/.config/dunst/dunstrc.d/`. Another module's
rule is that module's own file in the directory (ADR 0208 §4). dunst reads the directory after
`dunstrc`, so a drop-in outranks it.
- **Starts nothing.** The package registers dunst with D-Bus, which starts it on the first
notification, inside the account's service manager. There is no autostart line, no unit and no
session-start contribution.
- **Requires no display of its own.** dunst speaks both X11 and Wayland and picks the one the session
has, so it serves an X session and a later sway one alike.
## Tools
Every tool goes over the account's session bus. None needs the screen, and each answers clearly when
the account is not logged in.
| tool | does |
|---|---|
| `node-notifier.send` | a notification: title, body, urgency, sender, icon, how long; answers its id |
| `node-notifier.history` | what was shown, newest first, with how long ago |
| `dunst_pause` / `dunst_resume` | do not disturb: notifications are held back, not lost |
| `dunst_close_all` | clear the screen; the history keeps them |
| `dunst_rules` | the rules the running notifier holds, and the files they come from |
| `dunst_count` | shown, waiting, in history, and whether paused |
## What it chose, and what it improves
The workstations' files differed: one had the notifications bottom-right, 15 % transparent and with
rounded corners; the other top-right, opaque and square. This module takes the second, because the
rest of the desktop is square and opaque, and the top-right corner sits under the bar that shows the
count. The file keeps only the settings that differ from dunst's defaults.
- **The context menu works.** It called `/usr/bin/dmenu`, installed on neither machine. It now calls
`dmenu`, the seat command of whichever module holds `node-launcher` (`rofi` on the workstations).
- **The face is the interface one,** Inter (research 026/04), instead of a monospace Nerd font.
- `icon_path`, which named two directories of an icon theme that is not installed, is gone. The icon
theme is looked up recursively.
## What it leaves as found
- `~/.config/dunst/dunstrc.d/50-slack.conf`, the Slack rule. It becomes the Slack module's own drop-in
when there is one, and until then it is the operator's file in a directory this module owns.
## Migration (ADR 0182)
- The first push keeps the found `dunstrc` once, then writes the module's.
- **The desktop runs two notification daemons** because its session began before the session bus
fix (research 026/01). That ends at the next login, and nothing here starts a second one.
`dunst_count` after logging in again shows the one daemon's counts.
## Blockers
- `node-notifier` is ADR 0208's seat. Until the controller knows it, `mctl` reads the claim as
unknown.
- `dunst_rules` and the counts ask the running notifier. When none runs, the bus starts one, which
needs a session to draw on.
+97
View File
@@ -0,0 +1,97 @@
// Reading a tool's arguments: JSON numbers arrive as float64, and a missing argument is its default.
// The same in every desktop module that carries it.
package main
import (
"fmt"
"math"
"strings"
"time"
)
// text is a string argument, trimmed; required says an empty one is refused.
func text(args map[string]any, key string, required bool) (string, error) {
v, present := args[key]
if !present || v == nil {
if required {
return "", fmt.Errorf("%s is required", key)
}
return "", nil
}
s, ok := v.(string)
if !ok {
return "", fmt.Errorf("%s is a string, not %T", key, v)
}
s = strings.TrimSpace(s)
if s == "" && required {
return "", fmt.Errorf("%s is required", key)
}
return s, nil
}
// whole is a whole-number argument within [least, most], or def when absent.
func whole(args map[string]any, key string, def, least, most int) (int, error) {
v, present := args[key]
if !present || v == nil {
return def, nil
}
f, ok := v.(float64)
if !ok {
if i, isInt := v.(int); isInt {
f = float64(i)
} else {
return 0, fmt.Errorf("%s is a number, not %T", key, v)
}
}
if f != math.Trunc(f) {
return 0, fmt.Errorf("%s is a whole number, not %v", key, f)
}
n := int(f)
if n < least || n > most {
return 0, fmt.Errorf("%s is %d; it is between %d and %d", key, n, least, most)
}
return n, nil
}
// flag is a boolean argument, or def when absent.
func flag(args map[string]any, key string, def bool) (bool, error) {
v, present := args[key]
if !present || v == nil {
return def, nil
}
b, ok := v.(bool)
if !ok {
return false, fmt.Errorf("%s is true or false, not %T", key, v)
}
return b, nil
}
// texts is a list-of-strings argument.
func texts(args map[string]any, key string) ([]string, error) {
v, present := args[key]
if !present || v == nil {
return nil, nil
}
list, ok := v.([]any)
if !ok {
if ss, isStrings := v.([]string); isStrings {
return ss, nil
}
return nil, fmt.Errorf("%s is a list of strings, not %T", key, v)
}
out := make([]string, 0, len(list))
for i, item := range list {
s, ok := item.(string)
if !ok {
return nil, fmt.Errorf("%s[%d] is a string, not %T", key, i, item)
}
out = append(out, s)
}
return out, nil
}
// seconds is a timeout argument in seconds, defaulted and bounded below the runtime's call limit.
func seconds(args map[string]any, key string, def, most int) (time.Duration, error) {
n, err := whole(args, key, def, 1, most)
return time.Duration(n) * time.Second, err
}
+355
View File
@@ -0,0 +1,355 @@
package main
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"sort"
"strconv"
"strings"
"syscall"
"time"
"unsafe"
)
var urgencies = []string{"low", "normal", "critical"}
const busTimeout = 10 * time.Second
// Notification is what node-notifier.send shows.
type Notification struct {
Summary string
Body string
Urgency string
AppName string
Icon string
ExpireMS int
Category string
ReplaceID int
}
func notificationOf(args map[string]any) (Notification, error) {
var n Notification
var err error
if n.Summary, err = text(args, "summary", true); err != nil {
return n, err
}
if n.Body, err = text(args, "body", false); err != nil {
return n, err
}
if n.Urgency, err = text(args, "urgency", false); err != nil {
return n, err
}
if n.Urgency == "" {
n.Urgency = "normal"
}
known := false
for _, u := range urgencies {
known = known || u == n.Urgency
}
if !known {
return n, fmt.Errorf("urgency %q is low, normal or critical", n.Urgency)
}
if n.AppName, err = text(args, "app_name", false); err != nil {
return n, err
}
if n.AppName == "" {
n.AppName = "mesh"
}
if n.Icon, err = text(args, "icon", false); err != nil {
return n, err
}
if n.ExpireMS, err = whole(args, "expire_ms", -1, 0, 24*3600*1000); err != nil {
return n, err
}
if n.Category, err = text(args, "category", false); err != nil {
return n, err
}
n.ReplaceID, err = whole(args, "replace_id", 0, 0, 1<<31-1)
return n, err
}
// SendResult is what node-notifier.send answers.
type SendResult struct {
ID int `json:"id"`
}
// Send shows a notification through the desktop's notification service, whichever runs it.
func Send(n Notification) (SendResult, error) {
s, err := findBus()
if err != nil {
return SendResult{}, err
}
args := []string{"--print-id", "--urgency=" + n.Urgency, "--app-name=" + n.AppName}
if n.Icon != "" {
args = append(args, "--icon="+n.Icon)
}
if n.ExpireMS >= 0 {
args = append(args, "--expire-time="+strconv.Itoa(n.ExpireMS))
}
if n.Category != "" {
args = append(args, "--category="+n.Category)
}
if n.ReplaceID > 0 {
args = append(args, "--replace-id="+strconv.Itoa(n.ReplaceID))
}
// "--" so a title that starts with a dash is a title.
args = append(args, "--", n.Summary)
if n.Body != "" {
args = append(args, n.Body)
}
r, err := s.run(busTimeout, "", "notify-send", args...)
if err != nil {
return SendResult{}, err
}
if r.Code != 0 {
return SendResult{}, fmt.Errorf("notify-send: %s", strings.TrimSpace(r.Stderr))
}
id, err := strconv.Atoi(strings.TrimSpace(r.Stdout))
if err != nil {
return SendResult{}, fmt.Errorf("notify-send answered no id: %q", r.Stdout)
}
return SendResult{ID: id}, nil
}
// dunstctl runs one dunstctl command over the session bus and answers what it printed.
func dunstctl(args ...string) (string, error) {
s, err := findBus()
if err != nil {
return "", err
}
r, err := s.run(busTimeout, "", "dunstctl", args...)
if err != nil {
return "", err
}
if r.Code != 0 {
return "", fmt.Errorf("dunstctl %s: %s", strings.Join(args, " "), strings.TrimSpace(r.Stderr+r.Stdout))
}
return r.Stdout, nil
}
// variantMaps reads busctl's JSON form of an array of dictionaries (aa{sv}), which is how dunstctl
// answers history and rules, into plain maps.
func variantMaps(raw string) ([]map[string]any, error) {
var doc struct {
Type string `json:"type"`
Data [][]map[string]struct {
Data any `json:"data"`
} `json:"data"`
}
if err := json.Unmarshal([]byte(raw), &doc); err != nil {
return nil, fmt.Errorf("dunstctl's answer is not the bus's JSON: %w", err)
}
if doc.Type != "aa{sv}" {
return nil, fmt.Errorf("dunstctl answered %s, not aa{sv}", doc.Type)
}
out := []map[string]any{}
for _, group := range doc.Data {
for _, entry := range group {
m := map[string]any{}
for k, v := range entry {
m[k] = v.Data
}
out = append(out, m)
}
}
return out, nil
}
// Shown is one notification in the history.
type Shown struct {
ID int `json:"id"`
AppName string `json:"app_name"`
Summary string `json:"summary"`
Body string `json:"body,omitempty"`
Urgency string `json:"urgency"`
Category string `json:"category,omitempty"`
AgeSeconds int64 `json:"age_seconds"`
}
// HistoryResult is what node-notifier.history answers.
type HistoryResult struct {
Total int `json:"total"`
Notifications []Shown `json:"notifications"`
}
// History is dunst's history, newest first.
func History(limit int) (HistoryResult, error) {
raw, err := dunstctl("history")
if err != nil {
return HistoryResult{}, err
}
return parseHistory(raw, monotonicMicros(), limit)
}
func parseHistory(raw string, nowMicros int64, limit int) (HistoryResult, error) {
entries, err := variantMaps(raw)
if err != nil {
return HistoryResult{}, err
}
out := HistoryResult{Total: len(entries), Notifications: []Shown{}}
type stamped struct {
Shown
at int64
}
var all []stamped
for _, e := range entries {
at := number(e["timestamp"])
all = append(all, stamped{Shown{
ID: int(number(e["id"])), AppName: str(e["appname"]), Summary: str(e["summary"]), Body: str(e["body"]),
Urgency: strings.ToLower(str(e["urgency"])), Category: str(e["category"]),
AgeSeconds: max(0, (nowMicros-at)/1_000_000),
}, at})
}
sort.SliceStable(all, func(i, j int) bool { return all[i].at > all[j].at })
for i, s := range all {
if i == limit {
break
}
out.Notifications = append(out.Notifications, s.Shown)
}
return out, nil
}
func number(v any) int64 {
switch n := v.(type) {
case float64:
return int64(n)
case json.Number:
i, _ := n.Int64()
return i
}
return 0
}
func str(v any) string {
s, _ := v.(string)
return s
}
// monotonicMicros is the clock dunst stamps its notifications with (CLOCK_MONOTONIC, microseconds).
func monotonicMicros() int64 {
var ts syscall.Timespec
const clockMonotonic = 1
if _, _, errno := syscall.Syscall(syscall.SYS_CLOCK_GETTIME, clockMonotonic, uintptr(unsafe.Pointer(&ts)), 0); errno != 0 {
return 0
}
return ts.Sec*1_000_000 + ts.Nsec/1000
}
// PauseResult is what dunst_pause and dunst_resume answer.
type PauseResult struct {
Paused bool `json:"paused"`
Note string `json:"note"`
}
// SetPaused turns do-not-disturb on or off.
func SetPaused(on bool) (PauseResult, error) {
if _, err := dunstctl("set-paused", strconv.FormatBool(on)); err != nil {
return PauseResult{}, err
}
out, err := dunstctl("is-paused")
if err != nil {
return PauseResult{}, err
}
paused := strings.TrimSpace(out) == "true"
note := "notifications are shown"
if paused {
note = "notifications are held back until dunst_resume; the next login starts unpaused"
}
return PauseResult{Paused: paused, Note: note}, nil
}
// CloseAll closes what is on screen.
func CloseAll() (CountResult, error) {
if _, err := dunstctl("close-all"); err != nil {
return CountResult{}, err
}
return Count()
}
// CountResult is what dunst_count answers.
type CountResult struct {
Displayed int `json:"displayed"`
Waiting int `json:"waiting"`
History int `json:"history"`
Paused bool `json:"paused"`
}
// Count is how many notifications are where.
func Count() (CountResult, error) {
var c CountResult
for _, part := range []struct {
which string
into *int
}{{"displayed", &c.Displayed}, {"waiting", &c.Waiting}, {"history", &c.History}} {
out, err := dunstctl("count", part.which)
if err != nil {
return c, err
}
n, err := strconv.Atoi(strings.TrimSpace(out))
if err != nil {
return c, fmt.Errorf("dunstctl count %s answered %q", part.which, out)
}
*part.into = n
}
out, err := dunstctl("is-paused")
if err != nil {
return c, err
}
c.Paused = strings.TrimSpace(out) == "true"
return c, nil
}
// Rule is one notifier rule in force.
type Rule struct {
Name string `json:"name"`
Enabled bool `json:"enabled"`
Sets map[string]any `json:"sets"`
}
// RulesResult is what dunst_rules answers.
type RulesResult struct {
Files []string `json:"files"`
Rules []Rule `json:"rules"`
}
// Rules are the rules the running notifier holds, and the files it reads them from.
func Rules() (RulesResult, error) {
raw, err := dunstctl("rules", "--json")
if err != nil {
return RulesResult{}, err
}
return parseRules(raw, configFiles(filepath.Join(operatorHome(), ".config", "dunst")))
}
func parseRules(raw string, files []string) (RulesResult, error) {
entries, err := variantMaps(raw)
if err != nil {
return RulesResult{}, err
}
out := RulesResult{Files: files, Rules: []Rule{}}
for _, e := range entries {
r := Rule{Name: str(e["name"]), Enabled: e["enabled"] == true, Sets: map[string]any{}}
for k, v := range e {
if k != "name" && k != "enabled" {
r.Sets[k] = v
}
}
out.Rules = append(out.Rules, r)
}
sort.SliceStable(out.Rules, func(i, j int) bool { return out.Rules[i].Name < out.Rules[j].Name })
return out, nil
}
// configFiles are dunstrc and its drop-ins in the order dunst reads them.
func configFiles(dir string) []string {
files := []string{}
if _, err := os.Stat(filepath.Join(dir, "dunstrc")); err == nil {
files = append(files, filepath.Join(dir, "dunstrc"))
}
dropins, _ := filepath.Glob(filepath.Join(dir, "dunstrc.d", "*.conf"))
sort.Strings(dropins)
return append(files, dropins...)
}
+160
View File
@@ -0,0 +1,160 @@
package main
import (
"errors"
"os"
"path/filepath"
"reflect"
"strconv"
"strings"
"testing"
)
// withBus gives the fake machine the account's runtime directory and bus socket.
func withBus(t *testing.T) string {
t.Helper()
root := fakeMachine(t)
runtime := filepath.Join(runUserDir, strconv.Itoa(os.Getuid()))
if err := os.MkdirAll(runtime, 0o700); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(runtime, "bus"), nil, 0o600); err != nil {
t.Fatal(err)
}
return root
}
// A history as dunstctl answers it (busctl's JSON), two entries out of order.
const history = `{"type":"aa{sv}","data":[[
{"body":{"type":"s","data":"the build is green"},"summary":{"type":"s","data":"CI"},"appname":{"type":"s","data":"mesh"},
"category":{"type":"s","data":""},"id":{"type":"i","data":7},"timestamp":{"type":"x","data":100000000},"urgency":{"type":"s","data":"NORMAL"}},
{"body":{"type":"s","data":""},"summary":{"type":"s","data":"Battery low"},"appname":{"type":"s","data":"upower"},
"category":{"type":"s","data":"device"},"id":{"type":"i","data":9},"timestamp":{"type":"x","data":160000000},"urgency":{"type":"s","data":"CRITICAL"}}
]]}`
func TestTheHistoryIsNewestFirstWithAgesFromDunstsOwnClock(t *testing.T) {
got, err := parseHistory(history, 200_000_000, 20)
if err != nil {
t.Fatal(err)
}
want := []Shown{
{ID: 9, AppName: "upower", Summary: "Battery low", Urgency: "critical", Category: "device", AgeSeconds: 40},
{ID: 7, AppName: "mesh", Summary: "CI", Body: "the build is green", Urgency: "normal", AgeSeconds: 100},
}
if got.Total != 2 || !reflect.DeepEqual(got.Notifications, want) {
t.Fatalf("%+v", got)
}
if got, _ := parseHistory(history, 200_000_000, 1); len(got.Notifications) != 1 || got.Total != 2 {
t.Fatalf("limited: %+v", got)
}
if _, err := parseHistory(`{"type":"as","data":[]}`, 0, 1); err == nil {
t.Fatal("an answer of another type was accepted")
}
if monotonicMicros() <= 0 {
t.Fatal("the monotonic clock")
}
}
func TestSendAsksNotifySendOverTheAccountsBusAndAnswersTheId(t *testing.T) {
withBus(t)
bin := fakeBinaries(t, map[string]string{"notify-send": `for a in "$@"; do printf '[%s]' "$a"; done > "$LOG"; echo >> "$LOG"; echo "bus=$DBUS_SESSION_BUS_ADDRESS" >> "$LOG"; echo 42`})
t.Setenv("LOG", filepath.Join(bin, "log"))
n, err := notificationOf(map[string]any{"summary": "-dash title", "body": "hello", "urgency": "critical", "expire_ms": float64(0)})
if err != nil {
t.Fatal(err)
}
got, err := Send(n)
if err != nil || got.ID != 42 {
t.Fatalf("%+v, %v", got, err)
}
asked, _ := os.ReadFile(filepath.Join(bin, "log"))
want := "[--print-id][--urgency=critical][--app-name=mesh][--expire-time=0][--][-dash title][hello]\nbus=unix:path=" +
filepath.Join(runUserDir, strconv.Itoa(os.Getuid()), "bus") + "\n"
if string(asked) != want {
t.Fatalf("notify-send was asked:\n%s\nwant:\n%s", asked, want)
}
}
func TestANotificationIsRefusedForWhatItCannotBe(t *testing.T) {
for _, bad := range []map[string]any{{}, {"summary": " "}, {"summary": "x", "urgency": "urgent"}, {"summary": "x", "expire_ms": float64(-5)}} {
if _, err := notificationOf(bad); err == nil {
t.Errorf("accepted %v", bad)
}
}
n, _ := notificationOf(map[string]any{"summary": "x"})
if n.Urgency != "normal" || n.AppName != "mesh" || n.ExpireMS != -1 {
t.Fatalf("defaults: %+v", n)
}
}
func TestWithoutABusTheToolsSaySo(t *testing.T) {
fakeMachine(t)
if _, err := Send(Notification{Summary: "x"}); !errors.Is(err, ErrNoBus) {
t.Fatal(err)
}
if _, err := Count(); !errors.Is(err, ErrNoBus) {
t.Fatal(err)
}
}
func TestCountPauseAndCloseAllAreDunstctlsAnswers(t *testing.T) {
withBus(t)
bin := fakeBinaries(t, map[string]string{"dunstctl": `echo "$*" >> "$LOG"
case "$*" in
"count displayed") echo 1 ;;
"count waiting") echo 0 ;;
"count history") echo 12 ;;
is-paused) cat "$STATE" 2>/dev/null || echo false ;;
"set-paused true") echo true > "$STATE" ;;
"set-paused false") echo false > "$STATE" ;;
esac`})
t.Setenv("LOG", filepath.Join(bin, "log"))
t.Setenv("STATE", filepath.Join(bin, "paused"))
c, err := Count()
if err != nil || c != (CountResult{Displayed: 1, History: 12}) {
t.Fatalf("%+v, %v", c, err)
}
p, err := SetPaused(true)
if err != nil || !p.Paused || !strings.Contains(p.Note, "held back") {
t.Fatalf("%+v, %v", p, err)
}
if c, _ := CloseAll(); !c.Paused {
t.Fatalf("close-all answers the counts: %+v", c)
}
if p, _ := SetPaused(false); p.Paused {
t.Fatalf("resumed: %+v", p)
}
log, _ := os.ReadFile(filepath.Join(bin, "log"))
if !strings.Contains(string(log), "close-all\n") {
t.Fatalf("dunstctl was asked:\n%s", log)
}
}
func TestRulesAreTheRunningNotifiersWithTheFilesTheyComeFrom(t *testing.T) {
root := t.TempDir()
for _, f := range []string{"dunstrc", "dunstrc.d/50-slack.conf", "dunstrc.d/10-mail.conf", "dunstrc.d/notes.txt"} {
if err := os.MkdirAll(filepath.Dir(filepath.Join(root, f)), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(root, f), nil, 0o644); err != nil {
t.Fatal(err)
}
}
raw := `{"type":"aa{sv}","data":[[{"enabled":{"type":"b","data":true},"appname":{"type":"s","data":"Slack"},
"fc":{"type":"s","data":"#6715ebff"},"name":{"type":"s","data":"slack"},"timeout":{"type":"x","data":10000000}}]]}`
got, err := parseRules(raw, configFiles(root))
if err != nil {
t.Fatal(err)
}
if len(got.Rules) != 1 || got.Rules[0].Name != "slack" || !got.Rules[0].Enabled || got.Rules[0].Sets["appname"] != "Slack" {
t.Fatalf("%+v", got)
}
var names []string
for _, f := range got.Files {
rel, _ := filepath.Rel(root, f)
names = append(names, rel)
}
if !reflect.DeepEqual(names, []string{"dunstrc", "dunstrc.d/10-mail.conf", "dunstrc.d/50-slack.conf"}) {
t.Fatalf("files: %v", names)
}
}
+91
View File
@@ -0,0 +1,91 @@
// dunst's Go tools bundle (novox/hq ADR 0188, ADR 0193, ADR 0208): its implementation of
// node-notifier's verbs `send` and `history`, and its own tools, served by the node's runtime as the
// operator account. Everything here goes over the account's session bus; none of it needs the screen.
package main
import (
"fmt"
"os"
stdio "git.novox.be/novox/mesh-sdk/go"
)
func main() {
if err := stdio.Serve("", tools()); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
}
func tools() []stdio.Tool {
return []stdio.Tool{
{
Name: "node-notifier.send",
Description: "Show a notification on the operator's desktop: a title, a body, an urgency (low, " +
"normal, critical), and optionally the sending application's name, an icon and how long it stays. " +
"Answers the notification's id.",
Input: map[string]any{
"type": "object",
"properties": map[string]any{
"summary": map[string]any{"type": "string", "description": "the title"},
"body": map[string]any{"type": "string", "description": "the text; simple markup (<b>, <i>, <u>, <a href>) is shown"},
"urgency": map[string]any{"type": "string", "enum": urgencies, "description": "default normal"},
"app_name": map[string]any{"type": "string", "description": "who it is from (default: mesh); a notifier rule can match it"},
"icon": map[string]any{"type": "string", "description": "an icon name from the icon theme, or a file"},
"expire_ms": map[string]any{"type": "integer", "description": "how long it stays; 0 until dismissed (default: the urgency's own)"},
"category": map[string]any{"type": "string", "description": "a notification category, e.g. email.arrived"},
"replace_id": map[string]any{"type": "integer", "description": "replace the notification with this id instead of adding one"},
},
"required": []string{"summary"},
},
Run: func(args map[string]any) (any, error) {
n, err := notificationOf(args)
if err != nil {
return nil, err
}
return Send(n)
},
},
{
Name: "node-notifier.history",
Description: "The notifications the operator was shown, newest first: id, application, title, " +
"body, urgency and how long ago.",
Input: map[string]any{
"limit": map[string]any{"type": "integer", "description": "at most this many (default 20, at most 200)"},
},
Run: func(args map[string]any) (any, error) {
limit, err := whole(args, "limit", 20, 1, 200)
if err != nil {
return nil, err
}
return History(limit)
},
},
{
Name: "dunst_pause",
Description: "Do not disturb: hold every new notification back until resumed. They are shown then, not lost.",
Run: func(map[string]any) (any, error) { return SetPaused(true) },
},
{
Name: "dunst_resume",
Description: "End do-not-disturb: notifications held back are shown.",
Run: func(map[string]any) (any, error) { return SetPaused(false) },
},
{
Name: "dunst_close_all",
Description: "Close every notification on screen. They stay in the history.",
Run: func(map[string]any) (any, error) { return CloseAll() },
},
{
Name: "dunst_rules",
Description: "The notifier's rules in force — each rule's name, whether it is enabled, what it " +
"matches and what it sets — and the files they come from (the mesh's dunstrc, then dunstrc.d).",
Run: func(map[string]any) (any, error) { return Rules() },
},
{
Name: "dunst_count",
Description: "How many notifications are shown, waiting and in the history, and whether do-not-disturb is on.",
Run: func(map[string]any) (any, error) { return Count() },
},
}
}

Some files were not shown because too many files have changed in this diff Show More