lemurs: the login manager holds node-login-manager, its config in the current format (hq ADR 0208)

The official package in place of lemurs-git, and /etc/lemurs/config.toml in lemurs
0.4's structure. It offers only the session scripts that modules place in
/etc/lemurs/wms and /etc/lemurs/wayland, never a package's bare desktop entry, which
skips the session's start. The service is enabled and never started, stopped or
restarted by a push.

The tools are the seat's sessions, the default session (lemurs's cache, through
sudo -n) and logins from the journal and lemurs's own log. The package swap from
lemurs-git is a one-off step for the operator, listed in the README.
This commit is contained in:
jochen
2026-10-04 13:21:47 +02:00
parent b2c170acda
commit 98eb3fe33c
14 changed files with 2351 additions and 0 deletions
+101
View File
@@ -0,0 +1,101 @@
# lemurs
The login manager as a module (novox/hq ADR 0208, research 026, to-be 42 phase 2, step 3).
- **Claims `node-login-manager`** and serves its verb `sessions`.
- **Package `lemurs`**, from the official repositories. It replaces the user repository's
`lemurs-git` that both workstations run.
- **Service `lemurs.service`, enabled at boot.** It is its own `display-manager.service` alias. Its
state is declared as nothing: a push never starts, stops or restarts the login manager, because
restarting it ends the session it started. A change to its configuration applies at its next
start (a reboot).
- **Gated by `package-manager`, `service-manager` and `seat`.** A machine without a display has
nothing to log into.
## What it owns
| path | class | what |
|---|---|---|
| `/etc/lemurs/config.toml` | owned (the found file is kept once, ADR 0102) | lemurs 0.4's configuration in its current format: the structure of the shipped file, every option present, as lemurs requires. The source is [`config/config.toml`](config/config.toml), carried whole in the manifest |
| `/etc/lemurs/xsessions/`, `/etc/lemurs/wayland-sessions/` | owned, empty | where the configuration points lemurs for desktop entries, so none is offered |
**Sessions are drop-ins.** The sessions offered are the executable files session modules place in
`/etc/lemurs/wms/` (X) and `/etc/lemurs/wayland/` (Wayland). The file's name is the session's name.
`i3` places `/etc/lemurs/wms/i3`, and `sway` will place its own in `wayland/`. The desktop entries
packages install (`/usr/share/xsessions/i3.desktop`, `i3-with-shmlog.desktop`) are no longer offered.
They start the window manager bare, skipping `~/.xinitrc`, which holds the environment, the
resources and every module's session lines. Today the workstations log in through exactly such an
entry (`i3`). It reaches the session's start only because `~/.xprofile` sources `~/.xinitrc`.
**What the configuration changes** from the shipped file, each marked `mesh:` in it:
- the two desktop-entry directories, as above;
- `switcher_visibility = "F3"`. The session switcher stays hidden as today, and F3 shows it once a
second session (sway) exists.
Everything else is lemurs's default, which is also what runs today: X on `:1`, tty 2, the cache in
`/var/cache/lemurs`, `remember = true`. The workstations' current file is two releases old. The
running `lemurs-git` ignores its X keys and uses these defaults already, which is why X is on `:1`
although the file says `:0`.
## Tools
| tool | | what |
|---|---|---|
| `node-login-manager.sessions` | r | each session offered: name, X11 or Wayland, script or desktop entry, the file and what it runs, whether lemurs can run it (a script that is not executable is skipped); the default session and account from the cache |
| `lemurs_default_session` | r/a | the preselected session; set it to one that is offered. It writes the cache through `sudo -n`, and shows when lemurs next starts |
| `lemurs_logins` | r | logins from the journal (opened, closed, failed passwords), and which entry lemurs started each session with (its own log) |
None needs the graphical session.
## What it improves
- the official package instead of a `-git` build from the user repository;
- the configuration in the format the binary reads. Today's file is mostly ignored, and the unmerged
`.pacnew` sits beside it;
- one session per session module, each starting through the session's start, and no bare desktop
entries;
- a dead entry gone: `/etc/lemurs/wms/i3wm` (`exec startx`) would start a second X server inside
the one lemurs started.
## What it leaves found
- `/etc/lemurs/xsetup.sh`, the package's;
- `/etc/pam.d/lemurs`, the package's (it unlocks the login keyring through `pam_gnome_keyring`);
- `/var/cache/lemurs`, lemurs's own.
## The one-off migration (ADR 0182)
1. **Replace the package by hand, once per workstation, at a moment you choose:**
`sudo pacman -S lemurs`, answering yes to removing `lemurs-git` (and `lemurs-git-debug` on the
laptop). The host cannot do this. `pacman -Q lemurs` answers with `lemurs-git`, which provides
`lemurs`, so the declared package already reads as installed. A non-interactive install would
also refuse the conflict. The binary is replaced on disk, and the running login manager keeps
running the old one until the next boot.
2. **Delete `/etc/lemurs/config.toml.pacnew`.** The module's file is that structure.
3. **Delete `/etc/lemurs/wms/i3wm`** once `i3` is assigned and `/etc/lemurs/wms/i3` exists.
4. **Delete `~/.xprofile`**, or its `. ~/.xinitrc` line (see `xorg`'s README). Until then the X setup
runs `~/.xinitrc` from `.xprofile` before it reaches the session's entry. That still works, once.
Steps 1, 2 and 3 are harmless in any order. Step 4 waits for `i3`.
## What changes when it is assigned
| | g14 | shanks |
|---|---|---|
| package | nothing until step 1 (`lemurs-git` reads as installed) | the same |
| `/etc/lemurs/config.toml` | the found file kept once, then the module's written | the same |
| `/etc/lemurs/xsessions/`, `wayland-sessions/` | created, empty | the same |
| `lemurs.service` | already enabled: nothing | the same |
| the running login manager and session | **nothing** | **nothing** |
| next boot | the login screen offers `i3wm` until step 3, and `i3` once the `i3` module is assigned. It no longer offers the two desktop entries. The remembered session `i3` matches the `i3` module's entry by name | the same |
**Order matters at one point:** assign `i3` before the next reboot after `lemurs`. Otherwise the
screen offers only `i3wm`, which runs `startx` inside lemurs's own X server and fails. Assigned in
to-be 42's order (`xorg`, `lemurs`, `i3` in one sitting), this cannot happen.
## Blockers
- **The package swap is a person's act** (step 1). The host's package resource cannot replace a
package that provides the same name.
- **No restart, by design.** A configuration change takes a reboot to show.
@@ -0,0 +1,144 @@
package main
import (
"context"
"encoding/json"
"os"
"path/filepath"
"strings"
"testing"
"lemurs/internal/desktop"
)
// aMachine lays out a login manager in a directory: its configuration naming directories under it,
// two X scripts (one not executable), a package's desktop entry and a cache.
func aMachine(t *testing.T) (lemurs, string, *[]string) {
root := t.TempDir()
for _, d := range []string{"wms", "wayland", "xsessions", "wayland-sessions"} {
os.MkdirAll(filepath.Join(root, d), 0o755)
}
os.WriteFile(filepath.Join(root, "wms", "i3"), []byte("#!/bin/sh\n# the session\nexec /bin/sh \"$HOME/.xinitrc\"\n"), 0o755)
os.WriteFile(filepath.Join(root, "wms", "notes"), []byte("not a session\n"), 0o644)
os.WriteFile(filepath.Join(root, "xsessions", "i3.desktop"), []byte("[Desktop Entry]\nName=i3\nExec=i3\nType=Application\n"), 0o644)
os.WriteFile(filepath.Join(root, "cache"), []byte("i3\nop\n"), 0o644)
config := strings.Join([]string{
`tty = 2`, `cache_path = "` + root + `/cache"`,
`[x11]`, `x11_display = ":1"`, `scripts_path = "` + root + `/wms"`, `xsessions_path = "` + root + `/xsessions"`,
`[wayland]`, `scripts_path = "` + root + `/wayland"`, `wayland_sessions_path = "` + root + `/wayland-sessions"`,
}, "\n")
os.WriteFile(filepath.Join(root, "config.toml"), []byte(config), 0o644)
var ran []string
d := desktop.Desk{
Find: func() (*desktop.Session, error) { return nil, &desktop.NoSession{Reason: "none"} },
Run: func(_ context.Context, _ []string, stdin []byte, name string, args ...string) desktop.Result {
line := strings.Join(append([]string{name}, args...), " ")
ran = append(ran, line+" <<"+string(stdin))
if name == "journalctl" {
return desktop.Result{Stdout: journal}
}
return desktop.Result{}
},
}
return lemurs{d: d, config: filepath.Join(root, "config.toml"), log: filepath.Join(root, "lemurs.log"), uid: 1000}, root, &ran
}
const journal = `-- Boot a0 --
2026-10-02T13:08:44+02:00 host lemurs[1001]: gkr-pam: unable to locate daemon control file
2026-10-02T13:08:40+02:00 host lemurs[1001]: pam_unix(lemurs:auth): authentication failure; logname= uid=0 euid=0 tty=tty2 ruser= rhost= user=op
2026-10-02T13:08:44+02:00 host lemurs[2141]: pam_unix(lemurs:session): session opened for user op(uid=1000) by op(uid=0)
2026-10-02T18:00:01+02:00 host lemurs[2141]: pam_unix(lemurs:session): session closed for user op
`
func TestTheSessionsAreTheEntriesLemursOffersInItsOrder(t *testing.T) {
l, root, _ := aMachine(t)
got, err := l.sessions(context.Background(), desktop.Args{})
if err != nil {
t.Fatal(err)
}
s := got.(map[string]any)["sessions"].([]Session)
if len(s) != 3 || s[0].Source != "desktop-entry" || s[0].Exec != "i3" || s[1].Name != "i3" || s[1].Source != "script" {
t.Fatalf("%+v", s)
}
if s[1].Exec != `exec /bin/sh "$HOME/.xinitrc"` || !s[1].Offered {
t.Fatalf("a script answers what it runs: %+v", s[1])
}
if s[2].Name != "notes" || s[2].Executable || s[2].Offered {
t.Fatalf("a script that is not executable is not offered: %+v", s[2])
}
if d := got.(map[string]any)["default"].(Cached); d.Session != "i3" || d.Account != "op" {
t.Fatalf("%+v", d)
}
_ = root
}
func TestTheModulesConfigurationIsReadAsLemursReadsIt(t *testing.T) {
raw, err := os.ReadFile(filepath.Join("..", "..", "config", "config.toml"))
if err != nil {
t.Fatal(err)
}
c := ParseConfig(string(raw))
want := Config{Cache: "/var/cache/lemurs", X11Scripts: "/etc/lemurs/wms", X11Sessions: "/etc/lemurs/xsessions",
WaylandScripts: "/etc/lemurs/wayland", WaylandEntries: "/etc/lemurs/wayland-sessions", Display: ":1", TTY: 2}
if c != want {
t.Fatalf("%+v", c)
}
}
func TestTheDefaultSessionIsOneLemursOffersAndIsWrittenThroughSudo(t *testing.T) {
l, root, ran := aMachine(t)
if _, err := l.defaultSession(context.Background(), desktop.Args{"session": "notes"}); err == nil {
t.Fatal("a session lemurs does not offer is refused")
}
if _, err := l.defaultSession(context.Background(), desktop.Args{"session": "i3", "account": "op; rm"}); err == nil {
t.Fatal("an account that is not a name is refused")
}
got, err := l.defaultSession(context.Background(), desktop.Args{"session": "i3"})
if err != nil {
t.Fatal(err)
}
if len(*ran) != 1 || (*ran)[0] != "sudo -n tee "+root+"/cache <<i3\nop\n" {
t.Fatalf("%q", *ran)
}
if !strings.Contains(asJSON(got), `"shown"`) {
t.Fatalf("it says when it shows: %s", asJSON(got))
}
l.uid = 0
*ran = nil
l.defaultSession(context.Background(), desktop.Args{"session": "i3"})
if !strings.HasPrefix((*ran)[0], "tee ") {
t.Fatalf("as root, no sudo: %q", *ran)
}
}
func TestLoginsAreReadFromTheJournalAndLemursLog(t *testing.T) {
l, root, _ := aMachine(t)
os.WriteFile(filepath.Join(root, "lemurs.log"), []byte(
"[2026-10-02T11:08:44Z INFO lemurs] Starting new session for 'op' in environment 'X { xinitrc_path: \"i3\" }'\n"+
"[2026-10-02T11:08:44Z INFO lemurs::auth] Login attempt for 'op'\n"), 0o644)
got, err := l.logins(context.Background(), desktop.Args{"limit": float64(2)})
if err != nil {
t.Fatal(err)
}
m := got.(map[string]any)
ev := m["events"].([]Login)
if len(ev) != 2 || ev[0].Event != "opened" || ev[1].Event != "closed" || ev[1].Account != "op" || m["cut"] != true {
t.Fatalf("the newest two, cut: %+v", ev)
}
all := ParseJournal(journal)
if len(all) != 3 || all[0].Event != "failed" || all[0].Account != "op" {
t.Fatalf("%+v", all)
}
st := m["started"].([]Started)
if len(st) != 1 || st[0].Environment != `X { xinitrc_path: "i3" }` {
t.Fatalf("%+v", st)
}
if _, err := l.logins(context.Background(), desktop.Args{"since": "-1d; reboot"}); err == nil {
t.Fatal("since is a time")
}
}
func asJSON(v any) string {
b, _ := json.Marshal(v)
return string(b)
}
+69
View File
@@ -0,0 +1,69 @@
// lemurs's tools (novox/hq ADR 0208, research 026/05): node-login-manager's verb `sessions`, and the
// module's own `default_session` and `logins`.
//
// None of them needs the graphical session: they read the login manager's configuration, its session
// directories, its cache and the journal. Changing the default session writes the login manager's
// cache, which is root's, through `sudo -n` as the packet filter's tools escalate (to-be 38 WP4).
package main
import (
"context"
"fmt"
"os"
"time"
stdio "git.novox.be/novox/mesh-sdk/go"
"lemurs/internal/desktop"
)
func main() {
l := lemurs{d: desktop.Machine(), config: "/etc/lemurs/config.toml", log: "/var/log/lemurs.log", uid: os.Getuid()}
if err := stdio.Serve("", tools(l)); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
}
func call(run func(ctx context.Context, a desktop.Args) (any, error)) func(map[string]any) (any, error) {
return func(args map[string]any) (any, error) {
ctx, cancel := context.WithTimeout(context.Background(), 25*time.Second)
defer cancel()
return run(ctx, desktop.Args(args))
}
}
func tools(l lemurs) []stdio.Tool {
return []stdio.Tool{
{
Name: "node-login-manager.sessions",
Description: "The sessions the login screen offers on this machine — each with its name, X11 or Wayland, " +
"the file it runs and whether that file is executable (lemurs skips one that is not) — and the " +
"session and account it starts with by default (its cache).",
Input: desktop.Schema(map[string]any{}),
Run: call(l.sessions),
},
{
Name: "lemurs_default_session",
Description: "The session the login screen preselects. With session (one of the names sessions lists), " +
"make it the default: written into lemurs's cache, which lemurs reads when it starts, so it shows at " +
"the next start of the login screen (a reboot, or lemurs restarted). Escalates with sudo -n.",
Input: desktop.Schema(map[string]any{
"session": desktop.Str("the session to preselect (optional)"),
"account": desktop.Str("the account to preselect with it (optional; the cached one)"),
}),
Run: call(l.defaultSession),
},
{
Name: "lemurs_logins",
Description: "Who logged in through the login screen and when, newest last: sessions opened and closed " +
"and failed passwords, from the journal, and the sessions lemurs started (which entry, from its own " +
"log since it last started).",
Input: desktop.Schema(map[string]any{
"since": desktop.Str("how far back, as journalctl reads it (default -7d)"),
"limit": desktop.Int("at most this many events (default 50, at most 500)"),
}),
Run: call(l.logins),
},
}
}
@@ -0,0 +1,116 @@
package main
// lemurs's shape (novox/hq ADR 0208): it holds node-login-manager; it owns the configuration in
// lemurs 0.4's format and enables the service without ever starting, stopping or restarting it,
// because the running login manager is the operator's way in.
import (
"encoding/json"
"os"
"path/filepath"
"strings"
"testing"
)
type manifest struct {
Module string `json:"module"`
Capabilities []string `json:"capabilities"`
Claims []struct {
Name string `json:"name"`
Scope string `json:"scope"`
Serves []string `json:"serves"`
} `json:"claims"`
Seats any `json:"seats"`
Tools []string `json:"tools"`
Resources []map[string]any `json:"resources"`
Build struct {
Artifacts []map[string]any `json:"artifacts"`
} `json:"build"`
}
func readManifest(t *testing.T) manifest {
raw, err := os.ReadFile(filepath.Join("..", "..", "module.json"))
if err != nil {
t.Fatal(err)
}
var m manifest
if err := json.Unmarshal(raw, &m); err != nil {
t.Fatal(err)
}
return m
}
func resource(t *testing.T, m manifest, id string) map[string]any {
for _, r := range m.Resources {
if r["id"] == id {
return r
}
}
t.Fatalf("no resource %s", id)
return nil
}
func TestItHoldsTheLoginManagerSeatAndServesSessions(t *testing.T) {
m := readManifest(t)
if m.Seats != nil || len(m.Claims) != 1 || m.Claims[0].Name != "node-login-manager" || strings.Join(m.Claims[0].Serves, ",") != "sessions" {
t.Fatalf("%+v", m.Claims)
}
served := map[string]bool{}
for _, tool := range tools(lemurs{}) {
served[tool.Name] = true
}
if !served["node-login-manager.sessions"] || len(served) != 1+len(m.Tools) {
t.Fatalf("served %v, manifest %v", served, m.Tools)
}
for _, name := range m.Tools {
if !served[name] {
t.Errorf("%s is listed and not served", name)
}
}
}
func TestTheOfficialPackageAndItsServiceEnabledButNeverRestarted(t *testing.T) {
m := readManifest(t)
if p := resource(t, m, "package"); p["package"] != "lemurs" {
t.Fatalf("the official package, not lemurs-git: %v", p)
}
s := resource(t, m, "service")
if s["unit"] != "lemurs.service" || s["boot"] != "enabled" {
t.Fatalf("%v", s)
}
for _, k := range []string{"state", "restart-on", "reload-on"} {
if _, ok := s[k]; ok {
t.Fatalf("the login manager is never started, stopped or restarted by a push (%s): a change applies at its next start", k)
}
}
if strings.Join(m.Capabilities, ",") != "package-manager,service-manager,seat" {
t.Fatalf("%v", m.Capabilities)
}
}
func TestTheConfigurationIsTheModulesFileAndOffersOnlyTheSessionsModulesPlace(t *testing.T) {
m := readManifest(t)
c := resource(t, m, "config")
raw, _ := os.ReadFile(filepath.Join("..", "..", "config", "config.toml"))
if c["path"] != "/etc/lemurs/config.toml" || c["content"] != string(raw) || c["into"] != nil {
t.Fatal("the manifest carries config/config.toml whole, as an owned file")
}
text := c["content"].(string)
for _, want := range []string{"[x11]", "[wayland]", `xsetup_path = "/etc/lemurs/xsetup.sh"`, `scripts_path = "/etc/lemurs/wms"`,
`scripts_path = "/etc/lemurs/wayland"`, `xsessions_path = "/etc/lemurs/xsessions"`, `wayland_sessions_path = "/etc/lemurs/wayland-sessions"`,
`tty = 2`, `x11_display = ":1"`, `remember = true`} {
if !strings.Contains(text, want) {
t.Errorf("the configuration lacks %s", want)
}
}
for _, l := range strings.Split(text, "\n") {
if (strings.HasPrefix(l, "xsessions_path") || strings.HasPrefix(l, "wayland_sessions_path")) && strings.Contains(l, "/usr/share") {
t.Fatalf("a package's bare desktop entry would be offered: %s", l)
}
}
for _, id := range []string{"xsessions", "wayland-sessions"} {
if d := resource(t, m, id); d["type"] != "directory" || d["path"] != "/etc/lemurs/"+id {
t.Fatalf("%v", d)
}
}
}
+343
View File
@@ -0,0 +1,343 @@
package main
import (
"bufio"
"context"
"fmt"
"os"
"path/filepath"
"regexp"
"sort"
"strconv"
"strings"
"lemurs/internal/desktop"
)
type lemurs struct {
d desktop.Desk
config string
log string
uid int
}
// Config is the part of lemurs's configuration the tools read, with lemurs 0.4's defaults.
type Config struct {
Cache string `json:"cache_path"`
X11Scripts string `json:"x11_scripts"`
X11Sessions string `json:"x11_desktop_entries"`
WaylandScripts string `json:"wayland_scripts"`
WaylandEntries string `json:"wayland_desktop_entries"`
Display string `json:"x11_display"`
TTY int `json:"tty"`
}
// ParseConfig reads the keys it needs from lemurs's TOML: `[section]` headers and `key = value`
// lines, a quoted value unquoted. Enough for this file, which holds no nested values it needs.
func ParseConfig(text string) Config {
c := Config{
Cache: "/var/cache/lemurs", X11Scripts: "/etc/lemurs/wms", X11Sessions: "/usr/share/xsessions",
WaylandScripts: "/etc/lemurs/wayland", WaylandEntries: "/usr/share/wayland-sessions", Display: ":1", TTY: 2,
}
section := ""
sc := bufio.NewScanner(strings.NewReader(text))
for sc.Scan() {
line := strings.TrimSpace(sc.Text())
if line == "" || strings.HasPrefix(line, "#") {
continue
}
if strings.HasPrefix(line, "[") {
section = strings.Trim(line, "[] ")
continue
}
k, v, ok := strings.Cut(line, "=")
if !ok {
continue
}
k, v = strings.TrimSpace(k), strings.TrimSpace(v)
if uq, err := strconv.Unquote(v); err == nil {
v = uq
}
switch section + "." + k {
case ".cache_path":
c.Cache = v
case ".tty":
if n, err := strconv.Atoi(v); err == nil {
c.TTY = n
}
case "x11.scripts_path":
c.X11Scripts = v
case "x11.xsessions_path":
c.X11Sessions = v
case "x11.x11_display":
c.Display = v
case "wayland.scripts_path":
c.WaylandScripts = v
case "wayland.wayland_sessions_path":
c.WaylandEntries = v
}
}
return c
}
// Session is one entry of the login screen.
type Session struct {
Name string `json:"name"`
Kind string `json:"kind"` // x11 or wayland
Source string `json:"source"` // script or desktop-entry
Path string `json:"path"`
Exec string `json:"exec,omitempty"`
Executable bool `json:"executable"`
Offered bool `json:"offered"`
}
// ListSessions is every entry lemurs offers, in its order: X desktop entries, Wayland desktop
// entries, X scripts, Wayland scripts (lemurs's get_envs). A script that is not executable is listed
// as not offered, because lemurs skips it with only a warning in its log.
func ListSessions(c Config) []Session {
var out []Session
entries := func(dir, kind string) {
files, _ := os.ReadDir(dir)
for _, f := range files {
p := filepath.Join(dir, f.Name())
name, exec, ok := desktopEntry(p)
if !ok {
continue
}
out = append(out, Session{Name: name, Kind: kind, Source: "desktop-entry", Path: p, Exec: exec, Executable: true, Offered: true})
}
}
scripts := func(dir, kind string) {
files, _ := os.ReadDir(dir)
for _, f := range files {
p := filepath.Join(dir, f.Name())
info, err := os.Stat(p)
if err != nil || info.IsDir() {
continue
}
x := info.Mode()&0o111 != 0
out = append(out, Session{Name: f.Name(), Kind: kind, Source: "script", Path: p, Exec: firstCommand(p), Executable: x, Offered: x})
}
}
entries(c.X11Sessions, "x11")
entries(c.WaylandEntries, "wayland")
scripts(c.X11Scripts, "x11")
scripts(c.WaylandScripts, "wayland")
return out
}
// desktopEntry reads Name and Exec from a session's desktop entry, as lemurs does.
func desktopEntry(path string) (string, string, bool) {
b, err := os.ReadFile(path)
if err != nil {
return "", "", false
}
in, name, exec := false, "", ""
for _, l := range strings.Split(string(b), "\n") {
l = strings.TrimSpace(l)
if strings.HasPrefix(l, "[") {
in = l == "[Desktop Entry]"
continue
}
if !in {
continue
}
if v, ok := strings.CutPrefix(l, "Name="); ok && name == "" {
name = v
}
if v, ok := strings.CutPrefix(l, "Exec="); ok && exec == "" {
exec = v
}
}
if exec == "" {
return "", "", false
}
if name == "" {
name = exec
}
return name, exec, true
}
// firstCommand is a script's first line that is neither blank nor a comment: what it runs.
func firstCommand(path string) string {
b, err := os.ReadFile(path)
if err != nil {
return ""
}
for _, l := range strings.Split(string(b), "\n") {
if l = strings.TrimSpace(l); l != "" && !strings.HasPrefix(l, "#") {
return l
}
}
return ""
}
// Cached is lemurs's cache file: the session on its first line, the account on its second.
type Cached struct {
Session string `json:"session"`
Account string `json:"account"`
}
func readCache(path string) (Cached, error) {
b, err := os.ReadFile(path)
if err != nil {
return Cached{}, err
}
lines := strings.Split(strings.TrimSpace(string(b)), "\n")
c := Cached{Session: strings.TrimSpace(lines[0])}
if len(lines) > 1 {
c.Account = strings.TrimSpace(lines[1])
}
return c, nil
}
func (l lemurs) readConfig() (Config, error) {
b, err := os.ReadFile(l.config)
if err != nil {
return Config{}, fmt.Errorf("lemurs's configuration cannot be read (%v): is the lemurs module's package installed?", err)
}
return ParseConfig(string(b)), nil
}
func (l lemurs) sessions(ctx context.Context, a desktop.Args) (any, error) {
c, err := l.readConfig()
if err != nil {
return nil, err
}
answer := map[string]any{"config": l.config, "sessions": ListSessions(c), "directories": c}
if cached, err := readCache(c.Cache); err == nil {
answer["default"] = cached
} else {
answer["default"] = nil
}
return answer, nil
}
var accountName = regexp.MustCompile(`^[a-z_][a-z0-9_-]{0,31}$`)
func (l lemurs) defaultSession(ctx context.Context, a desktop.Args) (any, error) {
c, err := l.readConfig()
if err != nil {
return nil, err
}
cached, _ := readCache(c.Cache)
want := a.Opt("session", "")
if want == "" {
return map[string]any{"default": cached, "cache": c.Cache}, nil
}
known := false
var names []string
for _, s := range ListSessions(c) {
if s.Offered {
names = append(names, s.Name)
known = known || s.Name == want
}
}
if !known {
sort.Strings(names)
return nil, fmt.Errorf("%q is not a session lemurs offers; it offers %s", want, strings.Join(names, ", "))
}
account := a.Opt("account", cached.Account)
if account == "" {
account = os.Getenv("MESH_OPERATOR_ACCOUNT")
}
if !accountName.MatchString(account) {
return nil, fmt.Errorf("%q is not an account name", account)
}
content := []byte(want + "\n" + account + "\n")
var res desktop.Result
if l.uid == 0 {
res = l.d.Run(ctx, l.d.Base, content, "tee", c.Cache)
} else {
res = l.d.Run(ctx, l.d.Base, content, "sudo", "-n", "tee", c.Cache)
}
if !res.OK() {
return nil, fmt.Errorf("writing %s: %w", c.Cache, res.Err())
}
return map[string]any{
"default": Cached{Session: want, Account: account}, "was": cached, "cache": c.Cache,
"shown": "when lemurs next starts (a reboot, or the login manager restarted); lemurs rewrites it after each login when remember is on",
}, nil
}
// Login is one event of the login screen.
type Login struct {
Time string `json:"time"`
Event string `json:"event"` // opened, closed or failed
Account string `json:"account,omitempty"`
}
var (
opened = regexp.MustCompile(`pam_unix\(lemurs:session\): session opened for user ([^(\s]+)`)
closed = regexp.MustCompile(`pam_unix\(lemurs:session\): session closed for user ([^(\s]+)`)
failed = regexp.MustCompile(`pam_unix\(lemurs:auth\): authentication failure;.*?user=(\S+)`)
started = regexp.MustCompile(`^\[(\S+) INFO\s+lemurs\] Starting new session for '([^']*)' in environment '(.*)'$`)
)
// ParseJournal reads `journalctl -o short-iso` lines of lemurs into login events.
func ParseJournal(text string) []Login {
var out []Login
for _, line := range strings.Split(text, "\n") {
f := strings.Fields(line)
if len(f) < 3 || strings.HasPrefix(line, "--") {
continue
}
for _, p := range []struct {
re *regexp.Regexp
event string
}{{opened, "opened"}, {closed, "closed"}, {failed, "failed"}} {
if m := p.re.FindStringSubmatch(line); m != nil {
out = append(out, Login{Time: f[0], Event: p.event, Account: m[1]})
}
}
}
return out
}
// Started is one session lemurs started, from its own log.
type Started struct {
Time string `json:"time"`
Account string `json:"account"`
Environment string `json:"environment"`
}
// ParseStarts reads lemurs's own log for the sessions it started and which entry each ran.
func ParseStarts(text string) []Started {
var out []Started
for _, line := range strings.Split(text, "\n") {
if m := started.FindStringSubmatch(line); m != nil {
out = append(out, Started{Time: m[1], Account: m[2], Environment: m[3]})
}
}
return out
}
var since = regexp.MustCompile(`^[-+]?[0-9A-Za-z :.]{1,40}$`)
func (l lemurs) logins(ctx context.Context, a desktop.Args) (any, error) {
from := a.Opt("since", "-7d")
if !since.MatchString(from) {
return nil, fmt.Errorf("since is a time journalctl reads, e.g. -7d or 2026-10-01")
}
limit, err := a.Whole("limit", 50, 1, 500)
if err != nil {
return nil, err
}
res := l.d.Plain(ctx, "journalctl", "_COMM=lemurs", "--since", from, "-o", "short-iso", "--no-pager", "-q")
if !res.OK() {
return nil, res.Err()
}
events := ParseJournal(res.Stdout)
cut := false
if len(events) > limit {
events, cut = events[len(events)-limit:], true
}
answer := map[string]any{"since": from, "events": events}
if cut {
answer["cut"] = true
}
if b, err := os.ReadFile(l.log); err == nil {
answer["started"] = ParseStarts(string(b))
}
return answer, nil
}
+368
View File
@@ -0,0 +1,368 @@
# The login manager's configuration, written by the mesh (module lemurs, novox/hq ADR 0208). Replaced
# at every push; change the module instead. The structure is lemurs 0.4's own (the shipped file, with
# every option, as lemurs requires); what the mesh changes from it is marked "mesh:".
#
# The sessions offered are the executable files other modules place in the two scripts directories
# below (/etc/lemurs/wms for X, /etc/lemurs/wayland for Wayland), one per session module. A file there
# is named as the session is offered. Desktop entries that packages install (/usr/share/xsessions) are
# not offered: they start the window manager bare, skipping the session's start (~/.xinitrc), which is
# where the account's environment, the X resources and every module's session lines are.
#
# Lemurs configuration file.
# Contains all the customization options of lemurs.
#
# Note: that as of now you need to have all options in the selected
# configuration file. Otherwise Lemurs will not work.
#
# Colors:
# ---------
# There is a list of predefined colors. These include:
# - black
# - white
# - (dark) gray
# - (light) red
# - (light) blue
# - (light) green
# - (light) magenta
# - (light) cyan
# - (light) yellow
# - orange
#
# You can also utilize custom colors with hex color codes.
# "#87CEEB" will create a Sky Blue color.
#
# Note: If the color wasn't recognized, it will default to white.
# ---------
#
# Modifiers:
# ---------
# There is a number of modifiers you can use. These can be combined by
# delimiting them with a comma (e.g. "bold,italic"). The modifiers are:
# - bold
# - dim
# - italic
# - underlined
# - reverse
# - crossed out
# - hidden
# ---------
#
# The tty which contains lemurs. This has to be mirrored in the lemurs.service
tty = 2
# Where to log the main lemurs control flow.
main_log_path = "/var/log/lemurs.log"
# Where to log to for the client. The Client is the Desktop Environment or
# Window Manager for Xorg, the Compositor for Wayland and the Shell for TTY.
client_log_path = "/var/log/lemurs.client.log"
# At which point to point the cache. If you want to disable the cache globally
# you can use `/dev/null`.
cache_path = "/var/cache/lemurs"
# Disable all logging. This is overwritten by the `--no-log` flag.
do_log = true
# The PAM service that should be used to login
pam_service = "lemurs"
# Path to system shell that gets used to execute linux commands. In almost all
# cases, this should refer to a bash shell.
system_shell = "/bin/sh"
# Initial state of the `PATH` environment variable.
initial_path = "/usr/local/sbin:/usr/local/bin:/usr/bin"
# The type flag that will be appended to the shell that calls the session
# environment. This may depend on your shell. Options:
# - 'none'. Disables calling a login shell
# - 'short'. Produces the `-l` flag. Supported by most shells.
# - 'long'. This produces the `--login` flag and is suited for bash and zsh.
shell_login_flag = "short"
# Focus behaviour of fields when Lemurs is initially started
#
# Possible values:
# - default: Initially focus on first non-cached value
# - no-focus: No initial focus
# - environment: Initially focus on the environment selector
# - username: Initially focus on the username field
# - password: Initially focus on the password field
focus_behaviour = "default"
# General settings for background style
[background]
# Control whether to render background widget or not
show_background = false
[background.style]
# Allow to set the default background color for the login shell
color = "black"
# Settings for the background block's borders
show_border = true
border_color = "white"
[power_controls]
# The margin between hints
hint_margin = 2
# There are no additional entries by default
entries = []
# Example
# Reboot to another os option
#[[power_controls.entries]]
## The text in the top-left to display how to reboot.
#hint = "Reboot to OS"
#
## The color and modifiers of the hint in the top-left corner
#hint_color = "dark gray"
#hint_modifiers = ""
#
## The key used to reboot. Possibilities are F1 to F12.
#key = "F3"
## The command that is executed when the key is pressed
#cmd = "efibootmgr -n0 && systemctl reboot -l"
# If you want to remove the base_entries
# base_entries = []
# Shutdown option
[[power_controls.base_entries]]
# The text in the top-left to display how to shutdown.
hint = "Shutdown"
# The color and modifiers of the hint in the top-left corner
hint_color = "dark gray"
hint_modifiers = ""
# The key used to shutdown. Possibilities are F1 to F12.
key = "F1"
# The command that is executed when the key is pressed
cmd = "systemctl poweroff -l"
# Reboot option
[[power_controls.base_entries]]
# The text in the top-left to display how to reboot.
hint = "Reboot"
# The color and modifiers of the hint in the top-left corner
hint_color = "dark gray"
hint_modifiers = ""
# The key used to reboot. Possibilities are F1 to F12.
key = "F2"
# The command that is executed when the key is pressed
cmd = "systemctl reboot -l"
# Setting for the selector of the desktop environment you are using.
[environment_switcher]
# Terms:
# ---------
# Movers: indicators which show which direction one can move whilst selecting
# the desktop environment
# Selected: The currently selected desktop environment.
# Neighbours: The adjacent desktop environment to the one current selected
#
# Visualisation:
#
# < i3 bspwm awesome >
#
# ^ ^ ^ ^ ^
# | | | | |
# mover | selected | mover
# | |
# neighbour neighbour
# ---------
#
# Control the visibility of the switcher
# Options:
# - "visible" - Always show the switcher [default]
# - "hidden" - Always hide the switcher
# - [key] - F1-F12 to be able to toggle the visibility
# mesh: hidden, as both workstations had it, but F3 shows it, so a second session can be chosen.
switcher_visibility = "F3"
# The text in the top-left to display how to toggle the switcher. The text
# '%key%' will be replaced with the switcher_visibility key. This is not shown
# if switcher_visibility is set to "visible" or "hidden".
toggle_hint = "Switcher %key%"
# The color and modifiers of the hint in the top-left corner
toggle_hint_color = "dark gray"
toggle_hint_modifiers = ""
# Show an option for the TTY shell when logging in as one of the environments.
# NOTE: it is always shown when no viable options are found.
include_tty_shell = false
# Remember the selected environment after logging in for the next time
remember = true
# Enables showing the movers
show_movers = true
# Mover's color and modifiers whilst the selector is unfocused
mover_color = "dark gray"
mover_modifiers = ""
# Mover's color and modifiers whilst the selector is focused
mover_color_focused = "orange"
mover_modifiers_focused = "bold"
# The characters used to display the movers. Suggestions are:
# - "<" ">"
# - "<-" "->"
# - "<<" ">>"
# - "[" "]"
left_mover = "<"
right_mover = ">"
# The margin between the movers and the neighbours or selected (depending on
# `show_neighbours`)
mover_margin = 1
# Enables showing the neighbours
show_neighbours = true
# Neighbours' color and modifiers whilst the selector is unfocused
neighbour_color = "dark gray"
neighbour_modifiers = ""
# Neighbours' color and modifiers whilst the selector is focused
neighbour_color_focused = "gray"
neighbour_modifiers_focused = ""
# Margin between neighbours and selected
neighbour_margin = 1
# Selected's color and modifiers whilst the selector is unfocused
selected_color = "gray"
selected_modifiers = "underlined"
# Selected's color and modifiers whilst the selector is focused
selected_color_focused = "white"
selected_modifiers_focused = "bold"
# The length of the name of the desktop environment which is displayed.
max_display_length = 8
# The text used when no desktop environments are available
no_envs_text = "No environments..."
# The color and modifiers of the 'no desktop environments available text'
# whilst the selector is unfocused
no_envs_color = "white"
no_envs_modifiers = ""
# The color and modifiers of the 'no desktop environments available text'
# whilst the selector is focused
no_envs_color_focused = "red"
no_envs_modifiers_focused = ""
[username_field]
# Remember the username for the next time after a successful login attempt.
remember = true
[username_field.style]
# Enables showing a title
show_title = true
# The text used within the title
title = "Login"
# The title's color and modifiers whilst the username field is unfocused
title_color = "white"
content_color = "white"
# The title's color and modifiers whilst the username field is focused
title_color_focused = "orange"
content_color_focused = "orange"
# Enables showing the borders
show_border = true
# The borders' color and modifiers whilst the username field is unfocused
border_color = "white"
# The borders' color and modifiers whilst the username field is focused
border_color_focused = "orange"
# Constrain the width of the username field
use_max_width = true
# The constraint of the username field's width
max_width = 48
[password_field]
# The character used for replacement when typing a password. Leave empty for no
# feedback.
# Note: Only one character is accepted.
content_replacement_character = "*"
[password_field.style]
# Enables showing a title
show_title = true
# The text used within the title
title = "Password"
# The title's color and modifiers whilst the password field is unfocused
title_color = "white"
content_color = "white"
# The title's color and modifiers whilst the password field is focused
title_color_focused = "orange"
content_color_focused = "orange"
# Enables showing the borders
show_border = true
# The borders' color and modifiers whilst the password field is unfocused
border_color = "white"
# The borders' color and modifiers whilst the password field is focused
border_color_focused = "orange"
# Constrain the width of the password field
use_max_width = true
# The constraint of the password field's width
max_width = 48
[x11]
# Where to log to for the XServer.
xserver_log_path = "/var/log/lemurs.xorg.log"
# The value of the `DISPLAY` environment variable for X11 sessions
x11_display = ":1"
# How many seconds to give the X server to start. To make it infinitely, put it
# to 0.
xserver_timeout_secs = 60
# Where to find the X11 server binary
xserver_path = "/usr/bin/X"
# Where to find the X11 xauth binary
xauth_path = "/usr/bin/xauth"
# Path to the directory where the startup scripts for the X11 sessions are found
scripts_path = "/etc/lemurs/wms"
# Path to the xsetup script that is needed for the environment setup of the
# window manager.
xsetup_path = "/etc/lemurs/xsetup.sh"
# The directory to use for desktop entries X11 sessions.
# mesh: an empty directory of the module's own, so no package's desktop entry is offered.
xsessions_path = "/etc/lemurs/xsessions"
[wayland]
# Path to the directory where the startup scripts for the Wayland sessions are
# found
scripts_path = "/etc/lemurs/wayland"
# The directory to use for desktop entries wayland sessions.
# mesh: likewise for Wayland.
wayland_sessions_path = "/etc/lemurs/wayland-sessions"
+5
View File
@@ -0,0 +1,5 @@
module lemurs
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=
+160
View File
@@ -0,0 +1,160 @@
package desktop
import (
"fmt"
"math"
"os"
"path/filepath"
"strings"
)
// Args reads a tool's arguments as JSON decoded them: strings, float64 numbers, booleans.
type Args map[string]any
// Text is a required string, trimmed.
func (a Args) Text(name string) (string, error) {
v, ok := a[name].(string)
if !ok || strings.TrimSpace(v) == "" {
return "", fmt.Errorf("%s is required, as text", name)
}
return strings.TrimSpace(v), nil
}
// Opt is an optional string, trimmed, or def.
func (a Args) Opt(name, def string) string {
if v, ok := a[name].(string); ok && strings.TrimSpace(v) != "" {
return strings.TrimSpace(v)
}
return def
}
// Has is whether the caller gave the argument at all.
func (a Args) Has(name string) bool {
v, ok := a[name]
return ok && v != nil
}
// Bool is an optional boolean: its value, and whether it was given.
func (a Args) Bool(name string) (bool, bool, error) {
v, ok := a[name]
if !ok || v == nil {
return false, false, nil
}
b, isBool := v.(bool)
if !isBool {
return false, false, fmt.Errorf("%s is true or false", name)
}
return b, true, nil
}
// Number is an optional number: its value, and whether it was given.
func (a Args) Number(name string) (float64, bool, error) {
v, ok := a[name]
if !ok || v == nil {
return 0, false, nil
}
f, isNum := v.(float64)
if !isNum || math.IsNaN(f) || math.IsInf(f, 0) {
return 0, false, fmt.Errorf("%s is a number", name)
}
return f, true, nil
}
// Whole is an optional whole number within [lo, hi], or def.
func (a Args) Whole(name string, def, lo, hi int) (int, error) {
f, given, err := a.Number(name)
if err != nil {
return 0, err
}
if !given {
return def, nil
}
if f != math.Trunc(f) || f < float64(lo) || f > float64(hi) {
return 0, fmt.Errorf("%s is a whole number from %d to %d", name, lo, hi)
}
return int(f), nil
}
// OneOf is an optional string that must be one of choices, or def.
func (a Args) OneOf(name, def string, choices ...string) (string, error) {
v := a.Opt(name, def)
for _, c := range choices {
if v == c {
return v, nil
}
}
return "", fmt.Errorf("%s is one of %s", name, strings.Join(choices, ", "))
}
// Strings is an optional list of strings.
func (a Args) Strings(name string) ([]string, error) {
v, ok := a[name]
if !ok || v == nil {
return nil, nil
}
list, isList := v.([]any)
if !isList {
return nil, fmt.Errorf("%s is a list of text", name)
}
out := make([]string, 0, len(list))
for _, x := range list {
s, isText := x.(string)
if !isText {
return nil, fmt.Errorf("%s is a list of text", name)
}
out = append(out, s)
}
return out, nil
}
// Home is the operator account's home: the runtime's word for it, else this process's.
func Home() string {
if h := os.Getenv("MESH_OPERATOR_HOME"); h != "" {
return h
}
if h, err := os.UserHomeDir(); err == nil {
return h
}
return "/"
}
// InHome resolves a path the caller gave: `~/x` and a relative path are under the home. A path
// that leaves the home through `..` is refused, so a tool that writes never writes outside it.
func InHome(path string) (string, error) {
home := Home()
switch {
case path == "~":
path = home
case strings.HasPrefix(path, "~/"):
path = filepath.Join(home, path[2:])
case !filepath.IsAbs(path):
path = filepath.Join(home, path)
}
path = filepath.Clean(path)
if path != home && !strings.HasPrefix(path, home+string(filepath.Separator)) {
return "", fmt.Errorf("%s is outside the account's home", path)
}
return path, nil
}
// Schema builds a tool's input schema from property descriptions; required names those that must
// be given. A property is a string unless its description object says otherwise.
func Schema(props map[string]any, required ...string) map[string]any {
s := map[string]any{"type": "object", "properties": props}
if len(required) > 0 {
s["required"] = required
}
return s
}
// Str, Num, Flag, List and Enum describe one property.
func Str(desc string) map[string]any { return map[string]any{"type": "string", "description": desc} }
func Num(desc string) map[string]any { return map[string]any{"type": "number", "description": desc} }
func Int(desc string) map[string]any { return map[string]any{"type": "integer", "description": desc} }
func Flag(desc string) map[string]any { return map[string]any{"type": "boolean", "description": desc} }
func List(desc string) map[string]any {
return map[string]any{"type": "array", "items": map[string]any{"type": "string"}, "description": desc}
}
func Enum(desc string, values ...string) map[string]any {
return map[string]any{"type": "string", "enum": values, "description": desc}
}
@@ -0,0 +1,42 @@
package desktop
import (
"bytes"
"os"
"path/filepath"
"testing"
)
// The desktop modules that carry this package. Each builds alone, so each has its own copy; this
// test, itself one of the copied files, holds them to one text wherever the siblings are present.
var carriers = []string{"xorg", "lemurs", "i3", "xterm", "adwaita"}
func TestEveryDesktopModuleCarriesTheSameCopy(t *testing.T) {
mine, err := filepath.Glob("*.go")
if err != nil || len(mine) == 0 {
t.Fatal("no files of this package found", err)
}
compared := 0
for _, module := range carriers {
dir := filepath.Join("..", "..", "..", module, "internal", "desktop")
if _, err := os.Stat(dir); err != nil {
continue
}
theirs, _ := filepath.Glob(filepath.Join(dir, "*.go"))
if len(theirs) != len(mine) {
t.Errorf("%s carries %d files of this package, this copy %d", module, len(theirs), len(mine))
continue
}
for _, f := range mine {
a, _ := os.ReadFile(f)
b, err := os.ReadFile(filepath.Join(dir, f))
if err != nil || !bytes.Equal(a, b) {
t.Errorf("%s's copy of %s differs from this one: change every copy together", module, f)
}
}
compared++
}
if compared == 0 {
t.Log("no sibling copies beside this module")
}
}
+232
View File
@@ -0,0 +1,232 @@
package desktop
import (
"bytes"
"context"
"crypto/rand"
"encoding/hex"
"errors"
"fmt"
"os"
"os/exec"
"strings"
"syscall"
"time"
)
// Bounds on a command a tool runs: well below the runtime's 30 s call limit, and an answer that
// fits in a tool's reply.
const (
DefaultTimeout = 10 * time.Second
MostOutput = 256 << 10
)
// Result is what one command did.
type Result struct {
Command []string `json:"command"`
Code int `json:"exit_code"`
Stdout string `json:"stdout,omitempty"`
Stderr string `json:"stderr,omitempty"`
Truncated bool `json:"truncated,omitempty"`
TimedOut bool `json:"timed_out,omitempty"`
}
// OK is whether the command ran and exited 0.
func (r Result) OK() bool { return r.Code == 0 && !r.TimedOut }
// Err is the command's failure as an error naming it and what it said, or nil.
func (r Result) Err() error {
if r.OK() {
return nil
}
said := strings.TrimSpace(r.Stderr)
if said == "" {
said = strings.TrimSpace(r.Stdout)
}
if r.TimedOut {
return fmt.Errorf("%s did not finish in time", strings.Join(r.Command, " "))
}
return fmt.Errorf("%s exited %d: %s", strings.Join(r.Command, " "), r.Code, said)
}
// Runner runs a command with an environment and answers what it did. Tools take one, so their
// tests replace the machine with a table of answers.
type Runner func(ctx context.Context, env []string, stdin []byte, name string, args ...string) Result
// Exec is the machine's Runner: the command in its own process group, ended with everything it
// started at the deadline, each stream cut at MostOutput.
func Exec(ctx context.Context, env []string, stdin []byte, name string, args ...string) Result {
if _, ok := ctx.Deadline(); !ok {
var cancel context.CancelFunc
ctx, cancel = context.WithTimeout(ctx, DefaultTimeout)
defer cancel()
}
res := Result{Command: append([]string{name}, args...)}
cmd := exec.Command(name, args...)
cmd.Env = env
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
if stdin != nil {
cmd.Stdin = bytes.NewReader(stdin)
}
out, errb := &capped{}, &capped{}
cmd.Stdout, cmd.Stderr = out, errb
if err := cmd.Start(); err != nil {
res.Code = 127
res.Stderr = err.Error()
return res
}
done := make(chan error, 1)
go func() { done <- cmd.Wait() }()
var err error
select {
case err = <-done:
case <-ctx.Done():
_ = syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL)
err = <-done
res.TimedOut = true
}
res.Stdout, res.Stderr = out.String(), errb.String()
res.Truncated = out.cut || errb.cut
var exit *exec.ExitError
switch {
case err == nil:
case errors.As(err, &exit):
res.Code = exit.ExitCode()
if res.Code < 0 {
res.Code = 128
}
default:
res.Code = 1
if res.Stderr == "" {
res.Stderr = err.Error()
}
}
return res
}
// capped keeps the first MostOutput bytes written to it. Its buffer is a field, not embedded: an
// embedded bytes.Buffer brings ReadFrom along, and io.Copy would use it and never call Write.
type capped struct {
buf bytes.Buffer
cut bool
}
func (c *capped) Write(p []byte) (int, error) {
if room := MostOutput - c.buf.Len(); room < len(p) {
if room > 0 {
c.buf.Write(p[:room])
}
c.cut = true
return len(p), nil
}
return c.buf.Write(p)
}
func (c *capped) String() string { return c.buf.String() }
// Desk is what a desktop tool needs: how to find the session, and how to run a command.
type Desk struct {
Find func() (*Session, error)
Run Runner
// Base is the environment a command starts from, before the session's words.
Base []string
}
// Machine is the real Desk, preferring the named processes as the session's.
func Machine(prefer ...string) Desk {
return Desk{
Find: func() (*Session, error) { return Find(prefer...) },
Run: Exec,
Base: os.Environ(),
}
}
// InSession runs a command in the operator's session, or answers NoSession.
func (d Desk) InSession(ctx context.Context, name string, args ...string) (Result, *Session, error) {
s, err := d.Find()
if err != nil {
return Result{}, nil, err
}
return d.Run(ctx, s.Env(d.Base), nil, name, args...), s, nil
}
// InSessionWith is InSession with standard input.
func (d Desk) InSessionWith(ctx context.Context, stdin []byte, name string, args ...string) (Result, *Session, error) {
s, err := d.Find()
if err != nil {
return Result{}, nil, err
}
return d.Run(ctx, s.Env(d.Base), stdin, name, args...), s, nil
}
// Plain runs a command with the base environment: for what needs no session.
func (d Desk) Plain(ctx context.Context, name string, args ...string) Result {
return d.Run(ctx, d.Base, nil, name, args...)
}
// AsUser runs a command with the account's own runtime directory and bus, and no display.
func (d Desk) AsUser(ctx context.Context, name string, args ...string) Result {
return d.Run(ctx, UserEnv(d.Base, os.Getuid()), nil, name, args...)
}
// Launched is how a program was started in the session.
type Launched struct {
Unit string `json:"unit,omitempty"`
PID int `json:"pid,omitempty"`
How string `json:"how"`
}
// Launch starts a program in the operator's session that outlives the call and the runtime.
//
// **Not as a child of this process.** The runtime is a system service; everything it starts is in
// its control group, and the service manager ends that group whenever the runtime restarts — which
// is every push that changes it. So the program is handed to the account's own service manager as a
// transient unit (`systemd-run --user`), with the session's words set on it, and lives as long as the
// operator's user manager does. Without a user manager it is started detached as a last resort, and
// the answer says it will end with the runtime.
func (d Desk) Launch(ctx context.Context, s *Session, name string, argv ...string) (Launched, error) {
if len(argv) == 0 {
return Launched{}, errors.New("nothing to launch")
}
env := s.Env(d.Base)
unit := "mesh-" + name + "-" + token()
args := []string{"--user", "--collect", "--quiet", "--unit=" + unit}
for _, w := range []string{"DISPLAY", "WAYLAND_DISPLAY", "XAUTHORITY", "XDG_SESSION_TYPE", "XDG_CURRENT_DESKTOP", "XDG_SESSION_DESKTOP", "I3SOCK", "SWAYSOCK"} {
if v := lookup(env, w); v != "" {
args = append(args, "--setenv="+w+"="+v)
}
}
args = append(args, "--")
args = append(args, argv...)
res := d.Run(ctx, env, nil, "systemd-run", args...)
if res.OK() {
return Launched{Unit: unit, How: "a transient unit of the account's service manager; ends when it exits or when the operator logs out"}, nil
}
if s.Bus != "" {
return Launched{}, res.Err()
}
cmd := exec.Command(argv[0], argv[1:]...)
cmd.Env = env
cmd.SysProcAttr = &syscall.SysProcAttr{Setsid: true}
if err := cmd.Start(); err != nil {
return Launched{}, err
}
pid := cmd.Process.Pid
go func() { _ = cmd.Wait() }()
return Launched{PID: pid, How: "detached from the runtime with no user manager to hand it to; it ends when the runtime restarts"}, nil
}
func lookup(env []string, name string) string {
for i := len(env) - 1; i >= 0; i-- {
if k, v, ok := strings.Cut(env[i], "="); ok && k == name {
return v
}
}
return ""
}
func token() string {
b := make([]byte, 4)
_, _ = rand.Read(b)
return hex.EncodeToString(b)
}
+445
View File
@@ -0,0 +1,445 @@
// Package desktop is how a desktop module's tools act in the operator's graphical session
// (novox/hq ADR 0208, research 026/05).
//
// **One question, answered once for every desktop tool.** A tool runs inside the node's runtime: a
// process of node-tools.service, started by the system's service manager as the operator account,
// with no session around it — no DISPLAY, no XAUTHORITY, no session bus. The session it must act in
// was started elsewhere, by the login manager, and the only place its values are written down is
// the environment of the processes it started. So this package finds the session the way a person
// would: it looks at the operator account's own processes, takes the one that is plainly the
// session's (the window manager, or the oldest process carrying a display), confirms with logind
// that its session is a live local one, and checks that the display's socket is really there.
//
// **Only the session's own words are read.** A session's processes also carry whatever its start
// script exported — on the workstations that was a file of secrets — so the environment is filtered
// to a fixed list of names while it is read, and nothing else ever leaves /proc.
//
// The D-Bus address handed on is the user manager's socket, `unix:path=$XDG_RUNTIME_DIR/bus`,
// whenever it exists, because that is where the portal, the notifier and every user service
// listen. A session started on a private bus (a stale session, measured on one workstation) is
// reported as `session_bus` beside it, so the difference is visible rather than guessed at.
//
// The same copy of this package is vendored into every desktop module (xorg, lemurs, i3, xterm,
// adwaita); the catalogue builds each module alone, so it cannot be imported across them. Change
// every copy together — the modules' tests compare them.
package desktop
import (
"bufio"
"bytes"
"encoding/json"
"errors"
"fmt"
"os"
"os/exec"
"os/user"
"path/filepath"
"sort"
"strconv"
"strings"
"syscall"
"time"
)
// SessionWords are the only environment words read from a session's process: the ones that say
// where the session is. Everything else in that environment is the operator's, and is never read.
var SessionWords = []string{
"DISPLAY", "WAYLAND_DISPLAY", "XAUTHORITY",
"XDG_SESSION_ID", "XDG_SESSION_TYPE", "XDG_SESSION_DESKTOP", "XDG_CURRENT_DESKTOP",
"XDG_RUNTIME_DIR", "DBUS_SESSION_BUS_ADDRESS", "XDG_SEAT", "XDG_VTNR",
"I3SOCK", "SWAYSOCK",
}
// Session is the operator's running graphical session, as a tool needs it.
type Session struct {
UID int `json:"uid"`
ID string `json:"session_id,omitempty"`
Type string `json:"type"`
Display string `json:"display,omitempty"`
WaylandDisplay string `json:"wayland_display,omitempty"`
XAuthority string `json:"xauthority,omitempty"`
RuntimeDir string `json:"runtime_dir"`
Bus string `json:"bus,omitempty"`
SessionBus string `json:"session_bus,omitempty"`
Desktop string `json:"desktop,omitempty"`
// FoundIn is the process whose environment named the session.
FoundIn Process `json:"found_in"`
// Active is logind's word on the session, when logind answered.
Active *bool `json:"active,omitempty"`
words map[string]string
}
// Process is one process the search looked at.
type Process struct {
PID int `json:"pid"`
Command string `json:"command"`
start uint64
}
// NoSession is the answer when there is no graphical session to act in. Its text is JSON, so a tool
// that returns it as its error still answers structured data.
type NoSession struct {
Reason string `json:"reason"`
Looked []string `json:"looked"`
}
func (e *NoSession) Error() string {
b, _ := json.Marshal(map[string]any{"error": "no-graphical-session", "reason": e.Reason, "looked": e.Looked})
return string(b)
}
// IsNoSession is whether err says there is no session.
func IsNoSession(err error) bool {
var n *NoSession
return errors.As(err, &n)
}
// Finder holds where the search looks, so a test can point it at a tree of its own.
type Finder struct {
Proc string // the process table: /proc
X11Sockets string // where X servers listen: /tmp/.X11-unix
RuntimeBase string // the parent of every XDG_RUNTIME_DIR: /run/user
UID int // whose session
// Prefer names the processes that are the session's own, best first: the session's holder.
Prefer []string
// Logind answers `loginctl show-session` for one id; nil skips the check.
Logind func(id string) (map[string]string, error)
}
// DefaultFinder is the machine's: the account this process runs as, or — when it runs as root — the
// operator account the runtime names (MESH_OPERATOR_ACCOUNT).
func DefaultFinder(prefer ...string) Finder {
uid := os.Getuid()
if uid == 0 {
if name := os.Getenv("MESH_OPERATOR_ACCOUNT"); name != "" {
if u, err := user.Lookup(name); err == nil {
if n, err := strconv.Atoi(u.Uid); err == nil {
uid = n
}
}
}
}
return Finder{
Proc: "/proc", X11Sockets: "/tmp/.X11-unix", RuntimeBase: "/run/user",
UID: uid, Prefer: prefer, Logind: loginctl,
}
}
// Find is the operator's session on this machine, preferring a process named in prefer.
func Find(prefer ...string) (*Session, error) {
return DefaultFinder(prefer...).Find()
}
type candidate struct {
proc Process
words map[string]string
rank int
logind map[string]string
}
// Find looks for the session.
func (f Finder) Find() (*Session, error) {
entries, err := os.ReadDir(f.Proc)
if err != nil {
return nil, &NoSession{Reason: "the process table cannot be read: " + err.Error(), Looked: []string{f.Proc}}
}
looked := []string{fmt.Sprintf("the processes of uid %d in %s", f.UID, f.Proc)}
var found []candidate
stale := 0
for _, e := range entries {
pid, err := strconv.Atoi(e.Name())
if err != nil {
continue
}
dir := filepath.Join(f.Proc, e.Name())
info, err := os.Stat(dir)
if err != nil {
continue
}
if st, ok := info.Sys().(*syscall.Stat_t); !ok || int(st.Uid) != f.UID {
continue
}
words := readWords(filepath.Join(dir, "environ"))
if words["DISPLAY"] == "" && words["WAYLAND_DISPLAY"] == "" {
continue
}
if !f.reachable(words) {
stale++
continue
}
found = append(found, candidate{proc: Process{PID: pid, Command: comm(dir), start: startTime(dir)}, words: words})
}
if len(found) == 0 {
reason := fmt.Sprintf("no process of uid %d carries a display", f.UID)
if stale > 0 {
reason = fmt.Sprintf("%d process(es) of uid %d name a display whose socket is gone: the session they belonged to has ended", stale, f.UID)
}
return nil, &NoSession{Reason: reason, Looked: append(looked, f.X11Sockets, f.RuntimeBase)}
}
// logind's word on each session the candidates name, asked once per session.
asked := map[string]map[string]string{}
for i := range found {
id := found[i].words["XDG_SESSION_ID"]
if f.Logind == nil || id == "" {
found[i].rank = 1
continue
}
props, done := asked[id]
if !done {
props, _ = f.Logind(id)
asked[id] = props
}
found[i].logind = props
switch {
case props == nil:
found[i].rank = 1
case props["Remote"] == "yes":
found[i].rank = 3
case props["Active"] == "yes" && props["State"] != "closing":
found[i].rank = 0
case props["State"] == "closing":
found[i].rank = 3
default:
found[i].rank = 2
}
}
if f.Logind != nil {
looked = append(looked, "logind's sessions")
}
preferred := func(c candidate) int {
for i, p := range f.Prefer {
if c.proc.Command == p {
return i
}
}
return len(f.Prefer)
}
sort.SliceStable(found, func(i, j int) bool {
a, b := found[i], found[j]
if a.rank != b.rank {
return a.rank < b.rank
}
if pa, pb := preferred(a), preferred(b); pa != pb {
return pa < pb
}
if a.proc.start != b.proc.start {
return a.proc.start < b.proc.start
}
return a.proc.PID < b.proc.PID
})
best := found[0]
if best.rank == 3 {
return nil, &NoSession{Reason: "the only sessions found are remote or closing", Looked: looked}
}
return f.session(best), nil
}
func (f Finder) session(c candidate) *Session {
w := c.words
s := &Session{
UID: f.UID, ID: w["XDG_SESSION_ID"], Display: w["DISPLAY"], WaylandDisplay: w["WAYLAND_DISPLAY"],
XAuthority: w["XAUTHORITY"], RuntimeDir: w["XDG_RUNTIME_DIR"], FoundIn: c.proc, words: w,
}
s.Desktop = w["XDG_CURRENT_DESKTOP"]
if s.Desktop == "" {
s.Desktop = w["XDG_SESSION_DESKTOP"]
}
switch {
case w["XDG_SESSION_TYPE"] != "":
s.Type = w["XDG_SESSION_TYPE"]
case s.WaylandDisplay != "":
s.Type = "wayland"
default:
s.Type = "x11"
}
if s.RuntimeDir == "" {
s.RuntimeDir = filepath.Join(f.RuntimeBase, strconv.Itoa(f.UID))
}
if isSocket(filepath.Join(s.RuntimeDir, "bus")) {
s.Bus = "unix:path=" + filepath.Join(s.RuntimeDir, "bus")
}
if own := w["DBUS_SESSION_BUS_ADDRESS"]; own != "" && own != s.Bus {
s.SessionBus = own
}
if c.logind != nil {
active := c.logind["Active"] == "yes"
s.Active = &active
}
return s
}
// reachable is whether the display a process names is still served: the X server's socket, or the
// Wayland compositor's. A process outliving its session still carries the session's words.
func (f Finder) reachable(w map[string]string) bool {
if d := w["WAYLAND_DISPLAY"]; d != "" {
path := d
if !filepath.IsAbs(d) {
dir := w["XDG_RUNTIME_DIR"]
if dir == "" {
dir = filepath.Join(f.RuntimeBase, strconv.Itoa(f.UID))
}
path = filepath.Join(dir, d)
}
if isSocket(path) {
return true
}
}
n, ok := DisplayNumber(w["DISPLAY"])
return ok && isSocket(filepath.Join(f.X11Sockets, "X"+strconv.Itoa(n)))
}
// DisplayNumber is the server number of a local X display (":1", ":1.0", "unix:1"); a display on
// another host — an ssh session's forwarded one — is not the local session and answers false.
func DisplayNumber(display string) (int, bool) {
host, rest, ok := strings.Cut(display, ":")
if !ok || (host != "" && host != "unix") {
return 0, false
}
num, _, _ := strings.Cut(rest, ".")
n, err := strconv.Atoi(num)
if err != nil || n < 0 {
return 0, false
}
return n, true
}
// Word is one of the session's words as its process had it ("" when it had none).
func (s *Session) Word(name string) string { return s.words[name] }
// Env is base with the session's words in place of whatever base said for them.
func (s *Session) Env(base []string) []string {
drop := map[string]bool{}
for _, w := range SessionWords {
drop[w] = true
}
out := make([]string, 0, len(base)+8)
for _, kv := range base {
k, _, _ := strings.Cut(kv, "=")
if !drop[k] {
out = append(out, kv)
}
}
bus := s.Bus
if bus == "" {
bus = s.SessionBus
}
for _, kv := range [][2]string{
{"DISPLAY", s.Display}, {"WAYLAND_DISPLAY", s.WaylandDisplay}, {"XAUTHORITY", s.XAuthority},
{"XDG_RUNTIME_DIR", s.RuntimeDir}, {"DBUS_SESSION_BUS_ADDRESS", bus},
{"XDG_SESSION_TYPE", s.Type}, {"XDG_SESSION_ID", s.ID},
{"XDG_CURRENT_DESKTOP", s.words["XDG_CURRENT_DESKTOP"]},
{"XDG_SESSION_DESKTOP", s.words["XDG_SESSION_DESKTOP"]},
{"I3SOCK", s.words["I3SOCK"]}, {"SWAYSOCK", s.words["SWAYSOCK"]},
} {
if kv[1] != "" {
out = append(out, kv[0]+"="+kv[1])
}
}
return out
}
// UserEnv is base with the account's own runtime directory and bus, for a tool that talks to the
// user manager or the session bus and needs no display — it works with no session at all.
func UserEnv(base []string, uid int) []string {
dir := filepath.Join("/run/user", strconv.Itoa(uid))
out := make([]string, 0, len(base)+2)
for _, kv := range base {
k, _, _ := strings.Cut(kv, "=")
if k != "XDG_RUNTIME_DIR" && k != "DBUS_SESSION_BUS_ADDRESS" {
out = append(out, kv)
}
}
return append(out, "XDG_RUNTIME_DIR="+dir, "DBUS_SESSION_BUS_ADDRESS=unix:path="+filepath.Join(dir, "bus"))
}
// readWords reads a process's environment and keeps only SessionWords.
func readWords(path string) map[string]string {
raw, err := os.ReadFile(path)
if err != nil {
return nil
}
keep := map[string]bool{}
for _, w := range SessionWords {
keep[w] = true
}
out := map[string]string{}
for _, kv := range bytes.Split(raw, []byte{0}) {
k, v, ok := bytes.Cut(kv, []byte{'='})
if ok && keep[string(k)] {
out[string(k)] = string(v)
}
}
return out
}
func comm(dir string) string {
b, err := os.ReadFile(filepath.Join(dir, "comm"))
if err != nil {
return ""
}
return strings.TrimSpace(string(b))
}
// startTime is field 22 of /proc/<pid>/stat: when the process started, in clock ticks since boot.
// Read after the command's closing parenthesis, because the command may hold spaces.
func startTime(dir string) uint64 {
b, err := os.ReadFile(filepath.Join(dir, "stat"))
if err != nil {
return ^uint64(0)
}
i := bytes.LastIndexByte(b, ')')
if i < 0 {
return ^uint64(0)
}
fields := strings.Fields(string(b[i+1:]))
// fields[0] is the state, field 3 of the line; start time is field 22.
if len(fields) < 20 {
return ^uint64(0)
}
n, err := strconv.ParseUint(fields[19], 10, 64)
if err != nil {
return ^uint64(0)
}
return n
}
func isSocket(path string) bool {
info, err := os.Stat(path)
return err == nil && info.Mode()&os.ModeSocket != 0
}
// loginctl asks logind about one session, by its property lines.
func loginctl(id string) (map[string]string, error) {
cmd := exec.Command("loginctl", "show-session", id, "-p", "Active", "-p", "State", "-p", "Remote", "-p", "Type", "-p", "Class")
var out bytes.Buffer
cmd.Stdout = &out
done := make(chan error, 1)
if err := cmd.Start(); err != nil {
return nil, err
}
go func() { done <- cmd.Wait() }()
select {
case err := <-done:
if err != nil {
return nil, err
}
case <-time.After(3 * time.Second):
_ = cmd.Process.Kill()
return nil, errors.New("loginctl did not answer in 3s")
}
return ParseProperties(out.String()), nil
}
// ParseProperties reads `Key=Value` lines, as loginctl and systemctl show print them.
func ParseProperties(text string) map[string]string {
out := map[string]string{}
sc := bufio.NewScanner(strings.NewReader(text))
for sc.Scan() {
if k, v, ok := strings.Cut(sc.Text(), "="); ok {
out[k] = v
}
}
return out
}
@@ -0,0 +1,255 @@
package desktop
import (
"context"
"encoding/json"
"net"
"os"
"path/filepath"
"strconv"
"strings"
"testing"
)
// A machine in a directory: a process table, the X servers' socket directory and a runtime base.
type fakeMachine struct {
t *testing.T
proc, x11, runtime string
uid int
}
func newMachine(t *testing.T) *fakeMachine {
root, err := os.MkdirTemp("", "desk")
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { os.RemoveAll(root) })
m := &fakeMachine{t: t, proc: filepath.Join(root, "p"), x11: filepath.Join(root, "x"), runtime: filepath.Join(root, "r"), uid: os.Getuid()}
for _, d := range []string{m.proc, m.x11, filepath.Join(m.runtime, strconv.Itoa(m.uid))} {
if err := os.MkdirAll(d, 0o755); err != nil {
t.Fatal(err)
}
}
return m
}
func (m *fakeMachine) socket(path string) {
l, err := net.Listen("unix", path)
if err != nil {
m.t.Fatal(err)
}
m.t.Cleanup(func() { l.Close() })
}
func (m *fakeMachine) process(pid int, comm string, start int, env ...string) {
dir := filepath.Join(m.proc, strconv.Itoa(pid))
if err := os.MkdirAll(dir, 0o755); err != nil {
m.t.Fatal(err)
}
os.WriteFile(filepath.Join(dir, "environ"), []byte(strings.Join(env, "\x00")+"\x00"), 0o600)
os.WriteFile(filepath.Join(dir, "comm"), []byte(comm+"\n"), 0o644)
// pid (comm) state ppid pgrp session tty tpgid flags minflt cminflt majflt cmajflt utime stime
// cutime cstime priority nice threads itrealvalue starttime ...
stat := strconv.Itoa(pid) + " (" + comm + ") S 1 1 1 0 -1 0 0 0 0 0 0 0 0 0 20 0 1 0 " + strconv.Itoa(start) + " 0 0"
os.WriteFile(filepath.Join(dir, "stat"), []byte(stat), 0o644)
}
func (m *fakeMachine) finder(logind func(string) (map[string]string, error), prefer ...string) Finder {
return Finder{Proc: m.proc, X11Sockets: m.x11, RuntimeBase: m.runtime, UID: m.uid, Prefer: prefer, Logind: logind}
}
func active(id string) (map[string]string, error) {
return map[string]string{"Active": "yes", "State": "active", "Remote": "no", "Type": "x11"}, nil
}
func TestTheSessionIsFoundInTheWindowManagersEnvironmentAndOnlyItsWordsAreRead(t *testing.T) {
m := newMachine(t)
m.socket(filepath.Join(m.x11, "X1"))
run := filepath.Join(m.runtime, strconv.Itoa(m.uid))
m.socket(filepath.Join(run, "bus"))
m.process(100, "lemurs-child", 5, "DISPLAY=:1", "XDG_SESSION_ID=1")
m.process(200, "i3", 10, "DISPLAY=:1", "XAUTHORITY=/home/op/.Xauthority", "XDG_SESSION_ID=1",
"XDG_SESSION_TYPE=x11", "XDG_CURRENT_DESKTOP=i3", "XDG_RUNTIME_DIR="+run,
"DBUS_SESSION_BUS_ADDRESS=unix:path=/tmp/dbus-private", "NPM_TOKEN=secret", "OPENAI_API_KEY=secret")
m.process(300, "zsh", 50, "TERM=xterm") // no display: not a candidate
s, err := m.finder(active, "i3").Find()
if err != nil {
t.Fatal(err)
}
if s.FoundIn.PID != 200 || s.Display != ":1" || s.XAuthority != "/home/op/.Xauthority" || s.ID != "1" || s.Type != "x11" || s.Desktop != "i3" {
t.Fatalf("session: %+v", s)
}
if s.Bus != "unix:path="+filepath.Join(run, "bus") || s.SessionBus != "unix:path=/tmp/dbus-private" {
t.Fatalf("the user manager's bus first, the session's private one reported beside it: %q %q", s.Bus, s.SessionBus)
}
if s.Active == nil || !*s.Active {
t.Fatal("logind's word is carried")
}
env := strings.Join(s.Env([]string{"PATH=/usr/bin", "DISPLAY=:9", "HOME=/home/op"}), "\n")
for _, want := range []string{"PATH=/usr/bin", "HOME=/home/op", "DISPLAY=:1", "XAUTHORITY=/home/op/.Xauthority", "DBUS_SESSION_BUS_ADDRESS=unix:path=" + filepath.Join(run, "bus"), "XDG_RUNTIME_DIR=" + run} {
if !strings.Contains(env, want) {
t.Errorf("env lacks %s:\n%s", want, env)
}
}
if strings.Contains(env, ":9") || strings.Contains(env, "secret") || strings.Contains(env, "NPM_TOKEN") {
t.Fatalf("the base's display is replaced and no other word of the session's process passes:\n%s", env)
}
b, _ := json.Marshal(s)
if strings.Contains(string(b), "secret") {
t.Fatal("the answer carries a word outside the session's")
}
}
func TestWithoutAPreferenceTheOldestProcessOfTheLiveSessionWins(t *testing.T) {
m := newMachine(t)
m.socket(filepath.Join(m.x11, "X0"))
m.process(410, "xterm", 90, "DISPLAY=:0", "XDG_SESSION_ID=3")
m.process(400, "openbox", 20, "DISPLAY=:0", "XDG_SESSION_ID=3")
s, err := m.finder(nil).Find()
if err != nil || s.FoundIn.PID != 400 {
t.Fatalf("%+v %v", s, err)
}
if s.RuntimeDir != filepath.Join(m.runtime, strconv.Itoa(m.uid)) || s.Bus != "" {
t.Fatalf("an absent runtime directory word falls back to the account's, and no bus socket means no bus: %+v", s)
}
}
func TestALeftoverProcessOfAnEndedSessionIsNotTheSession(t *testing.T) {
m := newMachine(t)
m.process(500, "i3", 10, "DISPLAY=:2", "XDG_SESSION_ID=7") // no X2 socket
_, err := m.finder(active, "i3").Find()
if !IsNoSession(err) || !strings.Contains(err.Error(), "socket is gone") {
t.Fatalf("%v", err)
}
var answer map[string]any
if json.Unmarshal([]byte(err.Error()), &answer) != nil || answer["error"] != "no-graphical-session" {
t.Fatalf("the refusal is structured: %s", err)
}
}
func TestNoProcessWithADisplayIsAClearNoSession(t *testing.T) {
m := newMachine(t)
m.process(600, "sshd", 1, "SSH_CONNECTION=x")
_, err := m.finder(active).Find()
if !IsNoSession(err) || !strings.Contains(err.Error(), "no process of uid") {
t.Fatalf("%v", err)
}
}
func TestAnActiveLocalSessionBeatsAnInactiveOneAndARemoteOneIsRefused(t *testing.T) {
m := newMachine(t)
m.socket(filepath.Join(m.x11, "X0"))
m.socket(filepath.Join(m.x11, "X1"))
m.process(700, "i3", 5, "DISPLAY=:0", "XDG_SESSION_ID=a")
m.process(800, "i3", 9, "DISPLAY=:1", "XDG_SESSION_ID=b")
logind := func(id string) (map[string]string, error) {
if id == "a" {
return map[string]string{"Active": "no", "State": "online", "Remote": "no"}, nil
}
return map[string]string{"Active": "yes", "State": "active", "Remote": "no"}, nil
}
s, err := m.finder(logind, "i3").Find()
if err != nil || s.FoundIn.PID != 800 || s.Display != ":1" {
t.Fatalf("the active session: %+v %v", s, err)
}
remote := func(string) (map[string]string, error) {
return map[string]string{"Active": "yes", "Remote": "yes"}, nil
}
if _, err := m.finder(remote).Find(); !IsNoSession(err) {
t.Fatalf("a remote session is not the operator's desktop: %v", err)
}
}
func TestAWaylandSessionIsFoundByItsCompositorsSocket(t *testing.T) {
m := newMachine(t)
run := filepath.Join(m.runtime, strconv.Itoa(m.uid))
m.socket(filepath.Join(run, "wayland-1"))
m.process(900, "sway", 3, "WAYLAND_DISPLAY=wayland-1", "XDG_RUNTIME_DIR="+run, "SWAYSOCK=/run/x.sock")
s, err := m.finder(nil, "sway").Find()
if err != nil || s.Type != "wayland" || s.WaylandDisplay != "wayland-1" {
t.Fatalf("%+v %v", s, err)
}
if !strings.Contains(strings.Join(s.Env(nil), " "), "SWAYSOCK=/run/x.sock") {
t.Fatal("the compositor's socket word passes")
}
}
func TestADisplayOnAnotherHostIsNotTheLocalSession(t *testing.T) {
for d, want := range map[string]bool{":0": true, ":1.0": true, "unix:2": true, "localhost:10.0": false, "host:0": false, "": false, ":x": false} {
if _, ok := DisplayNumber(d); ok != want {
t.Errorf("%q: %v", d, ok)
}
}
}
func TestACommandIsBoundedAndItsFailureNamed(t *testing.T) {
r := Exec(context.Background(), os.Environ(), []byte("hello"), "cat")
if !r.OK() || r.Stdout != "hello" {
t.Fatalf("%+v", r)
}
r = Exec(context.Background(), os.Environ(), nil, "sh", "-c", "echo no >&2; exit 3")
if r.OK() || r.Code != 3 || !strings.Contains(r.Err().Error(), "exited 3: no") {
t.Fatalf("%+v", r)
}
r = Exec(context.Background(), os.Environ(), nil, "no-such-program-here")
if r.OK() || r.Code != 127 {
t.Fatalf("%+v", r)
}
r = Exec(context.Background(), os.Environ(), nil, "sh", "-c", "head -c 400000 /dev/zero")
if !r.Truncated || len(r.Stdout) != MostOutput {
t.Fatalf("cut at %d: %d %v", MostOutput, len(r.Stdout), r.Truncated)
}
}
func TestArgumentsAreReadStrictly(t *testing.T) {
a := Args{"name": " x ", "n": float64(3), "f": 1.5, "b": true, "l": []any{"a", "b"}}
if v, err := a.Text("name"); err != nil || v != "x" {
t.Fatal(v, err)
}
if _, err := a.Text("missing"); err == nil {
t.Fatal("a missing required text")
}
if n, err := a.Whole("n", 0, 1, 5); err != nil || n != 3 {
t.Fatal(n, err)
}
if _, err := a.Whole("f", 0, 0, 5); err == nil {
t.Fatal("1.5 is not whole")
}
if _, err := a.Whole("n", 0, 4, 5); err == nil {
t.Fatal("out of range")
}
if b, given, err := a.Bool("b"); !b || !given || err != nil {
t.Fatal("bool")
}
if _, _, err := a.Bool("name"); err == nil {
t.Fatal("text is not a bool")
}
if l, err := a.Strings("l"); err != nil || len(l) != 2 {
t.Fatal(l, err)
}
if _, err := a.OneOf("name", "", "y", "z"); err == nil {
t.Fatal("not one of")
}
}
func TestAPathIsKeptInsideTheHome(t *testing.T) {
t.Setenv("MESH_OPERATOR_HOME", "/home/op")
for in, want := range map[string]string{"~/a.png": "/home/op/a.png", "b/c": "/home/op/b/c", "/home/op/d": "/home/op/d", "~": "/home/op"} {
if got, err := InHome(in); err != nil || got != want {
t.Errorf("%s: %s %v", in, got, err)
}
}
for _, out := range []string{"/etc/passwd", "~/../other", "../x"} {
if _, err := InHome(out); err == nil {
t.Errorf("%s was accepted", out)
}
}
}
func TestPropertiesAreParsed(t *testing.T) {
p := ParseProperties("Active=yes\nState=active\nDisplay=\n")
if p["Active"] != "yes" || p["State"] != "active" || p["Display"] != "" {
t.Fatal(p)
}
}
File diff suppressed because one or more lines are too long