Compare commits

...
Author SHA1 Message Date
jochen dc140d5345 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:10:58 +02:00
jochen 635e8150e0 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:10:58 +02:00
jochen d95eecac53 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:10:58 +02:00
jochen bac52a9762 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:10:58 +02:00
jochen 04f3f66f4b 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:10:58 +02:00
jochen 1fcc0ffbe2 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:10:58 +02:00
jochen fba2d43591 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:10:58 +02:00
jochen bb7dd1be5b 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:10:58 +02:00
mesh-admin 19a4055bb5 Merge pull request 'The photo clients publish the endpoint they declare (hq issue 227)' (#263) from fix/the-photo-admin-client-publishes-the-port-it-declares into main 2026-10-04 10:27:22 +00:00
jschoubben 1bc6daf31b The photo clients publish the endpoint they declare (hq issue 227)
Each declares a web endpoint — 4001, 4012, 4013 — and published a bare 80,
which the mesh has nothing to assign for, so 80 reached the machine and
collided with the reverse proxy. Written the long way, the software's 80 is
published at the port the module declares and the mesh rewrites the outer
half to whatever it assigned.

photos is the one that failed on the control node; the other two are the same
fault waiting for a machine that runs a proxy.
2026-10-04 12:25:28 +02:00
mesh-admin 9208f7409a Merge pull request 'systemd owns its package; systemd-networkd configures networkd and claims none' (#261) from fix/systemd-owns-its-package into main 2026-10-04 10:17:23 +00:00
jochen a80af7a97f systemd owns its package; systemd-networkd configures networkd and claims none
The service manager's package was declared by the networking module, so the
module that is systemd could not own it and had to leave it out. networkd is a
component of systemd: its module configures it. Removing the package resource
from systemd-networkd uninstalls nothing — the host never removes a package
that is not declared absent.
2026-10-04 12:17:08 +02:00
mesh-admin 83a51832d7 Merge pull request 'claude-code writes its managed files from a staged file, not /dev/stdin' (#258) from fix/claude-code-writes-managed-from-a-file into main 2026-10-04 10:01:17 +00:00
mesh-admin 9e63a258d0 Merge pull request 'zsh: keep each PATH directory once' (#260) from fix/zsh-unique-path into main 2026-10-04 09:34:43 +00:00
jochen 328d90fb88 zsh: keep each PATH directory once
Every nested shell, and every sourced file that prepends, added the same
directories again; a workstation's PATH carried each of several entries three
times. typeset -U in the .zshenv block applies to every zsh.
2026-10-04 11:34:36 +02:00
mesh-admin ca5ab288f6 Merge pull request 'zsh: save history and initialise completion' (#259) from fix/zsh-completion-and-history into main 2026-10-04 09:33:28 +00:00
jochen 5fd0f72221 zsh: save history and initialise completion
zsh saves no history by default (SAVEHIST=0) and nothing called compinit, so
every machine had 30 lines of unsaved history and only basic completion.
Found reviewing the shell on its first machine (hq to-be 41).
2026-10-04 11:33:17 +02:00
jochen 5003dc0377 claude-code writes its managed files from a staged file, not /dev/stdin
Node hands a child its input over a socket, which /dev/stdin cannot open
(ENXIO): on the first assignment nothing under /etc/claude-code was written.
2026-10-04 11:31:44 +02:00
mesh-admin 0a78d130e5 Merge pull request 'claude-code watches its MCP servers beside the handshake, and retries' (#257) from fix/claude-code-watches-without-blocking into main 2026-10-04 09:21:26 +00:00
jochen 4295aad88e claude-code watches its MCP servers beside the handshake, and asks again until the state answers (novox/hq ADR 0201)
Awaited at import, a bucket not yet on the bus — or a grant the bus had not
reloaded — answered after the runtime's 10s handshake, and the module was left
unserved on its first assignment. Also cites module state as ADR 0201, as hq
main numbers it (folds #256).
2026-10-04 11:13:33 +02:00
mesh-admin 9dfd3b1105 Merge pull request 'claude-code: the operator's agent, its managed configuration and the licence consumer side (hq design 36, to-be 40 WP2)' (#244) from feat/claude-code-agent into main 2026-10-04 09:01:31 +00:00
mesh-admin 22e8714040 Merge pull request 'The shell and the account's environment as modules: node-env, zsh, powerlevel10k, two plugins, and systemd finished (hq to-be 41 WP3, WP4)' (#255) from feat/the-shell-and-its-environment into main 2026-10-04 08:55:11 +00:00
jochen 11e7ede8a4 Merge remote-tracking branch 'origin/main' into feat/the-shell-and-its-environment 2026-10-04 10:31:03 +02:00
mesh-admin 27315d35cf Merge pull request 'The store does not collect until every controller composes the window (hq ADR 0189)' (#254) from fix/the-store-collects-once-the-window-is-understood into main 2026-10-04 02:26:57 +00:00
jochen 42c80fa9e1 zsh: no doubled blank line in the block when the first slot is empty 2026-10-04 04:05:30 +02:00
jochen 56e0830700 zsh-autosuggestions, zsh-syntax-highlighting: the plugins as packages and one line each
The distribution packages both, so they are installed as packages rather than cloned or
vendored (novox/hq ADR 0205). Each contributes the line that loads the package's own
copy, from the path the Arch package installs, to a slot of the login shell's block
(ADR 0204). Syntax highlighting goes in last, as its upstream asks.
2026-10-04 04:04:35 +02:00
jochen 0844b35ebb powerlevel10k: the prompt as a pinned archive of the module's own, loaded from a slot
The distribution does not package the theme, and the predecessor cloned whatever
upstream's default branch held the day a hook ran (novox/hq ADR 0205). So upstream's
v1.20.0 release is vendored verbatim, with its licence, and shipped as an archive the
host unpacks under the account's home and checks by digest.

The prompt's configuration is today's ~/.p10k.zsh byte for byte, as a second archive.
Inline, its 86 KB would ride in every declaration and be unreviewable JSON. The zsh code
that loads both is a contribution to the normal slot (ADR 0204). Instant prompt stays off,
as it is today.
2026-10-04 04:04:10 +02:00
jochen 566739e02c zsh: hold the mesh's login-shell seat, source the environment, and leave the rest to slots
The seat is now the mesh's node-login-shell, which a shell module claims rather than
declares (novox/hq ADR 0204), and the environment is one module's that every module
contributes to (ADR 0203). Per hq to-be 41 WP3:

- no seat declaration; the claim is node-login-shell serving execute;
- EDITOR, VISUAL, XDG_CONFIG_HOME and the three PATH entries are an environment
  contribution, not exports in the block;
- a ~/.zshenv block sources ~/.config/mesh/environment.sh, so a script, a login and
  execute all see the environment;
- the ~/.zshrc block goes at the start, so the operator's lines run after it, and holds
  today's shared defaults between the first, normal and last slots. The prompt, the
  plugins and the operator's own lines are no longer in it;
- execute is bounded below the runtime's call limit (20 s default, 25 s at most), kills its
  whole process group on timeout, cuts each stream at 256 KiB and says so, runs in the
  account's home without the mesh's words, with the account's session words. The dead
  runuser branch is gone, because the runtime is the account;
- zsh_config shows both files with their block line counts;
- the README lists the one-off migration (ADR 0182).
2026-10-04 04:02:54 +02:00
jochen 38b56a7877 node-env: the account's environment as one module's two files
Every module contributes variables and PATH entries as facts, and one holder of
node-environment places them (novox/hq ADR 0203, to-be 41 WP3). This is that holder: no
package, no process, no tools — the directories it owns under the home and two files the
controller fills, the POSIX file at the path the seat fixes (~/.config/mesh/environment.sh,
sourced by the login shell) and environment.d's 50-mesh.conf for the account's service
manager and graphical session.
2026-10-04 03:59:50 +02:00
jochen 0596503db5 systemd: act as the runtime's account can, and never read a failure as an answer
The node tools runtime runs as the operator account, not root, and gives its bundles no
session words (novox/hq ADR 0175, 0188, 0193). So, per hq to-be 41 WP4:

- system-scope start/stop/restart/enable/disable go through sudo -n when not root, as the
  packet filter and intrusion prevention do, and a refusal is named by how it failed;
- user scope is plain --user with XDG_RUNTIME_DIR and the session bus of /run/user/<uid>;
  the dead --machine branches are gone;
- a failed systemctl or journalctl is an error, and an unreachable user manager is said
  even when systemctl exits 0; systemd_failed reports it beside the other manager's answer
  instead of claiming nothing failed;
- status says whether the mesh declares the unit: its loaded unit file begins with the
  header the host writes for a module's process. Only such a unit carries the restore note;
- the package resource goes: the service manager is always present, and it collided with
  systemd-networkd's identical declaration;
- calls are bounded below the runtime's call limit, a unit name is never an option, and
  the runner is injected so the tests use a fake one.
2026-10-04 03:58:19 +02:00
jochen 3668b02b94 claude-code keeps its MCP servers in state, not events (novox/hq ADR 0202)
One key per registration in the module's servers bucket — all.<server> or
<node>.<server> — watched by every node, so a node assigned after a
registration takes it at start, which the mcp.registered event could not do.
Also narrows apply()'s refusal by hand: the builder compiles without strict,
where the discriminated union does not narrow and the build failed.
2026-10-04 03:48:41 +02:00
jochen a1d7b9ad5a systemd: the service manager as a module — holds node-service-manager and answers for the units in both scopes
The holder of the seat the controller seeds under novox/hq ADR 0177. Eight
verbs under the seat's name — units, status, start, stop, restart, enable,
disable, journal — each taking an optional scope, "system" by default or
"user" for the operator account's own manager, reached as
`systemctl --user --machine=<account>@` when the runtime is not that account.
One tool of its own, systemd_failed, for every failed unit in both scopes.
A package, a claim and a bundle; no container, no process: served by the node
tools runtime (ADR 0175) once it exists. `module check` passes against a
controller that carries the seat; the tools type-check against the SDK.
2026-10-04 02:32:34 +02:00
jochen 5548b0f4e9 zsh: the shell as a module — package, the mesh's ~/.zshrc block, the login-shell seat and execute
The first module of the operator's environment (novox/hq to-be 37 §1, ADR 0173,
0176). A package, the mesh's default configuration as a block inside the
account's ~/.zshrc so the operator's own lines around it survive every push
(ADR 0174 as the host's `into: block` realises it), a `user` shape that makes
zsh the account's login shell, the `login-shell` seat declared with its one
verb, and a tools bundle: `execute` under the seat's name, `zsh_config` under
the module's. No container, no process: the tools are served by the node tools
runtime (ADR 0175), which does not exist yet — the bundle builds and the
manifest registers ahead of it. `module check` passes; the tools type-check
against the SDK.

Two things the manifest cannot yet say, left for the controller: the `user`
shape applies wherever the module is assigned, not only where it holds the
seat; and the runtime learns the account from MESH_OPERATOR_ACCOUNT, which
nothing sets yet.
2026-10-04 02:32:34 +02:00
jochen 295cc59e1e claude-code: declare using the licence manager's seat when that module exists; until then the mesh refuses a seat no module declares 2026-10-04 02:23:34 +02:00
jochen 6a7e4ebd5e claude-code over NATS: licence events, a token by request, a login pushed to the manager, MCP servers registered per node or mesh-wide
Events carry what happened and no secret; tokens travel on requests (design 32 §10). The manager's
licence.rotated/switched events make the module ask anthropic-licence-manager.current; at start it
asks once to catch up. A refresh token appearing in the credentials file is a login: it is pushed to
the manager's adopt at once, sealed to the manager's key — the one time a refresh token travels. A
switch replaces the old licence's grant whole, removes the API key and its helper, and rewrites
oauthAccount in ~/.claude.json. New tools register and unregister MCP servers on this node, or with
nodes: all / a list via an mcp.registered event every node consumes; called for one node, the
answer names the other nodes running claude-code. 26 tests.
2026-10-04 02:23:23 +02:00
jochen 03e729d103 claude-code owns /etc/claude-code and ~/.claude as declared directories
So the controller's ownership check refuses a second module owning either. ~/.claude is the
operator's at 0700 (it was 0755 on the workstations); of what is inside, the module owns only what it
writes, and the host keeps a directory that is not empty when the module goes (hq ADR 0182).
2026-10-03 23:49:16 +02:00
jochen eab335b755 claude-code: launched over stdio (ADR 0193), the console's five tools in its instructions (ADR 0195)
Every bundle is now a child speaking MCP over stdio, so stdout is the channel: the module logs on
stderr. The managed CLAUDE.md teaches mesh_search, mesh_describe, mesh_call, mesh_overview and
mesh_machine with addresses (<seat>.<verb>, <node>/<module>.<tool>) instead of flat tool names.
A hand-over is applied whatever the trailing render says; a failed render is reported beside it.
Proven over stdio as the runtime drives it: five tools listed, a key made on first use, a sealed
switch writing an access-token-only 0600 credentials file that keeps unknown keys.
2026-10-03 23:41:01 +02:00
jochen f42b58f789 claude-code: the manifest, the managed directory and the tools (hq design 36, to-be 40 WP2)
The module owns /etc/claude-code: managed-mcp.json lists the console as `mesh` over HTTP on
loopback plus the servers in its mcp_servers setting (exclusive, by the operator's choice — the
https rule of managedMcpServers refuses a loopback console); managed-settings.json carries the
attribution convention, keeps claude.ai connectors, and adds the key-helper only for an API-key
licence; CLAUDE.md says how a session here works. Rendered whenever the runtime collects the
tools, written only on change, through the operator account's sudo. Under the home, only the
credentials file, only on a hand-over. Nothing declared under a home or /etc; the console's
port comes from node-tools' mcp-endpoint (mesh-tools #34).
2026-10-03 23:40:21 +02:00
jochen 5737752744 claude-code: the sealed hand-over, the credentials write with the lineage rule, the identity read (hq to-be 40 WP2, in progress)
The parts of the agent module that hold whichever way the console is registered: X25519 +
HKDF + AES-GCM from Node's own library so the bundle carries no dependency; the predecessor's
lineage rule (rotation only if newer, a re-issue adopted, a switch regardless) with its
incidents as tests; an atomic 0600 write that strips any refresh token and keeps keys it does not
know; the account read from the agent's own state file. Manifest and renderer follow.
2026-10-03 23:40:21 +02:00
245 changed files with 52427 additions and 10 deletions
+74
View File
@@ -0,0 +1,74 @@
# claude-code
The operator's agent on a machine (novox/hq design 36): its package, its machine-wide managed
configuration, and the consumer side of the Anthropic licence manager (design 39, ADR 0183).
## What it owns
Two directories, declared, so the mesh refuses a second module owning either:
- `/etc/claude-code`, the agent's machine-wide managed directory, root's, `0755`.
- `~/.claude` under the operator account's home, the operator's, `0700`. The module owns the directory —
that it exists, who owns it, its mode — and of what is inside only what it writes. Everything else
in it (memory, history, projects, local settings, a person's own rules and skills) is the person's
and is never read or written (hq ADR 0182). Unassigned, the module leaves the directory: the host
removes a directory only when it is empty.
## What it writes
Under the agent's managed directory, `/etc/claude-code`, owned whole by this module and rewritten
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 |
| `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
this node a subscription token. Nothing else under the home is read or written.
## Over NATS
Everything between this module and the rest of the mesh is NATS, in three kinds: an **event** says that
something happened and carries no secret, because a stream keeps it; a **request** carries a token,
because nothing keeps it (hq design 32 §10); and **state** is the current value of something every node
must see, a node that joins later included — kept, so it carries no secret either (hq ADR 0201).
| what | how |
|---|---|
| the licence manager rotated a licence, or switched this node | its `licence.rotated` / `licence.switched` event; this module then asks `anthropic-licence-manager.current` for its token, sealed to the key it sends |
| this node starts | it asks `current` once, so a node that was off catches up |
| a person ran `/login` here | the credentials file gains a refresh token this module never writes; it asks `anthropic-licence-manager.adopt` at once with the grant sealed to the manager's key — the one time a refresh token travels, because the login made the manager's stale |
| an MCP server registered through this module | a key in the module's `servers` state — `all.<server>` for every node, `<node>.<server>` for one; every node watches it and renders what applies to it, a node's own entry over the one for every node. A node that joins later, or was off, reads the whole current set at start; unregistering is a delete. An entry with a secret in its `env` or `headers` is refused by the runtime |
## Tools
`claude_code_status`, `claude_code_render`, `claude_code_pull`, `claude_code_mcp_list`,
`claude_code_mcp_register` (this node by default; `nodes: "all"` or a list for more — called for this
node alone, its answer names the other nodes running claude-code), `claude_code_mcp_unregister`.
## Settings
Per node or for the whole mesh, through `mesh-controller.settings module=claude-code`:
- `role` — what this node is, in a few words; shown to every session.
- `mcp_servers` — extra tool servers, set by the operator for the mesh or a node, beside the ones
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.
## On a machine that carried the predecessor
Remove these by hand, once; the mesh removes nothing it did not make (ADR 0182):
- `~/.claude/CLAUDE.md`
- `~/.claude/rules/00-hal-mesh.md`, `~/.claude/rules/conventions.md`
- `~/.claude/skills/cleanup/`, `~/.claude/skills/hal-switch-license/`
- the hand-made console entry in `~/.claude.json` under `mcpServers` — it is ignored now anyway
## Escalation
Writing `/etc/claude-code` needs root. The runtime runs as the operator account, and the module uses
that account's passwordless `sudo`; on a machine without it, `claude_code_render` says so and nothing
is written.
+112
View File
@@ -0,0 +1,112 @@
// The agent's credentials file, and whether an offered grant may replace what it holds (novox/hq
// ADR 0183, design 36 §5). Pure where it decides, so the rules are tested without a file.
//
// The file is the vendor's: `{ claudeAiOauth: { accessToken, expiresAt, refreshTokenExpiresAt?,
// scopes?, subscriptionType?, rateLimitTier? }, ... }`. A node never holds a refresh token, so the
// one this module writes never carries one, and a full grant a login left behind is stripped the
// moment the manager hands the node its own.
//
// The lineage rule is the predecessor's, with the incidents that earned it: a rotation of the same
// licence is applied only if newer; a grant re-issued by a login is adopted whatever its expiry; a
// switch to another licence is applied regardless, because across licences the expiries are
// unrelated numbers.
import { readFileSync, renameSync, writeFileSync, mkdirSync } from "node:fs";
import { dirname } from "node:path";
export interface Grant {
readonly accessToken: string;
readonly expiresAt: number;
readonly refreshTokenExpiresAt?: number | null;
readonly scopes?: readonly string[] | null;
readonly subscriptionType?: string | null;
readonly rateLimitTier?: string | null;
}
export type ApplySource = "rotation" | "switch";
export type ApplyDecision =
| { apply: true; reissued?: boolean }
| { apply: false; reason: "already-current" }
| { apply: false; reason: "not-newer"; localExpiresAt: number };
/** Two refresh-token expiries within a day are one lineage; a login starts a fresh window weeks away. */
export const GENERATION_TOLERANCE_MS = 24 * 60 * 60 * 1000;
export function sameGeneration(a?: number | null, b?: number | null): boolean {
if (a == null || b == null) return true;
return Math.abs(Number(a) - Number(b)) <= GENERATION_TOLERANCE_MS;
}
export function decideApply(local: Grant | null | undefined, offered: Grant, source: ApplySource): ApplyDecision {
if (!local?.accessToken) return { apply: true };
if (local.accessToken === offered.accessToken) return { apply: false, reason: "already-current" };
const reissued = !sameGeneration(local.refreshTokenExpiresAt, offered.refreshTokenExpiresAt);
if (source === "rotation" && !reissued && Number(local.expiresAt) >= Number(offered.expiresAt)) {
return { apply: false, reason: "not-newer", localExpiresAt: Number(local.expiresAt) };
}
return reissued ? { apply: true, reissued: true } : { apply: true };
}
type Oauth = Record<string, unknown> & { accessToken?: string; refreshToken?: string; expiresAt?: number };
type Credentials = Record<string, unknown> & { claudeAiOauth?: Oauth };
export function readCredentials(path: string): Credentials | null {
try {
const parsed = JSON.parse(readFileSync(path, "utf8")) as Credentials;
return parsed && typeof parsed === "object" ? parsed : null;
} catch {
return null;
}
}
/** The grant the file holds, or null. */
export function grantOf(creds: Credentials | null): Grant | null {
const o = creds?.claudeAiOauth;
if (!o?.accessToken) return null;
return {
accessToken: o.accessToken,
expiresAt: Number(o.expiresAt ?? 0),
refreshTokenExpiresAt: o.refreshTokenExpiresAt == null ? null : Number(o.refreshTokenExpiresAt),
};
}
/** Does the file hold a full grant — a refresh token this module never writes, so a person's login? */
export function holdsLogin(creds: Credentials | null): boolean {
return typeof creds?.claudeAiOauth?.refreshToken === "string" && creds.claudeAiOauth.refreshToken.length > 0;
}
/**
* The handed grant laid over what is there — a rotation of the licence the node already holds — or,
* for a switch, in place of it: the old licence's grant goes whole, scopes and subscription included,
* and only keys outside the grant (another kind of credential the vendor keeps in the file) stay.
* Either way, no refresh token survives.
*/
export function replacedBy(local: Credentials | null, grant: Grant): Credentials {
const next: Credentials = { ...(local ?? {}) };
delete next.claudeAiOauth;
return withGrant(next, grant);
}
/** Overlay the handed grant on what is there, and delete any refresh token. */
export function withGrant(local: Credentials | null, grant: Grant): Credentials {
const next: Credentials = { ...(local ?? {}) };
const oauth: Oauth = { ...(local?.claudeAiOauth ?? {}) };
oauth.accessToken = grant.accessToken;
oauth.expiresAt = grant.expiresAt;
for (const k of ["refreshTokenExpiresAt", "scopes", "subscriptionType", "rateLimitTier"] as const) {
const v = grant[k];
if (v != null) oauth[k] = v as unknown;
}
delete oauth.refreshToken;
next.claudeAiOauth = oauth;
return next;
}
/** Write atomically 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, mode: 0o700 });
const tmp = `${path}.mesh-tmp`;
writeFileSync(tmp, JSON.stringify(creds, null, 2) + "\n", { mode: 0o600 });
renameSync(tmp, path);
}
+50
View File
@@ -0,0 +1,50 @@
// Which account the agent is logged in as (novox/hq ADR 0183): not in the token, but in the agent's
// own state file beside the home, `~/.claude.json` → `oauthAccount`. Read to attribute a login; written,
// three keys and nothing else, when a licence is switched, so the file Claude Code shows the account from
// names the account whose token it now holds (as the predecessor learned: two files that disagree make
// a later login look like the wrong account).
import { readFileSync, renameSync, writeFileSync } from "node:fs";
export interface Identity {
readonly accountUuid: string;
readonly emailAddress?: string;
readonly organizationUuid?: string;
}
export function readIdentity(stateFile: string): Identity | null {
try {
const raw = JSON.parse(readFileSync(stateFile, "utf8")) as { oauthAccount?: Record<string, unknown> };
const a = raw.oauthAccount;
if (!a || typeof a.accountUuid !== "string") return null;
return {
accountUuid: a.accountUuid,
emailAddress: typeof a.emailAddress === "string" ? a.emailAddress : undefined,
organizationUuid: typeof a.organizationUuid === "string" ? a.organizationUuid : undefined,
};
} catch {
return null;
}
}
/**
* Point the state file's account at `id`, keeping every other key as found. Returns whether the file
* changed; a file that cannot be read as an object is left alone rather than replaced.
*/
export function writeIdentity(stateFile: string, id: Identity): boolean {
let raw: Record<string, unknown>;
try {
raw = JSON.parse(readFileSync(stateFile, "utf8")) as Record<string, unknown>;
if (!raw || typeof raw !== "object") return false;
} catch {
raw = {};
}
const current = (raw.oauthAccount ?? {}) as Record<string, unknown>;
if (current.accountUuid === id.accountUuid && current.emailAddress === id.emailAddress
&& current.organizationUuid === id.organizationUuid) return false;
raw.oauthAccount = { ...current, accountUuid: id.accountUuid, emailAddress: id.emailAddress, organizationUuid: id.organizationUuid };
const tmp = `${stateFile}.mesh-tmp`;
writeFileSync(tmp, JSON.stringify(raw, null, 2), { mode: 0o600 });
renameSync(tmp, stateFile);
return true;
}
+90
View File
@@ -0,0 +1,90 @@
{
"module": "claude-code",
"version": "1",
"slug": "agent",
"capabilities": [
"package-manager"
],
"requires": [
"mcp-endpoint"
],
"binds": {
"mcp-endpoint": "${dir:state}/mcp-endpoint.json"
},
"consumes": [
"claude-licence-manager.licence.rotated",
"claude-licence-manager.licence.switched"
],
"state": [
"servers"
],
"tools": [
"claude_code_status",
"claude_code_render",
"claude_code_pull",
"claude_code_mcp_list",
"claude_code_mcp_register",
"claude_code_mcp_unregister"
],
"resources": [
{
"id": "package",
"type": "package",
"package": "claude-code"
},
{
"id": "managed",
"type": "directory",
"path": "/etc/claude-code",
"mode": "0755"
},
{
"id": "agent-home",
"type": "directory",
"path": "${machine:account-home}/.claude",
"mode": "0700",
"owner": "${machine:account}"
},
{
"id": "state",
"type": "directory",
"mode": "0700",
"owner": "${machine:account}",
"place": "."
},
{
"id": "facts",
"type": "file",
"path": "${dir:state}/facts.json",
"mode": "0600",
"owner": "${machine:account}",
"content": "{\n \"node\": \"${machine:name}\",\n \"console\": \"http://127.0.0.1:${bound:mcp-endpoint:port}/mcp\"\n}\n"
},
{
"id": "settings",
"type": "file",
"path": "${dir:state}/settings.json",
"mode": "0600",
"owner": "${machine:account}",
"merge": "json",
"content": "{\n \"role\": \"\",\n \"mcp_servers\": {}\n}\n"
}
],
"build": {
"artifacts": [
{
"name": "tools",
"kind": "bundle",
"language": "typescript",
"entrypoints": [
"tools/index.js"
],
"env": {
"MESH_CLAUDE_CODE_STATE": "${dir:state}",
"MESH_CLAUDE_CODE_FACTS": "${dir:state}/facts.json",
"MESH_CLAUDE_CODE_SETTINGS": "${dir:state}/settings.json"
}
}
]
}
}
+271
View File
@@ -0,0 +1,271 @@
// What claude-code does on a node, written against two things it is handed — a way to ask a tool on the
// bus and a way to emit an event — so every path is tested without a bus (novox/hq design 36 §4–§5,
// ADR 0183, ADR 0198).
//
// **Over NATS, in two kinds** (design 32 §10): an event says that something happened and carries no
// secret, because a stream keeps it; a token travels on a request, which nothing keeps. So:
// - the licence manager's `licence.rotated` and `licence.switched` events tell this module to ask the
// seat for its current token, sealed to the key it sends with the request;
// - a login a person made here — a refresh token this module never writes — is offered to the seat at
// once, sealed to the seat's key: the one moment a refresh token travels, because the login made the
// manager's stale;
// - an MCP server registered through this module is **state, not an event** (novox/hq ADR 0201): one
// key per server in the module's `servers` bucket — `all.<server>` for every node, `<node>.<server>`
// for one — which every node watches. A node that joins later, or was off, reads the whole current set
// at start; unregistering is a delete. A secret never goes in an entry: the runtime refuses one.
import { chmodSync, existsSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { render, entryProblem, MANAGED_DIR, type Binding, type Facts, type Settings, type Servers } from "./render.js";
import { generateKeyPair, open, seal, type SealedBox } from "./seal.js";
import { decideApply, grantOf, holdsLogin, readCredentials, replacedBy, withGrant, writeCredentials, type Grant } from "./grant.js";
import { readIdentity, writeIdentity, type Identity } from "./identity.js";
export const SEAT = "anthropic-licence-manager";
export interface Paths {
state: string;
facts: string;
settings: string;
home: string;
node: string;
}
/** A tool on the bus: its address and arguments in, its JSON answer out. */
export type Ask = (address: string, args: Record<string, unknown>) => Promise<unknown>;
/** An event of this module's, by its local name. */
export type Emit = (type: string, body: unknown) => Promise<void>;
/** Write one managed file; answers what happened. */
export type WriteManaged = (name: string, content: string) => string;
export const readJson = <T>(p: string, fallback: T): T => {
try {
return JSON.parse(readFileSync(p, "utf8")) as T;
} catch {
return fallback;
}
};
const credentialsPath = (p: Paths) => join(p.home, ".claude", ".credentials.json");
const accountPath = (p: Paths) => join(p.home, ".claude.json");
const bindingPath = (p: Paths) => join(p.state, "licence.json");
const apiKeyPath = (p: Paths) => join(p.state, "api-key");
export const helperPath = (p: Paths) => join(p.state, "api-key-helper");
const keyPath = (p: Paths) => join(p.state, "key.pem");
const pubPath = (p: Paths) => join(p.state, "key.pub.pem");
const registryPath = (p: Paths) => join(p.state, "mcp-servers.json");
export function keypair(p: Paths): { publicKey: string; privateKey: string } {
if (!existsSync(keyPath(p))) {
const k = generateKeyPair();
writeFileSync(keyPath(p), k.privateKey, { mode: 0o600 });
writeFileSync(pubPath(p), k.publicKey, { mode: 0o644 });
}
return { privateKey: readFileSync(keyPath(p), "utf8"), publicKey: readFileSync(pubPath(p), "utf8") };
}
export function registered(p: Paths): Servers {
return readJson<Servers>(registryPath(p), {});
}
export function renderNow(p: Paths, write: WriteManaged): string[] {
const facts = readJson<Facts | null>(p.facts, null);
if (!facts?.console) throw new Error(`the mesh has not rendered ${p.facts} yet; nothing to write`);
const files = render(facts, readJson<Settings>(p.settings, {}), readJson<Binding | null>(bindingPath(p), null),
helperPath(p), registered(p));
return Object.entries(files).map(([name, content]) => write(name, content));
}
// ---- the licence ----------------------------------------------------------------------------------
/** What the seat answers to `current`: the licence this node is bound to and its token, sealed. */
export interface Current {
licence: string;
kind: "subscription" | "api-key";
sealed: SealedBox;
identity?: Identity | null;
}
/** Ask the seat for this node's current token and apply it. */
export async function pull(p: Paths, ask: Ask, write: WriteManaged): Promise<Record<string, unknown>> {
const answer = (await ask(`${SEAT}.current`, { node: p.node, public_key: keypair(p).publicKey })) as Current | null;
if (!answer?.sealed) return { applied: false, reason: "the seat holds no licence for this node" };
return apply(p, answer, write);
}
/** Apply what the seat handed over. A switch replaces the grant whole and cleans up after the old licence. */
export function apply(p: Paths, handed: Current, write: WriteManaged): Record<string, unknown> {
const plain = open(handed.sealed, keypair(p).privateKey);
const previous = readJson<Binding | null>(bindingPath(p), null);
const switched = previous?.licence !== handed.licence;
let outcome: Record<string, unknown> = { applied: true, licence: handed.licence, kind: handed.kind, switched };
if (handed.kind === "api-key") {
writeFileSync(apiKeyPath(p), plain.trim() + "\n", { mode: 0o600 });
writeFileSync(helperPath(p), `#!/bin/sh\nexec cat '${apiKeyPath(p)}'\n`, { mode: 0o700 });
chmodSync(helperPath(p), 0o700);
} else {
const grant = JSON.parse(plain) as Grant;
const local = readCredentials(credentialsPath(p));
const d = decideApply(grantOf(local), grant, switched ? "switch" : "rotation");
if (d.apply) writeCredentials(credentialsPath(p), switched ? replacedBy(local, grant) : withGrant(local, grant));
else outcome = { applied: false, licence: handed.licence, reason: "reason" in d ? d.reason : undefined }; // narrowed by hand: the build compiles without strict
// Away from the API key: it goes, with its helper.
rmSync(apiKeyPath(p), { force: true });
rmSync(helperPath(p), { force: true });
}
if (switched && handed.identity?.accountUuid) {
outcome.account = writeIdentity(accountPath(p), handed.identity) ? "updated" : "unchanged";
}
writeFileSync(bindingPath(p), JSON.stringify({ licence: handed.licence, kind: handed.kind }) + "\n", { mode: 0o600 });
try {
outcome.rendered = renderNow(p, write); // the key-helper comes or goes with the licence's kind
} catch (err) {
outcome.rendered = { failed: err instanceof Error ? err.message : String(err) };
}
return outcome;
}
/** A licence event from the manager: is it for this node? */
export function concerns(p: Paths, type: string, body: { licence?: string; node?: string }): boolean {
if (type.endsWith("licence.switched")) return body.node === p.node;
if (type.endsWith("licence.rotated")) return body.licence === readJson<Binding | null>(bindingPath(p), null)?.licence;
return false;
}
/** A refresh token in the credentials file is a login: this module never writes one. Offer it to the seat. */
export async function offerLogin(p: Paths, ask: Ask): Promise<Record<string, unknown> | null> {
const creds = readCredentials(credentialsPath(p));
if (!holdsLogin(creds)) return null;
const key = (await ask(`${SEAT}.public_key`, {})) as { public_key?: string } | null;
if (!key?.public_key) throw new Error("the licence manager did not say what key to seal a login to");
return (await ask(`${SEAT}.adopt`, {
node: p.node,
identity: readIdentity(accountPath(p)),
sealed: seal(JSON.stringify(creds!.claudeAiOauth), key.public_key),
})) as Record<string, unknown>;
}
// ---- MCP servers ----------------------------------------------------------------------------------
export interface Registration {
name: string;
entry?: Record<string, unknown>;
/** Which nodes: this one (absent), every node running the module ("all"), or a list. */
nodes?: "all" | string[];
}
/** The `servers` state, as this module reaches it through the runtime (`state("servers")` in the SDK). */
export interface ServerState {
put(key: string, value: Record<string, unknown>): Promise<number>;
delete(key: string): Promise<void>;
keys(): Promise<string[]>;
}
/** One change to the `servers` state, as a watch hands it over. */
export interface ServerChange {
key: string;
op: "put" | "delete";
value?: Record<string, unknown>;
}
/** The key a registration lives at: `all.<server>` for every node, `<node>.<server>` for one. */
export const keyOf = (scope: string, name: string) => `${scope}.${name}`;
/**
* What this node takes from the `servers` state: the entries for every node and for this one, by key —
* kept in memory from the watch, and written through to the module's own file whenever what applies here
* changes, so the managed directory can be rendered without the bus.
*/
export class ServerView {
private readonly entries = new Map<string, Record<string, unknown>>();
constructor(private readonly p: Paths) {}
/** Take one change; answers whether what applies to this node changed. */
take(c: ServerChange): boolean {
const dot = c.key.indexOf(".");
const scope = c.key.slice(0, dot), name = c.key.slice(dot + 1);
if (dot <= 0 || (scope !== "all" && scope !== this.p.node)) return false;
if (c.op === "put" && c.value && entryProblem(name, c.value) === null) this.entries.set(c.key, c.value);
else this.entries.delete(c.key);
return this.writeThrough();
}
/** What applies here: every node's entries, with this node's own laid over them by server name. */
effective(): Servers {
const out: Record<string, Record<string, unknown>> = {};
for (const scope of ["all", this.p.node]) {
for (const [key, entry] of [...this.entries].sort(([a], [b]) => a.localeCompare(b))) {
if (key.startsWith(scope + ".")) out[key.slice(scope.length + 1)] = entry;
}
}
return out;
}
private writeThrough(): boolean {
const now = JSON.stringify(this.effective(), null, 2) + "\n";
let before = "";
try {
before = readFileSync(registryPath(this.p), "utf8");
} catch {
/* none yet */
}
if (now === before) return false;
writeFileSync(registryPath(this.p), now, { mode: 0o600 });
return true;
}
}
/** A change from the watch: take it, and render when what applies here changed. */
export function onServerChange(view: ServerView, c: ServerChange, p: Paths, write: WriteManaged): string | null {
if (!view.take(c)) return null;
renderNow(p, write);
return `${c.op === "put" ? "registered" : "unregistered"} ${c.key}`;
}
const scopesOf = (p: Paths, nodes: Registration["nodes"]): string[] =>
nodes === undefined ? [p.node] : nodes === "all" ? ["all"] : nodes;
/**
* Register (or with no entry, unregister) a server: a put (or delete) per scope in the `servers` state.
* Taken into this node's view at once, so the answer says what it did here; every other node takes it
* from its watch, and a node that joins later from the current state.
*/
export async function registerServer(p: Paths, r: Registration, servers: ServerState, view: ServerView,
write: WriteManaged, others: () => Promise<string[]>): Promise<Record<string, unknown>> {
if (r.entry) {
const problem = entryProblem(r.name, r.entry);
if (problem) return { registered: false, reason: problem };
}
const scopes = scopesOf(p, r.nodes);
// Compared before and after rather than read from take(): this node's own watch may hand the view the
// same change first, and then take() here finds nothing new although this call made it.
const before = JSON.stringify(view.effective());
for (const scope of scopes) {
const key = keyOf(scope, r.name);
if (r.entry) await servers.put(key, r.entry);
else await servers.delete(key);
view.take({ key, op: r.entry ? "put" : "delete", value: r.entry });
}
const changedHere = JSON.stringify(view.effective()) !== before;
const here = scopes.includes("all") || scopes.includes(p.node);
const answer: Record<string, unknown> = {
[r.entry ? "registered" : "unregistered"]: r.name,
on: r.nodes === undefined ? [p.node] : r.nodes,
here: here ? (changedHere ? "changed" : "already so") : "not this node",
rendered: changedHere ? renderNow(p, write) : [],
};
if (!r.entry && view.effective()[r.name]) {
answer.still = `${r.name} still applies here from another registration (for every node, or for this one); unregister that too`;
}
if (r.nodes === undefined) {
// The question the operator wanted asked: here only, or more?
const elsewhere = (await others().catch(() => [] as string[])).filter((n) => n !== p.node);
answer.also = elsewhere.length
? `claude-code also runs on ${elsewhere.join(", ")}. To ${r.entry ? "register" : "unregister"} it there too, call again with nodes: "all" or a list of those nodes.`
: `To do the same on every node running claude-code, call again with nodes: "all".`;
}
return answer;
}
export { MANAGED_DIR };
+18
View File
@@ -0,0 +1,18 @@
{
"name": "@novox/module-claude-code",
"version": "0.1.0",
"description": "claude-code — the operator's agent on a machine: its managed configuration, and the consumer side of the Anthropic licence manager (novox/hq design 36).",
"type": "module",
"private": true,
"scripts": {
"build": "tsc seal.ts grant.ts identity.ts render.ts node.ts tools/index.ts --module NodeNext --moduleResolution NodeNext --target ES2022 --rootDir . --outDir dist",
"test": "npm run build && node --test --experimental-strip-types 'test/*.test.ts'"
},
"dependencies": {
"@novox/mesh-sdk": "^0.1.7"
},
"devDependencies": {
"@types/node": "^22.0.0",
"typescript": "^5.6.0"
}
}
+135
View File
@@ -0,0 +1,135 @@
// What the module writes into the agent's machine-wide managed directory (novox/hq design 36 §1–§4).
// Pure: composed from the facts the mesh rendered, the settings the operator set and the licence the
// node holds, so what lands under /etc is tested without a machine.
//
// Three files, owned whole by this module:
// managed-mcp.json the tool servers every session loads: the mesh's console as `mesh`, and the
// servers the operator declared for the mesh or this node. Exclusive by the
// vendor's rule — a server not listed here does not load — which is why the
// list is the module's settings and nothing else (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.
// CLAUDE.md how a session on this mesh works, who this node is, the conventions.
export const MANAGED_DIR = "/etc/claude-code";
export interface Facts {
readonly node: string;
readonly console: string;
}
export interface Settings {
readonly role?: string;
/** Extra tool servers, in the vendor's `.mcp.json` entry shape, keyed by name. */
readonly mcp_servers?: Readonly<Record<string, Record<string, unknown>>>;
}
export interface Binding {
readonly licence: string;
readonly kind: "subscription" | "api-key";
}
export interface Rendered {
readonly [file: string]: string;
}
const MESH_ENTRY = "mesh";
export type Servers = Readonly<Record<string, Record<string, unknown>>>;
/** Whether an entry is one the vendor's managed file takes: a name of letters, digits, `-` and `_`, and
* an http/sse server with a url or a stdio server with a command. Returns why not, or null. */
export function entryProblem(name: string, entry: Record<string, unknown>): string | null {
if (!/^[A-Za-z0-9_-]+$/.test(name)) return `"${name}" is not a name the agent takes: letters, digits, - and _`;
if (name === MESH_ENTRY) return `"${MESH_ENTRY}" is the mesh's own entry`;
const type = entry?.type ?? "stdio";
if (type === "http" || type === "sse" || type === "streamable-http") {
return typeof entry.url === "string" && entry.url ? null : `an ${type} server needs a url`;
}
if (type === "stdio") return typeof entry.command === "string" && entry.command ? null : "a stdio server needs a command";
return `"${String(type)}" is not a server type the agent knows (http, sse, stdio)`;
}
/**
* Compose the three files. `registered` is the module's own list on this node — what was registered
* through its tools — laid over the servers the operator set in its settings.
*/
export function render(facts: Facts, settings: Settings, binding: Binding | null, helperPath: string,
registered: Servers = {}): Rendered {
const servers: Record<string, unknown> = {};
for (const [name, entry] of Object.entries({ ...(settings.mcp_servers ?? {}), ...registered })) {
if (entryProblem(name, entry) !== null) continue; // the mesh's own entry, or one the agent would refuse
servers[name] = entry;
}
servers[MESH_ENTRY] = { type: "http", url: facts.console };
const managed: Record<string, unknown> = {
attribution: { commit: "", pr: "" },
allowAllClaudeAiMcps: true,
};
if (binding?.kind === "api-key") managed.apiKeyHelper = helperPath;
return {
"managed-mcp.json": json({ mcpServers: sortKeys(servers) }),
"managed-settings.json": json(managed),
"CLAUDE.md": instructions(facts, settings),
};
}
function json(v: unknown): string {
return JSON.stringify(v, null, 2) + "\n";
}
function sortKeys(o: Record<string, unknown>): Record<string, unknown> {
return Object.fromEntries(Object.keys(o).sort().map((k) => [k, o[k]]));
}
export function instructions(facts: Facts, settings: Settings): string {
const role = settings.role?.trim() ? settings.role.trim() : "not stated — set it in this module's settings for the node";
return `# This machine is a node of a Novox mesh
Written by the mesh's \`claude-code\` module. Edit the module's settings or the catalogue, never this file:
it is rewritten whenever the module renders.
## Who this node is
- **Node:** \`${facts.node}\`
- **Role:** ${role}
- The other nodes, their roles and what runs where: ask the controller (\`mesh-controller.nodes\`,
\`mesh-controller.node\`). Nothing here lists them, because a copy drifts.
## How a session on this mesh works
The console is the only way to the mesh: the MCP server named \`mesh\`. It offers five tools, and
everything else is an address you find and call through them:
- \`mesh_search\` — words in, matching addresses out. \`mesh_describe\` — one address's arguments.
- \`mesh_call\` — call an address. A seat the mesh holds once is \`<seat>.<verb>\` (the mesh's own verbs
are \`mesh-controller.<verb>\`: \`status\`, \`plan\`, \`node\`, \`assign\`, \`push\`, \`settings\`);
a module on a machine is \`<node>/<module>.<tool>\`.
- \`mesh_overview\` and \`mesh_machine\` — the mesh's seats and machines, and what one machine runs.
- **Symptom first.** For an error, a failing service or anything unexpected, search the record with the
literal text before forming a hypothesis: the records module's \`records_search\`, then
\`records_read\`.
- **Ask the mesh before changing it**, and change it through the controller's verbs or the catalogue.
- **A licence** through the \`anthropic-licence-manager\` seat's verbs. Never edit the agent's credentials
file by hand, never print or ask for a token.
## Hard rules
- A file the mesh manages is changed through the verb or the catalogue that owns it, never on disk. If
unsure, \`mesh-controller.plan\` for the node says what the mesh writes there.
- Never write to a store's database by hand; schema changes are numbered migrations.
- Never push to a main branch: a branch, a pull request, and a human approval for every merge.
- The mesh creates no symlinks, and nobody else does either.
- A package is declared in a module, never installed by hand.
## Conventions
- Commit messages are concise, in the imperative, about why.
- Test before pushing: nodes update unattended.
- The playbooks in the record say how research, decisions, designs, issues and hand-offs are done.
`;
}
+77
View File
@@ -0,0 +1,77 @@
// Sealing a token to one recipient (novox/hq ADR 0183): the manager seals what it hands a node to that
// node's agent module key, and a node seals a waiting login to the key the manager names. X25519 for
// the agreement, HKDF-SHA256 for the key, AES-256-GCM for the box — all from Node's own library, so a
// bundle carries no dependency and no secret ever crosses the bus in the clear.
//
// A sealed box is `{ v: 1, eph, iv, tag, ct }`, every field base64. `eph` is a one-time public key, so
// two boxes of one value to one recipient share nothing, and only the recipient's private key opens it.
import {
createCipheriv, createDecipheriv, createPrivateKey, createPublicKey, diffieHellman,
generateKeyPairSync, hkdfSync, randomBytes, type KeyObject,
} from "node:crypto";
export interface SealedBox {
readonly v: 1;
readonly eph: string;
readonly iv: string;
readonly tag: string;
readonly ct: string;
}
/** A recipient's keypair, as the two PEM strings it is kept and published as. */
export interface KeyPairPem {
readonly publicKey: string;
readonly privateKey: string;
}
const INFO = Buffer.from("novox-mesh sealed box v1");
export function generateKeyPair(): KeyPairPem {
const { publicKey, privateKey } = generateKeyPairSync("x25519");
return {
publicKey: publicKey.export({ type: "spki", format: "pem" }).toString(),
privateKey: privateKey.export({ type: "pkcs8", format: "pem" }).toString(),
};
}
function keyFor(secret: Buffer, eph: Buffer, recipient: Buffer): Buffer {
// The ephemeral and the recipient's public halves are bound into the key, so a box cannot be
// re-addressed to another recipient by swapping its `eph`.
return Buffer.from(hkdfSync("sha256", secret, Buffer.concat([eph, recipient]), INFO, 32));
}
function rawPublic(key: KeyObject): Buffer {
return key.export({ type: "spki", format: "der" }).subarray(-32);
}
export function seal(plaintext: string, recipientPublicPem: string): SealedBox {
const recipient = createPublicKey(recipientPublicPem);
const eph = generateKeyPairSync("x25519");
const secret = diffieHellman({ privateKey: eph.privateKey, publicKey: recipient });
const ephRaw = eph.publicKey.export({ type: "spki", format: "der" });
const key = keyFor(secret, ephRaw, rawPublic(recipient));
const iv = randomBytes(12);
const cipher = createCipheriv("aes-256-gcm", key, iv);
const ct = Buffer.concat([cipher.update(plaintext, "utf8"), cipher.final()]);
return {
v: 1,
eph: ephRaw.toString("base64"),
iv: iv.toString("base64"),
tag: cipher.getAuthTag().toString("base64"),
ct: ct.toString("base64"),
};
}
/** Open a box with the recipient's private key. Throws on a box for another key or one tampered with. */
export function open(box: SealedBox, privateKeyPem: string): string {
if (!box || box.v !== 1) throw new Error("not a sealed box this module can open");
const priv = createPrivateKey(privateKeyPem);
const ephRaw = Buffer.from(box.eph, "base64");
const eph = createPublicKey({ key: ephRaw, format: "der", type: "spki" });
const secret = diffieHellman({ privateKey: priv, publicKey: eph });
const key = keyFor(secret, ephRaw, rawPublic(createPublicKey(priv)));
const decipher = createDecipheriv("aes-256-gcm", key, Buffer.from(box.iv, "base64"));
decipher.setAuthTag(Buffer.from(box.tag, "base64"));
return Buffer.concat([decipher.update(Buffer.from(box.ct, "base64")), decipher.final()]).toString("utf8");
}
+54
View File
@@ -0,0 +1,54 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { mkdtempSync, readFileSync, statSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import {
decideApply, grantOf, holdsLogin, readCredentials, withGrant, writeCredentials, type Grant,
} from "../dist/grant.js";
const NOW = 1_700_000_000_000;
const HOUR = 3_600_000;
const g = (over: Partial<Grant> = {}): Grant => ({
accessToken: "tok-A", expiresAt: NOW + HOUR, refreshTokenExpiresAt: NOW + 30 * 24 * HOUR, ...over,
});
test("a rotation applies a newer grant of the same licence", () => {
assert.deepEqual(decideApply(g(), g({ accessToken: "tok-B", expiresAt: NOW + 2 * HOUR }), "rotation"), { apply: true });
});
test("a rotation refuses a grant that arrived late and is older", () => {
const d = decideApply(g({ accessToken: "new", expiresAt: NOW + 2 * HOUR }), g({ accessToken: "old" }), "rotation");
assert.equal(d.apply === false && d.reason, "not-newer");
});
test("a grant re-issued by a login is adopted even though it expires sooner (2026-09-05)", () => {
const local = g({ expiresAt: NOW + 8 * HOUR, refreshTokenExpiresAt: NOW + 30 * 24 * HOUR });
const offered = g({ accessToken: "reissued", expiresAt: NOW + HOUR, refreshTokenExpiresAt: NOW + 5 * 24 * HOUR });
assert.deepEqual(decideApply(local, offered, "rotation"), { apply: true, reissued: true });
});
test("a switch to another licence applies whatever the expiries say", () => {
const local = g({ expiresAt: NOW + 8 * HOUR });
assert.equal(decideApply(local, g({ accessToken: "other", expiresAt: NOW + HOUR }), "switch").apply, true);
});
test("the same token is not rewritten", () => {
assert.deepEqual(decideApply(g(), g(), "switch"), { apply: false, reason: "already-current" });
});
test("a full grant left by a login is seen as a login, and stripped when the node's own is written", () => {
const dir = mkdtempSync(join(tmpdir(), "claude-code-"));
const path = join(dir, ".claude", ".credentials.json");
writeFileSync(join(dir, "x"), "");
const login = { claudeAiOauth: { accessToken: "at-login", refreshToken: "rt-login", expiresAt: NOW }, other: 1 };
assert.equal(holdsLogin(login), true);
writeCredentials(path, withGrant(login, g({ accessToken: "at-mesh", scopes: ["user:inference"] })));
const back = readCredentials(path)!;
assert.equal(holdsLogin(back), false);
assert.equal(grantOf(back)!.accessToken, "at-mesh");
assert.deepEqual(back.claudeAiOauth!.scopes, ["user:inference"]);
assert.equal(back.other, 1, "a key the module does not know was lost");
assert.ok(!readFileSync(path, "utf8").includes("rt-login"));
assert.equal(statSync(path).mode & 0o777, 0o600);
});
+19
View File
@@ -0,0 +1,19 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { mkdtempSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { readIdentity } from "../dist/identity.js";
test("the account is read from the agent's state file", () => {
const p = join(mkdtempSync(join(tmpdir(), "cc-id-")), ".claude.json");
writeFileSync(p, JSON.stringify({ oauthAccount: { accountUuid: "u-1", emailAddress: "a@example.org" }, other: 2 }));
assert.deepEqual(readIdentity(p), { accountUuid: "u-1", emailAddress: "a@example.org", organizationUuid: undefined });
});
test("no state file, or no account in it, is no identity rather than a guess", () => {
assert.equal(readIdentity("/nonexistent/.claude.json"), null);
const p = join(mkdtempSync(join(tmpdir(), "cc-id-")), ".claude.json");
writeFileSync(p, "{}");
assert.equal(readIdentity(p), null);
});
+173
View File
@@ -0,0 +1,173 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { existsSync, mkdirSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import {
apply, concerns, keypair, offerLogin, onServerChange, pull, registerServer, registered, ServerView, type Paths,
type ServerChange, type ServerState,
} from "../dist/node.js";
import { generateKeyPair, open, seal } from "../dist/seal.js";
const NOW = Date.now();
function node(name = "laptop"): { p: Paths; written: Record<string, string> } {
const root = mkdtempSync(join(tmpdir(), "cc-node-"));
const p = { state: join(root, "state"), facts: join(root, "state", "facts.json"), settings: join(root, "state", "settings.json"), home: join(root, "home"), node: name };
mkdirSync(p.state, { recursive: true });
mkdirSync(join(p.home, ".claude"), { recursive: true });
writeFileSync(p.facts, JSON.stringify({ node: name, console: "http://127.0.0.1:4270/mcp" }));
writeFileSync(p.settings, JSON.stringify({ role: "", mcp_servers: {} }));
return { p, written: {} };
}
const writer = (w: Record<string, string>) => (name: string, content: string) => { w[name] = content; return `${name}: written`; };
const creds = (p: Paths) => JSON.parse(readFileSync(join(p.home, ".claude", ".credentials.json"), "utf8"));
const grantFor = (p: Paths, licence: string, token: string, kind: "subscription" | "api-key" = "subscription", identity?: object) => ({
licence, kind, identity,
sealed: seal(kind === "api-key" ? token : JSON.stringify({ accessToken: token, expiresAt: NOW + 3_600_000, refreshTokenExpiresAt: NOW + 86_400_000, subscriptionType: licence }), keypair(p).publicKey),
});
test("a pull asks the seat with this node's key and applies what it answers", async () => {
const { p, written } = node();
let asked: [string, Record<string, unknown>] | null = null;
const r = await pull(p, async (address, args) => { asked = [address, args]; return grantFor(p, "personal", "at-1"); }, writer(written));
assert.equal(asked![0], "anthropic-licence-manager.current");
assert.equal(asked![1].node, "laptop");
assert.match(String(asked![1].public_key), /BEGIN PUBLIC KEY/);
assert.equal(r.applied, true);
assert.equal(creds(p).claudeAiOauth.accessToken, "at-1");
assert.ok(written["managed-mcp.json"]);
});
test("a switch replaces the old licence's grant whole and points the account at the new one", () => {
const { p, written } = node();
writeFileSync(join(p.home, ".claude.json"), JSON.stringify({ oauthAccount: { accountUuid: "old" }, projects: { keep: 1 } }));
apply(p, grantFor(p, "personal", "at-1"), writer(written));
const r = apply(p, grantFor(p, "work", "at-2", "subscription", { accountUuid: "new", emailAddress: "w@example.org" }), writer(written));
assert.equal(r.switched, true);
assert.equal(creds(p).claudeAiOauth.accessToken, "at-2");
assert.equal(creds(p).claudeAiOauth.subscriptionType, "work", "the old licence's subscription type survived the switch");
const account = JSON.parse(readFileSync(join(p.home, ".claude.json"), "utf8"));
assert.equal(account.oauthAccount.accountUuid, "new");
assert.deepEqual(account.projects, { keep: 1 });
});
test("switching to the API key adds the key-helper; switching away removes the key and the helper", () => {
const { p, written } = node();
apply(p, grantFor(p, "api", "sk-key", "api-key"), writer(written));
assert.ok(JSON.parse(written["managed-settings.json"]).apiKeyHelper);
assert.ok(existsSync(join(p.state, "api-key")));
apply(p, grantFor(p, "personal", "at-1"), writer(written));
assert.ok(!("apiKeyHelper" in JSON.parse(written["managed-settings.json"])));
assert.ok(!existsSync(join(p.state, "api-key")) && !existsSync(join(p.state, "api-key-helper")));
});
test("a rotation event concerns the node bound to that licence; a switch event the node it names", () => {
const { p, written } = node();
apply(p, grantFor(p, "personal", "at-1"), writer(written));
assert.equal(concerns(p, "claude-licence-manager.licence.rotated", { licence: "personal" }), true);
assert.equal(concerns(p, "claude-licence-manager.licence.rotated", { licence: "work" }), false);
assert.equal(concerns(p, "claude-licence-manager.licence.switched", { node: "laptop", licence: "work" }), true);
assert.equal(concerns(p, "claude-licence-manager.licence.switched", { node: "server" }), false);
});
test("a login is offered to the seat sealed to the seat's key, with the account it belongs to", async () => {
const { p } = node();
const manager = generateKeyPair();
writeFileSync(join(p.home, ".claude", ".credentials.json"), JSON.stringify({ claudeAiOauth: { accessToken: "at-login", refreshToken: "rt-login", expiresAt: NOW } }));
writeFileSync(join(p.home, ".claude.json"), JSON.stringify({ oauthAccount: { accountUuid: "u-9" } }));
const calls: [string, Record<string, unknown>][] = [];
await offerLogin(p, async (address, args) => { calls.push([address, args]); return address.endsWith("public_key") ? { public_key: manager.publicKey } : { adopted: true }; });
assert.deepEqual(calls.map((c) => c[0]), ["anthropic-licence-manager.public_key", "anthropic-licence-manager.adopt"]);
const adopt = calls[1][1] as { identity: { accountUuid: string }; sealed: never };
assert.equal(adopt.identity.accountUuid, "u-9");
assert.equal(JSON.parse(open(adopt.sealed, manager.privateKey)).refreshToken, "rt-login");
assert.ok(!JSON.stringify(adopt).includes("rt-login"), "the refresh token crossed in the clear");
});
test("no refresh token in the file is no login, and nothing is asked", async () => {
const { p } = node();
writeFileSync(join(p.home, ".claude", ".credentials.json"), JSON.stringify({ claudeAiOauth: { accessToken: "at", expiresAt: NOW } }));
assert.equal(await offerLogin(p, async () => { throw new Error("asked"); }), null);
});
/** The `servers` state as the bus holds it, shared by every node in a test, with each node's watch. */
function bus() {
const kept = new Map<string, Record<string, unknown>>();
const watchers: ((c: ServerChange) => void)[] = [];
const state: ServerState = {
put: async (key, value) => { kept.set(key, value); watchers.forEach((w) => w({ key, op: "put", value })); return kept.size; },
delete: async (key) => { kept.delete(key); watchers.forEach((w) => w({ key, op: "delete" })); },
keys: async () => [...kept.keys()].sort(),
};
/** A node joining: its view takes the current state, then every change. */
const join = (n: { p: Paths; written: Record<string, string> }) => {
const view = new ServerView(n.p);
for (const [key, value] of kept) onServerChange(view, { key, op: "put", value }, n.p, writer(n.written));
watchers.push((c) => onServerChange(view, c, n.p, writer(n.written)));
return view;
};
return { state, join, kept };
}
test("registering a server here puts it under this node's key, renders it, and asks about the other nodes", async () => {
const n = node();
const b = bus();
const view = b.join(n);
const r = await registerServer(n.p, { name: "search", entry: { type: "http", url: "https://s.example/mcp" } },
b.state, view, writer(n.written), async () => ["laptop", "server", "desktop"]);
assert.equal(r.here, "changed");
assert.match(String(r.also), /server, desktop/);
assert.deepEqual([...b.kept.keys()], ["laptop.search"]);
assert.ok(JSON.parse(n.written["managed-mcp.json"]).mcpServers.search);
});
test("registering for every node reaches the others through their watch, and a node joining later reads it", async () => {
const a = node("laptop"), s = node("server");
const b = bus();
const va = b.join(a);
b.join(s);
await registerServer(a.p, { name: "docs", entry: { type: "stdio", command: "docs-mcp" }, nodes: "all" },
b.state, va, writer(a.written), async () => []);
assert.deepEqual([...b.kept.keys()], ["all.docs"]);
assert.deepEqual(registered(s.p).docs, { type: "stdio", command: "docs-mcp" });
assert.ok(JSON.parse(s.written["managed-mcp.json"]).mcpServers.docs);
// The gap events left: a node assigned after the registration takes the whole current set at start.
const late = node("desktop");
b.join(late);
assert.deepEqual(registered(late.p).docs, { type: "stdio", command: "docs-mcp" });
// Unregistering is a delete, and every node's view drops it.
await registerServer(a.p, { name: "docs", nodes: "all" }, b.state, va, writer(a.written), async () => []);
assert.equal(registered(s.p).docs, undefined);
assert.equal(registered(late.p).docs, undefined);
});
test("a node's own registration overrides the one for every node; other nodes' keys leave this one alone", async () => {
const a = node("laptop"), s = node("server");
const b = bus();
const va = b.join(a);
const vs = b.join(s);
await registerServer(a.p, { name: "x", entry: { type: "http", url: "https://all" }, nodes: "all" }, b.state, va, writer(a.written), async () => []);
await registerServer(a.p, { name: "x", entry: { type: "http", url: "https://laptop" } }, b.state, va, writer(a.written), async () => []);
assert.equal(registered(a.p).x.url, "https://laptop");
assert.equal(registered(s.p).x.url, "https://all");
await registerServer(a.p, { name: "only", entry: { type: "http", url: "https://o" }, nodes: ["server"] }, b.state, va, writer(a.written), async () => []);
assert.equal(registered(a.p).only, undefined);
assert.equal(registered(s.p).only.url, "https://o");
// Unregistering here leaves the every-node one applying, and says so.
const r = await registerServer(a.p, { name: "x" }, b.state, va, writer(a.written), async () => []);
assert.match(String(r.still), /still applies here/);
assert.equal(registered(a.p).x.url, "https://all");
assert.equal(vs.effective().x.url, "https://all");
});
test("a bad entry is refused before anything is put; a repeated change changes nothing", async () => {
const n = node();
const b = bus();
const view = b.join(n);
const r = await registerServer(n.p, { name: "mesh", entry: { type: "http", url: "https://x" } }, b.state, view, writer(n.written), async () => []);
assert.equal(r.registered, false);
assert.equal(b.kept.size, 0);
assert.equal(onServerChange(view, { key: "all.a", op: "put", value: { type: "http", url: "https://a" } }, n.p, writer(n.written)), "registered all.a");
assert.equal(onServerChange(view, { key: "all.a", op: "put", value: { type: "http", url: "https://a" } }, n.p, writer(n.written)), null);
assert.equal(onServerChange(view, { key: "server.b", op: "put", value: { type: "http", url: "https://b" } }, n.p, writer(n.written)), null);
});
+40
View File
@@ -0,0 +1,40 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { render } from "../dist/render.js";
const facts = { node: "workstation", console: "http://127.0.0.1:4270/mcp" };
test("the console is the `mesh` server, and an operator's servers are listed beside it", () => {
const out = render(facts, { mcp_servers: { search: { type: "http", url: "https://s.example/mcp" } } }, null, "/h");
const mcp = JSON.parse(out["managed-mcp.json"]);
assert.deepEqual(Object.keys(mcp.mcpServers), ["mesh", "search"]);
assert.deepEqual(mcp.mcpServers.mesh, { type: "http", url: facts.console });
});
test("a setting cannot replace the mesh's own entry, and a name the vendor refuses is left out", () => {
const out = render(facts, { mcp_servers: { mesh: { type: "http", url: "http://evil" }, "bad name": {} } }, null, "/h");
const mcp = JSON.parse(out["managed-mcp.json"]);
assert.equal(mcp.mcpServers.mesh.url, facts.console);
assert.ok(!("bad name" in mcp.mcpServers));
});
test("managed settings carry the mesh's keys only, and the key-helper only for an API-key licence", () => {
const sub = JSON.parse(render(facts, {}, { licence: "personal", kind: "subscription" }, "/h")["managed-settings.json"]);
assert.deepEqual(sub, { attribution: { commit: "", pr: "" }, allowAllClaudeAiMcps: true });
const key = JSON.parse(render(facts, {}, { licence: "api", kind: "api-key" }, "/state/api-key-helper")["managed-settings.json"]);
assert.equal(key.apiKeyHelper, "/state/api-key-helper");
assert.ok(!("model" in key), "a preference is the person's");
});
test("the instruction file names the node and its role, and no other node", () => {
const md = render(facts, { role: "the laptop" }, null, "/h")["CLAUDE.md"];
assert.match(md, /\*\*Node:\*\* `workstation`/);
assert.match(md, /\*\*Role:\*\* the laptop/);
assert.match(md, /mesh_call/);
assert.match(md, /records_search/);
});
test("rendering is deterministic, so an unchanged input writes nothing", () => {
const s = { mcp_servers: { b: { type: "http", url: "https://b" }, a: { type: "http", url: "https://a" } } };
assert.deepEqual(render(facts, s, null, "/h"), render(facts, s, null, "/h"));
});
+31
View File
@@ -0,0 +1,31 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { generateKeyPair, open, seal } from "../dist/seal.js";
test("a box opens with its recipient's key and yields the value", () => {
const k = generateKeyPair();
assert.equal(open(seal("at-secret", k.publicKey), k.privateKey), "at-secret");
});
test("a box sealed for one node does not open with another node's key", () => {
const a = generateKeyPair();
const b = generateKeyPair();
assert.throws(() => open(seal("at-secret", a.publicKey), b.privateKey));
});
test("a tampered box is refused, not opened to garbage", () => {
const k = generateKeyPair();
const box = seal("at-secret", k.publicKey);
const ct = Buffer.from(box.ct, "base64");
ct[0] ^= 0xff;
assert.throws(() => open({ ...box, ct: ct.toString("base64") }, k.privateKey));
});
test("two boxes of one value share nothing a reader could compare", () => {
const k = generateKeyPair();
const x = seal("at-secret", k.publicKey);
const y = seal("at-secret", k.publicKey);
assert.notEqual(x.ct, y.ct);
assert.notEqual(x.eph, y.eph);
assert.ok(!JSON.stringify(x).includes("at-secret"));
});
+224
View File
@@ -0,0 +1,224 @@
// claude-code's bundle (novox/hq design 36, ADR 0183). The node's runtime launches it over stdio, as the
// operator account (ADR 0193), and is its bus (ADR 0198): it asks tools, emits and consumes through the
// runtime. It is given its state directory and two files the mesh renders into it (ADR 0192), beside the
// runtime's own words. **stdout is the MCP channel**: everything this module says, it says on stderr.
//
// At start it renders the agent's managed directory, asks the licence manager for this node's token,
// begins watching the credentials file for a login, takes the manager's licence events, and watches the
// module's `servers` state — every node's MCP server registrations (novox/hq ADR 0201). node.ts holds the
// logic.
import { mkdtempSync, readFileSync, rmSync, watchFile, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { spawnSync } from "node:child_process";
import { join } from "node:path";
import { registerModuleTools, type ToolDefinition } from "@novox/mesh-sdk/tools";
import { broker } from "@novox/mesh-sdk/messaging";
import { on } from "@novox/mesh-sdk/events";
import { state } from "@novox/mesh-sdk/state";
import {
MANAGED_DIR, SEAT, ServerView, concerns, keypair, offerLogin, onServerChange, pull, readJson, registerServer,
registered, renderNow, type Ask, type Paths, type Registration, type ServerChange, type ServerState, type WriteManaged,
} from "../node.js";
import { grantOf, holdsLogin, readCredentials } from "../grant.js";
import { createHash } from "node:crypto";
const say = (line: string) => console.error(`[claude-code] ${line}`);
const fingerprint = (s: string) => "sha256:" + createHash("sha256").update(s).digest("hex").slice(0, 16);
function pathsFrom(env: NodeJS.ProcessEnv): Paths | null {
const state = env.MESH_CLAUDE_CODE_STATE, facts = env.MESH_CLAUDE_CODE_FACTS;
const settings = env.MESH_CLAUDE_CODE_SETTINGS, home = env.MESH_OPERATOR_HOME, node = env.MESH_NODE;
if (!state || !facts || !settings || !home || !node) return null;
return { state, facts, settings, home, node };
}
/** Write one managed file as root, only when its content changed. */
const writeManaged: WriteManaged = (name, content) => {
const path = join(MANAGED_DIR, name);
try {
if (readFileSync(path, "utf8") === content) return `${name}: unchanged`;
} catch {
/* absent */
}
// From a file, never /dev/stdin: Node hands a child its input over a socket, which /dev/stdin cannot
// open (ENXIO) — found on the first assignment, where nothing under /etc/claude-code was ever written.
const staged = mkdtempSync(join(tmpdir(), "claude-code-"));
const source = join(staged, name);
writeFileSync(source, content, { mode: 0o644 });
const asRoot = process.getuid?.() === 0;
const cmd = asRoot ? ["install", "-D", "-m", "0644", source, path] : ["sudo", "-n", "install", "-D", "-m", "0644", source, path];
const r = spawnSync(cmd[0], cmd.slice(1), { encoding: "utf8" });
rmSync(staged, { recursive: true, force: true });
if (r.status !== 0) {
throw new Error(`${name}: could not be written to ${MANAGED_DIR} (${(r.stderr || r.error?.message || "").trim()}); ` +
`the module writes there through the operator account's passwordless sudo`);
}
return `${name}: written`;
};
/** A tool on the bus, through the runtime; its MCP answer read back as JSON where it is JSON. */
const ask: Ask = async (address, args) => {
const answer = (await broker().request<Record<string, unknown>, { content?: { text?: string }[]; isError?: boolean }>(address, args)) ?? {};
const text = answer.content?.map((c) => c.text ?? "").join("") ?? "";
if (answer.isError) throw new Error(`${address}: ${text}`);
try {
return JSON.parse(text);
} catch {
return text;
}
};
/** The nodes claude-code runs on, from the controller's list of modules — for the register tool's question. */
async function nodesRunningMe(): Promise<string[]> {
const out = await ask("mesh-controller.modules", {});
const text = typeof out === "string" ? out : String((out as { output?: string })?.output ?? "");
const line = text.split("\n").find((l) => /^claude-code\s/.test(l)) ?? "";
const on = line.split(" on ")[1] ?? "";
return on.trim() === "nothing" ? [] : on.split(",").map((s) => s.trim()).filter(Boolean);
}
function status(p: Paths): Record<string, unknown> {
const creds = readCredentials(join(p.home, ".claude", ".credentials.json"));
const grant = grantOf(creds);
const managed = ["managed-mcp.json", "managed-settings.json", "CLAUDE.md"].map((f) => {
try {
return { file: join(MANAGED_DIR, f), fingerprint: fingerprint(readFileSync(join(MANAGED_DIR, f), "utf8")) };
} catch {
return { file: join(MANAGED_DIR, f), fingerprint: null };
}
});
return {
node: p.node,
licence: readJson(join(p.state, "licence.json"), null),
token: grant ? { fingerprint: fingerprint(grant.accessToken), expiresAt: new Date(grant.expiresAt).toISOString(),
loginWaiting: holdsLogin(creds) } : null,
managed,
registered: Object.keys(registered(p)),
};
}
/** The module's MCP servers on the bus (ADR 0201): its own state, which every node of it watches. */
const servers = () => state<Record<string, unknown>>("servers") as unknown as ServerState;
/** What this node takes from that state, kept from the watch. One per process. */
let view: ServerView | null = null;
const viewOf = (p: Paths) => (view ??= new ServerView(p));
function tools(p: Paths): ToolDefinition[] {
const nodesArg = { type: "string", description: 'more nodes: "all" for every node running claude-code, or a comma-separated list; absent is this node only' };
const nodesOf = (v: unknown): Registration["nodes"] =>
v === undefined || v === "" ? undefined : v === "all" ? "all" : String(v).split(",").map((s) => s.trim()).filter(Boolean);
return [
{
name: "claude_code_status",
description: "Claude Code on this machine as the mesh configured it: the licence it holds and when its token expires, the managed files, the MCP servers registered here. Fingerprints only, never a token.",
input: {},
run: async () => status(p),
},
{
name: "claude_code_render",
description: "Write Claude Code's managed directory now, from the mesh's facts, this module's settings and the servers registered here.",
input: {},
run: async () => ({ rendered: renderNow(p, writeManaged) }),
},
{
name: "claude_code_pull",
description: "Ask the licence manager for this node's current token now and apply it, rather than waiting for its next event.",
input: {},
run: async () => pull(p, ask, writeManaged),
},
{
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.",
input: {},
run: async () => ({ here: registered(p), everywhere: await servers().keys() }),
},
{
name: "claude_code_mcp_register",
description: "Register an MCP server with Claude Code on this node, every node, or a list — an http/sse server by url, or a stdio server by command. Kept on the bus, so a node that joins later takes it too. Never put a secret in env or headers: the mesh refuses one.",
input: {
name: { type: "string", description: "the server's name: letters, digits, - and _" },
type: { type: "string", description: "http, sse or stdio (default stdio when a command is given, http when a url is)" },
url: { type: "string", description: "an http or sse server's url" },
command: { type: "string", description: "a stdio server's program" },
args: { type: "array", description: "a stdio server's arguments" },
env: { type: "object", description: "a stdio server's environment" },
headers: { type: "object", description: "an http server's headers" },
nodes: nodesArg,
},
run: async (a) => {
const entry: Record<string, unknown> = { type: a.type ?? (a.url ? "http" : "stdio") };
for (const k of ["url", "command", "args", "env", "headers"]) if (a[k] !== undefined) entry[k] = a[k];
return registerServer(p, { name: String(a.name ?? ""), entry, nodes: nodesOf(a.nodes) }, servers(), viewOf(p), writeManaged, nodesRunningMe);
},
},
{
name: "claude_code_mcp_unregister",
description: "Remove an MCP server registered through this module, on this node or more.",
input: { name: { type: "string", description: "the server's name" }, nodes: nodesArg },
run: async (a) => registerServer(p, { name: String(a.name ?? ""), nodes: nodesOf(a.nodes) }, servers(), viewOf(p), writeManaged, nodesRunningMe),
},
];
}
registerModuleTools("claude-code", (env) => {
const p = pathsFrom(env);
if (!p) return [];
try {
keypair(p);
for (const line of renderNow(p, writeManaged)) if (!line.endsWith("unchanged")) say(line);
} catch (err) {
say(err instanceof Error ? err.message : String(err));
}
return tools(p);
});
// Launched by the runtime: the bus is there from the first line (ADR 0198). Outside it — a test, a
// build — nothing below runs.
const p = process.env.MESH_SERVED_MODULE ? pathsFrom(process.env) : null;
if (p) {
const loud = (what: string) => (err: unknown) => say(`${what}: ${err instanceof Error ? err.message : String(err)}`);
void on<{ licence?: string; node?: string }>("claude-licence-manager.licence.*", async (event) => {
if (!concerns(p, event.type, event.body ?? {})) return;
say(`${event.type} — asking ${SEAT} for this node's token`);
say(JSON.stringify(await pull(p, ask, writeManaged).catch((e) => ({ failed: String(e) }))));
}).catch(loud("the licence events"));
// Every node's MCP servers: the whole current set first, then each change (ADR 0201). **Not awaited
// where the module is imported**: the runtime waits on the handshake, and a bucket that is not on the
// bus yet — or a grant the bus has not reloaded — answers late; awaited here, that left the bundle
// unable to answer `initialize` in time and the module unserved (found on its first assignment). So it
// watches beside the handshake and asks again until the state answers; until then the managed
// directory holds what the file kept from the last run.
const watchServers = (attempt = 0): void => {
state<Record<string, unknown>>("servers").watch((c) => {
try {
const done = onServerChange(viewOf(p), c as ServerChange, p, writeManaged);
if (done) say(done);
} catch (err) {
loud(`taking ${c.op} ${c.key}`)(err); // the view took it; the next render writes it
}
}).then(
() => say(`watching the MCP servers${attempt ? ` (after ${attempt} refusal(s))` : ""}`),
(err) => {
const wait = [2, 5, 10, 30][attempt] ?? 60;
say(`the MCP servers cannot be watched yet (${err instanceof Error ? err.message : String(err)}); asking again in ${wait}s`);
setTimeout(() => watchServers(attempt + 1), wait * 1000);
});
};
watchServers();
// Catch up once at start: a node that was off takes its current token now.
void pull(p, ask, writeManaged).then((r) => say(`at start: ${JSON.stringify(r)}`), loud("asking for this node's token at start"));
// A login: a refresh token appears in the credentials file. Polled, because the file is replaced by
// rename and a watch on the old inode would go quiet.
const credentials = join(p.home, ".claude", ".credentials.json");
watchFile(credentials, { interval: 5000 }, () => {
void offerLogin(p, ask).then((r) => { if (r) say(`a login here was offered to ${SEAT}: ${JSON.stringify(r)}`); },
loud("offering a login to the licence manager"));
});
}
+12
View File
@@ -0,0 +1,12 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"noEmit": true
},
"include": ["seal.ts", "grant.ts", "identity.ts", "render.ts", "node.ts", "tools/index.ts"]
}
+69
View File
@@ -0,0 +1,69 @@
# 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.
@@ -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,175 @@
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"`
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)
}
}
}
@@ -0,0 +1,64 @@
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
if strings.Count(code, "\nclipmenud &\n") != 1 || 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.sameAsSource(t, "i3-bindings", "files/i3/50-clipmenu.conf")
if c := m.resource(t, "i3-bindings")["content"].(string); !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=
+75
View File
@@ -0,0 +1,75 @@
{
"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.\nclipmenud &\n"
}
],
"resources": [
{
"id": "package",
"type": "package",
"package": "clipmenu"
},
{
"id": "greenclip",
"type": "package",
"package": "rofi-greenclip",
"absent": true
},
{
"id": "i3-bindings",
"type": "file",
"path": "${machine:account-home}/.config/i3/config.d/50-clipmenu.conf",
"owner": "${machine:account}",
"mode": "0644",
"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"
}
],
"build": {
"artifacts": [
{
"name": "tools",
"kind": "bundle",
"language": "go",
"system": "arch",
"from": "cmd/clipmenu-tools",
"binary": "clipmenu-tools",
"loads": [
"clipmenu-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() },
},
}
}
@@ -0,0 +1,175 @@
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"`
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)
}
}
}
@@ -0,0 +1,77 @@
package main
import (
"reflect"
"strings"
"testing"
)
// dunst's shape (novox/hq ADR 0208): it claims node-notifier serving send and history, owns its
// dunstrc and the drop-in directory other modules' rules go in, and starts nothing — D-Bus starts it
// on the first notification. It draws only once a notification arrives, through the bus's
// activation, so it requires no display of its own.
func TestItClaimsTheNotifierSeatServingSendAndHistory(t *testing.T) {
m := readManifest(t)
if m.Module != "dunst" || m.Seats != nil {
t.Fatalf("module %q declares seats %v", m.Module, m.Seats)
}
if !reflect.DeepEqual(m.Claims, []claim{{Name: "node-notifier", Scope: "node", Serves: []string{"send", "history"}}}) {
t.Fatalf("claims: %+v", m.Claims)
}
if present, absent := m.packages(); !reflect.DeepEqual(present, []string{"dunst", "libnotify"}) || absent != nil {
t.Fatalf("packages: %v, absent %v", present, absent)
}
}
func TestItOwnsItsFileAndTheDropInDirectory(t *testing.T) {
m := readManifest(t)
m.sameAsSource(t, "configuration", "files/dunstrc")
if p := m.resource(t, "configuration")["path"]; p != "${machine:account-home}/.config/dunst/dunstrc" {
t.Fatalf("path: %v", p)
}
if d := m.resource(t, "dropins"); d["type"] != "directory" || d["path"] != "${machine:account-home}/.config/dunst/dunstrc.d" {
t.Fatalf("drop-ins: %v", d)
}
for _, r := range m.Resources {
if p, _ := r["path"].(string); strings.Contains(p, "dunstrc.d/") {
t.Fatalf("a rule of another module's: %v", r)
}
}
}
func TestTheFileIsTheDecidedOneAndCallsTheSeatsMenu(t *testing.T) {
m := readManifest(t)
c := m.resource(t, "configuration")["content"].(string)
for _, want := range []string{"origin = top-right", "transparency = 0", "corner_radius = 0", "font = Inter 10", "dmenu = dmenu -p dunst"} {
if !strings.Contains(c, " "+want+"\n") {
t.Errorf("lacks %q", want)
}
}
for _, never := range []string{"/usr/bin/dmenu", "Hack", "icon_path", "/home/"} {
if strings.Contains(c, never) {
t.Errorf("names %q", never)
}
}
}
func TestNothingStartsItButTheBus(t *testing.T) {
m := readManifest(t)
if m.Shell != nil {
t.Fatalf("a session start: %+v", m.Shell)
}
for _, r := range m.Resources {
if r["type"] == "service" || r["type"] == "process" {
t.Fatalf("a unit: %v", r)
}
if p, _ := r["path"].(string); strings.Contains(p, "autostart") || strings.Contains(p, "i3/config.d") {
t.Fatalf("a start: %v", r)
}
}
}
func TestTheToolsAgreeWithTheManifest(t *testing.T) {
m := readManifest(t)
checkTheToolsAgree(t, m)
checkNoSecretsOrInstallationNames(t)
}
+423
View File
@@ -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
}
+92
View File
@@ -0,0 +1,92 @@
# dunst, the notifier (module dunst, novox/hq ADR 0208). Owned by the mesh: this file is replaced
# at every push. Adopted from the laptop's file of 2026-10-04 (the two workstations differed in
# position, transparency and corner radius; the laptop's square, opaque, top-right one matches the
# rest of the desktop). Only what differs from dunst's defaults, and what the desktop relies on.
#
# Other modules' rules go in ~/.config/dunst/dunstrc.d/*.conf, which dunst reads after this file,
# so a drop-in outranks it. dunst is started by D-Bus on the first notification: nothing starts it.
[global]
monitor = 0
follow = none
# Geometry
width = 250
height = (0, 300)
origin = top-right
offset = (10, 50)
notification_limit = 20
progress_bar = true
progress_bar_height = 10
progress_bar_frame_width = 1
progress_bar_min_width = 150
progress_bar_max_width = 300
indicate_hidden = yes
transparency = 0
separator_height = 2
padding = 8
horizontal_padding = 8
text_icon_padding = 0
frame_width = 3
frame_color = "#de5200"
gap_size = 0
separator_color = frame
sort = yes
corner_radius = 0
# Text: the interface face (research 026/04)
font = Inter 10
line_height = 0
markup = full
format = "<b>%s</b>\n%b"
alignment = left
vertical_alignment = center
show_age_threshold = 60
ellipsize = middle
ignore_newline = no
stack_duplicates = true
hide_duplicate_count = false
show_indicators = yes
# Icons, from the desktop's icon theme
enable_recursive_icon_lookup = true
icon_theme = Adwaita
icon_position = left
min_icon_size = 32
max_icon_size = 128
# History
sticky_history = yes
history_length = 20
# The context menu is the node's dmenu-compatible command, which the holder of node-launcher
# answers (rofi on the workstations). Links open in the desktop's default browser.
dmenu = dmenu -p dunst
browser = /usr/bin/xdg-open
always_run_script = true
title = Dunst
class = Dunst
ignore_dbusclose = false
mouse_left_click = close_current
mouse_middle_click = do_action, close_current
mouse_right_click = close_all
[urgency_low]
background = "#000000"
foreground = "#ffffff"
timeout = 10
[urgency_normal]
background = "#000000"
foreground = "#ffffff"
timeout = 10
[urgency_critical]
background = "#000000"
foreground = "#ffffff"
frame_color = "#ff0000"
timeout = 0
+5
View File
@@ -0,0 +1,5 @@
module dunst
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=
+73
View File
@@ -0,0 +1,73 @@
{
"module": "dunst",
"version": "1",
"capabilities": [
"package-manager"
],
"claims": [
{
"name": "node-notifier",
"scope": "node",
"serves": [
"send",
"history"
]
}
],
"tools": [
"dunst_pause",
"dunst_resume",
"dunst_close_all",
"dunst_rules",
"dunst_count"
],
"resources": [
{
"id": "package",
"type": "package",
"package": "dunst"
},
{
"id": "client",
"type": "package",
"package": "libnotify"
},
{
"id": "configuration-dir",
"type": "directory",
"path": "${machine:account-home}/.config/dunst",
"owner": "${machine:account}",
"mode": "0755"
},
{
"id": "dropins",
"type": "directory",
"path": "${machine:account-home}/.config/dunst/dunstrc.d",
"owner": "${machine:account}",
"mode": "0755"
},
{
"id": "configuration",
"type": "file",
"path": "${machine:account-home}/.config/dunst/dunstrc",
"owner": "${machine:account}",
"mode": "0644",
"content": "# dunst, the notifier (module dunst, novox/hq ADR 0208). Owned by the mesh: this file is replaced\n# at every push. Adopted from the laptop's file of 2026-10-04 (the two workstations differed in\n# position, transparency and corner radius; the laptop's square, opaque, top-right one matches the\n# rest of the desktop). Only what differs from dunst's defaults, and what the desktop relies on.\n#\n# Other modules' rules go in ~/.config/dunst/dunstrc.d/*.conf, which dunst reads after this file,\n# so a drop-in outranks it. dunst is started by D-Bus on the first notification: nothing starts it.\n\n[global]\n monitor = 0\n follow = none\n\n # Geometry\n width = 250\n height = (0, 300)\n origin = top-right\n offset = (10, 50)\n notification_limit = 20\n\n progress_bar = true\n progress_bar_height = 10\n progress_bar_frame_width = 1\n progress_bar_min_width = 150\n progress_bar_max_width = 300\n\n indicate_hidden = yes\n transparency = 0\n separator_height = 2\n padding = 8\n horizontal_padding = 8\n text_icon_padding = 0\n frame_width = 3\n frame_color = \"#de5200\"\n gap_size = 0\n separator_color = frame\n sort = yes\n corner_radius = 0\n\n # Text: the interface face (research 026/04)\n font = Inter 10\n line_height = 0\n markup = full\n format = \"<b>%s</b>\\n%b\"\n alignment = left\n vertical_alignment = center\n show_age_threshold = 60\n ellipsize = middle\n ignore_newline = no\n stack_duplicates = true\n hide_duplicate_count = false\n show_indicators = yes\n\n # Icons, from the desktop's icon theme\n enable_recursive_icon_lookup = true\n icon_theme = Adwaita\n icon_position = left\n min_icon_size = 32\n max_icon_size = 128\n\n # History\n sticky_history = yes\n history_length = 20\n\n # The context menu is the node's dmenu-compatible command, which the holder of node-launcher\n # answers (rofi on the workstations). Links open in the desktop's default browser.\n dmenu = dmenu -p dunst\n browser = /usr/bin/xdg-open\n always_run_script = true\n\n title = Dunst\n class = Dunst\n ignore_dbusclose = false\n\n mouse_left_click = close_current\n mouse_middle_click = do_action, close_current\n mouse_right_click = close_all\n\n[urgency_low]\n background = \"#000000\"\n foreground = \"#ffffff\"\n timeout = 10\n\n[urgency_normal]\n background = \"#000000\"\n foreground = \"#ffffff\"\n timeout = 10\n\n[urgency_critical]\n background = \"#000000\"\n foreground = \"#ffffff\"\n frame_color = \"#ff0000\"\n timeout = 0\n"
}
],
"build": {
"artifacts": [
{
"name": "tools",
"kind": "bundle",
"language": "go",
"system": "arch",
"from": "cmd/dunst-tools",
"binary": "dunst-tools",
"loads": [
"dunst-tools"
]
}
]
}
}
+47
View File
@@ -0,0 +1,47 @@
# feh
The wallpaper as a module (novox/hq ADR 0208, research 026/05).
- Installs `feh` and requires `x11-display` on its own machine. It holds no seat: a wallpaper is not a
role anything else calls.
- **Carries the wallpaper itself.** `wallpaper/default.jpg` is built into an archive of the module
(ADR 0205) and unpacked into `~/.local/share/feh/wallpapers/`, which the module owns.
- Owns `~/.fehbg`, which sets that image, filled, on every monitor, without rewriting itself
(`--no-fehbg`).
- Runs `~/.fehbg` once per session, from the session's start (the `xinitrc` slot `normal`).
- Binds `$mod+Shift+b` to the same file, as its own i3 drop-in (`50-feh.conf`): the declared wallpaper
back, after a monitor change.
## Tools
| tool | does |
|---|---|
| `feh_set` | set images (one for all monitors, or one each) in a mode: fill, center, max, scale, tile. For this session; the declared wallpaper returns at the next login |
| `feh_current` | the declared wallpaper (from `~/.fehbg`) and the one `feh_set` put up in this session |
`feh_set` never writes `~/.fehbg`. A wallpaper that should stay is a change to this module, or a
setting once issue 168 closes, not a file the next push would overwrite.
## What it improves on what was found
- **The wallpaper no longer lives in the predecessor's tree.** `~/.fehbg` pointed into a directory
of the retired predecessor's. Deleting that directory would have left the desktop black, silently.
- **One image file, the same on both workstations.** The image was byte-identical on both. It is now
the module's own.
## What it leaves as found
- The predecessor's wallpaper directory. It is part of the predecessor's tree, which goes as a whole.
## Migration (ADR 0182)
- The first push keeps the found `~/.fehbg` once, then writes the module's.
- Once the `xorg` module writes the session's start, delete the `~/.fehbg &` line from your own part
of `~/.xinitrc`.
- The image's origin is the predecessor's desktop module. Check that it may be redistributed before
this catalogue is published anywhere public.
## Blockers
- `x11-display` and the `xinitrc` slot are ADR 0208's. Until the controller knows them, `mctl` reads
them as unknown.
+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
}
+53
View File
@@ -0,0 +1,53 @@
// feh's Go tools bundle (novox/hq ADR 0188, ADR 0193, ADR 0208): the wallpaper's tools, served by the
// node's runtime as the operator account.
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: "feh_set",
Description: "Set the wallpaper in the operator's session: one image for every monitor, or one per " +
"monitor in the X screen order, filled, centred, scaled to fit, stretched or tiled. Lasts until the " +
"next session start, when the declared wallpaper returns; the declared one is untouched.",
Input: map[string]any{
"type": "object",
"properties": map[string]any{
"images": map[string]any{"type": "array", "items": map[string]any{"type": "string"}, "description": "image files on this machine, absolute or under the account's home; one per monitor, or one for all"},
"mode": map[string]any{"type": "string", "enum": modes, "description": "default fill"},
},
"required": []string{"images"},
},
Run: func(args map[string]any) (any, error) {
images, err := texts(args, "images")
if err != nil {
return nil, err
}
mode, err := text(args, "mode", false)
if err != nil {
return nil, err
}
return Set(images, mode)
},
},
{
Name: "feh_current",
Description: "The wallpaper: the declared one the session start sets (images and mode, read from " +
"~/.fehbg), and the one feh_set put up in this session, if any.",
Run: func(map[string]any) (any, error) { return Current() },
},
}
}
@@ -0,0 +1,175 @@
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"`
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)
}
}
}
@@ -0,0 +1,79 @@
package main
import (
"os"
"path/filepath"
"reflect"
"strings"
"testing"
)
// feh's shape (novox/hq ADR 0208): no seat; it requires the X display on its own machine, carries
// the wallpaper as its own archive (ADR 0205), owns ~/.fehbg pointing at it, and sets it once from
// the session's start.
func TestItRequiresTheXDisplayAndClaimsNothing(t *testing.T) {
m := readManifest(t)
if m.Module != "feh" || m.Seats != nil || m.Claims != nil {
t.Fatalf("module %q, seats %v, claims %v", m.Module, m.Seats, m.Claims)
}
if !reflect.DeepEqual(m.Requires, []string{"x11-display"}) {
t.Fatalf("requires: %v", m.Requires)
}
if present, absent := m.packages(); !reflect.DeepEqual(present, []string{"feh"}) || absent != nil {
t.Fatalf("packages: %v, absent %v", present, absent)
}
}
func TestTheWallpaperIsTheModulesOwnArchive(t *testing.T) {
m := readManifest(t)
a := m.resource(t, "wallpapers")
if a["type"] != "archive" || a["artifact"] != "wallpapers" || a["path"] != "${machine:account-home}/.local/share/feh/wallpapers" || a["owner"] != "${machine:account}" {
t.Fatalf("%v", a)
}
found := false
for _, art := range m.Build.Artifacts {
if art["name"] == "wallpapers" && art["kind"] == "archive" && art["from"] == "wallpaper" {
found = true
}
}
if !found {
t.Fatal("no archive artifact built from wallpaper/")
}
info, err := os.Stat(filepath.Join("..", "..", "wallpaper", "default.jpg"))
if err != nil || info.Size() == 0 {
t.Fatalf("the image: %v", err)
}
}
func TestFehbgIsOwnedAndTheSessionStartRunsItOnce(t *testing.T) {
m := readManifest(t)
m.sameAsSource(t, "fehbg", "files/fehbg")
f := m.resource(t, "fehbg")
if f["path"] != "${machine:account-home}/.fehbg" || f["mode"] != "0755" {
t.Fatalf("%v", f)
}
if c := f["content"].(string); !strings.Contains(c, "$HOME/.local/share/feh/wallpapers/default.jpg") || strings.Contains(c, ".hal") {
t.Fatalf("%s", c)
}
if len(m.Shell) != 1 || m.Shell[0].For != "xinitrc" || m.Shell[0].Slot != "normal" || strings.Count(m.Shell[0].Code, `"$HOME/.fehbg"`) != 1 {
t.Fatalf("%+v", m.Shell)
}
}
func TestTheKeyThatRestoresTheWallpaperIsAnI3DropIn(t *testing.T) {
m := readManifest(t)
m.sameAsSource(t, "i3-bindings", "files/i3/50-feh.conf")
if p := m.resource(t, "i3-bindings")["path"]; p != "${machine:account-home}/.config/i3/config.d/50-feh.conf" {
t.Fatalf("path: %v", p)
}
if c := m.resource(t, "i3-bindings")["content"].(string); !strings.Contains(c, "bindsym $mod+Shift+b exec --no-startup-id ~/.fehbg\n") {
t.Fatalf("%s", c)
}
}
func TestTheToolsAgreeWithTheManifest(t *testing.T) {
m := readManifest(t)
checkTheToolsAgree(t, m)
checkNoSecretsOrInstallationNames(t)
}
+423
View File
@@ -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
}
+174
View File
@@ -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
}
+174
View File
@@ -0,0 +1,174 @@
package main
import (
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"regexp"
"strings"
"time"
)
// modes are feh's background modes, by the word feh_set takes.
var modes = []string{"fill", "center", "max", "scale", "tile"}
// Wallpaper is images and how they are laid on the screens.
type Wallpaper struct {
Images []string `json:"images"`
Mode string `json:"mode"`
At string `json:"at,omitempty"`
}
func fehbg() string { return filepath.Join(operatorHome(), ".fehbg") }
// sessionRecord is where feh_set notes what it put up, for feh_current: in the runtime directory,
// so it lasts exactly as long as the login, like the wallpaper itself.
func sessionRecord(s Session) string {
if s.RuntimeDir == "" {
return ""
}
return filepath.Join(s.RuntimeDir, "feh", "current.json")
}
// SetResult is what feh_set answers.
type SetResult struct {
Wallpaper
Note string `json:"note"`
}
// Set puts images up as the wallpaper for this session.
func Set(images []string, mode string) (SetResult, error) {
if mode == "" {
mode = "fill"
}
known := false
for _, m := range modes {
known = known || m == mode
}
if !known {
return SetResult{}, fmt.Errorf("mode %q is one of %s", mode, strings.Join(modes, ", "))
}
if len(images) == 0 {
return SetResult{}, errors.New("images is required: at least one image")
}
var paths []string
for _, img := range images {
p := img
if strings.HasPrefix(p, "~/") {
p = filepath.Join(operatorHome(), p[2:])
}
if !filepath.IsAbs(p) {
p = filepath.Join(operatorHome(), p)
}
info, err := os.Stat(p)
if err != nil {
return SetResult{}, fmt.Errorf("image %s: %w", img, err)
}
if info.IsDir() {
return SetResult{}, fmt.Errorf("image %s is a directory", img)
}
paths = append(paths, p)
}
s, err := findSession()
if err != nil {
return SetResult{}, err
}
args := append([]string{"--no-fehbg", "--bg-" + mode}, paths...)
r, err := s.run(15*time.Second, "", "feh", args...)
if err != nil {
return SetResult{}, err
}
if r.Code != 0 {
return SetResult{}, fmt.Errorf("feh: %s", strings.TrimSpace(r.Stderr))
}
w := Wallpaper{Images: paths, Mode: mode, At: time.Now().Format(time.RFC3339)}
if rec := sessionRecord(s); rec != "" {
if err := os.MkdirAll(filepath.Dir(rec), 0o700); err == nil {
raw, _ := json.Marshal(w)
_ = os.WriteFile(rec, raw, 0o600)
}
}
return SetResult{Wallpaper: w, Note: "for this session; the declared wallpaper returns at the next login"}, nil
}
// CurrentResult is what feh_current answers.
type CurrentResult struct {
Declared *Wallpaper `json:"declared"`
Session *Wallpaper `json:"session,omitempty"`
}
var bgMode = regexp.MustCompile(`--bg-(fill|center|max|scale|tile)\b`)
// Current is the declared wallpaper and the one set in this session.
func Current() (CurrentResult, error) {
var out CurrentResult
if raw, err := os.ReadFile(fehbg()); err == nil {
out.Declared = parseFehbg(string(raw), operatorHome())
}
if rec := sessionRecord(findEnvironment()); rec != "" {
if raw, err := os.ReadFile(rec); err == nil {
var w Wallpaper
if json.Unmarshal(raw, &w) == nil {
out.Session = &w
}
}
}
return out, nil
}
// parseFehbg reads the feh line of a ~/.fehbg: its mode and its images, with $HOME expanded.
func parseFehbg(script, home string) *Wallpaper {
for _, line := range strings.Split(script, "\n") {
line = strings.TrimSpace(line)
if !strings.HasPrefix(line, "feh ") {
continue
}
w := &Wallpaper{Images: []string{}}
if m := bgMode.FindStringSubmatch(line); m != nil {
w.Mode = m[1]
}
for _, word := range shellWords(line)[1:] {
if strings.HasPrefix(word, "-") {
continue
}
word = strings.ReplaceAll(strings.ReplaceAll(word, "${HOME}", home), "$HOME", home)
w.Images = append(w.Images, word)
}
return w
}
return nil
}
// shellWords splits a simple command line on blanks, honouring single and double quotes.
func shellWords(line string) []string {
var words []string
var cur strings.Builder
var quote byte
in := false
for i := 0; i < len(line); i++ {
c := line[i]
switch {
case quote != 0 && c == quote:
quote = 0
case quote != 0:
cur.WriteByte(c)
case c == '\'' || c == '"':
quote, in = c, true
case c == ' ' || c == '\t':
if in {
words = append(words, cur.String())
cur.Reset()
in = false
}
default:
cur.WriteByte(c)
in = true
}
}
if in {
words = append(words, cur.String())
}
return words
}
@@ -0,0 +1,92 @@
package main
import (
"errors"
"os"
"path/filepath"
"reflect"
"strconv"
"strings"
"testing"
)
const nobody = 4194400
func TestTheDeclaredWallpaperIsReadFromTheModulesFehbg(t *testing.T) {
raw, err := os.ReadFile(filepath.Join("..", "..", "files", "fehbg"))
if err != nil {
t.Fatal(err)
}
w := parseFehbg(string(raw), "/home/op")
if w == nil || w.Mode != "fill" || !reflect.DeepEqual(w.Images, []string{"/home/op/.local/share/feh/wallpapers/default.jpg"}) {
t.Fatalf("%+v", w)
}
// The form feh writes itself, single-quoted, two monitors.
w = parseFehbg("#!/bin/sh\nfeh --no-fehbg --bg-center '/a b/one.png' '/two.png' \n", "/home/op")
if w.Mode != "center" || !reflect.DeepEqual(w.Images, []string{"/a b/one.png", "/two.png"}) {
t.Fatalf("%+v", w)
}
if parseFehbg("#!/bin/sh\n", "/h") != nil {
t.Fatal("a file without feh declares a wallpaper")
}
}
func TestSetPutsTheImagesUpForThisSessionAndCurrentSaysSo(t *testing.T) {
root := fakeMachine(t)
fakeProcess(t, nobody, "i3", "DISPLAY=:1")
if err := os.MkdirAll(filepath.Join(runUserDir, strconv.Itoa(os.Getuid())), 0o700); err != nil {
t.Fatal(err)
}
home := filepath.Join(root, "home")
for _, f := range []string{"Pictures/a.jpg", "Pictures/b.jpg"} {
if err := os.MkdirAll(filepath.Dir(filepath.Join(home, f)), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(home, f), []byte("jpg"), 0o644); err != nil {
t.Fatal(err)
}
}
src, _ := os.ReadFile(filepath.Join("..", "..", "files", "fehbg"))
if err := os.WriteFile(filepath.Join(home, ".fehbg"), src, 0o755); err != nil {
t.Fatal(err)
}
bin := fakeBinaries(t, map[string]string{"feh": `echo "$* DISPLAY=$DISPLAY" > "$LOG"`})
t.Setenv("LOG", filepath.Join(bin, "log"))
got, err := Set([]string{"~/Pictures/a.jpg", "Pictures/b.jpg"}, "scale")
if err != nil || !strings.Contains(got.Note, "next login") {
t.Fatalf("%+v, %v", got, err)
}
asked, _ := os.ReadFile(filepath.Join(bin, "log"))
want := "--no-fehbg --bg-scale " + filepath.Join(home, "Pictures/a.jpg") + " " + filepath.Join(home, "Pictures/b.jpg") + " DISPLAY=:1\n"
if string(asked) != want {
t.Fatalf("feh was asked %q, want %q", asked, want)
}
cur, err := Current()
if err != nil || cur.Declared == nil || cur.Session == nil || cur.Session.Mode != "scale" || len(cur.Session.Images) != 2 ||
cur.Declared.Images[0] != filepath.Join(home, ".local/share/feh/wallpapers/default.jpg") {
t.Fatalf("%+v, %v", cur, err)
}
}
func TestSetRefusesWhatFehCannotShow(t *testing.T) {
fakeMachine(t)
if _, err := Set([]string{"/nowhere.jpg"}, ""); err == nil {
t.Fatal("a missing image was accepted")
}
if _, err := Set([]string{"/"}, ""); err == nil {
t.Fatal("a directory was accepted")
}
if _, err := Set([]string{"/etc/hostname"}, "stretch"); err == nil {
t.Fatal("an unknown mode was accepted")
}
if _, err := Set(nil, ""); err == nil {
t.Fatal("no image was accepted")
}
f := filepath.Join(t.TempDir(), "x.jpg")
if err := os.WriteFile(f, nil, 0o644); err != nil {
t.Fatal(err)
}
if _, err := Set([]string{f}, ""); !errors.Is(err, ErrNoSession) {
t.Fatalf("without a session: %v", err)
}
}
+6
View File
@@ -0,0 +1,6 @@
#!/bin/sh
# The wallpaper (module feh, novox/hq ADR 0208). Owned by the mesh: replaced at every push. The
# session's start runs it, and so may anything that wants the declared wallpaper back. The image is
# the module's own, in ~/.local/share/feh/wallpapers. feh_set changes the wallpaper for a session
# without touching this file.
feh --no-fehbg --bg-fill "$HOME/.local/share/feh/wallpapers/default.jpg"
+3
View File
@@ -0,0 +1,3 @@
# The wallpaper's key (module feh, novox/hq ADR 0208). Owned by the mesh: replaced at every push.
# It puts the declared wallpaper back, after a monitor change or a wallpaper set for the session.
bindsym $mod+Shift+b exec --no-startup-id ~/.fehbg
+5
View File
@@ -0,0 +1,5 @@
module feh
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=
+71
View File
@@ -0,0 +1,71 @@
{
"module": "feh",
"version": "1",
"capabilities": [
"package-manager"
],
"requires": [
"x11-display"
],
"tools": [
"feh_set",
"feh_current"
],
"shell": [
{
"for": "xinitrc",
"slot": "normal",
"code": "# The wallpaper (module feh, novox/hq ADR 0208): the declared one, set once per session.\n\"$HOME/.fehbg\"\n"
}
],
"resources": [
{
"id": "package",
"type": "package",
"package": "feh"
},
{
"id": "wallpapers",
"type": "archive",
"path": "${machine:account-home}/.local/share/feh/wallpapers",
"owner": "${machine:account}",
"artifact": "wallpapers"
},
{
"id": "fehbg",
"type": "file",
"path": "${machine:account-home}/.fehbg",
"owner": "${machine:account}",
"mode": "0755",
"content": "#!/bin/sh\n# The wallpaper (module feh, novox/hq ADR 0208). Owned by the mesh: replaced at every push. The\n# session's start runs it, and so may anything that wants the declared wallpaper back. The image is\n# the module's own, in ~/.local/share/feh/wallpapers. feh_set changes the wallpaper for a session\n# without touching this file.\nfeh --no-fehbg --bg-fill \"$HOME/.local/share/feh/wallpapers/default.jpg\"\n"
},
{
"id": "i3-bindings",
"type": "file",
"path": "${machine:account-home}/.config/i3/config.d/50-feh.conf",
"owner": "${machine:account}",
"mode": "0644",
"content": "# The wallpaper's key (module feh, novox/hq ADR 0208). Owned by the mesh: replaced at every push.\n# It puts the declared wallpaper back, after a monitor change or a wallpaper set for the session.\nbindsym $mod+Shift+b exec --no-startup-id ~/.fehbg\n"
}
],
"build": {
"artifacts": [
{
"name": "wallpapers",
"kind": "archive",
"from": "wallpaper"
},
{
"name": "tools",
"kind": "bundle",
"language": "go",
"system": "arch",
"from": "cmd/feh-tools",
"binary": "feh-tools",
"loads": [
"feh-tools"
]
}
]
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 933 KiB

+101
View File
@@ -0,0 +1,101 @@
# gnome-keyring
The secret service as a module (novox/hq ADR 0208, ADR 0102).
- Installs `gnome-keyring` (it brings `gcr-4`, whose ssh agent this uses), `libsecret` (the client
library and `secret-tool`) and `seahorse` (the keyrings' manager, for the operator).
- Claims the mesh's `node-secret-service` seat (no verbs yet, ADR 0208 §2). It requires no display:
the secret service is a D-Bus service, and a Wayland session uses the same module.
- **Writes the PAM lines into the stacks, never over them** (ADR 0102). Each is a marked block at the
end of the file; every other line stays the distribution's.
- `/etc/pam.d/login`: `auth optional pam_gnome_keyring.so` and
`session optional pam_gnome_keyring.so auto_start`. The login manager's stack includes `login`,
so the password typed at the login screen unlocks the login keyring, and the login session starts
the daemon.
- `/etc/pam.d/passwd`: `password optional pam_gnome_keyring.so`, so changing the account's password
changes the keyring's, and the next login still unlocks it.
- **Starts no daemon.** PAM starts it at login, and D-Bus would if PAM had not.
- Names gcr's ssh agent socket for the session, in the `xinitrc` slot `first`: `SSH_AUTH_SOCK` is
`$XDG_RUNTIME_DIR/gcr/ssh`. The agent itself is `gcr-ssh-agent.socket`, a user unit the package
enables by preset.
## Tools
None reads a secret. They ask the Secret Service for names, counts and lock states, and the agent for
fingerprints.
| tool | does |
|---|---|
| `gnome_keyring_unlocked` | the login and default keyrings, locked or not; whether the daemon runs |
| `gnome_keyring_lock` | lock a keyring now (login by default); unlocking stays the operator's |
| `gnome_keyring_collections` | every keyring: id, label, locked, item count, created, changed, default |
| `gnome_keyring_ssh_keys` | the agent's keys by fingerprint, size, type and comment |
## What it improves on what was found
- **The desktop's login unlocks the keyring.** Its `/etc/pam.d/login` had no keyring lines, so the
keyring stayed locked after every login, and a script prompted for the password to unlock it. Both
workstations now get the same lines.
- **One daemon.** The session's start ran `gnome-keyring-daemon --start` a second time, asking for an
ssh component that gnome-keyring no longer has, and the window manager ran an unlock-prompt script.
Both go.
- **The session has an ssh agent.** The found `export SSH_AUTH_SOCK` exported nothing: the second
daemon printed no socket. The session's processes had no agent, although gcr's was listening.
## The default keyring is not the login keyring
On both workstations, measured on 2026-10-04, the default keyring, where programs store new secrets,
is a second keyring, `Default_keyring`. The login keyring holds one item at most. PAM unlocks only the
login keyring. Another keyring opens with it only if its password is stored in the login keyring
("unlock automatically"). On the desktop the login keyring was locked and the default one unlocked,
which the unlock-prompt script did.
After the first login with this module: `gnome_keyring_unlocked` shows both. If the default keyring
is still locked, choose one, once, in `seahorse`:
- tick its *unlock automatically* when prompted;
- or move its items into the login keyring and make that the default.
Which keyring is the default is the operator's data, never the module's.
## Blockers and a proposal
**`SSH_AUTH_SOCK` belongs in the account's environment, and ADR 0203 cannot say it yet.** The value
is a path under the account's runtime directory (`/run/user/<uid>`). ADR 0203 forbids `$` in a
contributed value, and no `${machine:…}` fact names that directory. So today the variable reaches
only the X session and what it starts, through the `xinitrc` slot. An ssh login, the login shell's
`execute` and the user manager's services do not get it.
**Proposed:** a machine fact `${machine:account-runtime-dir}`, resolved like `${machine:account-home}`
from the account's uid. The variable then becomes an environment contribution:
> `environment.variables.SSH_AUTH_SOCK` = `${machine:account-runtime-dir}/gcr/ssh`
The slot contribution then goes. That is a progressive insight on ADR 0203, or a small record of its
own. It changes the controller's machine facts, not this module's shape.
**User-scoped units (mesh-host #72).** `gcr-ssh-agent.socket` and `gnome-keyring-daemon.socket` are
enabled by the package's presets on both workstations, and nothing in the mesh asserts it. Once user
units ship, this module should declare both enabled.
## What it leaves as found
- The keyrings themselves (`~/.local/share/keyrings/`): the operator's data, never touched.
- `~/.config/i3/unlock-keyring.sh`, the unlock-prompt script.
## Migration (ADR 0182)
1. **On the laptop,** `/etc/pam.d/login` already has the two lines outside any block. After the first
push they are there twice. Delete the two hand-written ones, outside the `# BEGIN mesh` block.
2. Once the `xorg` module writes the session's start, delete from your own part of `~/.xinitrc` the
`eval $(/usr/bin/gnome-keyring-daemon --start …)` line and the `export SSH_AUTH_SOCK` after it.
3. Once the `i3` module carries the main configuration, its `exec … unlock-keyring.sh` line is gone.
Delete `~/.config/i3/unlock-keyring.sh`.
4. **On the desktop,** log in again after the first push. The keyring is unlocked by the login from
then on.
## Blockers
- `node-secret-service` and the `xinitrc` slot are ADR 0208's. Until the controller knows them,
`mctl` reads them as unknown.
- The environment fact above, and user-scoped units (mesh-host #72).
@@ -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,260 @@
package main
import (
"encoding/json"
"fmt"
"path/filepath"
"regexp"
"sort"
"strconv"
"strings"
"time"
)
const (
service = "org.freedesktop.secrets"
servicePath = "/org/freedesktop/secrets"
collectionDir = "/org/freedesktop/secrets/collection/"
busTimeout = 10 * time.Second
)
// busctl runs one busctl call on the account's session bus and answers its JSON.
func busctl(s Session, args ...string) (json.RawMessage, error) {
r, err := s.run(busTimeout, "", "busctl", append([]string{"--user", "--json=short"}, args...)...)
if err != nil {
return nil, err
}
if r.Code != 0 {
return nil, fmt.Errorf("the secret service: %s", strings.TrimSpace(r.Stderr))
}
var v struct {
Data json.RawMessage `json:"data"`
}
if err := json.Unmarshal([]byte(r.Stdout), &v); err != nil {
return nil, fmt.Errorf("busctl answered no JSON: %w", err)
}
return v.Data, nil
}
// collectionID is the part of a collection's object path after .../collection/, unescaped the way
// the Secret Service escapes it ("_5f" is "_").
func collectionID(path string) string { return strings.TrimPrefix(path, collectionDir) }
func collectionPath(id string) string { return collectionDir + id }
var validID = regexp.MustCompile(`^[A-Za-z0-9_]+$`)
// Collection is one keyring.
type Collection struct {
ID string `json:"id"`
Label string `json:"label"`
Locked bool `json:"locked"`
Items int `json:"items"`
Created string `json:"created,omitempty"`
Modified string `json:"modified,omitempty"`
Default bool `json:"default,omitempty"`
}
// CollectionsResult is what gnome_keyring_collections answers.
type CollectionsResult struct {
Collections []Collection `json:"collections"`
}
func paths(s Session) ([]string, error) {
raw, err := busctl(s, "get-property", service, servicePath, "org.freedesktop.Secret.Service", "Collections")
if err != nil {
return nil, err
}
var out []string
if err := json.Unmarshal(raw, &out); err != nil {
return nil, fmt.Errorf("the collections: %w", err)
}
return out, nil
}
func defaultCollection(s Session) string {
raw, err := busctl(s, "call", service, servicePath, "org.freedesktop.Secret.Service", "ReadAlias", "s", "default")
if err != nil {
return ""
}
var out []string
if json.Unmarshal(raw, &out) != nil || len(out) == 0 || out[0] == "/" {
return ""
}
return collectionID(out[0])
}
// describe reads a collection's properties: label, lock, the number of items (never the items), times.
func describe(s Session, path string) (Collection, error) {
raw, err := busctl(s, "call", service, path, "org.freedesktop.DBus.Properties", "GetAll", "s", "org.freedesktop.Secret.Collection")
if err != nil {
return Collection{}, err
}
return parseCollection(path, raw)
}
func parseCollection(path string, raw json.RawMessage) (Collection, error) {
var answer []map[string]struct {
Data json.RawMessage `json:"data"`
}
if err := json.Unmarshal(raw, &answer); err != nil || len(answer) != 1 {
return Collection{}, fmt.Errorf("collection %s: not a property map", path)
}
p := answer[0]
c := Collection{ID: collectionID(path)}
_ = json.Unmarshal(p["Label"].Data, &c.Label)
_ = json.Unmarshal(p["Locked"].Data, &c.Locked)
var items []string
_ = json.Unmarshal(p["Items"].Data, &items)
c.Items = len(items)
for key, into := range map[string]*string{"Created": &c.Created, "Modified": &c.Modified} {
var t int64
if json.Unmarshal(p[key].Data, &t) == nil && t > 0 {
*into = time.Unix(t, 0).Format(time.RFC3339)
}
}
return c, nil
}
// Collections are the operator's keyrings.
func Collections() (CollectionsResult, error) {
s, err := findBus()
if err != nil {
return CollectionsResult{}, err
}
ps, err := paths(s)
if err != nil {
return CollectionsResult{}, err
}
def := defaultCollection(s)
out := CollectionsResult{Collections: []Collection{}}
for _, p := range ps {
c, err := describe(s, p)
if err != nil {
return CollectionsResult{}, err
}
c.Default = c.ID == def
out.Collections = append(out.Collections, c)
}
sort.Slice(out.Collections, func(i, j int) bool { return out.Collections[i].ID < out.Collections[j].ID })
return out, nil
}
// UnlockedResult is what gnome_keyring_unlocked answers.
type UnlockedResult struct {
Daemon bool `json:"daemon_running"`
Login *Collection `json:"login,omitempty"`
Default *Collection `json:"default,omitempty"`
Note string `json:"note,omitempty"`
}
// Unlocked is whether the login and default keyrings are unlocked.
func Unlocked() (UnlockedResult, error) {
s, err := findBus()
if err != nil {
return UnlockedResult{}, err
}
// gnome-keyring-daemon, as the kernel shortens a command's name to 15 characters.
out := UnlockedResult{Daemon: len(processesOf("gnome-keyring-d")) > 0}
if c, err := describe(s, collectionPath("login")); err == nil {
out.Login = &c
} else {
out.Note = "no login keyring: " + err.Error()
}
if def := defaultCollection(s); def != "" && def != "login" {
if c, err := describe(s, collectionPath(def)); err == nil {
c.Default = true
out.Default = &c
}
} else if out.Login != nil {
out.Login.Default = def == "login"
}
return out, nil
}
// LockResult is what gnome_keyring_lock answers.
type LockResult struct {
Collection string `json:"collection"`
Locked bool `json:"locked"`
}
// Lock locks one keyring.
func Lock(id string) (LockResult, error) {
if id == "" {
id = "login"
}
if !validID.MatchString(id) {
return LockResult{}, fmt.Errorf("collection %q is not a keyring id", id)
}
s, err := findBus()
if err != nil {
return LockResult{}, err
}
if _, err := busctl(s, "call", service, servicePath, "org.freedesktop.Secret.Service", "Lock", "ao", "1", collectionPath(id)); err != nil {
return LockResult{}, err
}
c, err := describe(s, collectionPath(id))
if err != nil {
return LockResult{}, err
}
return LockResult{Collection: id, Locked: c.Locked}, nil
}
// Key is one key the ssh agent holds.
type Key struct {
Bits int `json:"bits"`
Fingerprint string `json:"fingerprint"`
Comment string `json:"comment"`
Type string `json:"type"`
}
// SSHKeysResult is what gnome_keyring_ssh_keys answers.
type SSHKeysResult struct {
Agent string `json:"agent"`
Keys []Key `json:"keys"`
Note string `json:"note,omitempty"`
}
// agentSocket is gcr's ssh agent socket, which its user socket unit listens on.
func agentSocket(s Session) string { return filepath.Join(s.RuntimeDir, "gcr", "ssh") }
var keyLine = regexp.MustCompile(`^(\d+)\s+(\S+)\s+(.*?)\s*\(([A-Z0-9-]+)\)$`)
func parseKeys(out string) []Key {
keys := []Key{}
for _, line := range strings.Split(out, "\n") {
m := keyLine.FindStringSubmatch(strings.TrimSpace(line))
if m == nil {
continue
}
bits, _ := strconv.Atoi(m[1])
keys = append(keys, Key{Bits: bits, Fingerprint: m[2], Comment: m[3], Type: m[4]})
}
return keys
}
// SSHKeys lists what gcr's ssh agent holds, by fingerprint.
func SSHKeys() (SSHKeysResult, error) {
s, err := findBus()
if err != nil {
return SSHKeysResult{}, err
}
sock := agentSocket(s)
// The agent is named for this one command only; nothing else of the tool's environment changes.
r, err := s.run(busTimeout, "", "env", "SSH_AUTH_SOCK="+sock, "ssh-add", "-l", "-E", "sha256")
if err != nil {
return SSHKeysResult{}, err
}
out := SSHKeysResult{Agent: sock, Keys: parseKeys(r.Stdout)}
switch r.Code {
case 0:
case 1:
out.Note = "the agent holds no keys"
case 127:
return SSHKeysResult{}, fmt.Errorf("ssh-add is not installed on this machine")
default:
return SSHKeysResult{}, fmt.Errorf("the ssh agent at %s does not answer: %s (is gcr-ssh-agent.socket enabled?)",
sock, strings.TrimSpace(r.Stderr))
}
return out, nil
}
@@ -0,0 +1,131 @@
package main
import (
"errors"
"os"
"path/filepath"
"strconv"
"strings"
"testing"
)
const nobody = 4194400
// secretService fakes busctl answering as gnome-keyring did on 2026-10-04: a session, a login and a
// default keyring, the login one unlocked; Lock locks it.
func secretService(t *testing.T) string {
t.Helper()
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)
}
bin := fakeBinaries(t, map[string]string{"busctl": `echo "$*" >> "$LOG"
locked=false; [ -f "$LOG.locked" ] && locked=true
case "$*" in
*"get-property org.freedesktop.secrets /org/freedesktop/secrets org.freedesktop.Secret.Service Collections")
echo '{"type":"ao","data":["/org/freedesktop/secrets/collection/session","/org/freedesktop/secrets/collection/login","/org/freedesktop/secrets/collection/Default_5fkeyring"]}' ;;
*"ReadAlias s default") echo '{"type":"o","data":["/org/freedesktop/secrets/collection/Default_5fkeyring"]}' ;;
*"/collection/login org.freedesktop.DBus.Properties GetAll"*)
echo '{"type":"a{sv}","data":[{"Items":{"type":"ao","data":["/x/1","/x/2"]},"Label":{"type":"s","data":"Login"},"Locked":{"type":"b","data":'$locked'},"Created":{"type":"t","data":1752488320},"Modified":{"type":"t","data":0}}]}' ;;
*"/collection/"*"GetAll"*)
echo '{"type":"a{sv}","data":[{"Items":{"type":"ao","data":[]},"Label":{"type":"s","data":"Other"},"Locked":{"type":"b","data":true},"Created":{"type":"t","data":0},"Modified":{"type":"t","data":0}}]}' ;;
*"Lock ao 1 /org/freedesktop/secrets/collection/login") touch "$LOG.locked"; echo '{"type":"aoo","data":[["/org/freedesktop/secrets/collection/login"],"/"]}' ;;
*) echo "no such call: $*" >&2; exit 1 ;;
esac`})
t.Setenv("LOG", filepath.Join(bin, "log"))
return bin
}
func TestCollectionsAreNamesCountsAndLocksNeverItems(t *testing.T) {
bin := secretService(t)
got, err := Collections()
if err != nil {
t.Fatal(err)
}
if len(got.Collections) != 3 {
t.Fatalf("%+v", got)
}
var login, def Collection
for _, c := range got.Collections {
switch c.ID {
case "login":
login = c
case "Default_5fkeyring":
def = c
}
}
if login.Label != "Login" || login.Locked || login.Items != 2 || login.Created == "" || login.Modified != "" || login.Default {
t.Fatalf("login: %+v", login)
}
if !def.Default || !def.Locked {
t.Fatalf("default: %+v", def)
}
asked, _ := os.ReadFile(filepath.Join(bin, "log"))
if strings.Contains(string(asked), "GetSecret") || strings.Contains(string(asked), "Item") && strings.Contains(string(asked), "Secret.Item") {
t.Fatalf("a secret was asked for:\n%s", asked)
}
}
func TestUnlockedAnswersTheLoginAndTheDefaultKeyring(t *testing.T) {
secretService(t)
fakeProcess(t, nobody, "gnome-keyring-d")
got, err := Unlocked()
if err != nil || !got.Daemon || got.Login == nil || got.Login.Locked || got.Default == nil || !got.Default.Default {
t.Fatalf("%+v, %v", got, err)
}
}
func TestLockLocksTheLoginKeyringAndRefusesAPathForAnId(t *testing.T) {
secretService(t)
got, err := Lock("")
if err != nil || got.Collection != "login" || !got.Locked {
t.Fatalf("%+v, %v", got, err)
}
for _, bad := range []string{"../service", "login /org/x", "a b"} {
if _, err := Lock(bad); err == nil {
t.Errorf("%q was accepted", bad)
}
}
}
func TestTheAgentsKeysAreFingerprints(t *testing.T) {
keys := parseKeys("256 SHA256:x+LmFabc op@laptop (ED25519)\n3072 SHA256:yyy a comment with spaces (RSA)\nThe agent has no identities.\n")
if len(keys) != 2 || keys[0] != (Key{Bits: 256, Fingerprint: "SHA256:x+LmFabc", Comment: "op@laptop", Type: "ED25519"}) ||
keys[1].Comment != "a comment with spaces" || keys[1].Type != "RSA" {
t.Fatalf("%+v", keys)
}
}
func TestSSHKeysAsksGcrsAgentAndSaysWhenItDoesNotAnswer(t *testing.T) {
secretService(t)
bin := fakeBinaries(t, map[string]string{"ssh-add": `echo "$SSH_AUTH_SOCK $*" > "$LOG.ssh"; [ -f "$NOAGENT" ] && { echo "Could not open a connection" >&2; exit 2; }; echo "256 SHA256:abc op (ED25519)"`})
t.Setenv("NOAGENT", filepath.Join(bin, "noagent"))
got, err := SSHKeys()
if err != nil || len(got.Keys) != 1 || !strings.HasSuffix(got.Agent, "/gcr/ssh") {
t.Fatalf("%+v, %v", got, err)
}
asked, _ := os.ReadFile(os.Getenv("LOG") + ".ssh")
if !strings.HasSuffix(strings.TrimSpace(string(asked)), "/gcr/ssh -l -E sha256") {
t.Fatalf("asked: %s", asked)
}
if err := os.WriteFile(filepath.Join(bin, "noagent"), nil, 0o644); err != nil {
t.Fatal(err)
}
if _, err := SSHKeys(); err == nil || !strings.Contains(err.Error(), "gcr-ssh-agent.socket") {
t.Fatalf("no agent: %v", err)
}
}
func TestWithoutABusTheToolsSaySo(t *testing.T) {
fakeMachine(t)
if _, err := Collections(); !errors.Is(err, ErrNoBus) {
t.Fatal(err)
}
if _, err := SSHKeys(); !errors.Is(err, ErrNoBus) {
t.Fatal(err)
}
}
@@ -0,0 +1,57 @@
// gnome-keyring's Go tools bundle (novox/hq ADR 0188, ADR 0193, ADR 0208): the secret service's
// tools, served by the node's runtime as the operator account. node-secret-service has no verbs yet
// (ADR 0208 §2), so every tool here is the module's own. None of them ever reads a secret: they ask the
// Secret Service for names, counts and lock states, and the ssh agent for fingerprints.
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: "gnome_keyring_unlocked",
Description: "Is the operator's keyring unlocked: the login keyring and the default one, each locked " +
"or not, and whether the keyring daemon runs.",
Run: func(map[string]any) (any, error) { return Unlocked() },
},
{
Name: "gnome_keyring_lock",
Description: "Lock a keyring now (the login keyring unless another is named): programs must ask " +
"for its password again. Unlocking is the operator's, at their desktop.",
Input: map[string]any{
"collection": map[string]any{"type": "string", "description": "the keyring's id, as gnome_keyring_collections answers it (default login)"},
},
Run: func(args map[string]any) (any, error) {
c, err := text(args, "collection", false)
if err != nil {
return nil, err
}
return Lock(c)
},
},
{
Name: "gnome_keyring_collections",
Description: "The operator's keyrings: each one's id, label, whether it is locked, how many items it " +
"holds, when it was created and changed, and which is the default. Never an item, never a secret.",
Run: func(map[string]any) (any, error) { return Collections() },
},
{
Name: "gnome_keyring_ssh_keys",
Description: "The keys the session's ssh agent (gcr's) holds, by fingerprint, size, type and comment. " +
"Never a key.",
Run: func(map[string]any) (any, error) { return SSHKeys() },
},
}
}
@@ -0,0 +1,175 @@
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"`
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)
}
}
}
@@ -0,0 +1,64 @@
package main
import (
"reflect"
"strings"
"testing"
)
// gnome-keyring's shape (novox/hq ADR 0208, ADR 0102): it claims node-secret-service, writes its PAM
// lines into the login and passwd stacks as marked blocks (never over the files), and starts no daemon
// of its own: PAM and D-Bus do.
func TestItClaimsTheSecretServiceSeat(t *testing.T) {
m := readManifest(t)
if m.Module != "gnome-keyring" || m.Seats != nil || m.Requires != nil {
t.Fatalf("module %q, seats %v, requires %v", m.Module, m.Seats, m.Requires)
}
if !reflect.DeepEqual(m.Claims, []claim{{Name: "node-secret-service", Scope: "node"}}) {
t.Fatalf("claims: %+v", m.Claims)
}
if present, absent := m.packages(); !reflect.DeepEqual(present, []string{"gnome-keyring", "libsecret", "seahorse"}) || absent != nil {
t.Fatalf("packages: %v, absent %v", present, absent)
}
}
func TestThePAMLinesAreBlocksWrittenIntoTheStacks(t *testing.T) {
m := readManifest(t)
for id, want := range map[string]struct{ path, source string }{
"pam-login": {"/etc/pam.d/login", "files/pam/login"},
"pam-passwd": {"/etc/pam.d/passwd", "files/pam/passwd"},
} {
m.sameAsSource(t, id, want.source)
r := m.resource(t, id)
if r["path"] != want.path || r["into"] != "block" || r["at"] != "end" || r["owner"] != nil {
t.Errorf("%s: %v", id, r)
}
}
login := m.resource(t, "pam-login")["content"].(string)
if !strings.Contains(login, "\nauth optional pam_gnome_keyring.so\n") ||
!strings.Contains(login, "\nsession optional pam_gnome_keyring.so auto_start\n") {
t.Fatalf("%s", login)
}
}
func TestItStartsNoDaemonAndOnlyNamesTheAgentsSocket(t *testing.T) {
m := readManifest(t)
if len(m.Shell) != 1 || m.Shell[0].For != "xinitrc" || m.Shell[0].Slot != "first" {
t.Fatalf("%+v", m.Shell)
}
code := m.Shell[0].Code
if strings.Contains(code, "gnome-keyring-daemon") || strings.Contains(code, "--unlock") ||
!strings.Contains(code, `SSH_AUTH_SOCK="${XDG_RUNTIME_DIR:-/run/user/$(id -u)}/gcr/ssh"`) {
t.Fatalf("%q", code)
}
if m.Environment != nil {
t.Fatal("SSH_AUTH_SOCK needs the runtime directory, which ADR 0203 forbids in a value; it is not an environment contribution yet")
}
}
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
}
+4
View File
@@ -0,0 +1,4 @@
# The login keyring (module gnome-keyring, novox/hq ADR 0208, ADR 0102): the password typed at the
# login screen unlocks the keyring, and the login session starts the keyring daemon with it.
auth optional pam_gnome_keyring.so
session optional pam_gnome_keyring.so auto_start
+3
View File
@@ -0,0 +1,3 @@
# The login keyring (module gnome-keyring, novox/hq ADR 0208, ADR 0102): changing the account's
# password changes the login keyring's with it, so the next login still unlocks it.
password optional pam_gnome_keyring.so
+5
View File
@@ -0,0 +1,5 @@
module gnomekeyring
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=
+76
View File
@@ -0,0 +1,76 @@
{
"module": "gnome-keyring",
"version": "1",
"capabilities": [
"package-manager"
],
"claims": [
{
"name": "node-secret-service",
"scope": "node"
}
],
"tools": [
"gnome_keyring_unlocked",
"gnome_keyring_lock",
"gnome_keyring_collections",
"gnome_keyring_ssh_keys"
],
"shell": [
{
"for": "xinitrc",
"slot": "first",
"code": "# The ssh agent (module gnome-keyring, novox/hq ADR 0208): gcr's, which the account's service manager\n# starts on first use from its socket. Named here, for the session and every terminal it starts, until\n# the account's environment can say a path under the runtime directory (see the module's README).\nSSH_AUTH_SOCK=\"${XDG_RUNTIME_DIR:-/run/user/$(id -u)}/gcr/ssh\"\nexport SSH_AUTH_SOCK\n"
}
],
"resources": [
{
"id": "package",
"type": "package",
"package": "gnome-keyring"
},
{
"id": "library",
"type": "package",
"package": "libsecret"
},
{
"id": "manager",
"type": "package",
"package": "seahorse"
},
{
"id": "pam-login",
"type": "file",
"path": "/etc/pam.d/login",
"mode": "0644",
"into": "block",
"at": "end",
"content": "# The login keyring (module gnome-keyring, novox/hq ADR 0208, ADR 0102): the password typed at the\n# login screen unlocks the keyring, and the login session starts the keyring daemon with it.\nauth optional pam_gnome_keyring.so\nsession optional pam_gnome_keyring.so auto_start\n"
},
{
"id": "pam-passwd",
"type": "file",
"path": "/etc/pam.d/passwd",
"mode": "0644",
"into": "block",
"at": "end",
"content": "# The login keyring (module gnome-keyring, novox/hq ADR 0208, ADR 0102): changing the account's\n# password changes the login keyring's with it, so the next login still unlocks it.\npassword optional pam_gnome_keyring.so\n"
}
],
"build": {
"artifacts": [
{
"name": "tools",
"kind": "bundle",
"language": "go",
"system": "arch",
"from": "cmd/gnome-keyring-tools",
"binary": "gnome-keyring-tools",
"loads": [
"gnome-keyring-tools"
]
}
]
}
}
+86
View File
@@ -0,0 +1,86 @@
# i3status-rust
The bars as a module (novox/hq ADR 0208, research 026/05).
- Installs `i3status-rust`, and `pacman-contrib` for `checkupdates`, which the update block uses.
Claims the mesh's `node-bar` seat (no verbs yet, ADR 0208 §2). Requires `x11-display` on its own
machine: its bars are i3bar's.
- Owns `~/.config/i3status-rust/`, both bars (`top-bar.toml`, `bottom-bar.toml`) and their icon set
(`icons/custom-icons.toml`).
- Places the two `bar { }` blocks as its own i3 drop-in, `~/.config/i3/config.d/60-i3status-rust.conf`:
- the bottom bar shows the machine;
- the top bar shows the focused window and the tray, on the primary output.
- Places two programs in `~/.local/bin`:
- `i3status-updates`, the pending-updates block;
- `i3bar-watchdog`, which brings back a bar that died.
- Starts the watchdog once per session, from the session's start (the `xinitrc` slot `normal`). It ends
with the session's own process.
## Tools
| tool | does |
|---|---|
| `i3status_rust_reload` | the running bars re-read their files (i3status-rs restarts in place) |
| `i3status_rust_blocks` | each bar's blocks in order, kind and settings, and what each shows now (one run of the bar) |
| `i3status_rust_block_run` | one block alone, with the bar's theme and icons, to see what it shows or why it errors |
| `i3status_rust_themes` | the themes on the machine and the one each bar uses |
## The battery, and what else was left out
A bar block that follows one machine's hardware is that machine model's hardware module's (ADR 0208
§1), so the bottom bar here has none:
- **the battery,** which only the laptop has;
- **the GPU,** which was the laptop's AMD block, commented out for NVIDIA on the desktop;
- **three Bluetooth headsets** by hardware address: one person's devices, not the bar's.
i3status-rust reads no drop-in directory, and ADR 0208's slots cover `xinitrc` and `xresources` only,
so a hardware module has no way into this file yet. Two ways forward, for the operator to choose:
1. **Give the bar a slot.** A `shell`-style contribution `for: i3status-rust` would let the laptop's
hardware module add its battery block. That is a decision record of its own, extending ADR 0208 §4.
2. **Blocks that show only where they apply.** i3status-rust's common `if_command` option (for
example `test -d /sys/class/power_supply/BAT0`) hides a block on a machine without the hardware.
That is "say it by what is there" (ADR 0112), but the knowledge stays in the bar.
Until one of them is chosen, **the laptop's bar shows no battery** once this module is assigned.
## What it improves on what was found
- **The weather needs no key.** The found block used a weather service with an API key written in
plain text in the file, and a fixed city. It now uses the Norwegian Meteorological Institute, which
needs no key, located automatically.
- **The update count is the module's own,** adopted from the operator's `~/scripts/pkg-updates`. It
counts AUR updates only where an AUR helper is installed. Clicking it lists the updates in the
launcher's menu, instead of a theme from a cloned theme repository.
- **The faces** are JetBrains Mono Nerd Font, not Hack.
- **The watchdog is no longer a hand-enabled user unit** that ran on one workstation and not the
other. It runs on both, once per session, and ends with the session.
- The docker block's click, which ran a program installed on neither machine, is gone.
## What it leaves as found
- `~/.config/i3status-rust/scripts/` (a mouse battery script for one device).
- `~/scripts/pkg-updates` and `~/scripts/i3-bar-watchdog`, replaced here.
- The user unit `~/.config/systemd/user/i3-bar-watchdog.service` and its enablement link.
## Migration (ADR 0182)
1. **Revoke the weather API key** that was in the found `bottom-bar.toml`. It sat in plain text on
both workstations. The first push keeps the found file once and then writes the module's, which
has no key.
2. Before the first push, on the laptop: `systemctl --user disable --now i3-bar-watchdog.service`.
Otherwise two watchdogs run. Then delete the unit file, `~/scripts/i3-bar-watchdog` and
`~/scripts/pkg-updates`.
3. Until the `i3` module carries the main configuration, the found `~/.config/i3/config` still has its
own two `bar { }` blocks, and i3 shows four bars. The `i3` module's configuration has none.
## Blockers
- `node-bar`, `x11-display` and the `xinitrc` slot are ADR 0208's. Until the controller knows them,
`mctl` reads them as unknown.
- The battery block waits for one of the two ways above.
- The watchdog would be a user unit once user-scoped units ship (mesh-host #72). It is not, because it
runs per session and ends with the session, as a session-start line already does.
- `pacman-contrib` is declared here. A future `pacman` module that wants `checkupdates` or `paccache`
takes it over, since a package is declared once per node.
@@ -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,306 @@
package main
import (
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"regexp"
"sort"
"strconv"
"strings"
"syscall"
"time"
)
var bars = []string{"top", "bottom"}
// A bar runs this long when a tool reads its values: long enough for every block's first update.
const runFor = 4 * time.Second
func configDir() string { return filepath.Join(operatorHome(), ".config", "i3status-rust") }
func barFile(bar string) string { return filepath.Join(configDir(), bar+"-bar.toml") }
func barsOf(bar string) ([]string, error) {
if bar == "" {
return bars, nil
}
for _, b := range bars {
if b == bar {
return []string{bar}, nil
}
}
return nil, fmt.Errorf("bar %q is top or bottom", bar)
}
// ReloadResult is what i3status_rust_reload answers.
type ReloadResult struct {
Reached []int `json:"reached"`
Note string `json:"note"`
}
// Reload restarts every running i3status-rs in place, which re-reads its file (SIGUSR2).
func Reload() ReloadResult {
reached := signalAll("i3status-rs", syscall.SIGUSR2)
if reached == nil {
return ReloadResult{Reached: []int{}, Note: "no bar is running: i3 starts them with the session"}
}
return ReloadResult{Reached: reached, Note: "each bar re-read its configuration"}
}
// Config is a bar's file cut into the part before its blocks and each block's own text.
type Config struct {
Header string
Blocks []string
}
var blockStart = regexp.MustCompile(`(?m)^\[\[block\]\]\s*$`)
func splitConfig(raw string) Config {
idx := blockStart.FindAllStringIndex(raw, -1)
if len(idx) == 0 {
return Config{Header: raw}
}
c := Config{Header: raw[:idx[0][0]]}
for i, at := range idx {
end := len(raw)
if i+1 < len(idx) {
end = idx[i+1][0]
}
c.Blocks = append(c.Blocks, raw[at[0]:end])
}
return c
}
// Block is one block of a bar.
type Block struct {
Index int `json:"index"`
Kind string `json:"block"`
Settings map[string]string `json:"settings"`
Text *string `json:"text,omitempty"`
State string `json:"state,omitempty"`
}
var keyValue = regexp.MustCompile(`^([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.+?)\s*$`)
// describe reads a block's own top-level settings (not its sub-tables, such as clicks), as written.
func describe(index int, raw string) Block {
b := Block{Index: index, Settings: map[string]string{}}
for _, line := range strings.Split(raw, "\n")[1:] {
line = strings.TrimSpace(line)
if strings.HasPrefix(line, "[") {
break
}
if line == "" || strings.HasPrefix(line, "#") {
continue
}
if m := keyValue.FindStringSubmatch(line); m != nil {
v := strings.Trim(m[2], `"`)
if m[1] == "block" {
b.Kind = v
} else {
b.Settings[m[1]] = v
}
}
}
return b
}
// BarBlocks is one bar's blocks.
type BarBlocks struct {
Bar string `json:"bar"`
File string `json:"file"`
Blocks []Block `json:"blocks"`
Error string `json:"error,omitempty"`
}
// Blocks describes each bar's blocks, and with values what each shows now.
func Blocks(bar string, values bool) ([]BarBlocks, error) {
which, err := barsOf(bar)
if err != nil {
return nil, err
}
var out []BarBlocks
for _, b := range which {
file := barFile(b)
raw, err := os.ReadFile(file)
if err != nil {
out = append(out, BarBlocks{Bar: b, File: file, Blocks: []Block{}, Error: err.Error()})
continue
}
c := splitConfig(string(raw))
bb := BarBlocks{Bar: b, File: file, Blocks: []Block{}}
for i, text := range c.Blocks {
bb.Blocks = append(bb.Blocks, describe(i, text))
}
if values {
shown, err := runBar(file)
if err != nil {
bb.Error = err.Error()
}
for i := range bb.Blocks {
if w, ok := shown[i]; ok {
text := w.text
bb.Blocks[i].Text, bb.Blocks[i].State = &text, w.state
}
}
}
out = append(out, bb)
}
return out, nil
}
type widgetText struct{ text, state string }
// runBar runs i3status-rs on a file for a moment and answers each block's latest text, by index.
// i3status-rs names each block's widgets by an instance "<index>:…", which is how they are told apart.
func runBar(file string) (map[int]widgetText, error) {
s := findEnvironment() // the session when there is one; blocks that need none run without
r, err := s.run(runFor, "", "i3status-rs", file)
if err != nil && !errors.Is(err, ErrTimedOut) {
return nil, err
}
shown := parseStatus(r.Stdout)
if len(shown) == 0 {
msg := strings.TrimSpace(r.Stderr)
if msg == "" {
msg = "the bar showed nothing within " + runFor.String()
}
return shown, errors.New(msg)
}
return shown, nil
}
// parseStatus reads i3bar's protocol — a header, "[", then one JSON array per update — and answers
// the last complete update's text per block index.
func parseStatus(stream string) map[int]widgetText {
var last []map[string]any
for _, line := range strings.Split(stream, "\n") {
line = strings.TrimSuffix(strings.TrimPrefix(strings.TrimSpace(line), ","), ",")
if !strings.HasPrefix(line, "[{") && line != "[]" {
continue
}
var update []map[string]any
if json.Unmarshal([]byte(line), &update) == nil {
last = update
}
}
out := map[int]widgetText{}
for _, w := range last {
instance, _ := w["instance"].(string)
head, _, ok := strings.Cut(instance, ":")
if !ok {
continue // a separator
}
i, err := strconv.Atoi(head)
if err != nil {
continue
}
text, _ := w["full_text"].(string)
cur := out[i]
cur.text = strings.TrimSpace(strings.TrimSpace(cur.text) + " " + strings.TrimSpace(text))
if colour, _ := w["color"].(string); strings.EqualFold(colour, "#FF0000FF") {
cur.state = "critical"
}
out[i] = cur
}
return out
}
// RunResult is what i3status_rust_block_run answers.
type RunResult struct {
Bar string `json:"bar"`
Block Block `json:"block"`
Error string `json:"error,omitempty"`
}
// BlockRun runs one block of a bar alone, with the bar's theme and icons.
func BlockRun(bar string, index int) (RunResult, error) {
if _, err := barsOf(bar); err != nil || bar == "" {
return RunResult{}, fmt.Errorf("bar %q is top or bottom", bar)
}
raw, err := os.ReadFile(barFile(bar))
if err != nil {
return RunResult{}, err
}
c := splitConfig(string(raw))
if index >= len(c.Blocks) {
return RunResult{}, fmt.Errorf("the %s bar has %d blocks, numbered from 0", bar, len(c.Blocks))
}
tmp, err := os.CreateTemp("", "i3status-rust-block-*.toml")
if err != nil {
return RunResult{}, err
}
defer os.Remove(tmp.Name())
if _, err := tmp.WriteString(c.Header + c.Blocks[index]); err != nil {
tmp.Close()
return RunResult{}, err
}
tmp.Close()
out := RunResult{Bar: bar, Block: describe(index, c.Blocks[index])}
shown, err := runBar(tmp.Name())
if err != nil {
out.Error = err.Error()
}
if w, ok := shown[0]; ok {
text := w.text
out.Block.Text, out.Block.State = &text, w.state
}
return out, nil
}
// Theme is one bar theme.
type Theme struct {
Name string `json:"name"`
File string `json:"file"`
Source string `json:"source"`
}
// ThemesResult is what i3status_rust_themes answers.
type ThemesResult struct {
InUse map[string]string `json:"in_use"`
Themes []Theme `json:"themes"`
}
type themeDir struct{ path, source string }
func themeDirs() []themeDir {
return []themeDir{
{filepath.Join(configDir(), "themes"), "the account's"},
{filepath.Join(operatorHome(), ".local", "share", "i3status-rust", "themes"), "the account's"},
{"/usr/share/i3status-rust/themes", "the distribution's"},
}
}
var themeName = regexp.MustCompile(`(?ms)^\[theme\]\s*$.*?^theme\s*=\s*"([^"]+)"`)
// Themes lists the themes, the first directory winning a name, and the one each bar names.
func Themes(dirs []themeDir) ThemesResult {
out := ThemesResult{InUse: map[string]string{}, Themes: []Theme{}}
for _, b := range bars {
if raw, err := os.ReadFile(barFile(b)); err == nil {
if m := themeName.FindStringSubmatch(string(raw)); m != nil {
out.InUse[b] = m[1]
}
}
}
seen := map[string]bool{}
for _, d := range dirs {
entries, err := os.ReadDir(d.path)
if err != nil {
continue
}
for _, e := range entries {
name, ok := strings.CutSuffix(e.Name(), ".toml")
if !ok || seen[name] {
continue
}
seen[name] = true
out.Themes = append(out.Themes, Theme{Name: name, File: filepath.Join(d.path, e.Name()), Source: d.source})
}
}
sort.Slice(out.Themes, func(i, j int) bool { return out.Themes[i].Name < out.Themes[j].Name })
return out
}
@@ -0,0 +1,141 @@
package main
import (
"os"
"path/filepath"
"reflect"
"strings"
"testing"
)
func source(t *testing.T, name string) string {
t.Helper()
raw, err := os.ReadFile(filepath.Join("..", "..", "files", name))
if err != nil {
t.Fatal(err)
}
return string(raw)
}
func TestTheModulesBarsSplitIntoTheirBlocksInOrder(t *testing.T) {
c := splitConfig(source(t, "bottom-bar.toml"))
var kinds []string
for i, b := range c.Blocks {
kinds = append(kinds, describe(i, b).Kind)
}
want := []string{"tea_timer", "custom", "cpu", "disk_space", "disk_space", "memory", "docker", "sound", "weather", "notify", "time"}
if !reflect.DeepEqual(kinds, want) {
t.Fatalf("%v", kinds)
}
if !strings.Contains(c.Header, `theme = "plain"`) || strings.Contains(c.Header, "[[block]]") {
t.Fatalf("header: %s", c.Header)
}
custom := describe(1, c.Blocks[1])
if custom.Settings["command"] != "~/.local/bin/i3status-updates 10" || custom.Settings["interval"] != "1800" {
t.Fatalf("a block's own settings, not its click's: %+v", custom)
}
if _, leaked := custom.Settings["cmd"]; leaked {
t.Fatal("the click's cmd was read as the block's")
}
top := splitConfig(source(t, "top-bar.toml"))
if len(top.Blocks) != 2 || describe(0, top.Blocks[0]).Kind != "focused_window" {
t.Fatalf("top: %+v", top.Blocks)
}
}
// Two updates of i3bar's protocol, as i3status-rs wrote them for the top bar on 2026-10-04.
const stream = `{"version": 1, "click_events": true}
[
[{"full_text":" | ","color":"#FFFFFFFF","separator":false},{"full_text":" up 1d ","color":"#F1F1F1FF","name":"0","instance":"1:"}],
[{"full_text":" | ","color":"#FFFFFFFF","separator":false},{"full_text":" ","color":"#FF0000FF","name":"0","instance":"0:"},{"full_text":"failed to connect to wayland ","color":"#FF0000FF","name":"0","instance":"0:"},{"full_text":" | "},{"full_text":" up 2d ","color":"#F1F1F1FF","name":"0","instance":"1:"}],
`
func TestTheLatestUpdateIsReadPerBlockIndex(t *testing.T) {
got := parseStatus(stream)
if got[1].text != "up 2d" || got[1].state != "" || got[0].text != "failed to connect to wayland" || got[0].state != "critical" || len(got) != 2 {
t.Fatalf("%+v", got)
}
if len(parseStatus("garbage\n[\n")) != 0 {
t.Fatal("no update is no blocks")
}
}
// installBars puts the module's bars where i3status-rs would read them, and a fake i3status-rs.
func installBars(t *testing.T) string {
t.Helper()
root := fakeMachine(t)
dir := filepath.Join(root, "home", ".config", "i3status-rust")
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatal(err)
}
for _, f := range []string{"top-bar.toml", "bottom-bar.toml"} {
if err := os.WriteFile(filepath.Join(dir, f), []byte(source(t, f)), 0o644); err != nil {
t.Fatal(err)
}
}
bin := fakeBinaries(t, map[string]string{"i3status-rs": `cp "$1" "$LOG.config"
printf '%s\n' '{"version": 1}' '[' '[{"full_text":"first","instance":"0:"},{"full_text":"second","instance":"1:"}],'`})
t.Setenv("LOG", filepath.Join(bin, "log"))
return bin
}
func TestBlocksPairEachBlockWithWhatItShows(t *testing.T) {
installBars(t)
got, err := Blocks("top", true)
if err != nil || len(got) != 1 {
t.Fatalf("%+v, %v", got, err)
}
b := got[0].Blocks
if len(b) != 2 || b[0].Kind != "focused_window" || b[0].Text == nil || *b[0].Text != "first" || *b[1].Text != "second" {
t.Fatalf("%+v", got)
}
if all, _ := Blocks("", false); len(all) != 2 || all[1].Blocks[0].Text != nil {
t.Fatalf("both bars, without values: %+v", all)
}
if _, err := Blocks("side", false); err == nil {
t.Fatal("an unknown bar was accepted")
}
}
func TestOneBlockRunsAloneWithTheBarsThemeAndIcons(t *testing.T) {
bin := installBars(t)
got, err := BlockRun("bottom", 8)
if err != nil || got.Block.Kind != "weather" || got.Block.Text == nil || *got.Block.Text != "first" {
t.Fatalf("%+v, %v", got, err)
}
ran, _ := os.ReadFile(filepath.Join(bin, "log.config"))
if strings.Count(string(ran), "[[block]]") != 1 || !strings.Contains(string(ran), `name = "metno"`) ||
!strings.Contains(string(ran), `icons = "custom-icons"`) {
t.Fatalf("the config it ran:\n%s", ran)
}
if _, err := BlockRun("bottom", 11); err == nil {
t.Fatal("a block past the end was accepted")
}
if _, err := BlockRun("", 0); err == nil {
t.Fatal("no bar was accepted")
}
}
func TestThemesListEachOnceWithTheOnesInUse(t *testing.T) {
installBars(t)
root := t.TempDir()
for _, f := range []string{"mine/plain.toml", "system/plain.toml", "system/nord-dark.toml", "system/README"} {
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)
}
}
got := Themes([]themeDir{{filepath.Join(root, "mine"), "mine"}, {filepath.Join(root, "system"), "system"}})
if got.InUse["top"] != "plain" || got.InUse["bottom"] != "plain" || len(got.Themes) != 2 || got.Themes[1].Source != "mine" {
t.Fatalf("%+v", got)
}
}
func TestReloadWithNoBarRunningSaysSo(t *testing.T) {
fakeMachine(t)
if r := Reload(); len(r.Reached) != 0 || !strings.Contains(r.Note, "no bar") {
t.Fatalf("%+v", r)
}
}
@@ -0,0 +1,81 @@
// i3status-rust's Go tools bundle (novox/hq ADR 0188, ADR 0193, ADR 0208): the bars' tools, served by
// the node's runtime as the operator account. node-bar has no verbs yet (ADR 0208 §2), so every tool
// here is the module's own.
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)
}
}
var barInput = map[string]any{"type": "string", "enum": bars, "description": "which bar (default: both)"}
func tools() []stdio.Tool {
return []stdio.Tool{
{
Name: "i3status_rust_reload",
Description: "Make the running bars re-read their configuration (i3status-rs restarts in place). " +
"Answers the processes reached.",
Run: func(map[string]any) (any, error) { return Reload(), nil },
},
{
Name: "i3status_rust_blocks",
Description: "What each bar shows: every block in order, its kind and settings, and with values " +
"(the default) what it shows right now, from one run of the bar's configuration.",
Input: map[string]any{
"bar": barInput,
"values": map[string]any{"type": "boolean", "description": "run the bar once to read each block's current text (default true; a few seconds)"},
},
Run: func(args map[string]any) (any, error) {
bar, err := text(args, "bar", false)
if err != nil {
return nil, err
}
values, err := flag(args, "values", true)
if err != nil {
return nil, err
}
return Blocks(bar, values)
},
},
{
Name: "i3status_rust_block_run",
Description: "Run one block of a bar once, alone, and answer what it shows — to see a block that " +
"errors, or what a click would act on.",
Input: map[string]any{
"type": "object",
"properties": map[string]any{
"bar": map[string]any{"type": "string", "enum": bars},
"index": map[string]any{"type": "integer", "description": "the block's position in the bar, from 0, as i3status_rust_blocks answers it"},
},
"required": []string{"bar", "index"},
},
Run: func(args map[string]any) (any, error) {
bar, err := text(args, "bar", true)
if err != nil {
return nil, err
}
index, err := whole(args, "index", 0, 0, 200)
if err != nil {
return nil, err
}
return BlockRun(bar, index)
},
},
{
Name: "i3status_rust_themes",
Description: "The bar themes on this machine (the distribution's and the account's own) and the " +
"one each bar uses.",
Run: func(map[string]any) (any, error) { return Themes(themeDirs()), nil },
},
}
}
@@ -0,0 +1,175 @@
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"`
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)
}
}
}
@@ -0,0 +1,71 @@
package main
import (
"reflect"
"strings"
"testing"
)
// i3status-rust's shape (novox/hq ADR 0208): it claims node-bar, requires the X display on its own
// machine (its bars are i3bar's), owns its two bars and their icons, places the bars as an i3 drop-in,
// and runs the bar watchdog once per session. No block follows one machine's hardware or one person's
// devices.
func TestItClaimsTheBarSeatAndRequiresTheXDisplay(t *testing.T) {
m := readManifest(t)
if m.Module != "i3status-rust" || m.Seats != nil {
t.Fatalf("module %q declares seats %v", m.Module, m.Seats)
}
if !reflect.DeepEqual(m.Claims, []claim{{Name: "node-bar", Scope: "node"}}) {
t.Fatalf("claims: %+v", m.Claims)
}
if !reflect.DeepEqual(m.Requires, []string{"x11-display"}) {
t.Fatalf("requires: %v", m.Requires)
}
if present, absent := m.packages(); !reflect.DeepEqual(present, []string{"i3status-rust", "pacman-contrib"}) || absent != nil {
t.Fatalf("packages: %v, absent %v", present, absent)
}
}
func TestItOwnsItsFilesAsWrittenInTheModule(t *testing.T) {
m := readManifest(t)
for id, src := range map[string]string{
"top-bar": "files/top-bar.toml", "bottom-bar": "files/bottom-bar.toml", "icons": "files/icons/custom-icons.toml",
"updates": "files/bin/i3status-updates", "watchdog": "files/bin/i3bar-watchdog", "i3-bars": "files/i3/60-i3status-rust.conf",
} {
m.sameAsSource(t, id, src)
}
}
func TestNoBlockFollowsAMachinesHardwareOrAPersonsDevicesOrCarriesAKey(t *testing.T) {
m := readManifest(t)
bottom := m.resource(t, "bottom-bar")["content"].(string)
for _, never := range []string{`block = "battery"`, `block = "amd_gpu"`, `block = "nvidia_gpu"`, "mac = ", "api_key", "city_id", "openweathermap", "~/scripts/"} {
if strings.Contains(bottom, never) {
t.Errorf("the bottom bar has %s", never)
}
}
}
func TestTheBarsAreAnI3DropInAndTheWatchdogRunsOncePerSession(t *testing.T) {
m := readManifest(t)
bars := m.resource(t, "i3-bars")
if bars["path"] != "${machine:account-home}/.config/i3/config.d/60-i3status-rust.conf" {
t.Fatalf("%v", bars["path"])
}
c := bars["content"].(string)
if strings.Count(c, "bar {") != 2 || !strings.Contains(c, "status_command i3status-rs ~/.config/i3status-rust/bottom-bar.toml") ||
!strings.Contains(c, "font pango:JetBrainsMono Nerd Font 11") {
t.Fatalf("%s", c)
}
if len(m.Shell) != 1 || m.Shell[0].For != "xinitrc" || m.Shell[0].Slot != "normal" ||
!strings.Contains(m.Shell[0].Code, `"$HOME/.local/bin/i3bar-watchdog" "$$" &`) {
t.Fatalf("%+v", m.Shell)
}
}
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
}
+64
View File
@@ -0,0 +1,64 @@
#!/usr/bin/env bash
# i3bar-watchdog [session-pid] (module i3status-rust, novox/hq ADR 0208): respawns a bar that died.
#
# i3 starts one i3bar per `bar { }` block and never restarts one that exits, and a reload does not
# either. Changing the monitor setup reliably kills the bar that owns the tray. This brings back the
# one missing i3bar, without restarting i3, and logs the outputs at that moment: the evidence for the
# cause, which is i3bar's.
#
# Started once per session from the session's start, with the session's own pid; it ends when that
# process does. Adopted from the predecessor's i3-bar-watchdog user unit of 2026-10-04.
set -uo pipefail
session_pid="${1:-}"
interval="${I3_BAR_WATCHDOG_INTERVAL:-5}"
# Two misses in a row before acting: an i3 restart tears every bar down and starts them again.
confirm="${I3_BAR_WATCHDOG_CONFIRM:-2}"
# Never respawn the same bar more often than this, so a bar that dies at once is not a tight loop.
cooldown="${I3_BAR_WATCHDOG_COOLDOWN:-30}"
log() { printf 'i3bar-watchdog: %s\n' "$*" >&2; }
for tool in i3-msg i3bar pgrep; do
command -v "$tool" >/dev/null 2>&1 || {
log "$tool is missing; not watching"
exit 1
}
done
declare -A missed=() fixed=()
bar_ids() { i3-msg -t get_bar_config 2>/dev/null | grep -oE '"[^"]+"' | tr -d '"'; }
outputs() { xrandr --listmonitors 2>/dev/null | tail -n +2 | awk '{print $2" "$3}' | tr '\n' ' '; }
session_alive() { [ -z "$session_pid" ] || kill -0 "$session_pid" 2>/dev/null; }
while session_alive; do
sleep "$interval"
socket="$(i3 --get-socketpath 2>/dev/null)"
[ -n "$socket" ] && [ -S "$socket" ] || continue
ids="$(bar_ids)"
[ -n "$ids" ] || continue
while read -r id; do
[ -n "$id" ] || continue
if pgrep -u "$EUID" -f -- "i3bar --bar_id=$id" >/dev/null 2>&1; then
missed[$id]=0
continue
fi
missed[$id]=$((${missed[$id]:-0} + 1))
[ "${missed[$id]}" -ge "$confirm" ] || continue
now="$(date +%s)"
if [ $((now - ${fixed[$id]:-0})) -lt "$cooldown" ]; then
continue
fi
log "$id is gone; respawning it. Outputs now: $(outputs)"
nohup i3bar --bar_id="$id" --socket="$socket" >/dev/null 2>&1 &
disown 2>/dev/null || true
fixed[$id]="$now"
sleep 2
if pgrep -u "$EUID" -f -- "i3bar --bar_id=$id" >/dev/null 2>&1; then
missed[$id]=0
else
log "$id exited within 2s of respawning; check its status_command"
fi
done <<<"$ids"
done
+63
View File
@@ -0,0 +1,63 @@
#!/usr/bin/env bash
# i3status-updates [threshold] (module i3status-rust, novox/hq ADR 0208): the pending updates, for
# the bottom bar's custom block (json = true). Emits one JSON line.
#
# Adopted from the operator's pkg-updates of 2026-10-04. The native `packages` block showed a raw
# red error on every transient network failure, which on a roaming laptop is often. This keeps the
# last good result through a failure and shows an error only after `threshold` failures in a row
# (default 10). Official packages through checkupdates (pacman-contrib, a temporary database, no
# root); AUR packages through paru when it is installed, and none counted when it is not.
set -uo pipefail
threshold="${1:-10}"
icon="update"
state_dir="${XDG_RUNTIME_DIR:-/tmp}/i3status-updates"
mkdir -p "$state_dir"
last="$state_dir/last_good.json"
failures="$state_dir/failures"
json() { printf '{"icon":"%s","state":"%s","text":"%s","short_text":"%s"}' "$icon" "$1" "$2" "$3"; }
count() { [ -n "$1" ] && printf '%s\n' "$1" | grep -c . || echo 0; }
official="$(checkupdates 2>/dev/null)"
official_rc=$?
aur="" aur_rc=0
if command -v paru >/dev/null 2>&1; then
aur="$(paru -Qua 2>/dev/null)"
aur_rc=$?
fi
# checkupdates: 0 updates listed, 2 none pending; anything else is a failure.
if { [ "$official_rc" -eq 0 ] || [ "$official_rc" -eq 2 ]; } && [ "$aur_rc" -eq 0 ]; then
o="$(count "$official")"
a="$(count "$aur")"
total=$((o + a))
if [ "$total" -eq 0 ]; then
payload="$(json Good "up to date" "")"
else
# A kernel update wants a reboot: the one worth not putting off.
state=Info
printf '%s\n' "$official" | grep -qE '^(linux|linux-lts|linux-zen) ' && state=Warning
label="$o + $a = $total updates"
[ "$a" -eq 0 ] && label="$total updates"
[ "$total" -eq 1 ] && label="1 update"
payload="$(json "$state" "$label" "$total")"
fi
printf '%s' "$payload" >"$last"
printf '0' >"$failures"
printf '%s\n' "$payload"
exit 0
fi
n=$(($(cat "$failures" 2>/dev/null || echo 0) + 1))
printf '%s' "$n" >"$failures"
if [ "$n" -ge "$threshold" ]; then
json Critical "update check failing (${n}x)" "!"
echo
elif [ -s "$last" ]; then
cat "$last"
echo
else
json Idle "" ""
echo
fi
+100
View File
@@ -0,0 +1,100 @@
# The bottom bar (module i3status-rust, novox/hq ADR 0208). Owned by the mesh: replaced at every
# push. Adopted from the workstations of 2026-10-04 with what follows a machine's hardware or one
# person's devices left out: the battery and the GPU belong to a machine model's hardware module,
# and a headset's address is not the bar's to know. Every block here works the same on any machine.
icons_format = "{icon}"
[theme]
theme = "plain"
[theme.overrides]
idle_bg = "#000000"
idle_fg = "#f1f1f1"
info_bg = "#000000"
info_fg = "#f1f1f1"
good_bg = "#000000"
good_fg = "#859900"
warning_bg = "#000000"
warning_fg = "#de5200"
critical_bg = "#000000"
critical_fg = "#ff0000"
separator = " | "
separator_fg = "#ffffff"
separator_bg = "#000000"
[icons]
icons = "custom-icons"
[[block]]
block = "tea_timer"
format = "$icon{ $minutes:$seconds|}"
done_cmd = "notify-send 'Timer finished'"
# Pending updates: official and, where an AUR helper is installed, AUR. The script keeps the last good
# count through a network gap and shows an error only after ten failed checks in a row. Click: the
# list, in the launcher's menu.
[[block]]
block = "custom"
command = "~/.local/bin/i3status-updates 10"
json = true
interval = 1800
[[block.click]]
button = "left"
cmd = "checkupdates | dmenu -l 20 -p Updates"
[[block]]
block = "cpu"
[[block]]
block = "disk_space"
path = "/"
info_type = "used"
alert_unit = "GB"
interval = 20
warning = 850
alert = 920
format = "$icon / $used.eng(w:2) ($percentage.eng(w:2))"
[[block]]
block = "disk_space"
path = "/home"
info_type = "used"
alert_unit = "GB"
interval = 20
warning = 850
alert = 920
format = "$icon /home $used.eng(w:2) ($percentage.eng(w:2))"
[[block]]
block = "memory"
format = "$icon $mem_total_used.eng(w:2) ($mem_total_used_percents.eng(w:2))"
format_alt = "$icon_swap $swap_used.eng(w:2) ($swap_used_percents.eng(w:2))"
[[block]]
block = "docker"
interval = 2
format = "$icon $running/$total"
[[block]]
block = "sound"
[[block.click]]
button = "left"
cmd = "pavucontrol"
# The weather from the Norwegian Meteorological Institute, which needs no key, where the machine is.
[[block]]
block = "weather"
format = "$icon $weather_verbose ($location) $temp"
autolocate = true
autolocate_interval = 600
[block.service]
name = "metno"
[[block]]
block = "notify"
driver = "dunst"
format = " $icon {($notification_count.eng(w:1))|}"
[[block]]
block = "time"
interval = 1
format = "$timestamp.datetime(f:'%a %d/%m %H:%M:%S')"
@@ -0,0 +1,34 @@
# The bars (module i3status-rust, novox/hq ADR 0208). Owned by the mesh: replaced at every push.
# i3 reads this file through its configuration's `include ~/.config/i3/config.d/*.conf`. The bottom
# bar shows the machine; the top bar the focused window and the tray, on the primary output. The
# face is the monospace one every desktop module names.
bar {
font pango:JetBrainsMono Nerd Font 11
position bottom
status_command i3status-rs ~/.config/i3status-rust/bottom-bar.toml
tray_output none
colors {
separator #ffffff
background #000000
statusline #ffffff
# The text on an accent-coloured button is dark: light text on the accent was barely
# legible.
focused_workspace #de5200 #de5200 #000000
active_workspace #de5200 #de5200 #000000
inactive_workspace #000000 #000000 #ffffff
urgent_workspace #2f343a #900000 #ffffff
}
}
bar {
font pango:JetBrainsMono Nerd Font 11
position top
status_command i3status-rs ~/.config/i3status-rust/top-bar.toml
workspace_buttons no
tray_output primary
colors {
separator #ffffff
background #000000
statusline #ffffff
}
}
@@ -0,0 +1,135 @@
# Icons for the bars (module i3status-rust, novox/hq ADR 0208), from the JetBrains Mono Nerd Font.
# Owned by the mesh: replaced at every push. Adopted unchanged from the workstations of 2026-10-04.
# Material from NerdFont
# https://www.nerdfonts.com/cheat-sheet
backlight = [
"\ue38d", # nf-weather-moon_new
"\ue3d4", # nf-weather-moon_alt_waxing_gibbous_6
"\ue3d3", # nf-weather-moon_alt_waxing_gibbous_5
"\ue3d2", # nf-weather-moon_alt_waxing_gibbous_4
"\ue3d1", # nf-weather-moon_alt_waxing_gibbous_3
"\ue3d0", # nf-weather-moon_alt_waxing_gibbous_2
"\ue3cf", # nf-weather-moon_alt_waxing_gibbous_1
"\ue3ce", # nf-weather-moon_alt_first_quarter
"\ue3cd", # nf-weather-moon_alt_waxing_crescent_6
"\ue3cc", # nf-weather-moon_alt_waxing_crescent_5
"\ue3cb", # nf-weather-moon_alt_waxing_crescent_4
"\ue3ca", # nf-weather-moon_alt_waxing_crescent_3
"\ue3c9", # nf-weather-moon_alt_waxing_crescent_2
"\ue3c8", # nf-weather-moon_alt_waxing_crescent_1
"\ue39b", # nf-weather-moon_full
]
bat_charging = "\U000f0084" # nf-md-battery_charging
bat_not_available = "\U000f0091" # nf-md-battery_unknown
bat = [
"\U000f007a", # nf-md-battery_10
"\U000f007b", # nf-md-battery_20
"\U000f007c", # nf-md-battery_30
"\U000f007d", # nf-md-battery_40
"\U000f007e", # nf-md-battery_50
"\U000f007f", # nf-md-battery_60
"\U000f0080", # nf-md-battery_70
"\U000f0081", # nf-md-battery_80
"\U000f0082", # nf-md-battery_90
"\U000f0079", # nf-md-battery
]
bell = "\U000f009c" # nf-md-bell_outline
bell-slash = "\U000f009b" # nf-md-bell_off
blackberry = "\uf307" # nf-md-blackberry
bluetooth = "\U000f00af" # nf-md-bluetooth
calendar = "\U000f00ed" # nf-md-calendar
cogs = "\U000f0493" # nf-md-cog
cpu = [
"\U000F0F86", # nf-md-speedometer_slow
"\U000F0F85", # nf-md-speedometer_medium
"\U000F04C5", # nf-md-speedometer
]
cpu_boost_on = "\U000f0521" # nf-md-toggle_switch
cpu_boost_off = "\U000f0a19" # nf-md-toggle_switch_off_outline
disk_drive = "\U000f02ca" # nf-md-harddisk
docker = "\uf308" # nf-linux-docker
github = "\U000f02a4" # nf-md-github
gpu = "\U000f0379" # nf-md-monitor
headphones = "\U000f02cb" # nf-md-headphones
joystick = "\U000f0297" # nf-md-gamepad_variant
keyboard = "\U000f030c" # nf-md-keyboard
mail = "\U000f01ee" # nf-md-email
memory_mem = "\U000f035b" # nf-md-memory
memory_swap = "\U000f02ca" # nf-md-harddisk
mouse = "\U000f037d" # nf-md-mouse
music = "\U000f075a" # nf-md-music
music_next = "\U000f04ad" # nf-md-skip_next
music_pause = "\U000f03e4" # nf-md-pause
music_play = "\U000f040a" # nf-md-play
music_prev = "\U000f04ae" # nf-md-skip_previous
net_bridge = "\U000f04aa" # nf-md-sitemap
net_down = "\U000f01da" # nf-md-download
net_loopback = "\U000f006f" # nf-md-backup_restore
net_modem = "\U000f03f2" # nf-md-phone
net_cellular = [
"\U000F08FD", # nf-md-network_strength_off_outline
"\U000F08FE", # nf-md-network_strength_outline
"\U000F08F4", # nf-md-network_strength_1
"\U000F08F6", # nf-md-network_strength_2
"\U000F08F8", # nf-md-network_strength_3
"\U000F08FA", # nf-md-network_strength_4
]
net_up = "\U000f0552" # nf-md-upload
net_vpn = "\U000f0582" # nf-md-vpn
net_wired = "\U000f0200" # nf-md-ethernet
net_wireless = [
"\U000F092F", # nf-md-wifi_strength_outline
"\U000F091F", # nf-md-wifi_strength_1
"\U000F0922", # nf-md-wifi_strength_2
"\U000F0925", # nf-md-wifi_strength_3
"\U000F0928", # nf-md-wifi_strength_4
]
notification = "\U000f009c" # nf-md-bell_outline
phone = "\U000f03f2" # nf-md-phone
phone_disconnected = "\U000f0658" # nf-md-phone_minus
ping = "\U000f051f" # nf-md-timer_sand
pomodoro = "\ue001" # nf-pom-pomodoro_done
pomodoro_break = "\U000f0176" # nf-md-coffee
pomodoro_paused = "\U000f03e4" # nf-md-pause
pomodoro_started = "\U000f040a" # nf-md-play
pomodoro_stopped = "\U000f04db" # nf-md-stop
resolution = "\U000f0293" # nf-md-fullscreen
tasks = "\U000f05c7" # nf-md-playlist_check
tea = "\U000f0d9e" # nf-md-tea
thermometer = [
"\U000f10c3", # nf-md-thermometer_low
"\U000f050f", # nf-md-thermometer
"\U000f10c2", # nf-md-thermometer_high
]
time = "\U000f0150" # nf-md-clock_outline
toggle_off = "\U000f0a19" # nf-md-toggle_switch_off_outline
toggle_on = "\U000f0521" # nf-md-toggle_switch
unknown = "\U000f0186" # nf-md-comment_question_outline | TODO: Make default?
update = "\U000f03d5" # nf-md-package_up
uptime = "\U000f0153" # nf-md-clock_in
volume_muted = "\U000f075f" # nf-md-volume_mute
volume = [
"\U000f057f", # nf-md-volume_low
"\U000f0580", # nf-md-volume_medium
"\U000f057e", # nf-md-volume_high
]
microphone_muted = "\U000f036d" # nf-md-microphone_off
microphone = [
"\U000f036e", # nf-md-microphone_outline
"\U000f036c", # nf-md-microphone
"\U000f036c", # nf-md-microphone
]
xrandr = "\U000f037a" # nf-md-monitor_multiple
# Weather icons (https://greshake.github.io/i3status-rust/i3status_rs/blocks/weather/index.html)
weather_sun = "\ue30d" # nf-weather-day_sunny (when weather is reported as “Clear” during the day)
weather_moon = "\ue32b" # nf-weather-night_clear (when weather is reported as “Clear” at night)
weather_clouds = "\ue312" # nf-weather-cloudy (when weather is reported as “Clouds” during the day)
weather_clouds_night = "\ue37e" # nf-weather-night_alt_cloudy (when weather is reported as “Clouds” at night)
weather_fog = "\ue313" # nf-weather-fog (when weather is reported as “Fog” or “Mist” during the day)
weather_fog_night = "\ue346" # nf-weather-night_fog (when weather is reported as “Fog” or “Mist” at night)
weather_rain = "\ue318" # nf-weather-rain (when weather is reported as “Rain” or “Drizzle” during the day)
weather_rain_night = "\ue325" # nf-weather-night_alt_rain (when weather is reported as “Rain” or “Drizzle” at night)
weather_snow = "\ue31a" # nf-weather-snow (when weather is reported as “Snow”)
weather_thunder = "\ue31d" # nf-weather-thunderstorm (when weather is reported as “Thunderstorm” during the day)
weather_thunder_night = "\ue32a" # nf-weather-night_alt_thunderstorm (when weather is reported as “Thunderstorm” at night)
+33
View File
@@ -0,0 +1,33 @@
# The top bar (module i3status-rust, novox/hq ADR 0208): the focused window's title and the uptime.
# Owned by the mesh: replaced at every push. Adopted unchanged from the workstations of 2026-10-04.
icons_format = "{icon}"
[theme]
theme = "plain"
[theme.overrides]
idle_bg = "#000000"
idle_fg = "#f1f1f1"
info_bg = "#000000"
info_fg = "#f1f1f1"
good_bg = "#000000"
good_fg = "#859900"
warning_bg = "#000000"
warning_fg = "#de5200"
critical_bg = "#000000"
critical_fg = "#ff0000"
separator = " | "
separator_fg = "#ffffff"
separator_bg = "#000000"
[icons]
icons = "custom-icons"
[[block]]
block = "focused_window"
[block.format]
full = " $title.str(max_w:50) |"
short = " $title.str(max_w:100) |"
[[block]]
block = "uptime"
interval = 60
+5
View File
@@ -0,0 +1,5 @@
module i3statusrust
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=
File diff suppressed because one or more lines are too long
+42
View File
@@ -0,0 +1,42 @@
{
"module": "node-env",
"version": "1",
"claims": [
{
"name": "node-environment",
"scope": "node"
}
],
"resources": [
{
"id": "mesh-config-dir",
"type": "directory",
"path": "${machine:account-home}/.config/mesh",
"owner": "${machine:account}",
"mode": "0755"
},
{
"id": "environment-d",
"type": "directory",
"path": "${machine:account-home}/.config/environment.d",
"owner": "${machine:account}",
"mode": "0755"
},
{
"id": "posix",
"type": "file",
"path": "${machine:account-home}/.config/mesh/environment.sh",
"owner": "${machine:account}",
"mode": "0644",
"content": "# The operator account's environment, generated by the mesh (module node-env, novox/hq ADR 0203).\n# Do not edit: this file is replaced at every push. Every line names the module that contributed it.\n# Sourced by the login shell from its always-read startup file (for zsh, ~/.zshenv), so a script, a\n# login and the shell's execute verb all see it. Your own variables belong in your shell's own lines.\n${environment:posix}"
},
{
"id": "systemd",
"type": "file",
"path": "${machine:account-home}/.config/environment.d/50-mesh.conf",
"owner": "${machine:account}",
"mode": "0644",
"content": "# The operator account's environment for its service manager and graphical session, generated by\n# the mesh (module node-env, novox/hq ADR 0203). Do not edit: this file is replaced at every push.\n# The same facts as ~/.config/mesh/environment.sh, in environment.d(5) syntax.\n${environment:systemd}"
}
]
}
+1 -1
View File
@@ -45,7 +45,7 @@
"image": "registry-api.novox.be/novox/photos-client@sha256:f87d63ee7bfb44c9f9748b99be6ba6dc0daf955a1d463699e9e7e3009693c0ab",
"network": "photos-eef",
"ports": [
"80"
"4012:80"
],
"names-on-purpose": {
"registry-api.novox.be": "built outside the mesh, from the application's own repository, and pulled from the registry that built it; moves when that repository is a build source on the git seat (novox/hq ADR 0155, issue 122)"
+1 -1
View File
@@ -45,7 +45,7 @@
"image": "registry-api.novox.be/novox/photos-client@sha256:f87d63ee7bfb44c9f9748b99be6ba6dc0daf955a1d463699e9e7e3009693c0ab",
"network": "photos-filip",
"ports": [
"80"
"4013:80"
],
"names-on-purpose": {
"registry-api.novox.be": "built outside the mesh, from the application's own repository, and pulled from the registry that built it; moves when that repository is a build source on the git seat (novox/hq ADR 0155, issue 122)"
+1 -1
View File
@@ -89,7 +89,7 @@
"image": "registry-api.novox.be/novox/photos-admin-client@sha256:f437fa9ed28b29a012f715fb8d9b809a15cff4a672794c620d5d400f57695580",
"network": "photos",
"ports": [
"80"
"4001:80"
],
"names-on-purpose": {
"registry-api.novox.be": "built outside the mesh, from the application's own repository, and pulled from the registry that built it; moves when that repository is a build source on the git seat (novox/hq ADR 0155, issue 122)"
+62
View File
@@ -0,0 +1,62 @@
# picom
The X compositor as a module (novox/hq ADR 0208, research 026).
- Installs `picom`, and `xorg-xprop` for setting a window's own opacity.
- Claims the mesh's `node-compositor` seat (no verbs yet, ADR 0208 §2) and requires `x11-display`.
That requirement has the machine's reach: the display server must be held on this module's own
machine, or assignment is refused, naming the seat's holders.
- Owns `~/.config/picom/` and `~/.config/picom/picom.conf`, which it writes whole at every push.
- **Adds no start of its own.** picom's package ships an XDG autostart entry
(`/etc/xdg/autostart/picom.desktop`), which the session runs: the `i3` module's `dex --autostart`.
That is the tool's own grain (ADR 0208 §4) and its one start. The desktop modules follow one rule:
a process has one starter. Its package's autostart entry is that starter where there is one, and
the `xinitrc` slot where there is none.
## Tools
Served by the node's runtime as the operator account (ADR 0175). The tools find the operator's X
session from the window manager's own environment, and say so plainly when nobody is logged in.
| tool | does |
|---|---|
| `picom_restart` | a running picom re-reads its file (`SIGUSR1`); `hard`, or none running, starts a fresh one |
| `picom_rules` | the global options and the window rules in force, in picom's order, and whether it runs |
| `picom_window_opacity` | read or set one window's own opacity, the focused window by default |
| `picom_toggle` | compositing off or on, for a game or a test; the next login starts it again |
A picom a tool starts runs under the account's own service manager (`systemd-run --user`, unit
`picom`). As a child of the runtime it would die whenever the runtime restarts.
## What it improves on what was found
- **Window rules** replace `opacity-rule`, `inactive-opacity` and `inactive-dim`, which picom 12 and
later supersede. The behaviour is the same: only terminals are translucent (95 % focused, 75 %
unfocused). The browser and video "pin to 100 %" rule went, because nothing else is made
translucent any more.
- **Full-screen windows are opaque,** a terminal included.
- **One start.** Today both the window manager's configuration and the autostart entry start picom,
and the second one exits.
- On the desktop, picom was not running at all on the day it was measured, although both starts were
in place. It probably fails to start on that machine's GPU. `picom_restart` with `hard` after
assignment answers with what happened. The next step is the `picom` user unit's journal.
## What it leaves as found
The window manager's own `exec --no-startup-id picom` line belongs to the `i3` module's configuration,
which no longer carries it. Nothing else of picom's lies outside the directory this module owns.
## Migration (ADR 0182)
- The first push keeps the found `picom.conf` once, then writes the module's.
- Until the `i3` module replaces the hand-kept `~/.config/i3/config`, its `exec picom` line starts a
second picom. The second one exits at once, because a compositor is already running. Nothing to
do, unless the line is still there after `i3` is assigned.
## Blockers
- The seat `node-compositor` and the provision `x11-display` are ADR 0208's.
Until the controller knows them, `mctl` reads the claim and the requirement as unknown, and refuses
the module.
- `xorg-xprop` is declared here because the `xorg` module does not install it. If `xorg` comes to
declare it, it leaves this module, since a package is declared once per node.
+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
}
+89
View File
@@ -0,0 +1,89 @@
// picom's Go tools bundle (novox/hq ADR 0188, ADR 0193, ADR 0208): the compositor's own tools, served
// by the node's runtime as the operator account. node-compositor has no verbs yet (ADR 0208 §2), so
// every tool here is the module's own.
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: "picom_restart",
Description: "Make the compositor re-read its configuration: a running picom is told to reinitialise " +
"(SIGUSR1); with hard, or when none runs, a fresh one is started in the operator's session under " +
"their service manager. Answers what was done and the pids.",
Input: map[string]any{
"hard": map[string]any{"type": "boolean", "description": "end the running picom and start a new one (default false)"},
},
Run: func(args map[string]any) (any, error) {
hard, err := flag(args, "hard", false)
if err != nil {
return nil, err
}
return Restart(hard)
},
},
{
Name: "picom_rules",
Description: "The compositor's configuration in force: its global options and each window rule " +
"(match, opacity, shadow, fade…) in the order picom applies them, from the file the mesh placed, " +
"and whether picom is running.",
Run: func(map[string]any) (any, error) { return Rules(configPath()) },
},
{
Name: "picom_window_opacity",
Description: "Read or set one window's own opacity (_NET_WM_WINDOW_OPACITY), the focused window " +
"unless one is named. A window rule that sets opacity (terminals) outranks it. Lasts as long as " +
"the window; the declared rules are untouched.",
Input: map[string]any{
"window": map[string]any{"type": "string", "description": "X window id, 0x… or decimal (default: the focused window)"},
"opacity": map[string]any{"type": "integer", "description": "0-100 to set; 100 removes the window's own value; omit to read"},
},
Run: func(args map[string]any) (any, error) {
window, err := text(args, "window", false)
if err != nil {
return nil, err
}
if _, set := args["opacity"]; !set {
return WindowOpacity(window, -1)
}
opacity, err := whole(args, "opacity", 100, 0, 100)
if err != nil {
return nil, err
}
return WindowOpacity(window, opacity)
},
},
{
Name: "picom_toggle",
Description: "Turn compositing off or on in the operator's session, for a game or a test: off ends " +
"picom, on starts it. Without `on`, flips it. The session start brings it back at the next login.",
Input: map[string]any{
"on": map[string]any{"type": "boolean", "description": "true to start, false to stop (default: the opposite of now)"},
},
Run: func(args map[string]any) (any, error) {
var want *bool
if _, given := args["on"]; given {
on, err := flag(args, "on", false)
if err != nil {
return nil, err
}
want = &on
}
return Toggle(want)
},
},
}
}
@@ -0,0 +1,175 @@
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"`
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)
}
}
}
@@ -0,0 +1,61 @@
package main
import (
"reflect"
"strings"
"testing"
)
// picom's shape (novox/hq ADR 0208): it claims node-compositor and serves no verb of it, requires the
// X display on its own machine, owns its configuration file, and is started once, by its package's
// XDG autostart entry, never by the window manager or the session's start as well.
func TestItClaimsTheCompositorSeatAndRequiresTheXDisplay(t *testing.T) {
m := readManifest(t)
if m.Module != "picom" || m.Seats != nil {
t.Fatalf("module %q declares seats %v", m.Module, m.Seats)
}
if !reflect.DeepEqual(m.Claims, []claim{{Name: "node-compositor", Scope: "node"}}) {
t.Fatalf("claims: %+v", m.Claims)
}
if !reflect.DeepEqual(m.Requires, []string{"x11-display"}) {
t.Fatalf("requires: %v", m.Requires)
}
if present, absent := m.packages(); !reflect.DeepEqual(present, []string{"picom", "xorg-xprop"}) || absent != nil {
t.Fatalf("packages: %v, absent %v", present, absent)
}
}
func TestItOwnsItsConfigurationAsWrittenInTheModule(t *testing.T) {
m := readManifest(t)
m.sameAsSource(t, "configuration", "files/picom.conf")
if p := m.resource(t, "configuration")["path"]; p != "${machine:account-home}/.config/picom/picom.conf" {
t.Fatalf("path: %v", p)
}
if d := m.resource(t, "configuration-dir"); d["type"] != "directory" || d["path"] != "${machine:account-home}/.config/picom" {
t.Fatalf("the directory: %v", d)
}
}
func TestThePackagesAutostartEntryIsItsOnlyStart(t *testing.T) {
m := readManifest(t)
// picom's package ships /etc/xdg/autostart/picom.desktop, which the session's dex runs. A second
// start from here would be a second compositor, refused by the first.
if m.Shell != nil {
t.Fatalf("a session-start line as well as the package's autostart entry: %+v", m.Shell)
}
for _, r := range m.Resources {
if p, _ := r["path"].(string); strings.Contains(p, "i3/config.d") || strings.Contains(p, "autostart") {
t.Fatalf("a second start: %v", r)
}
if r["type"] == "service" || r["type"] == "process" {
t.Fatalf("a unit: %v", r)
}
}
}
func TestTheToolsAgreeWithTheManifest(t *testing.T) {
m := readManifest(t)
checkTheToolsAgree(t, m)
checkNoSecretsOrInstallationNames(t)
}
+293
View File
@@ -0,0 +1,293 @@
package main
import (
"errors"
"fmt"
"math"
"os"
"path/filepath"
"regexp"
"strconv"
"strings"
"syscall"
"time"
)
// unit is the name picom runs under in the account's service manager when a tool starts it. The
// session start runs its own, and a tool that starts one first ends any other (Toggle, Restart).
const unit = "picom"
func configPath() string { return filepath.Join(operatorHome(), ".config", "picom", "picom.conf") }
// Running is picom's processes of this account.
func running() []int { return processesOf("picom") }
// RestartResult is what picom_restart answers.
type RestartResult struct {
Action string `json:"action"`
PIDs []int `json:"pids"`
Note string `json:"note,omitempty"`
}
// Restart tells a running picom to reinitialise, or starts one.
func Restart(hard bool) (RestartResult, error) {
if pids := running(); len(pids) > 0 && !hard {
return RestartResult{Action: "reinitialised", PIDs: signalAll("picom", syscall.SIGUSR1),
Note: "picom re-read " + configPath()}, nil
}
s, err := findSession()
if err != nil {
return RestartResult{}, err
}
if err := stopAll(); err != nil {
return RestartResult{}, err
}
if err := s.detach(unit, "picom", "--config", configPath()); err != nil {
return RestartResult{}, err
}
pids := waitFor(func() []int { return running() }, 3*time.Second)
return RestartResult{Action: "started", PIDs: pids,
Note: "started under the account's service manager as " + unit + ".service"}, nil
}
// stopAll ends every picom of this account and waits for them to go.
func stopAll() error {
signalAll("picom", syscall.SIGTERM)
deadline := time.Now().Add(3 * time.Second)
for time.Now().Before(deadline) {
if len(running()) == 0 {
return nil
}
time.Sleep(100 * time.Millisecond)
}
if left := running(); len(left) > 0 {
return fmt.Errorf("picom %v did not end within 3s", left)
}
return nil
}
func waitFor(get func() []int, within time.Duration) []int {
deadline := time.Now().Add(within)
for {
if got := get(); len(got) > 0 || time.Now().After(deadline) {
return got
}
time.Sleep(100 * time.Millisecond)
}
}
// ToggleResult is what picom_toggle answers.
type ToggleResult struct {
Compositing bool `json:"compositing"`
PIDs []int `json:"pids"`
Note string `json:"note"`
}
// Toggle stops or starts compositing; want nil flips it.
func Toggle(want *bool) (ToggleResult, error) {
on := len(running()) == 0
if want != nil {
on = *want
}
if !on {
if err := stopAll(); err != nil {
return ToggleResult{}, err
}
return ToggleResult{Compositing: false, PIDs: []int{},
Note: "picom ended; the session start runs it again at the next login, or call picom_toggle with on"}, nil
}
if pids := running(); len(pids) > 0 {
return ToggleResult{Compositing: true, PIDs: pids, Note: "picom was already running"}, nil
}
r, err := Restart(true)
if err != nil {
return ToggleResult{}, err
}
return ToggleResult{Compositing: len(r.PIDs) > 0, PIDs: r.PIDs, Note: r.Note}, nil
}
// Rule is one window rule, its options as written.
type Rule struct {
Match string `json:"match"`
Options map[string]string `json:"options"`
}
// RulesResult is what picom_rules answers.
type RulesResult struct {
File string `json:"file"`
Running []int `json:"running"`
Global map[string]string `json:"global"`
Rules []Rule `json:"rules"`
// Legacy names options that window rules supersede, which picom ignores when rules are set.
Legacy []string `json:"legacy,omitempty"`
}
var (
commentLine = regexp.MustCompile(`(?m)^\s*#.*$`)
rulesBlock = regexp.MustCompile(`(?s)\brules\s*[=:]\s*\((.*?)\)\s*;`)
ruleGroup = regexp.MustCompile(`(?s)\{(.*?)\}`)
assignment = regexp.MustCompile(`(?m)^\s*([A-Za-z][A-Za-z0-9-]*)\s*[=:]\s*(.+?)\s*;?\s*$`)
)
// superseded are the options picom's window rules replace (picom(1), WINDOW RULES).
var superseded = []string{"opacity-rule", "inactive-opacity", "active-opacity", "inactive-dim",
"shadow-exclude", "fade-exclude", "focus-exclude", "rounded-corners-exclude", "blur-background-exclude",
"corner-radius-rules", "wintypes", "inactive-opacity-override", "mark-wmwin-focused"}
// Rules reads picom's configuration file into its global options and its window rules.
func Rules(path string) (RulesResult, error) {
raw, err := os.ReadFile(path)
if err != nil {
return RulesResult{}, fmt.Errorf("picom's configuration: %w", err)
}
return parseRules(path, string(raw), running()), nil
}
func parseRules(path, conf string, pids []int) RulesResult {
out := RulesResult{File: path, Running: pids, Global: map[string]string{}, Rules: []Rule{}}
conf = commentLine.ReplaceAllString(conf, "")
rest := conf
if m := rulesBlock.FindStringSubmatchIndex(conf); m != nil {
body := conf[m[2]:m[3]]
rest = conf[:m[0]] + conf[m[1]:]
for _, g := range ruleGroup.FindAllStringSubmatch(body, -1) {
r := Rule{Options: map[string]string{}}
for _, part := range strings.Split(g[1], ";") {
k, v, ok := strings.Cut(part, "=")
if !ok {
continue
}
k, v = strings.TrimSpace(k), strings.TrimSpace(v)
if k == "match" {
r.Match = unquote(v)
} else {
r.Options[k] = unquote(v)
}
}
out.Rules = append(out.Rules, r)
}
}
for _, m := range assignment.FindAllStringSubmatch(rest, -1) {
out.Global[m[1]] = unquote(strings.TrimSuffix(m[2], ";"))
}
if len(out.Rules) > 0 {
for _, name := range superseded {
if _, set := out.Global[name]; set {
out.Legacy = append(out.Legacy, name)
}
}
}
return out
}
func unquote(v string) string {
v = strings.TrimSpace(v)
if len(v) >= 2 && v[0] == '"' && v[len(v)-1] == '"' {
return v[1 : len(v)-1]
}
return v
}
// OpacityResult is what picom_window_opacity answers.
type OpacityResult struct {
Window string `json:"window"`
Class string `json:"class,omitempty"`
Title string `json:"title,omitempty"`
// Opacity is the window's own value in percent; nil when it has none.
Opacity *int `json:"opacity"`
Note string `json:"note,omitempty"`
}
var (
activeWindow = regexp.MustCompile(`window id # (0x[0-9a-fA-F]+)`)
cardinal = regexp.MustCompile(`_NET_WM_WINDOW_OPACITY\(CARDINAL\) = (\d+)`)
wmClass = regexp.MustCompile(`WM_CLASS\(STRING\) = "[^"]*", "([^"]*)"`)
wmName = regexp.MustCompile(`_NET_WM_NAME\(UTF8_STRING\) = "(.*)"`)
)
// WindowOpacity reads a window's own opacity, and sets it when percent is 0-100.
func WindowOpacity(window string, percent int) (OpacityResult, error) {
s, err := findSession()
if err != nil {
return OpacityResult{}, err
}
if window == "" {
r, err := s.run(5*time.Second, "", "xprop", "-root", "_NET_ACTIVE_WINDOW")
if err != nil {
return OpacityResult{}, err
}
m := activeWindow.FindStringSubmatch(r.Stdout)
if m == nil || m[1] == "0x0" {
return OpacityResult{}, errors.New("no window is focused")
}
window = m[1]
}
id, err := windowID(window)
if err != nil {
return OpacityResult{}, err
}
if percent >= 0 {
var r Result
if percent == 100 {
r, err = s.run(5*time.Second, "", "xprop", "-id", id, "-remove", "_NET_WM_WINDOW_OPACITY")
} else {
r, err = s.run(5*time.Second, "", "xprop", "-id", id, "-f", "_NET_WM_WINDOW_OPACITY", "32c",
"-set", "_NET_WM_WINDOW_OPACITY", strconv.FormatUint(opacityCardinal(percent), 10))
}
if err != nil {
return OpacityResult{}, err
}
if r.Code != 0 {
return OpacityResult{}, fmt.Errorf("xprop: %s", strings.TrimSpace(r.Stderr))
}
}
r, err := s.run(5*time.Second, "", "xprop", "-id", id, "_NET_WM_WINDOW_OPACITY", "WM_CLASS", "_NET_WM_NAME")
if err != nil {
return OpacityResult{}, err
}
if r.Code != 0 {
return OpacityResult{}, fmt.Errorf("window %s: %s", id, strings.TrimSpace(r.Stderr))
}
out := parseWindow(id, r.Stdout)
if rules, err := Rules(configPath()); err == nil && out.Class != "" {
for _, rule := range rules.Rules {
if _, sets := rule.Options["opacity"]; sets && strings.Contains(rule.Match, "class_g = '"+out.Class+"'") {
out.Note = "a window rule sets the opacity of class " + out.Class + " and outranks the window's own value"
}
}
}
return out, nil
}
func windowID(w string) (string, error) {
w = strings.TrimSpace(strings.ToLower(w))
base := 10
if strings.HasPrefix(w, "0x") {
w, base = w[2:], 16
}
n, err := strconv.ParseUint(w, base, 32)
if err != nil || n == 0 {
return "", fmt.Errorf("window %q is not an X window id", w)
}
return fmt.Sprintf("0x%x", n), nil
}
func opacityCardinal(percent int) uint64 {
return uint64(math.Round(float64(percent) / 100 * float64(math.MaxUint32)))
}
func parseWindow(id, out string) OpacityResult {
r := OpacityResult{Window: id}
if m := cardinal.FindStringSubmatch(out); m != nil {
n, _ := strconv.ParseUint(m[1], 10, 64)
p := int(math.Round(float64(n) / float64(math.MaxUint32) * 100))
r.Opacity = &p
}
if m := wmClass.FindStringSubmatch(out); m != nil {
r.Class = m[1]
}
if m := wmName.FindStringSubmatch(out); m != nil {
r.Title = m[1]
}
return r
}
+131
View File
@@ -0,0 +1,131 @@
package main
import (
"errors"
"os"
"path/filepath"
"strings"
"testing"
)
// A pid above the kernel's largest, so a signal a test sends reaches nothing.
const nobody = 4194400
func TestTheModulesOwnConfigurationReadsAsRulesInOrder(t *testing.T) {
raw, err := os.ReadFile(filepath.Join("..", "..", "files", "picom.conf"))
if err != nil {
t.Fatal(err)
}
r := parseRules("picom.conf", string(raw), nil)
if r.Global["backend"] != "glx" || r.Global["vsync"] != "true" || r.Global["shadow"] != "false" {
t.Fatalf("global options: %v", r.Global)
}
if len(r.Rules) != 5 {
t.Fatalf("five rules: %+v", r.Rules)
}
focused, unfocused := r.Rules[2], r.Rules[3]
if focused.Match != "class_g = 'XTerm' && focused" || focused.Options["opacity"] != "0.95" ||
unfocused.Match != "class_g = 'XTerm' && !focused" || unfocused.Options["opacity"] != "0.75" {
t.Fatalf("the terminal rules: %+v %+v", focused, unfocused)
}
if last := r.Rules[4]; last.Match != "fullscreen" || last.Options["opacity"] != "1" {
t.Fatalf("full screen is opaque, and last so it wins: %+v", last)
}
if len(r.Legacy) != 0 {
t.Fatalf("the module's file sets no option the rules supersede: %v", r.Legacy)
}
if _, inGlobal := r.Global["match"]; inGlobal {
t.Fatal("a rule's key leaked into the global options")
}
}
func TestTodaysFileIsReportedForTheOptionsItsRulesWouldIgnore(t *testing.T) {
conf := "inactive-opacity = 1.0;\nopacity-rule = [ \"95:class_g = 'XTerm'\" ];\nrules = (\n { match = \"focused\"; opacity = 1; }\n);\n"
r := parseRules("x", conf, []int{7})
if len(r.Rules) != 1 || r.Rules[0].Match != "focused" || len(r.Running) != 1 {
t.Fatalf("%+v", r)
}
if strings.Join(r.Legacy, ",") != "opacity-rule,inactive-opacity" {
t.Fatalf("legacy: %v", r.Legacy)
}
}
func TestAWindowIdIsHexOrDecimalAndNeverZero(t *testing.T) {
for in, want := range map[string]string{"0x3C00012": "0x3c00012", "62914578": "0x3c00012"} {
if got, err := windowID(in); err != nil || got != want {
t.Errorf("%s: %s, %v", in, got, err)
}
}
for _, bad := range []string{"0", "0x0", "window", "-1", "0x1ffffffff"} {
if _, err := windowID(bad); err == nil {
t.Errorf("%s was accepted", bad)
}
}
}
func TestOpacityIsAPercentOfTheFullCardinal(t *testing.T) {
if opacityCardinal(0) != 0 || opacityCardinal(100) != 0xffffffff || opacityCardinal(75) != 3221225471 {
t.Fatalf("%d %d %d", opacityCardinal(0), opacityCardinal(100), opacityCardinal(75))
}
r := parseWindow("0x1", "_NET_WM_WINDOW_OPACITY(CARDINAL) = 3221225471\nWM_CLASS(STRING) = \"xterm\", \"XTerm\"\n_NET_WM_NAME(UTF8_STRING) = \"op@host: ~\"\n")
if r.Opacity == nil || *r.Opacity != 75 || r.Class != "XTerm" || r.Title != "op@host: ~" {
t.Fatalf("%+v", r)
}
if r := parseWindow("0x1", "_NET_WM_WINDOW_OPACITY: not found.\n"); r.Opacity != nil {
t.Fatalf("no value of its own: %+v", r)
}
}
func TestSettingTheFocusedWindowsOpacityAsksXpropAndNamesTheRuleThatOutranksIt(t *testing.T) {
root := fakeMachine(t)
fakeProcess(t, nobody, "i3", "DISPLAY=:1")
conf := filepath.Join(root, "home", ".config", "picom")
if err := os.MkdirAll(conf, 0o755); err != nil {
t.Fatal(err)
}
src, _ := os.ReadFile(filepath.Join("..", "..", "files", "picom.conf"))
if err := os.WriteFile(filepath.Join(conf, "picom.conf"), src, 0o644); err != nil {
t.Fatal(err)
}
bin := fakeBinaries(t, map[string]string{"xprop": `echo "xprop $* DISPLAY=$DISPLAY" >> "$LOG"
case "$*" in
"-root _NET_ACTIVE_WINDOW") echo "_NET_ACTIVE_WINDOW(WINDOW): window id # 0x3c00012" ;;
*WM_CLASS*) printf '_NET_WM_WINDOW_OPACITY(CARDINAL) = 2147483648\nWM_CLASS(STRING) = "xterm", "XTerm"\n' ;;
esac`})
t.Setenv("LOG", filepath.Join(bin, "log"))
r, err := WindowOpacity("", 50)
if err != nil {
t.Fatal(err)
}
if r.Window != "0x3c00012" || r.Opacity == nil || *r.Opacity != 50 || !strings.Contains(r.Note, "XTerm") {
t.Fatalf("%+v", r)
}
log, _ := os.ReadFile(filepath.Join(bin, "log"))
if !strings.Contains(string(log), "xprop -id 0x3c00012 -f _NET_WM_WINDOW_OPACITY 32c -set _NET_WM_WINDOW_OPACITY 2147483648 DISPLAY=:1") {
t.Fatalf("xprop was asked:\n%s", log)
}
}
func TestWithoutASessionTheToolsThatDrawSaySo(t *testing.T) {
fakeMachine(t)
if _, err := WindowOpacity("", -1); !errors.Is(err, ErrNoSession) {
t.Fatalf("window opacity: %v", err)
}
on := true
if _, err := Toggle(&on); !errors.Is(err, ErrNoSession) {
t.Fatalf("toggle on: %v", err)
}
}
func TestARunningPicomIsToldToReinitialiseNotRestarted(t *testing.T) {
fakeMachine(t)
fakeProcess(t, nobody, "picom", "DISPLAY=:1")
r, err := Restart(false)
if err != nil || r.Action != "reinitialised" {
t.Fatalf("%+v, %v", r, err)
}
on := true
if r, err := Toggle(&on); err != nil || !r.Compositing || r.PIDs[0] != nobody {
t.Fatalf("on while running: %+v, %v", r, err)
}
}
+423
View File
@@ -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
}

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