Feature 57 (config and provisioning): modules declare config and provisions, their files rendered from templates; synced with main (issues of feature 57, 874)

This commit is contained in:
2026-10-16 07:37:43 +00:00
parent 1a2c917abf
commit 9652b2d5ce
771 changed files with 52063 additions and 1774 deletions
+4 -4
View File
@@ -22,11 +22,11 @@ for GTK, Qt, the portal and the cursor, dark by default. It claims no seat, beca
| path | class | from |
|---|---|---|
| `~/.config/gtk-3.0/settings.ini` | owned (the found file kept once) | [`config/gtk-settings.ini`](config/gtk-settings.ini) |
| `~/.config/gtk-3.0/settings.ini` | owned (the found file kept once) | [`files/gtk-settings.ini`](files/gtk-settings.ini) |
| `~/.config/gtk-4.0/settings.ini` | owned | the same file |
| `~/.config/qt6ct/qt6ct.conf` | owned | [`config/qt6ct.conf`](config/qt6ct.conf) |
| `~/.config/xdg-desktop-portal/portals.conf` | owned | [`config/portals.conf`](config/portals.conf) |
| `~/.icons/default/index.theme` | owned | [`config/cursor-index.theme`](config/cursor-index.theme): the cursor for programs that read neither `XCURSOR_THEME` nor the resources |
| `~/.config/qt6ct/qt6ct.conf` | owned | [`files/qt6ct.conf`](files/qt6ct.conf) |
| `~/.config/xdg-desktop-portal/portals.conf` | owned | [`files/portals.conf`](files/portals.conf) |
| `~/.icons/default/index.theme` | owned | [`files/cursor-index.theme`](files/cursor-index.theme): the cursor for programs that read neither `XCURSOR_THEME` nor the resources |
**`qt6ct.conf` is the mesh's now.** A change made in qt6ct's own window is replaced at the next push,
and the window's saved geometry goes with it. The theme is this module's to say. Settings will make it
@@ -18,10 +18,18 @@ type manifest struct {
Environment struct {
Variables map[string]string `json:"variables"`
} `json:"environment"`
Items []struct {
Collection string `json:"collection"`
Attributes map[string]string `json:"attributes"`
Template string `json:"template"`
} `json:"items"`
Shell []struct {
For, Slot, Code string
} `json:"shell"`
Resources []map[string]any `json:"resources"`
Build struct {
Artifacts []map[string]any `json:"artifacts"`
} `json:"build"`
}
func readManifest(t *testing.T) manifest {
@@ -33,6 +41,23 @@ func readManifest(t *testing.T) manifest {
if err := json.Unmarshal(raw, &m); err != nil {
t.Fatal(err)
}
// The module's code and variables are the templates of its items (design 57 L3, issues 795 and 807).
for _, it := range m.Items {
text, err := os.ReadFile(filepath.Join("..", "..", it.Template))
if err != nil {
t.Fatal(err)
}
parts := strings.Split(it.Collection, ":")
switch {
case len(parts) == 3 && parts[0] == "shell":
m.Shell = append(m.Shell, struct{ For, Slot, Code string }{parts[1], parts[2], string(text)})
case it.Collection == "environment" && it.Attributes["kind"] == "variable":
if m.Environment.Variables == nil {
m.Environment.Variables = map[string]string{}
}
m.Environment.Variables[it.Attributes["name"]] = strings.TrimSuffix(string(text), "\n")
}
}
return m
}
@@ -96,9 +121,10 @@ func TestItOwnsTheFilesItsSourcesHoldAndNoQt5Duplicate(t *testing.T) {
pkgs = append(pkgs, r["package"].(string))
continue
}
raw, _ := os.ReadFile(filepath.Join("..", "..", "config", sources[id]))
if r["content"] != string(raw) || r["into"] != nil || r["owner"] != "${machine:account}" {
t.Errorf("%s is not config/%s, whole and the account's", id, sources[id])
_, inline := r["content"]
if _, err := os.Stat(filepath.Join("..", "..", "files", sources[id])); err != nil || inline ||
r["template"] != "files/"+sources[id] || r["into"] != nil || r["owner"] != "${machine:account}" {
t.Errorf("%s is not files/%s, whole, as its template, and the account's", id, sources[id])
}
if strings.Contains(r["path"].(string), "qt5ct") {
t.Error("qt5ct: both workstations run Qt 6 programs through qt6ct; a second tool is a duplicate")
@@ -107,7 +133,7 @@ func TestItOwnsTheFilesItsSourcesHoldAndNoQt5Duplicate(t *testing.T) {
if strings.Join(pkgs, ",") != "gnome-themes-extra,adwaita-icon-theme,adwaita-cursors,qt6ct,xdg-desktop-portal-gtk" {
t.Fatalf("%v", pkgs)
}
qt := readINI(filepath.Join("..", "..", "config", "qt6ct.conf"))
qt := readINI(filepath.Join("..", "..", "files", "qt6ct.conf"))
if qt["Appearance"]["style"] != "Fusion" || qt["Appearance"]["custom_palette"] != "true" || !strings.Contains(qt["Fonts"]["general"], "Inter,11") {
t.Fatalf("%v", qt)
}
@@ -116,7 +142,7 @@ func TestItOwnsTheFilesItsSourcesHoldAndNoQt5Duplicate(t *testing.T) {
t.Fatalf("qt6ct ships no %s", qt["Appearance"]["color_scheme_path"])
}
}
gtk := readINI(filepath.Join("..", "..", "config", "gtk-settings.ini"))["Settings"]
gtk := readINI(filepath.Join("..", "..", "files", "gtk-settings.ini"))["Settings"]
if gtk["gtk-theme-name"] != "Adwaita" || gtk["gtk-application-prefer-dark-theme"] != "1" || gtk["gtk-font-name"] != "Inter 11" {
t.Fatalf("%v", gtk)
}
@@ -244,3 +270,31 @@ func asJSON(v any) string {
b, _ := json.Marshal(v)
return string(b)
}
// No file resource carries its text inline: each names its template in files/, and the build packs files/
// into the one archive the controller reads the templates back from (novox/hq ADR 0349 §2).
func TestEveryFileTakesItsTextFromATemplate(t *testing.T) {
m := readManifest(t)
for _, r := range m.Resources {
if _, inline := r["content"]; inline {
t.Errorf("resource %v keeps its text inline: keep it in files/ and name it as the template", r["id"])
}
if name, ok := r["template"].(string); ok {
if _, err := os.Stat(filepath.Join("..", "..", name)); err != nil {
t.Errorf("resource %v names %s: %v", r["id"], name, err)
}
}
}
packs := 0
for _, a := range m.Build.Artifacts {
if a["kind"] == "archive" && a["from"] == "files" {
packs++
if a["name"] != "files" {
t.Errorf("the templates archive is named %v, not files", a["name"])
}
}
}
if packs != 1 {
t.Errorf("the build packs files/ into %d archives, not one", packs)
}
}
+1
View File
@@ -0,0 +1 @@
Adwaita:dark
+1
View File
@@ -0,0 +1 @@
/usr/share/themes/Adwaita-dark/gtk-2.0/gtkrc
@@ -0,0 +1 @@
qt6ct
+1
View File
@@ -0,0 +1 @@
6
@@ -0,0 +1 @@
Fusion
+1
View File
@@ -0,0 +1 @@
24
+1
View File
@@ -0,0 +1 @@
Adwaita
+10
View File
@@ -0,0 +1,10 @@
# The appearance (module adwaita): GSettings is where the portal reads dark or light, and the portal is
# the only way it reaches Electron, Chromium, Firefox and flatpaks. Set at every session start to the
# module's default; adwaita_appearance switches it for a session.
gsettings set org.gnome.desktop.interface color-scheme 'prefer-dark' || true
gsettings set org.gnome.desktop.interface gtk-theme 'Adwaita' || true
gsettings set org.gnome.desktop.interface icon-theme 'Adwaita' || true
gsettings set org.gnome.desktop.interface cursor-theme 'Adwaita' || true
gsettings set org.gnome.desktop.interface cursor-size 24 || true
gsettings set org.gnome.desktop.interface font-name 'Inter 11' || true
gsettings set org.gnome.desktop.interface monospace-font-name 'JetBrainsMono Nerd Font 11' || true
+3
View File
@@ -0,0 +1,3 @@
! adwaita: the cursor, for X programs that take it from the resources.
Xcursor.theme: Adwaita
Xcursor.size: 24
+81 -23
View File
@@ -1,6 +1,7 @@
{
"module": "adwaita",
"version": "1",
"moved": true,
"capabilities": [
"package-manager"
],
@@ -10,27 +11,79 @@
"adwaita_icons",
"adwaita_portal_check"
],
"environment": {
"variables": {
"GTK_THEME": "Adwaita:dark",
"GTK2_RC_FILES": "/usr/share/themes/Adwaita-dark/gtk-2.0/gtkrc",
"QT_QPA_PLATFORMTHEME": "qt6ct",
"QT_STYLE_OVERRIDE": "Fusion",
"QT_SELECT": "6",
"XCURSOR_THEME": "Adwaita",
"XCURSOR_SIZE": "24"
}
},
"shell": [
"items": [
{
"for": "xresources",
"slot": "normal",
"code": "! adwaita: the cursor, for X programs that take it from the resources.\nXcursor.theme: Adwaita\nXcursor.size: 24\n"
"collection": "environment",
"name": "gtk-theme",
"attributes": {
"kind": "variable",
"name": "GTK_THEME"
},
"template": "files/env-gtk-theme"
},
{
"for": "xinitrc",
"slot": "normal",
"code": "# The appearance (module adwaita): GSettings is where the portal reads dark or light, and the portal is\n# the only way it reaches Electron, Chromium, Firefox and flatpaks. Set at every session start to the\n# module's default; adwaita_appearance switches it for a session.\ngsettings set org.gnome.desktop.interface color-scheme 'prefer-dark' || true\ngsettings set org.gnome.desktop.interface gtk-theme 'Adwaita' || true\ngsettings set org.gnome.desktop.interface icon-theme 'Adwaita' || true\ngsettings set org.gnome.desktop.interface cursor-theme 'Adwaita' || true\ngsettings set org.gnome.desktop.interface cursor-size 24 || true\ngsettings set org.gnome.desktop.interface font-name 'Inter 11' || true\ngsettings set org.gnome.desktop.interface monospace-font-name 'JetBrainsMono Nerd Font 11' || true\n"
"collection": "environment",
"name": "gtk2-rc-files",
"attributes": {
"kind": "variable",
"name": "GTK2_RC_FILES"
},
"template": "files/env-gtk2-rc-files"
},
{
"collection": "environment",
"name": "qt-qpa-platformtheme",
"attributes": {
"kind": "variable",
"name": "QT_QPA_PLATFORMTHEME"
},
"template": "files/env-qt-qpa-platformtheme"
},
{
"collection": "environment",
"name": "qt-style-override",
"attributes": {
"kind": "variable",
"name": "QT_STYLE_OVERRIDE"
},
"template": "files/env-qt-style-override"
},
{
"collection": "environment",
"name": "qt-select",
"attributes": {
"kind": "variable",
"name": "QT_SELECT"
},
"template": "files/env-qt-select"
},
{
"collection": "environment",
"name": "xcursor-theme",
"attributes": {
"kind": "variable",
"name": "XCURSOR_THEME"
},
"template": "files/env-xcursor-theme"
},
{
"collection": "environment",
"name": "xcursor-size",
"attributes": {
"kind": "variable",
"name": "XCURSOR_SIZE"
},
"template": "files/env-xcursor-size"
},
{
"collection": "shell:xresources:normal",
"name": "xresources-normal",
"template": "files/xresources-normal"
},
{
"collection": "shell:xinitrc:normal",
"name": "xinitrc-normal",
"template": "files/xinitrc-normal"
}
],
"resources": [
@@ -65,7 +118,7 @@
"path": "${machine:account-home}/.config/gtk-3.0/settings.ini",
"owner": "${machine:account}",
"mode": "0644",
"content": "# Written by the mesh (module adwaita, novox/hq ADR 0208), for GTK 3 and GTK 4 alike. Replaced at\n# every push; adwaita_appearance switches dark and light for the running session.\n[Settings]\ngtk-theme-name=Adwaita\ngtk-icon-theme-name=Adwaita\ngtk-cursor-theme-name=Adwaita\ngtk-cursor-theme-size=24\ngtk-font-name=Inter 11\ngtk-application-prefer-dark-theme=1\n"
"template": "files/gtk-settings.ini"
},
{
"id": "gtk4",
@@ -73,7 +126,7 @@
"path": "${machine:account-home}/.config/gtk-4.0/settings.ini",
"owner": "${machine:account}",
"mode": "0644",
"content": "# Written by the mesh (module adwaita, novox/hq ADR 0208), for GTK 3 and GTK 4 alike. Replaced at\n# every push; adwaita_appearance switches dark and light for the running session.\n[Settings]\ngtk-theme-name=Adwaita\ngtk-icon-theme-name=Adwaita\ngtk-cursor-theme-name=Adwaita\ngtk-cursor-theme-size=24\ngtk-font-name=Inter 11\ngtk-application-prefer-dark-theme=1\n"
"template": "files/gtk-settings.ini"
},
{
"id": "qt6ct",
@@ -81,7 +134,7 @@
"path": "${machine:account-home}/.config/qt6ct/qt6ct.conf",
"owner": "${machine:account}",
"mode": "0644",
"content": "[Appearance]\ncolor_scheme_path=/usr/share/qt6ct/colors/darker.conf\ncustom_palette=true\nicon_theme=Adwaita\nstandard_dialogs=default\nstyle=Fusion\n\n[Fonts]\nfixed=\"JetBrainsMono Nerd Font,11,-1,5,50,0,0,0,0,0\"\ngeneral=\"Inter,11,-1,5,50,0,0,0,0,0\"\n\n[Interface]\nactivate_item_on_single_click=1\nbuttonbox_layout=0\ncursor_flash_time=1000\ndialog_buttons_have_icons=1\ndouble_click_interval=400\ngui_effects=@Invalid()\nkeyboard_scheme=2\nmenus_have_icons=true\nshow_shortcuts_in_context_menus=true\nstylesheets=@Invalid()\ntoolbutton_style=4\nunderline_shortcut=1\nwheel_scroll_lines=3\n\n[Troubleshooting]\nforce_raster_widgets=1\nignored_applications=@Invalid()\n"
"template": "files/qt6ct.conf"
},
{
"id": "portals",
@@ -89,7 +142,7 @@
"path": "${machine:account-home}/.config/xdg-desktop-portal/portals.conf",
"owner": "${machine:account}",
"mode": "0644",
"content": "# Written by the mesh (module adwaita, novox/hq ADR 0208). Replaced at every push.\n#\n# Which portal backend answers each interface. i3 is not a desktop xdg-desktop-portal knows, so with\n# no preference it uses whichever backend happens to be installed: fine while gtk is the only one,\n# wrong the day another arrives as somebody else's dependency. Named instead. gtk also serves\n# org.freedesktop.appearance (dark or light) from GSettings, which the session's start sets.\n[preferred]\ndefault=gtk\n# Secrets for sandboxed programs come from the keyring's backend. Without this line no backend answers\n# the interface: gtk does not implement it, and gnome-keyring's names only GNOME as its desktop.\norg.freedesktop.impl.portal.Secret=gnome-keyring\n"
"template": "files/portals.conf"
},
{
"id": "cursor",
@@ -97,7 +150,7 @@
"path": "${machine:account-home}/.icons/default/index.theme",
"owner": "${machine:account}",
"mode": "0644",
"content": "# Written by the mesh (module adwaita, novox/hq ADR 0208): the default cursor theme, for programs that\n# read neither XCURSOR_THEME nor the X resources.\n[Icon Theme]\nName=Default\nInherits=Adwaita\n"
"template": "files/cursor-index.theme"
}
],
"build": {
@@ -112,6 +165,11 @@
"loads": [
"adwaita-tools"
]
},
{
"name": "files",
"kind": "archive",
"from": "files"
}
]
}
@@ -0,0 +1,64 @@
package main
import (
"encoding/json"
"os"
"path/filepath"
"testing"
)
// The module's directory, where its manifest and its files/ are.
const moduleDir = "../.."
// templateText is the text a file resource places on the machine: its template's, in the module's files/
// (novox/hq ADR 0349 §2). A resource that names no template, or a template that is not there, fails the test.
func templateText(t *testing.T, r map[string]any) string {
t.Helper()
name, _ := r["template"].(string)
if name == "" {
t.Fatalf("resource %v names no template", r["id"])
}
raw, err := os.ReadFile(filepath.Join(moduleDir, filepath.FromSlash(name)))
if err != nil {
t.Fatalf("resource %v names %s: %v", r["id"], name, err)
}
return string(raw)
}
// No file resource carries its text inline: each names its template in files/, and the build packs files/
// into the one archive the controller reads the templates back from (novox/hq ADR 0349 §2).
func TestEveryFileTakesItsTextFromATemplate(t *testing.T) {
raw, err := os.ReadFile(filepath.Join(moduleDir, "module.json"))
if err != nil {
t.Fatal(err)
}
var m struct {
Resources []map[string]any `json:"resources"`
Build struct {
Artifacts []map[string]any `json:"artifacts"`
} `json:"build"`
}
if err := json.Unmarshal(raw, &m); err != nil {
t.Fatal(err)
}
for _, r := range m.Resources {
if _, inline := r["content"]; inline {
t.Errorf("resource %v keeps its text inline: keep it in files/ and name it as the template", r["id"])
}
if r["type"] == "file" {
templateText(t, r)
}
}
packs := 0
for _, a := range m.Build.Artifacts {
if a["kind"] == "archive" && a["from"] == "files" {
packs++
if a["name"] != "files" {
t.Errorf("the templates archive is named %v, not files", a["name"])
}
}
}
if packs != 1 {
t.Errorf("the build packs files/ into %d archives, not one", packs)
}
}
@@ -0,0 +1,4 @@
# The names the artifact store had before, from the setting older-store-names, written by the mesh.
# artifact_store_references reads every definition under these too (novox/hq issue 323).
# Replaced on every push; change the setting, never this file.
older=${config:older-store-names}
+14 -8
View File
@@ -13,13 +13,14 @@
"artifact_store_references",
"artifact_store_collect"
],
"settings": {
"older-store-names": {
"kind": "preference",
"default": "none",
"why": "the names the artifact store had before, as a definition may still pin an image under them: host names or host:port, separated by spaces; none when it had no other. artifact_store_references reads every definition under these as well as under the store's current name, so an image a definition pins under one is said to be named by a definition (novox/hq issue 323)"
}
},
"config": {
"older-store-names": {
"type": "string",
"description": "The names the artifact store had before, as host names or host:port separated by spaces, or none when it had no other. artifact_store_references reads every definition under these as well as under the store's current name, so an image a definition pins under one is said to be named by a definition (novox/hq issue 323).",
"pattern": "none|[A-Za-z0-9.:-]+( [A-Za-z0-9.:-]+)*",
"default": "none"
}
},
"resources": [
{
"id": "state",
@@ -33,7 +34,7 @@
"path": "${dir:state}/store-names",
"mode": "0600",
"trusted": false,
"content": "# The names the artifact store had before, from the setting older-store-names, written by the mesh.\n# artifact_store_references reads every definition under these too (novox/hq issue 323).\n# Replaced on every push; change the setting, never this file.\nolder=${setting:older-store-names}\n"
"template": "files/store-names"
}
],
"build": {
@@ -52,6 +53,11 @@
"MESH_ARTIFACT_STORE_CONTAINER": "mesh-registry",
"MESH_ARTIFACT_STORE_NAMES_FILE": "${dir:state}/store-names"
}
},
{
"name": "files",
"kind": "archive",
"from": "files"
}
]
}
@@ -34,11 +34,9 @@ func TestKeysListsTriggersI3LinesAndFirmwareKeys(t *testing.T) {
home := "/home/operator"
t.Setenv("MESH_OPERATOR_HOME", home)
// The i3 module's file as the controller composes it: other modules' lines around this one's.
var own string
for _, c := range m.Contributions {
if c.Seat == "node-display-session" {
own += c.Content
}
own := m.i3Lines()
if own == "" || len(m.Contributions) != 0 {
t.Fatalf("the lines for the window manager are one item, and no contribution is left: %q %v", own, m.Contributions)
}
put(t, f.root, home+"/"+I3Config, "set $mod Mod4\n# rofi\nbindsym $mod+d exec rofi\n# "+ModuleName+"\n"+own+
"# triggerhappy-not-here\nbindsym $mod+Shift+z exec other\n#########################################\ninclude x\n")
@@ -15,19 +15,41 @@ type manifest struct {
Tools []string `json:"tools"`
Emits []string `json:"emits"`
Resources []map[string]any `json:"resources"`
// Contributions to other modules' seats (novox/hq ADR 0212): the vendor keys go to node-hotkeys.
Contributions []struct {
Seat string `json:"seat"`
Kind string `json:"kind"`
Content string `json:"content"`
} `json:"contributions"`
// Items of collections (novox/hq ADR 0349 clause 13): the vendor keys are an item of the node-hotkeys
// triggers, its text the template file beside the manifest.
Items []struct {
Collection string `json:"collection"`
Template string `json:"template"`
} `json:"items"`
// Old-form contributions to other modules' seats (novox/hq ADR 0212): none is left.
Contributions []any `json:"contributions"`
}
// triggers is the module's contribution to node-hotkeys: its vendor keys.
// i3Lines is the module's lines for the window manager: the text of the template of its item of the collection
// contribution:node-display-session:config (design 57 L3, issue 848).
func (m manifest) i3Lines() string {
for _, it := range m.Items {
if it.Collection == "contribution:node-display-session:config" {
raw, err := os.ReadFile(filepath.Join("..", "..", it.Template))
if err != nil {
return ""
}
return string(raw)
}
}
return ""
}
// triggers is the module's contribution to node-hotkeys: its vendor keys, the text of the template of its
// item of the collection contribution:node-hotkeys:trigger.
func (m manifest) triggers() string {
for _, c := range m.Contributions {
if c.Seat == "node-hotkeys" && c.Kind == "trigger" {
return c.Content
for _, it := range m.Items {
if it.Collection == "contribution:node-hotkeys:trigger" {
raw, err := os.ReadFile(filepath.Join("..", "..", it.Template))
if err != nil {
return ""
}
return string(raw)
}
}
return ""
@@ -0,0 +1,83 @@
package main
import (
"encoding/json"
"os"
"path/filepath"
"strings"
"testing"
)
// Each file's text is a template in templates/, the directory the manifest names (novox/hq ADR 0349 §2, issue 731),
// never in files/: files/ is the archive the machine unpacks, and stays exactly what the machine is given.
func TestEveryFileTakesItsTextFromATemplateOutsideTheUnpackedFiles(t *testing.T) {
raw, err := os.ReadFile("../../module.json")
if err != nil {
t.Fatal(err)
}
var m struct {
Templates string `json:"templates"`
Resources []map[string]any `json:"resources"`
Build struct {
Artifacts []map[string]any `json:"artifacts"`
} `json:"build"`
}
if err := json.Unmarshal(raw, &m); err != nil {
t.Fatal(err)
}
if m.Templates != "templates" {
t.Fatalf("the manifest names the templates directory %q, not templates", m.Templates)
}
files := 0
for _, r := range m.Resources {
if _, inline := r["content"]; inline {
t.Errorf("resource %v keeps its text inline: keep it in templates/ and name it as the template", r["id"])
}
if r["type"] != "file" {
continue
}
files++
name, _ := r["template"].(string)
if !strings.HasPrefix(name, "templates/") {
t.Errorf("resource %v names the template %q, not one in templates/", r["id"], name)
continue
}
if st, err := os.Lstat(filepath.Join("../..", name)); err != nil || !st.Mode().IsRegular() {
t.Errorf("resource %v names the template %s, which is not a plain file: %v", r["id"], name, err)
}
}
if files == 0 {
t.Fatal("no file resource")
}
packs := 0
for _, a := range m.Build.Artifacts {
if a["kind"] == "archive" && a["from"] == "templates" {
packs++
if a["name"] != "templates" {
t.Errorf("the templates archive is named %v, not templates", a["name"])
}
}
}
if packs != 1 {
t.Fatalf("the build packs templates/ into %d archives, not one", packs)
}
// The templates archive is read by the controller, never unpacked onto the machine.
for _, r := range m.Resources {
if r["artifact"] == "templates" {
t.Errorf("resource %v places the templates archive on the machine", r["id"])
}
}
// And files/ holds what the machine runs, no template.
err = filepath.WalkDir("../../files", func(at string, d os.DirEntry, err error) error {
if err != nil {
return err
}
if !d.IsDir() && !strings.HasPrefix(filepath.ToSlash(at), "../../files/bin/") {
t.Errorf("files/ holds %s, which is not one of the scripts the machine unpacks", at)
}
return nil
})
if err != nil {
t.Fatal(err)
}
}
+32 -34
View File
@@ -1,6 +1,8 @@
{
"module": "asus-zephyrus-g14",
"version": "1",
"moved": true,
"templates": "templates",
"capabilities": [
"package-manager",
"service-manager"
@@ -59,14 +61,14 @@
"type": "file",
"path": "/etc/modprobe.d/g14-nvidia-power.conf",
"mode": "0644",
"content": "# Managed by the mesh (module asus-zephyrus-g14). Replaced on every push; edit the catalogue instead.\n#\n# The discrete GPU's driver options on the ROG Zephyrus G14 (GA403, RTX 40 series, hybrid graphics).\n#\n# NVreg_DynamicPowerManagement=0x00 turns runtime D3 off. With it on, a change of power source sends\n# the driver an ACPI notification it fails to handle on this model (\"RmHandleDNotifierEvent: Failed to\n# handle ACPI D-Notifier event, status=0x62\"), and the GPU stops making progress until the machine is\n# powered off. Off costs a few idle watts in hybrid mode and keeps the machine up.\n#\n# NVreg_PreserveVideoMemoryAllocations=1 saves video memory across suspend, so what used the GPU still\n# works after waking. It needs nvidia-suspend, -hibernate and -resume to run around a sleep, which this\n# module's drop-ins on the sleep services ask for.\n#\n# A change here applies when the driver next loads: at the next boot.\noptions nvidia NVreg_PreserveVideoMemoryAllocations=1\noptions nvidia NVreg_DynamicPowerManagement=0x00\n"
"template": "templates/modprobe.d/g14-nvidia-power.conf"
},
{
"id": "video-options",
"type": "file",
"path": "/etc/modprobe.d/video-brightness-switch.conf",
"mode": "0644",
"content": "# Managed by the mesh (module asus-zephyrus-g14). Replaced on every push; edit the catalogue instead.\n#\n# The ACPI video driver does not change the backlight itself on the brightness keys: on this model it\n# moves the wrong one. The keys are triggerhappy's (see /etc/triggerhappy/triggers.d/asus-g14.conf).\n# Applies when the module next loads: at the next boot.\noptions video brightness_switch_enabled=0\n"
"template": "templates/modprobe.d/video-brightness-switch.conf"
},
{
"id": "suspend-drop-ins",
@@ -79,7 +81,7 @@
"type": "file",
"path": "/etc/systemd/system/systemd-suspend.service.d/asus-zephyrus-g14-nvidia.conf",
"mode": "0644",
"content": "# Managed by the mesh (module asus-zephyrus-g14). Replaced on every push; edit the catalogue instead.\n#\n# The NVIDIA driver's own sleep actions, asked for by the sleep itself rather than enabled as\n# links: the mesh declares files and never makes links (novox/hq ADR 0012), and the host's service\n# shape must not start these units by hand, which would put the GPU to sleep with the machine awake.\n[Unit]\nWants=nvidia-suspend.service nvidia-resume.service\n"
"template": "templates/systemd/system/systemd-suspend.service.d/asus-zephyrus-g14-nvidia.conf"
},
{
"id": "hibernate-drop-ins",
@@ -92,7 +94,7 @@
"type": "file",
"path": "/etc/systemd/system/systemd-hibernate.service.d/asus-zephyrus-g14-nvidia.conf",
"mode": "0644",
"content": "# Managed by the mesh (module asus-zephyrus-g14). Replaced on every push; edit the catalogue instead.\n#\n# The NVIDIA driver's own sleep actions, asked for by the sleep itself rather than enabled as\n# links: the mesh declares files and never makes links (novox/hq ADR 0012), and the host's service\n# shape must not start these units by hand, which would put the GPU to sleep with the machine awake.\n[Unit]\nWants=nvidia-hibernate.service nvidia-resume.service\n"
"template": "templates/systemd/system/systemd-hibernate.service.d/asus-zephyrus-g14-nvidia.conf"
},
{
"id": "suspend-then-hibernate-drop-ins",
@@ -105,7 +107,7 @@
"type": "file",
"path": "/etc/systemd/system/systemd-suspend-then-hibernate.service.d/asus-zephyrus-g14-nvidia.conf",
"mode": "0644",
"content": "# Managed by the mesh (module asus-zephyrus-g14). Replaced on every push; edit the catalogue instead.\n#\n# The NVIDIA driver's own sleep actions, asked for by the sleep itself rather than enabled as\n# links: the mesh declares files and never makes links (novox/hq ADR 0012), and the host's service\n# shape must not start these units by hand, which would put the GPU to sleep with the machine awake.\n[Unit]\nWants=nvidia-suspend-then-hibernate.service nvidia-resume.service\n"
"template": "templates/systemd/system/systemd-suspend-then-hibernate.service.d/asus-zephyrus-g14-nvidia.conf"
},
{
"id": "powerd-drop-ins",
@@ -118,14 +120,14 @@
"type": "file",
"path": "/etc/systemd/system/nvidia-powerd.service.d/asus-zephyrus-g14.conf",
"mode": "0644",
"content": "# Managed by the mesh (module asus-zephyrus-g14). Replaced on every push; edit the catalogue instead.\n#\n# nvidia-powerd (Dynamic Boost) was the first error in the chain that hung this model's GPU on a change\n# of power source, and asusd starts it on mains. It runs only when the kernel command line says\n# zephyrus.nvidia-powerd — an explicit opt-in, at boot.\n[Unit]\nConditionKernelCommandLine=zephyrus.nvidia-powerd\n"
"template": "templates/systemd/system/nvidia-powerd.service.d/asus-zephyrus-g14.conf"
},
{
"id": "backlight-rule",
"type": "file",
"path": "/etc/udev/rules.d/90-backlight.rules",
"mode": "0644",
"content": "# Managed by the mesh (module asus-zephyrus-g14). Replaced on every push; edit the catalogue instead.\n#\n# The backlights are writable by the video group, so the brightness keys and the module's brightness tool\n# move the panel without root.\nACTION==\"add\", SUBSYSTEM==\"backlight\", RUN+=\"/usr/bin/chgrp video /sys/class/backlight/%k/brightness\", RUN+=\"/usr/bin/chmod g+w /sys/class/backlight/%k/brightness\"\n"
"template": "templates/udev/rules.d/90-backlight.rules"
},
{
"id": "udev",
@@ -151,7 +153,7 @@
"type": "file",
"path": "/etc/UPower/UPower.conf.d/50-asus-zephyrus-g14.conf",
"mode": "0644",
"content": "# Managed by the mesh (module asus-zephyrus-g14). Replaced on every push; edit the catalogue instead.\n#\n# On low battery the machine suspends rather than powering off, at 7 % — s2idle still draws a little,\n# so it leaves headroom. A drop-in over the package's own UPower.conf, which stays the package's.\n[UPower]\nUsePercentageForPolicy=true\nPercentageLow=15.0\nPercentageCritical=10.0\nPercentageAction=7.0\nCriticalPowerAction=Suspend\nAllowRiskyCriticalPowerAction=true\n"
"template": "templates/UPower/UPower.conf.d/50-asus-zephyrus-g14.conf"
},
{
"id": "upower",
@@ -174,7 +176,22 @@
"type": "file",
"path": "/etc/X11/xorg.conf.d/30-asus-zephyrus-g14-touchpad.conf",
"mode": "0644",
"content": "# Managed by the mesh (module asus-zephyrus-g14). Replaced on every push; edit the catalogue instead.\n#\n# The touchpad's settings, applied by X every time the device appears — at login and after every\n# resume, when the device is initialised again. This replaces the predecessor's sleep hook, which ran\n# xinput after a resume as a named person on a guessed display.\nSection \"InputClass\"\n Identifier \"asus-zephyrus-g14 touchpad\"\n MatchIsTouchpad \"on\"\n Option \"Tapping\" \"on\"\n Option \"NaturalScrolling\" \"true\"\n Option \"AccelSpeed\" \"0.15\"\nEndSection\n"
"template": "templates/X11/xorg.conf.d/30-asus-zephyrus-g14-touchpad.conf"
}
],
"items": [
{
"collection": "shell:after-wake:normal",
"template": "templates/after-wake"
},
{
"collection": "contribution:node-hotkeys:trigger",
"template": "templates/hotkeys-triggers"
},
{
"collection": "contribution:node-display-session:config",
"name": "display-session",
"template": "templates/display-session"
}
],
"build": {
@@ -194,31 +211,12 @@
"name": "scripts",
"kind": "archive",
"from": "files"
},
{
"name": "templates",
"kind": "archive",
"from": "templates"
}
]
},
"contributions": [
{
"seat": "node-hotkeys",
"kind": "trigger",
"content": "# The ROG Zephyrus G14's vendor keys, which reach no X client: media (the M-keys), panel brightness,\n# and the touchpad key. Each runs this module's own script, as the operator's account.\nKEY_PROG1\t1\t/usr/local/lib/asus-zephyrus-g14/bin/zephyrus-session /usr/local/lib/asus-zephyrus-g14/bin/zephyrus-media play-pause\nKEY_PROG3\t1\t/usr/local/lib/asus-zephyrus-g14/bin/zephyrus-session /usr/local/lib/asus-zephyrus-g14/bin/zephyrus-media previous\nKEY_PROG4\t1\t/usr/local/lib/asus-zephyrus-g14/bin/zephyrus-session /usr/local/lib/asus-zephyrus-g14/bin/zephyrus-media next\nKEY_BRIGHTNESSDOWN\t1\t/usr/local/lib/asus-zephyrus-g14/bin/zephyrus-backlight -\nKEY_BRIGHTNESSDOWN\t2\t/usr/local/lib/asus-zephyrus-g14/bin/zephyrus-backlight -\nKEY_BRIGHTNESSUP\t1\t/usr/local/lib/asus-zephyrus-g14/bin/zephyrus-backlight +\nKEY_BRIGHTNESSUP\t2\t/usr/local/lib/asus-zephyrus-g14/bin/zephyrus-backlight +\nKEY_F21\t1\t/usr/local/lib/asus-zephyrus-g14/bin/zephyrus-touchpad reset\n"
},
{
"seat": "node-display-session",
"kind": "config",
"content": "# The laptop's own lines in i3 (module asus-zephyrus-g14, novox/hq ADR 0208, ADR 0210). Owned by the\n# mesh: replaced at every push. Once the controller places contributions to node-display-session\n# (ADR 0210), these become the module's contribution instead of a file in i3's directory.\n#\n# The keys the firmware turns into ordinary key presses. The vendor keys that reach no X client are\n# triggerhappy's (/etc/triggerhappy/triggers.d/asus-g14.conf): M4 and Fn+F4/F5 for media, Fn+F7/F8 for\n# the panel, Fn+F10 for the touchpad. The keyboard backlight (Fn+F2/F3) is the firmware's and asusd's.\n\n# Fn+F6, the screenshot key: the firmware sends Super+Shift+S. Released before it runs, because the\n# screenshot grabs the pointer to select a region, which fails while the key is still held.\nbindsym --release $mod+Shift+s exec --no-startup-id $XDG_CONFIG_HOME/i3/scripts/screenshot.sh\n\n# Fn+F9, the display key: the firmware sends Super+P. Odd workspaces to the panel, even ones to the\n# external output.\nbindsym $mod+p exec --no-startup-id /usr/local/lib/asus-zephyrus-g14/bin/zephyrus-display order\n\n# The keyboard backlight's level, shown when it changes.\nexec --no-startup-id /usr/local/lib/asus-zephyrus-g14/bin/zephyrus-kbd-notify\n"
},
{
"seat": "node-display-session",
"kind": "config",
"content": "# The laptop's own lines in i3 (module asus-zephyrus-g14, novox/hq ADR 0208, ADR 0210). Owned by the\n# mesh: replaced at every push. Once the controller places contributions to node-display-session\n# (ADR 0210), these become the module's contribution instead of a file in i3's directory.\n#\n# The model's panel and touchpad.\n\n# The internal panel is the primary output, where the bars' tray goes. Which monitors are on and where\n# is the display server's (autorandr, run at every session start).\nexec --no-startup-id /usr/local/lib/asus-zephyrus-g14/bin/zephyrus-display primary\n\n# The touchpad's settings again, by hand. X applies them itself whenever the device appears.\nbindsym $mod+Shift+x exec --no-startup-id /usr/local/lib/asus-zephyrus-g14/bin/zephyrus-touchpad reset\n"
}
],
"shell": [
{
"for": "after-wake",
"slot": "normal",
"code": "# The touchpad's settings again after waking, in the operator's session: X applies the module's\n# input class when the device appears, and this covers a wake that does not initialise it again.\n# Runs as root from the power module; the reset itself runs as the session's owner.\nsleep 2\nowner=$(ps -o user= -C i3 | head -n 1)\n[ -n \"$owner\" ] && runuser -u \"$owner\" -- /usr/local/lib/asus-zephyrus-g14/bin/zephyrus-touchpad reset\ntrue\n"
}
]
}
}
@@ -0,0 +1,11 @@
# Managed by the mesh (module asus-zephyrus-g14). Replaced on every push; edit the catalogue instead.
#
# On low battery the machine suspends rather than powering off, at 7 % — s2idle still draws a little,
# so it leaves headroom. A drop-in over the package's own UPower.conf, which stays the package's.
[UPower]
UsePercentageForPolicy=true
PercentageLow=15.0
PercentageCritical=10.0
PercentageAction=7.0
CriticalPowerAction=Suspend
AllowRiskyCriticalPowerAction=true
@@ -0,0 +1,12 @@
# Managed by the mesh (module asus-zephyrus-g14). Replaced on every push; edit the catalogue instead.
#
# The touchpad's settings, applied by X every time the device appears — at login and after every
# resume, when the device is initialised again. This replaces the predecessor's sleep hook, which ran
# xinput after a resume as a named person on a guessed display.
Section "InputClass"
Identifier "asus-zephyrus-g14 touchpad"
MatchIsTouchpad "on"
Option "Tapping" "on"
Option "NaturalScrolling" "true"
Option "AccelSpeed" "0.15"
EndSection
@@ -0,0 +1,7 @@
# The touchpad's settings again after waking, in the operator's session: X applies the module's
# input class when the device appears, and this covers a wake that does not initialise it again.
# Runs as root from the power module; the reset itself runs as the session's owner.
sleep 2
owner=$(ps -o user= -C i3 | head -n 1)
[ -n "$owner" ] && runuser -u "$owner" -- /usr/local/lib/asus-zephyrus-g14/bin/zephyrus-touchpad reset
true
@@ -0,0 +1,30 @@
# The laptop's own lines in i3 (module asus-zephyrus-g14, novox/hq ADR 0208, ADR 0210). Owned by the
# mesh: replaced at every push. Once the controller places contributions to node-display-session
# (ADR 0210), these become the module's contribution instead of a file in i3's directory.
#
# The keys the firmware turns into ordinary key presses. The vendor keys that reach no X client are
# triggerhappy's (/etc/triggerhappy/triggers.d/asus-g14.conf): M4 and Fn+F4/F5 for media, Fn+F7/F8 for
# the panel, Fn+F10 for the touchpad. The keyboard backlight (Fn+F2/F3) is the firmware's and asusd's.
# Fn+F6, the screenshot key: the firmware sends Super+Shift+S. Released before it runs, because the
# screenshot grabs the pointer to select a region, which fails while the key is still held.
bindsym --release $mod+Shift+s exec --no-startup-id $XDG_CONFIG_HOME/i3/scripts/screenshot.sh
# Fn+F9, the display key: the firmware sends Super+P. Odd workspaces to the panel, even ones to the
# external output.
bindsym $mod+p exec --no-startup-id /usr/local/lib/asus-zephyrus-g14/bin/zephyrus-display order
# The keyboard backlight's level, shown when it changes.
exec --no-startup-id /usr/local/lib/asus-zephyrus-g14/bin/zephyrus-kbd-notify
# The laptop's own lines in i3 (module asus-zephyrus-g14, novox/hq ADR 0208, ADR 0210). Owned by the
# mesh: replaced at every push. Once the controller places contributions to node-display-session
# (ADR 0210), these become the module's contribution instead of a file in i3's directory.
#
# The model's panel and touchpad.
# The internal panel is the primary output, where the bars' tray goes. Which monitors are on and where
# is the display server's (autorandr, run at every session start).
exec --no-startup-id /usr/local/lib/asus-zephyrus-g14/bin/zephyrus-display primary
# The touchpad's settings again, by hand. X applies them itself whenever the device appears.
bindsym $mod+Shift+x exec --no-startup-id /usr/local/lib/asus-zephyrus-g14/bin/zephyrus-touchpad reset
@@ -0,0 +1,10 @@
# The ROG Zephyrus G14's vendor keys, which reach no X client: media (the M-keys), panel brightness,
# and the touchpad key. Each runs this module's own script, as the operator's account.
KEY_PROG1 1 /usr/local/lib/asus-zephyrus-g14/bin/zephyrus-session /usr/local/lib/asus-zephyrus-g14/bin/zephyrus-media play-pause
KEY_PROG3 1 /usr/local/lib/asus-zephyrus-g14/bin/zephyrus-session /usr/local/lib/asus-zephyrus-g14/bin/zephyrus-media previous
KEY_PROG4 1 /usr/local/lib/asus-zephyrus-g14/bin/zephyrus-session /usr/local/lib/asus-zephyrus-g14/bin/zephyrus-media next
KEY_BRIGHTNESSDOWN 1 /usr/local/lib/asus-zephyrus-g14/bin/zephyrus-backlight -
KEY_BRIGHTNESSDOWN 2 /usr/local/lib/asus-zephyrus-g14/bin/zephyrus-backlight -
KEY_BRIGHTNESSUP 1 /usr/local/lib/asus-zephyrus-g14/bin/zephyrus-backlight +
KEY_BRIGHTNESSUP 2 /usr/local/lib/asus-zephyrus-g14/bin/zephyrus-backlight +
KEY_F21 1 /usr/local/lib/asus-zephyrus-g14/bin/zephyrus-touchpad reset
@@ -0,0 +1,16 @@
# Managed by the mesh (module asus-zephyrus-g14). Replaced on every push; edit the catalogue instead.
#
# The discrete GPU's driver options on the ROG Zephyrus G14 (GA403, RTX 40 series, hybrid graphics).
#
# NVreg_DynamicPowerManagement=0x00 turns runtime D3 off. With it on, a change of power source sends
# the driver an ACPI notification it fails to handle on this model ("RmHandleDNotifierEvent: Failed to
# handle ACPI D-Notifier event, status=0x62"), and the GPU stops making progress until the machine is
# powered off. Off costs a few idle watts in hybrid mode and keeps the machine up.
#
# NVreg_PreserveVideoMemoryAllocations=1 saves video memory across suspend, so what used the GPU still
# works after waking. It needs nvidia-suspend, -hibernate and -resume to run around a sleep, which this
# module's drop-ins on the sleep services ask for.
#
# A change here applies when the driver next loads: at the next boot.
options nvidia NVreg_PreserveVideoMemoryAllocations=1
options nvidia NVreg_DynamicPowerManagement=0x00
@@ -0,0 +1,6 @@
# Managed by the mesh (module asus-zephyrus-g14). Replaced on every push; edit the catalogue instead.
#
# The ACPI video driver does not change the backlight itself on the brightness keys: on this model it
# moves the wrong one. The keys are triggerhappy's (see /etc/triggerhappy/triggers.d/asus-g14.conf).
# Applies when the module next loads: at the next boot.
options video brightness_switch_enabled=0
@@ -0,0 +1,7 @@
# Managed by the mesh (module asus-zephyrus-g14). Replaced on every push; edit the catalogue instead.
#
# nvidia-powerd (Dynamic Boost) was the first error in the chain that hung this model's GPU on a change
# of power source, and asusd starts it on mains. It runs only when the kernel command line says
# zephyrus.nvidia-powerd — an explicit opt-in, at boot.
[Unit]
ConditionKernelCommandLine=zephyrus.nvidia-powerd
@@ -0,0 +1,7 @@
# Managed by the mesh (module asus-zephyrus-g14). Replaced on every push; edit the catalogue instead.
#
# The NVIDIA driver's own sleep actions, asked for by the sleep itself rather than enabled as
# links: the mesh declares files and never makes links (novox/hq ADR 0012), and the host's service
# shape must not start these units by hand, which would put the GPU to sleep with the machine awake.
[Unit]
Wants=nvidia-hibernate.service nvidia-resume.service
@@ -0,0 +1,7 @@
# Managed by the mesh (module asus-zephyrus-g14). Replaced on every push; edit the catalogue instead.
#
# The NVIDIA driver's own sleep actions, asked for by the sleep itself rather than enabled as
# links: the mesh declares files and never makes links (novox/hq ADR 0012), and the host's service
# shape must not start these units by hand, which would put the GPU to sleep with the machine awake.
[Unit]
Wants=nvidia-suspend-then-hibernate.service nvidia-resume.service
@@ -0,0 +1,7 @@
# Managed by the mesh (module asus-zephyrus-g14). Replaced on every push; edit the catalogue instead.
#
# The NVIDIA driver's own sleep actions, asked for by the sleep itself rather than enabled as
# links: the mesh declares files and never makes links (novox/hq ADR 0012), and the host's service
# shape must not start these units by hand, which would put the GPU to sleep with the machine awake.
[Unit]
Wants=nvidia-suspend.service nvidia-resume.service
@@ -0,0 +1,5 @@
# Managed by the mesh (module asus-zephyrus-g14). Replaced on every push; edit the catalogue instead.
#
# The backlights are writable by the video group, so the brightness keys and the module's brightness tool
# move the panel without root.
ACTION=="add", SUBSYSTEM=="backlight", RUN+="/usr/bin/chgrp video /sys/class/backlight/%k/brightness", RUN+="/usr/bin/chmod g+w /sys/class/backlight/%k/brightness"
+4
View File
@@ -0,0 +1,4 @@
{
"password": "${secret:admin}",
"host": "${bound:route:name}"
}
+7
View File
@@ -0,0 +1,7 @@
DATABASE_HOST=${bound:postgres-database:at}
DATABASE_PORT=${bound:postgres-database:port}
DATABASE_NAME=${bound:postgres-database:as}
DATABASE_USER=${bound:postgres-database:as}
DATABASE_PASSWORD_FILE=/run/secrets/database
DISABLE_EMBEDDED_PSQL=true
BASEROW_PUBLIC_URL=https://${bound:route:name}
+28 -2
View File
@@ -46,6 +46,26 @@
}
]
},
"config": {
"host": {
"type": "string",
"description": "The Host header the module's tools send to Baserow, a host name; the file's default is the name the module's route was given.",
"trusted": true,
"default": "${bound:route:name}"
},
"email": {
"type": "string",
"description": "The email address of the Baserow account the module's tools sign in as; unset, the code falls back to its environment.",
"trusted": true,
"default": ""
},
"url": {
"type": "string",
"description": "The address the module's tools reach Baserow at, as a URL; unset, the code falls back to its environment, else this machine's Baserow on port 80.",
"trusted": true,
"default": ""
}
},
"resources": [
{
"id": "mesh-state",
@@ -70,7 +90,7 @@
"type": "file",
"path": "${dir:state}/server.env",
"mode": "0600",
"content": "DATABASE_HOST=${bound:postgres-database:at}\nDATABASE_PORT=${bound:postgres-database:port}\nDATABASE_NAME=${bound:postgres-database:as}\nDATABASE_USER=${bound:postgres-database:as}\nDATABASE_PASSWORD_FILE=/run/secrets/database\nDISABLE_EMBEDDED_PSQL=true\nBASEROW_PUBLIC_URL=https://${bound:route:name}\n"
"template": "files/server.env"
},
{
"id": "net",
@@ -102,8 +122,9 @@
"type": "file",
"path": "${dir:mesh-state}/config.json",
"mode": "0600",
"content": "{\n \"password\": \"${secret:admin}\",\n \"host\": \"${bound:route:name}\"\n}\n",
"template": "files/config.json",
"merge": "json",
"takes-config": ["host", "email", "url"],
"protected": ["password"]
}
],
@@ -123,6 +144,11 @@
"MESH_BASEROW_URL": "http://127.0.0.1:${port:80}",
"MESH_BASEROW_CONFIG_FILE": "${dir:mesh-state}/config.json"
}
},
{
"name": "files",
"kind": "archive",
"from": "files"
}
]
}
+1 -1
View File
@@ -208,7 +208,7 @@ node set up from nothing, the declared accounts are written at the second start
Five more settings, mesh-wide with a node's layer over each (an entry set to `null` in a node's layer is not on
that node). `identities`, `calendars`, `address-books` and `filters` reach the machine as
`~/.config/betterbird/configuration.json`, trusted as `accounts` is; `tags` as `tags.json`, not trusted (§5).
`~/.config/betterbird/configuration.json`, trusted as `accounts` is; `tags` as `tags.json`, trusted too since issue 751 (the operator's decision), so a change to it is the operator's to approve.
**No value holds a password, a token or a cookie**: a key shaped like one is refused, and so is an address whose
user part carries a password (§2). A setting holds no line break, so a signature is a list of its lines.
@@ -7,7 +7,8 @@ package main
//
// - ~/.config/betterbird/configuration.json: identities, calendars, address-books and filters, trusted as
// accounts is (§5), because each names where a password, a reply or forwarded mail goes;
// - ~/.config/betterbird/tags.json: tags, which names no server and sends no mail, and is not trusted.
// - ~/.config/betterbird/tags.json: tags, which names no server and sends no mail; trusted too since issue 751,
// so a change to it is the operator's to approve.
//
// An entry is keyed by a name of the operator's (lower-case letters and digits, as an account's), a tag by its
// own key. A node's layer may set an entry to null to take it out on that node, and may give an identity, a
@@ -25,7 +25,7 @@ type manifest struct {
Upgrade map[string]string `json:"upgrade"`
Requires []string `json:"requires"`
Invokes []string `json:"invokes"`
Settings map[string]any `json:"settings"`
Config map[string]any `json:"config"`
Tools []string `json:"tools"`
Resources []map[string]any `json:"resources"`
Build struct {
@@ -114,13 +114,21 @@ func TestItPlacesBetterbirdWhereTheCommunityPackageDidAndOnlyAfterItIsGone(t *te
}
}
// ADR 0329 §4–§6, ADR 0326, issue 586: every setting reaches the machine as a mergeable file of the account's that
// names the keys it takes; four kinds and accounts and prefs are trusted, tags is not; no setting has a default in the
// definition, since each is the operator's own value; and each file's default declares nothing.
func TestTheSettingsAreMergeableFilesThatNameTheirKeysAndTagsAloneIsUntrusted(t *testing.T) {
// ADR 0329 §4–§6, ADR 0326, issue 586, ADR 0348: every value reaches the machine as a mergeable file of the
// account's that names the keys it takes, and every one is trusted, tags too since issue 751 (the operator's
// decision); each is an object whose default is the file's own, empty, never required, since a node without the
// operator's setup still runs Betterbird; and each file's default declares nothing.
func TestTheValuesAreTrustedMergeableFilesThatNameTheirKeys(t *testing.T) {
m, _ := readManifest(t)
if len(m.Settings) != 0 {
t.Fatalf("no setting has a default in the definition: the operator's setup is the settings' mesh-wide layer (issue 586, ADR 0262): %v", m.Settings)
if _, said := m.Config["tags"]; !said || len(m.Config) != 7 {
t.Fatalf("the seven values are declared under config: %v", m.Config)
}
for key, raw := range m.Config {
d, _ := raw.(map[string]any)
def, _ := d["default"].(map[string]any)
if d["type"] != "object" || d["trusted"] != true || d["required"] != nil || def == nil || len(def) != 0 {
t.Errorf("%s is a trusted object whose default is empty and which is not required: %v", key, d)
}
}
files := map[string]struct {
path, mode string
@@ -130,7 +138,7 @@ func TestTheSettingsAreMergeableFilesThatNameTheirKeysAndTagsAloneIsUntrusted(t
"prefs": {prefsFile, "0644", []any{"prefs"}, true},
"accounts": {accountsFile, "0600", []any{"accounts"}, true},
"configuration": {configurationFile, "0600", []any{"identities", "calendars", "address-books", "filters"}, true},
"tags": {tagsFile, "0644", []any{"tags"}, false},
"tags": {tagsFile, "0644", []any{"tags"}, true},
}
merging := 0
for _, r := range m.Resources {
@@ -144,27 +152,27 @@ func TestTheSettingsAreMergeableFilesThatNameTheirKeysAndTagsAloneIsUntrusted(t
for id, want := range files {
r := resource(t, m, id)
if r["path"] != "${machine:account-home}/"+want.path || r["merge"] != "json" || r["trusted"] != want.trusted ||
r["mode"] != want.mode || r["owner"] != "${machine:account}" || !reflect.DeepEqual(r["takes-settings"], want.takes) {
r["mode"] != want.mode || r["owner"] != "${machine:account}" || !reflect.DeepEqual(r["takes-config"], want.takes) {
t.Errorf("%s: %v", id, r)
}
var content map[string]any
if err := json.Unmarshal([]byte(r["content"].(string)), &content); err != nil || len(content) != len(want.takes) {
if err := json.Unmarshal([]byte(templateText(t, r)), &content); err != nil || len(content) != len(want.takes) {
t.Errorf("%s's default holds each key it takes, empty: %v %v", id, content, err)
}
}
got, err := parseAccounts([]byte(resource(t, m, "accounts")["content"].(string)))
got, err := parseAccounts([]byte(templateText(t, resource(t, m, "accounts"))))
if err != nil || len(got) != 0 {
t.Fatalf("its default declares no account: %v %v", got, err)
}
prefs, err := parseDeclaredPrefs([]byte(resource(t, m, "prefs")["content"].(string)))
prefs, err := parseDeclaredPrefs([]byte(templateText(t, resource(t, m, "prefs"))))
if err != nil || len(prefs) != 0 {
t.Fatalf("its default declares no preference: %v %v", prefs, err)
}
var c Configuration
if err := decodeInto([]byte(resource(t, m, "configuration")["content"].(string)), configurationFile, &c); err != nil {
if err := decodeInto([]byte(templateText(t, resource(t, m, "configuration"))), configurationFile, &c); err != nil {
t.Fatal(err)
}
if err := decodeInto([]byte(resource(t, m, "tags")["content"].(string)), tagsFile, &c); err != nil {
if err := decodeInto([]byte(templateText(t, resource(t, m, "tags"))), tagsFile, &c); err != nil {
t.Fatal(err)
}
}
@@ -268,13 +276,13 @@ func TestThePolicyTurnsTheUpdaterOffAndInstallsTheMeshsOwlEverywhere(t *testing.
func TestTheStartPreparesThenStartsBetterbirdAndTheLauncherUsesIt(t *testing.T) {
m, _ := readManifest(t)
start := resource(t, m, "start")
content := start["content"].(string)
content := templateText(t, start)
if start["path"] != "/usr/local/bin/betterbird" || start["mode"] != "0755" ||
!strings.Contains(content, "\n"+preparation+" prepare ||") || !strings.HasSuffix(content, "\nexec "+startBin+" \"$@\"\n") {
t.Fatalf("the start runs the preparation, says when it did not complete, and starts Betterbird either way: %v", start)
}
launcher := resource(t, m, "launcher")
lc := launcher["content"].(string)
lc := templateText(t, launcher)
if launcher["path"] != "/usr/local/share/applications/eu.betterbird.Betterbird.desktop" ||
!strings.Contains(lc, "\nExec=/usr/local/bin/betterbird %u\n") || !strings.Contains(lc, "\nIcon="+installDir+"/") ||
!strings.Contains(lc, "\nStartupWMClass=eu.betterbird.Betterbird\n") {
@@ -0,0 +1,64 @@
package main
import (
"encoding/json"
"os"
"path/filepath"
"testing"
)
// The module's directory, where its manifest and its files/ are.
const moduleDir = "../.."
// templateText is the text a file resource places on the machine: its template's, in the module's files/
// (novox/hq ADR 0349 §2). A resource that names no template, or a template that is not there, fails the test.
func templateText(t *testing.T, r map[string]any) string {
t.Helper()
name, _ := r["template"].(string)
if name == "" {
t.Fatalf("resource %v names no template", r["id"])
}
raw, err := os.ReadFile(filepath.Join(moduleDir, filepath.FromSlash(name)))
if err != nil {
t.Fatalf("resource %v names %s: %v", r["id"], name, err)
}
return string(raw)
}
// No file resource carries its text inline: each names its template in files/, and the build packs files/
// into the one archive the controller reads the templates back from (novox/hq ADR 0349 §2).
func TestEveryFileTakesItsTextFromATemplate(t *testing.T) {
raw, err := os.ReadFile(filepath.Join(moduleDir, "module.json"))
if err != nil {
t.Fatal(err)
}
var m struct {
Resources []map[string]any `json:"resources"`
Build struct {
Artifacts []map[string]any `json:"artifacts"`
} `json:"build"`
}
if err := json.Unmarshal(raw, &m); err != nil {
t.Fatal(err)
}
for _, r := range m.Resources {
if _, inline := r["content"]; inline {
t.Errorf("resource %v keeps its text inline: keep it in files/ and name it as the template", r["id"])
}
if r["type"] == "file" {
templateText(t, r)
}
}
packs := 0
for _, a := range m.Build.Artifacts {
if a["kind"] == "archive" && a["from"] == "files" {
packs++
if a["name"] != "files" {
t.Errorf("the templates archive is named %v, not files", a["name"])
}
}
}
if packs != 1 {
t.Errorf("the build packs files/ into %d archives, not one", packs)
}
}
+3
View File
@@ -0,0 +1,3 @@
{
"accounts": {}
}
+10
View File
@@ -0,0 +1,10 @@
#!/bin/sh
# Betterbird's start, written by the mesh (module betterbird, novox/hq ADR 0316). Replaced at every push.
#
# Before Betterbird starts, the module's tools place its user.js in the profile (the settings prefs, accounts, identities, calendars,
# address-books and tags) and write what Betterbird rewrites itself: the account lists and each declared account's filters. They do
# it only while no Betterbird of this account runs, because Betterbird rewrites its saved preferences when it quits; a
# second start while one runs only hands over to it. What the preparation could not do it says here, and
# betterbird_check says it too. Betterbird starts either way: its configuration never holds the mail back.
/usr/local/lib/mesh-betterbird/betterbird-tools prepare || echo "betterbird: the mesh's preparation did not complete; betterbird_check says why" >&2
exec /opt/betterbird/betterbird "$@"
@@ -0,0 +1,6 @@
{
"identities": {},
"calendars": {},
"address-books": {},
"filters": {}
}
@@ -0,0 +1,25 @@
[Desktop Entry]
Version=1.0
Type=Application
Name=Betterbird
GenericName=Mail Client
Comment=Send and receive mail with Betterbird
Exec=/usr/local/bin/betterbird %u
Icon=/opt/betterbird/chrome/icons/default/default128.png
Terminal=false
Categories=Network;Email;
MimeType=message/rfc822;x-scheme-handler/mailto;application/x-xpinstall;application/x-extension-ics;text/calendar;text/vcard;text/x-vcard;x-scheme-handler/webcal;x-scheme-handler/webcals;x-scheme-handler/mid;
StartupNotify=true
StartupWMClass=eu.betterbird.Betterbird
Keywords=Email;E-mail;Calendar;Thunderbird;
Actions=ComposeMessage;OpenAddressBook;
# Written by the mesh (module betterbird, novox/hq ADR 0316 §3), in place of the community package's entry:
# it starts Betterbird through the module's start, and names the icon in the installation, with no link.
[Desktop Action ComposeMessage]
Name=Write new message
Exec=/usr/local/bin/betterbird -compose
[Desktop Action OpenAddressBook]
Name=Open address book
Exec=/usr/local/bin/betterbird -addressbook
+3
View File
@@ -0,0 +1,3 @@
{
"prefs": {}
}
+3
View File
@@ -0,0 +1,3 @@
{
"tags": {}
}
+60 -11
View File
@@ -33,6 +33,50 @@
"betterbird_drift",
"betterbird_reminder_test"
],
"config": {
"prefs": {
"type": "object",
"description": "Betterbird's preferences the operator sets, by their own names (mailnews.*, calendar.* and the like), laid into prefs.json; an account's, a server's and a tag's are refused, as accounts, the four kinds and tags own them (novox/hq ADR 0329 §6).",
"trusted": true,
"default": {}
},
"accounts": {
"type": "object",
"description": "The mail accounts Betterbird is set up with, each by a name of the operator's: its address, its sender's name and its incoming and outgoing servers; no password, token or cookie (novox/hq ADR 0329 §2).",
"trusted": true,
"default": {}
},
"identities": {
"type": "object",
"description": "The identities beside an account's own, each by a name of the operator's: the address and name it sends as, its signature as a list of lines, and the profile's key to adopt on a node.",
"trusted": true,
"default": {}
},
"calendars": {
"type": "object",
"description": "The calendars Betterbird shows, each by a name of the operator's: where it is and how it is reached, and the profile's key to adopt on a node.",
"trusted": true,
"default": {}
},
"address-books": {
"type": "object",
"description": "The address books Betterbird uses, each by a name of the operator's: where it is and how it is reached, and the profile's key to adopt on a node.",
"trusted": true,
"default": {}
},
"filters": {
"type": "object",
"description": "The message filters of each account, by account: what each matches and what it does with the mail.",
"trusted": true,
"default": {}
},
"tags": {
"type": "object",
"description": "The tags Betterbird offers, each by its own key: its name, colour and order; trusted since issue 751, so a change to it is the operator's to approve.",
"trusted": true,
"default": {}
}
},
"resources": [
{
"id": "configuration-dir",
@@ -48,10 +92,10 @@
"owner": "${machine:account}",
"mode": "0644",
"merge": "json",
"takes-settings": [
"takes-config": [
"prefs"
],
"content": "{\n \"prefs\": {}\n}\n",
"template": "files/prefs.json",
"trusted": true
},
{
@@ -61,10 +105,10 @@
"owner": "${machine:account}",
"mode": "0600",
"merge": "json",
"takes-settings": [
"takes-config": [
"accounts"
],
"content": "{\n \"accounts\": {}\n}\n",
"template": "files/accounts.json",
"trusted": true
},
{
@@ -74,13 +118,13 @@
"owner": "${machine:account}",
"mode": "0600",
"merge": "json",
"takes-settings": [
"takes-config": [
"identities",
"calendars",
"address-books",
"filters"
],
"content": "{\n \"identities\": {},\n \"calendars\": {},\n \"address-books\": {},\n \"filters\": {}\n}\n",
"template": "files/configuration.json",
"trusted": true
},
{
@@ -90,11 +134,11 @@
"owner": "${machine:account}",
"mode": "0644",
"merge": "json",
"takes-settings": [
"takes-config": [
"tags"
],
"content": "{\n \"tags\": {}\n}\n",
"trusted": false
"template": "files/tags.json",
"trusted": true
},
{
"id": "community-package",
@@ -137,14 +181,14 @@
"type": "file",
"path": "/usr/local/bin/betterbird",
"mode": "0755",
"content": "#!/bin/sh\n# Betterbird's start, written by the mesh (module betterbird, novox/hq ADR 0316). Replaced at every push.\n#\n# Before Betterbird starts, the module's tools place its user.js in the profile (the settings prefs, accounts, identities, calendars,\n# address-books and tags) and write what Betterbird rewrites itself: the account lists and each declared account's filters. They do\n# it only while no Betterbird of this account runs, because Betterbird rewrites its saved preferences when it quits; a\n# second start while one runs only hands over to it. What the preparation could not do it says here, and\n# betterbird_check says it too. Betterbird starts either way: its configuration never holds the mail back.\n/usr/local/lib/mesh-betterbird/betterbird-tools prepare || echo \"betterbird: the mesh's preparation did not complete; betterbird_check says why\" >&2\nexec /opt/betterbird/betterbird \"$@\"\n"
"template": "files/betterbird"
},
{
"id": "launcher",
"type": "file",
"path": "/usr/local/share/applications/eu.betterbird.Betterbird.desktop",
"mode": "0644",
"content": "[Desktop Entry]\nVersion=1.0\nType=Application\nName=Betterbird\nGenericName=Mail Client\nComment=Send and receive mail with Betterbird\nExec=/usr/local/bin/betterbird %u\nIcon=/opt/betterbird/chrome/icons/default/default128.png\nTerminal=false\nCategories=Network;Email;\nMimeType=message/rfc822;x-scheme-handler/mailto;application/x-xpinstall;application/x-extension-ics;text/calendar;text/vcard;text/x-vcard;x-scheme-handler/webcal;x-scheme-handler/webcals;x-scheme-handler/mid;\nStartupNotify=true\nStartupWMClass=eu.betterbird.Betterbird\nKeywords=Email;E-mail;Calendar;Thunderbird;\nActions=ComposeMessage;OpenAddressBook;\n# Written by the mesh (module betterbird, novox/hq ADR 0316 §3), in place of the community package's entry:\n# it starts Betterbird through the module's start, and names the icon in the installation, with no link.\n\n[Desktop Action ComposeMessage]\nName=Write new message\nExec=/usr/local/bin/betterbird -compose\n\n[Desktop Action OpenAddressBook]\nName=Open address book\nExec=/usr/local/bin/betterbird -addressbook\n"
"template": "files/eu.betterbird.Betterbird.desktop"
}
],
"build": {
@@ -191,6 +235,11 @@
"name": "defaults",
"kind": "archive",
"from": "install/defaults-pref"
},
{
"name": "files",
"kind": "archive",
"from": "files"
}
]
}
+1 -1
View File
@@ -14,7 +14,7 @@ WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -trimpath -o /mesh-builder ./cmd/mesh-builder
RUN CGO_ENABLED=0 GOOS=linux go build -trimpath -buildvcs=false -ldflags=-buildid= -o /mesh-builder ./cmd/mesh-builder
# Unlike mesh-controller's own FROM scratch (ADR 0006: nothing to audit but one binary), the build
# machine's whole job is shelling out to git and docker — it needs a real userland to do that in,
@@ -0,0 +1,8 @@
MESH_BROKER_FILE=/run/mesh/broker
MESH_NODE=${machine:name}
MESH_REGISTRY=${bound:artifact-store:at}:${bound:artifact-store:port}
MESH_PACKAGE_BINDING=/run/mesh/package-registry.json
MESH_NPM_TOKEN_FILE=/run/mesh/package-registry.secret
MESH_GENESIS_PACKAGE_REGISTRY=/run/mesh/genesis-package-registry.json
MESH_WORKSPACE=${dir:workspace}
MESH_JUDGE_CACHE=${dir:judge-cache}
+59 -2
View File
@@ -75,7 +75,10 @@ func TestTheRegistrysBindsAndSecretsAreOnlyWhenBound(t *testing.T) {
t.Fatalf("the agent is not told where the registry is once it binds: binds %v, secrets %v", m.Binds, m.Secrets)
}
for _, r := range m.Resources {
content, _ := r["content"].(string)
if r["type"] != "file" {
continue
}
content := templateText(t, r)
if strings.Contains(content, "${bound:npm-package-registry:") || strings.Contains(content, "${secret:npm-package-registry}") {
if r["only-when-bound"] != "npm-package-registry" {
t.Errorf("%v names the registry's binding and is delivered while it is absent", r["id"])
@@ -106,7 +109,7 @@ func TestTheGenesisDocumentIsDeclaredAndIssuedOutside(t *testing.T) {
inside := "/run/mesh/" + strings.TrimPrefix(own.Path, "${dir:mesh-state}/")
for _, r := range m.Resources {
if r["id"] == "agent-env" {
content, _ := r["content"].(string)
content := templateText(t, r)
if !strings.Contains(content, "MESH_GENESIS_PACKAGE_REGISTRY="+inside+"\n") {
t.Errorf("the agent is not told where the genesis document is (%s):\n%s", inside, content)
}
@@ -115,3 +118,57 @@ func TestTheGenesisDocumentIsDeclaredAndIssuedOutside(t *testing.T) {
}
t.Fatal("the build agent has no environment file")
}
// **The judge cache is the agent's own, beside its workspace and never in it** (novox/hq ADR 0358 §6, issue 704): a
// directory resource of its own, kept as the agent's data, mounted into the agent at the same path as on the host —
// the container runtime resolves a judge build's mounts on the host — and handed to the agent as MESH_JUDGE_CACHE.
func TestTheJudgeCacheIsADirectoryOfItsOwnMountedAtTheSamePath(t *testing.T) {
raw, err := os.ReadFile("module.json")
if err != nil {
t.Fatal(err)
}
var m struct {
Data struct {
Own []struct {
ID string `json:"id"`
Path string `json:"path"`
Class string `json:"class"`
} `json:"own"`
} `json:"data"`
Resources []struct {
ID string `json:"id"`
Type string `json:"type"`
Template string `json:"template"`
Volumes []string `json:"volumes"`
} `json:"resources"`
}
if err := json.Unmarshal(raw, &m); err != nil {
t.Fatal(err)
}
dir, data, env, mounted := false, false, false, false
for _, d := range m.Data.Own {
if d.ID == "judge-cache" && d.Path == "${dir:judge-cache}" && d.Class == "cache" {
data = true
}
}
for _, r := range m.Resources {
switch {
case r.ID == "judge-cache" && r.Type == "directory":
dir = true
case r.ID == "agent-env":
env = strings.Contains(templateText(t, map[string]any{"id": r.ID, "template": r.Template}), "MESH_JUDGE_CACHE=${dir:judge-cache}\n")
case r.ID == "server":
for _, v := range r.Volumes {
if v == "${dir:judge-cache}:${dir:judge-cache}" {
mounted = true
}
if strings.HasPrefix(v, "${dir:workspace}") && strings.Contains(v, "judge-cache") {
t.Errorf("the judge cache is mounted inside the workspace: %s", v)
}
}
}
}
if !dir || !data || !env || !mounted {
t.Fatalf("the judge cache: directory %v, data %v, env %v, mounted at the same path %v", dir, data, env, mounted)
}
}
+18 -1
View File
@@ -39,6 +39,12 @@
"path": "${dir:workspace}",
"class": "cache",
"why": "a build's working copy, cloned again for every build"
},
{
"id": "judge-cache",
"path": "${dir:judge-cache}",
"class": "cache",
"why": "the Go cache every merge check's judge is built with and the release-branch judges kept per head; mounted into judge builds only, never into a check's containers (novox/hq ADR 0358 §6)"
}
]
},
@@ -54,12 +60,17 @@
"type": "directory",
"mode": "0700"
},
{
"id": "judge-cache",
"type": "directory",
"mode": "0700"
},
{
"id": "agent-env",
"type": "file",
"path": "${dir:mesh-state}/build-agent.env",
"mode": "0600",
"content": "MESH_BROKER_FILE=/run/mesh/broker\nMESH_NODE=${machine:name}\nMESH_REGISTRY=${bound:artifact-store:at}:${bound:artifact-store:port}\nMESH_PACKAGE_BINDING=/run/mesh/package-registry.json\nMESH_NPM_TOKEN_FILE=/run/mesh/package-registry.secret\nMESH_GENESIS_PACKAGE_REGISTRY=/run/mesh/genesis-package-registry.json\nMESH_WORKSPACE=${dir:workspace}\n"
"template": "files/build-agent.env"
},
{
"id": "server",
@@ -72,6 +83,7 @@
"volumes": [
"${dir:mesh-state}:/run/mesh:ro",
"${dir:workspace}:${dir:workspace}",
"${dir:judge-cache}:${dir:judge-cache}",
"/var/run/docker.sock:/var/run/docker.sock"
],
"restart-on": [
@@ -103,6 +115,11 @@
"repository": "novox/mesh-controller",
"ref": "main"
}
},
{
"name": "files",
"kind": "archive",
"from": "files"
}
],
"on": [
+64
View File
@@ -0,0 +1,64 @@
package buildagent
import (
"encoding/json"
"os"
"path/filepath"
"testing"
)
// The module's directory, where its manifest and its files/ are.
const moduleDir = "."
// templateText is the text a file resource places on the machine: its template's, in the module's files/
// (novox/hq ADR 0349 §2). A resource that names no template, or a template that is not there, fails the test.
func templateText(t *testing.T, r map[string]any) string {
t.Helper()
name, _ := r["template"].(string)
if name == "" {
t.Fatalf("resource %v names no template", r["id"])
}
raw, err := os.ReadFile(filepath.Join(moduleDir, filepath.FromSlash(name)))
if err != nil {
t.Fatalf("resource %v names %s: %v", r["id"], name, err)
}
return string(raw)
}
// No file resource carries its text inline: each names its template in files/, and the build packs files/
// into the one archive the controller reads the templates back from (novox/hq ADR 0349 §2).
func TestEveryFileTakesItsTextFromATemplate(t *testing.T) {
raw, err := os.ReadFile(filepath.Join(moduleDir, "module.json"))
if err != nil {
t.Fatal(err)
}
var m struct {
Resources []map[string]any `json:"resources"`
Build struct {
Artifacts []map[string]any `json:"artifacts"`
} `json:"build"`
}
if err := json.Unmarshal(raw, &m); err != nil {
t.Fatal(err)
}
for _, r := range m.Resources {
if _, inline := r["content"]; inline {
t.Errorf("resource %v keeps its text inline: keep it in files/ and name it as the template", r["id"])
}
if r["type"] == "file" {
templateText(t, r)
}
}
packs := 0
for _, a := range m.Build.Artifacts {
if a["kind"] == "archive" && a["from"] == "files" {
packs++
if a["name"] != "files" {
t.Errorf("the templates archive is named %v, not files", a["name"])
}
}
}
if packs != 1 {
t.Errorf("the build packs files/ into %d archives, not one", packs)
}
}
+58
View File
@@ -0,0 +1,58 @@
#!/bin/sh
# The mesh's internal certificate authority, trusted by this machine.
#
# Written by the mesh from the ca-trust module's manifest (novox/hq ADR 0147).
# Editing it here lasts until the next apply.
#
# There is no prior trust to verify the fetch against — this is the thing that
# establishes it — so it is made over the mesh's own private network, which is
# what authenticates it (novox/hq ADR 0098, the same reasoning that lets the
# route proxy fetch this root for itself). What comes back is checked here: a
# body that is not a certificate is refused now, rather than believed and then
# failed by whatever reads the trust store next.
set -eu
ROOTS='https://${bound:internal-acme-ca:at}:${bound:internal-acme-ca:port}${bound:internal-acme-ca:roots}'
ANCHORS=/etc/ca-certificates/trust-source/anchors
ANCHOR="$ANCHORS/mesh-internal-ca.crt"
# Arch's layout, said out loud rather than assumed: a machine that keeps its
# anchors elsewhere fails here, visibly, instead of writing a file nothing
# reads. That failure is the signal that this belongs in the host, where one
# operating system's difference lives (novox/hq ADR 0147, option 2).
[ -d "$ANCHORS" ] || {
echo "this machine keeps no trust anchors in $ANCHORS; ca-trust is written for that layout" >&2
exit 1
}
case "${1:-}" in
install)
tmp=$(mktemp)
trap 'rm -f "$tmp"' EXIT
# The authority may still be starting, or this machine may have come up
# before it: two minutes of asking, then an honest failure.
n=0
while [ "$n" -lt 60 ]; do
if curl --fail --silent --show-error --insecure --max-time 10 \
--output "$tmp" "$ROOTS" &&
grep -q 'BEGIN CERTIFICATE' "$tmp"; then
install -m 0644 "$tmp" "$ANCHOR"
update-ca-trust
exit 0
fi
n=$((n + 1))
sleep 2
done
echo "the authority at $ROOTS did not serve a certificate within two minutes" >&2
exit 1
;;
remove)
# What stopping the unit does, and therefore what being unassigned does.
rm -f "$ANCHOR"
update-ca-trust
;;
*)
echo "usage: $(basename "$0") install|remove" >&2
exit 2
;;
esac
@@ -0,0 +1,16 @@
[Unit]
Description=The mesh's internal certificate authority, trusted by this machine
# novox/hq ADR 0147. Starting this unit places the mesh's root among this
# machine's trust anchors; stopping it takes the root away again, which is what
# the host does when the module is no longer assigned here.
Wants=network-online.target
After=network-online.target
[Service]
Type=oneshot
RemainAfterExit=yes
ExecStart=${dir:state}/anchor install
ExecStop=${dir:state}/anchor remove
[Install]
WantedBy=multi-user.target
+12 -3
View File
@@ -32,14 +32,14 @@
"type": "file",
"path": "${dir:state}/anchor",
"mode": "0755",
"content": "#!/bin/sh\n# The mesh's internal certificate authority, trusted by this machine.\n#\n# Written by the mesh from the ca-trust module's manifest (novox/hq ADR 0147).\n# Editing it here lasts until the next apply.\n#\n# There is no prior trust to verify the fetch against \u2014 this is the thing that\n# establishes it \u2014 so it is made over the mesh's own private network, which is\n# what authenticates it (novox/hq ADR 0098, the same reasoning that lets the\n# route proxy fetch this root for itself). What comes back is checked here: a\n# body that is not a certificate is refused now, rather than believed and then\n# failed by whatever reads the trust store next.\nset -eu\n\nROOTS='https://${bound:internal-acme-ca:at}:${bound:internal-acme-ca:port}${bound:internal-acme-ca:roots}'\nANCHORS=/etc/ca-certificates/trust-source/anchors\nANCHOR=\"$ANCHORS/mesh-internal-ca.crt\"\n\n# Arch's layout, said out loud rather than assumed: a machine that keeps its\n# anchors elsewhere fails here, visibly, instead of writing a file nothing\n# reads. That failure is the signal that this belongs in the host, where one\n# operating system's difference lives (novox/hq ADR 0147, option 2).\n[ -d \"$ANCHORS\" ] || {\n\techo \"this machine keeps no trust anchors in $ANCHORS; ca-trust is written for that layout\" >&2\n\texit 1\n}\n\ncase \"${1:-}\" in\ninstall)\n\ttmp=$(mktemp)\n\ttrap 'rm -f \"$tmp\"' EXIT\n\t# The authority may still be starting, or this machine may have come up\n\t# before it: two minutes of asking, then an honest failure.\n\tn=0\n\twhile [ \"$n\" -lt 60 ]; do\n\t\tif curl --fail --silent --show-error --insecure --max-time 10 \\\n\t\t\t--output \"$tmp\" \"$ROOTS\" &&\n\t\t\tgrep -q 'BEGIN CERTIFICATE' \"$tmp\"; then\n\t\t\tinstall -m 0644 \"$tmp\" \"$ANCHOR\"\n\t\t\tupdate-ca-trust\n\t\t\texit 0\n\t\tfi\n\t\tn=$((n + 1))\n\t\tsleep 2\n\tdone\n\techo \"the authority at $ROOTS did not serve a certificate within two minutes\" >&2\n\texit 1\n\t;;\nremove)\n\t# What stopping the unit does, and therefore what being unassigned does.\n\trm -f \"$ANCHOR\"\n\tupdate-ca-trust\n\t;;\n*)\n\techo \"usage: $(basename \"$0\") install|remove\" >&2\n\texit 2\n\t;;\nesac\n"
"template": "files/anchor"
},
{
"id": "unit",
"type": "file",
"path": "/etc/systemd/system/mesh-ca-trust.service",
"mode": "0644",
"content": "[Unit]\nDescription=The mesh's internal certificate authority, trusted by this machine\n# novox/hq ADR 0147. Starting this unit places the mesh's root among this\n# machine's trust anchors; stopping it takes the root away again, which is what\n# the host does when the module is no longer assigned here.\nWants=network-online.target\nAfter=network-online.target\n\n[Service]\nType=oneshot\nRemainAfterExit=yes\nExecStart=${dir:state}/anchor install\nExecStop=${dir:state}/anchor remove\n\n[Install]\nWantedBy=multi-user.target\n"
"template": "files/mesh-ca-trust.service"
},
{
"id": "trust",
@@ -52,5 +52,14 @@
"unit"
]
}
]
],
"build": {
"artifacts": [
{
"name": "files",
"kind": "archive",
"from": "files"
}
]
}
}
+12 -1
View File
@@ -43,7 +43,7 @@ whenever the node's tool runtime collects the module's tools:
| file | holds |
|---|---|
| `managed-mcp.json` | the tool servers every session loads: the mesh's console as `mesh`, and the servers set in this module's `mcp_servers` setting. **Exclusive**: a server not listed here does not load — not one added with `claude mcp add`, not a project's `.mcp.json`, not a plugin's |
| `managed-settings.json` | the keys set in this module's `managed_settings` setting — at the controller's terminal alone (hq issue 340) — under the mesh's own keys: the repositories' attribution convention, the claude.ai connectors kept beside the managed servers, the key-helper while the node holds an API-key licence, and the two that name the `nox-mesh` marketplace and enable its plugin |
| `managed-settings.json` | the keys set in this module's `managed_settings` setting — at the controller's terminal alone (hq issue 340) — under the mesh's own keys: the repositories' attribution convention, the claude.ai connectors kept beside the managed servers, the key-helper while the node holds an API-key licence, git's credential file while the node holds the forge's read credential (below), and the two that name the `nox-mesh` marketplace and enable its plugin |
| `CLAUDE.md` | how a session on this mesh works, this node's name and role, the conventions, the **"instead of" table** — then the instruction sections registered for every node and for this one |
| `marketplace/` | the `nox-mesh` plugin (hq ADR 0216): the skills, subagents, commands and output styles registered for every node and for this one, offered in a session as `nox-mesh:<name>` — and the **guard on the agent's shell** (`guard/`), the mesh's own hook and the plugin's only one (hq issue 340). Replaced whole, staged beside and swapped in |
@@ -55,6 +55,17 @@ same content, and never through a directory that is a symbolic link. A placed fi
alone, and one the person deleted stays deleted until the item is unregistered (hq ADR 0182). The status tool reads the rest of the home's
items to report them; nothing else is read or written.
**The forge's read credential** (hq ADR 0365 §4, issue 737). The module requires `gitea-read`, optionally: the forge
mints this node's agent a token of its own account, `agent-<node>`, scoped to read repositories alone, and the vault
keeps it for this module on this machine alone (issue 749); it arrives in the state as `gitea-read.secret`, git's
credential-store lines. Every few seconds the module places them at `~/.config/mesh/forge.credentials` in the agent's
home, mode 0600, written as the agent's account; puts the file back when git's store helper or a person changed it;
and takes it back when none is held. A value that is not credential lines (one the mesh made before the forge was
asked) is never placed. While one is held, `managed-settings.json` gives every session's environment
`GIT_CONFIG_COUNT`, `GIT_CONFIG_KEY_0=credential.helper` and `GIT_CONFIG_VALUE_0=store --file='<that path>'`: the
path, never the token. So `git clone` and `git fetch` of what the agent's account may read work with nothing typed,
printed or put on a command line; pushing stays `gitea_commit`'s, and the forge refuses the token a push.
## Over NATS
Everything between this module and the rest of the mesh is NATS, in three kinds: an **event** says that
@@ -0,0 +1,142 @@
package main
// The forge's read credential in the agent's home (novox/hq ADR 0365 §4, issue 737).
//
// The forge's module mints each node's agent a token of its own account, `agent-<node>`, scoped to read
// repositories alone, and the vault keeps it for this module on this machine alone (provision `gitea-read`, issue
// 749). What arrives in this module's state is git's credential-store lines for it, one per origin the forge is
// reached at. This module places them in the agent's home, a file of mode 0600 written as the agent's account (ADR
// 0266: no other module writes there), and points every session's git at it through the managed settings'
// environment: the file's path, never its content. So an agent clones and fetches what its account may read, and no
// token is printed, typed on a command line or put in an environment a process listing shows.
//
// Git's own `store` helper reads the file; on a token the forge rejects it erases that line, and on a password a
// person typed elsewhere it adds one. Both are undone at the next look (every few seconds): the file holds what the
// forge issued, and nothing else. A value that is not credential lines — one the mesh made while the forge had not
// been asked yet — is not placed, and what was placed before is taken back.
import (
"bytes"
"errors"
"fmt"
"io/fs"
"net/url"
"os"
"path/filepath"
"strings"
)
// ForgeProvision is the provision whose credential this is, and the file this module's manifest has it delivered to.
const (
ForgeProvision = "gitea-read"
forgeSecret = "gitea-read.secret"
)
func (p Paths) forgeSecret() string { return filepath.Join(p.State, forgeSecret) }
// forgeCredentials is where the agent's git reads the credential: under the agent's home, in a directory of the
// mesh's own.
func (p Paths) forgeCredentials() string {
return filepath.Join(p.Home, ".config", "mesh", "forge.credentials")
}
// ForgeCredentialLines are the credential lines held, checked: each an http(s) origin with an account and a token,
// and nothing else. Anything else is refused, said without the value.
func ForgeCredentialLines(raw string) (string, error) {
var lines []string
for _, line := range strings.Split(raw, "\n") {
line = strings.TrimSpace(line)
if line == "" {
continue
}
u, err := url.Parse(line)
if err != nil || (u.Scheme != "https" && u.Scheme != "http") || u.Host == "" || u.User == nil ||
u.User.Username() == "" || u.Path != "" || u.RawQuery != "" || u.Fragment != "" {
return "", errors.New("what the mesh holds for the forge's read credential is not git credential lines " +
"(the forge has not issued it yet, or something else was given)")
}
if pw, ok := u.User.Password(); !ok || pw == "" {
return "", errors.New("a forge credential line names no token")
}
lines = append(lines, line)
}
if len(lines) == 0 {
return "", errors.New("the forge's read credential is empty")
}
return strings.Join(lines, "\n") + "\n", nil
}
// ForgeCredentialHeld says whether this machine holds a forge read credential to place.
func ForgeCredentialHeld(p Paths) bool {
raw, err := os.ReadFile(p.forgeSecret())
if err != nil {
return false
}
_, err = ForgeCredentialLines(string(raw))
return err == nil
}
// PlaceForgeCredential puts the forge's read credential in the agent's home as it is held — written, rewritten when
// it differs, taken back when none is held — and says what it did, or "" when nothing changed. Never the value.
func PlaceForgeCredential(p Paths) (string, error) {
path := p.forgeCredentials()
raw, err := os.ReadFile(p.forgeSecret())
var lines string
var why error
switch {
case errors.Is(err, fs.ErrNotExist):
why = errors.New("this machine holds no forge read credential")
case err != nil:
return "", fmt.Errorf("the forge's read credential cannot be read: %w", err)
default:
lines, why = ForgeCredentialLines(string(raw))
}
if why != nil {
if _, err := fsys.Stat(path); errors.Is(err, fs.ErrNotExist) {
return "", nil
}
if err := fsys.Remove(path); err != nil && !errors.Is(err, fs.ErrNotExist) {
return "", fmt.Errorf("the forge credential at %s could not be taken back: %w", path, err)
}
return fmt.Sprintf("forge credential: taken back from %s: %v", path, why), nil
}
if held, err := fsys.ReadFile(path); err == nil && bytes.Equal(held, []byte(lines)) {
if info, err := fsys.Stat(path); err == nil && info.Mode().Perm() == 0o600 {
return "", nil
}
}
if err := fsys.MkdirAll(filepath.Dir(path), 0o700); err != nil {
return "", fmt.Errorf("the directory for the forge credential could not be made: %w", err)
}
tmp := path + ".mesh-tmp"
if err := fsys.WriteFile(tmp, []byte(lines), 0o600); err != nil {
return "", fmt.Errorf("the forge credential could not be written: %w", err)
}
if err := fsys.Chmod(tmp, 0o600); err != nil {
_ = fsys.Remove(tmp)
return "", fmt.Errorf("the forge credential could not be made 0600: %w", err)
}
if err := fsys.Rename(tmp, path); err != nil {
_ = fsys.Remove(tmp)
return "", fmt.Errorf("the forge credential could not be put in place: %w", err)
}
return fmt.Sprintf("forge credential: placed at %s for %d origin(s)", path, strings.Count(lines, "\n")), nil
}
// forgeEnv is what every session's environment carries for git to read the credential: the file's path in git's
// configuration from the environment, never the credential. Laid over the operator's `env`, the mesh's keys winning.
func forgeEnv(managed map[string]any, credentials string) {
if credentials == "" {
return
}
env := map[string]any{}
if held, ok := managed["env"].(map[string]any); ok {
for k, v := range held {
env[k] = v
}
}
env["GIT_CONFIG_COUNT"] = "1"
env["GIT_CONFIG_KEY_0"] = "credential.helper"
env["GIT_CONFIG_VALUE_0"] = "store --file=" + shellQuote(credentials)
managed["env"] = env
}
@@ -0,0 +1,262 @@
package main
import (
"encoding/json"
"net/http"
"net/http/cgi"
"net/http/httptest"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
)
const agentToken = "0123456789abcdef0123456789abcdef01234567"
// privateForge is a forge serving one private repository over git's smart http, to agent-anchor with its token
// alone, as the forge does for an account a team lets read it.
func privateForge(t *testing.T) (origin, bare string) {
t.Helper()
backend := filepath.Join(gitExecPath(t), "git-http-backend")
if _, err := os.Stat(backend); err != nil {
t.Skipf("git-http-backend is not installed: %v", err)
}
root := t.TempDir()
bare = filepath.Join(root, "private.git")
work := t.TempDir()
runGit(t, work, nil, "git", "init", "-q", "-b", "main", ".")
commit(t, work, "one")
runGit(t, root, nil, "git", "clone", "-q", "--bare", work, bare)
cgiHandler := &cgi.Handler{Path: backend, Env: []string{"GIT_PROJECT_ROOT=" + root, "GIT_HTTP_EXPORT_ALL=1"}}
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
user, pass, ok := r.BasicAuth()
if !ok || user != "agent-anchor" || pass != agentToken {
w.Header().Set("WWW-Authenticate", `Basic realm="Gitea"`)
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
if r.Method == http.MethodPost && strings.HasSuffix(r.URL.Path, "git-receive-pack") {
http.Error(w, "read only", http.StatusForbidden)
return
}
cgiHandler.ServeHTTP(w, r)
}))
t.Cleanup(srv.Close)
return srv.URL, bare
}
func gitExecPath(t *testing.T) string {
t.Helper()
out, err := exec.Command("git", "--exec-path").Output()
if err != nil {
t.Skipf("git is not installed: %v", err)
}
return strings.TrimSpace(string(out))
}
func runGit(t *testing.T, dir string, env []string, name string, args ...string) string {
t.Helper()
out, err := runErr(dir, env, name, args...)
if err != nil {
t.Fatalf("%s %v: %v\n%s", name, args, err, out)
}
return out
}
func runErr(dir string, env []string, name string, args ...string) (string, error) {
cmd := exec.Command(name, args...)
cmd.Dir = dir
cmd.Env = append(os.Environ(), "GIT_AUTHOR_NAME=t", "GIT_AUTHOR_EMAIL=t@t", "GIT_COMMITTER_NAME=t",
"GIT_COMMITTER_EMAIL=t@t", "GIT_TERMINAL_PROMPT=0", "GIT_ASKPASS=", "SSH_ASKPASS=")
cmd.Env = append(cmd.Env, env...)
out, err := cmd.CombinedOutput()
return string(out), err
}
func commit(t *testing.T, dir, text string) {
t.Helper()
if err := os.WriteFile(filepath.Join(dir, "file"), []byte(text+"\n"), 0o644); err != nil {
t.Fatal(err)
}
runGit(t, dir, nil, "git", "add", "file")
runGit(t, dir, nil, "git", "commit", "-q", "-m", text)
}
// sessionEnv is the environment a session starts with: the managed settings' `env`, in a home with no git
// configuration of its own.
func sessionEnv(t *testing.T, home, managedSettings string) []string {
t.Helper()
var settings struct {
Env map[string]string `json:"env"`
}
if err := json.Unmarshal([]byte(managedSettings), &settings); err != nil {
t.Fatal(err)
}
env := []string{"HOME=" + home, "XDG_CONFIG_HOME=" + filepath.Join(home, ".config"), "GIT_CONFIG_NOSYSTEM=1"}
for k, v := range settings.Env {
env = append(env, k+"="+v)
}
return env
}
func forgePaths(t *testing.T) Paths {
t.Helper()
return Paths{State: t.TempDir(), Home: t.TempDir(), Node: "anchor"}
}
func managedFor(p Paths) string {
facts := Facts{Node: p.Node, Console: "http://console"}
if ForgeCredentialHeld(p) {
facts.ForgeCredentials = p.forgeCredentials()
}
return Render(facts, Settings{}, nil, "", nil, Config{})["managed-settings.json"]
}
// Issue 737's done-when, as far as a machine of its own proves it: an agent session clones a private repository and
// fetches it again, and no credential appears in a log, a command line or the session.
func TestAnAgentClonesAPrivateRepositoryAndFetchesItAgainWithTheCredentialPlaced(t *testing.T) {
origin, bare := privateForge(t)
p := forgePaths(t)
repo := origin + "/private.git"
// Before the forge issued anything, the session's git has no credential: the clone is refused, as today.
before := sessionEnv(t, p.Home, managedFor(p))
if out, err := runErr(t.TempDir(), before, "git", "clone", "-q", repo, "x"); err == nil {
t.Fatalf("a private repository was cloned with no credential:\n%s", out)
}
// The vault hands this module what the forge issued for this machine's agent.
lines := strings.Replace(origin, "http://", "http://agent-anchor:"+agentToken+"@", 1) + "\n" +
"https://agent-anchor:" + agentToken + "@git.example.test\n"
if err := os.WriteFile(p.forgeSecret(), []byte(lines), 0o600); err != nil {
t.Fatal(err)
}
said, err := PlaceForgeCredential(p)
if err != nil {
t.Fatal(err)
}
info, err := os.Stat(p.forgeCredentials())
if err != nil || info.Mode().Perm() != 0o600 {
t.Fatalf("the credential file: %v %v, want mode 0600", info, err)
}
if again, err := PlaceForgeCredential(p); err != nil || again != "" {
t.Fatalf("placed again with nothing changed: %q %v", again, err)
}
managed := managedFor(p)
env := sessionEnv(t, p.Home, managed)
work := t.TempDir()
cloneOut := runGit(t, work, env, "git", "clone", repo, "clone")
clone := filepath.Join(work, "clone")
if got := runGit(t, clone, env, "git", "log", "--format=%s"); strings.TrimSpace(got) != "one" {
t.Fatalf("cloned %q", got)
}
// The forge's repository moves on; the agent fetches it again.
other := t.TempDir()
runGit(t, other, nil, "git", "clone", "-q", bare, "w")
commit(t, filepath.Join(other, "w"), "two")
runGit(t, filepath.Join(other, "w"), nil, "git", "push", "-q", "origin", "main")
fetchOut := runGit(t, clone, env, "git", "fetch", "origin")
if got := runGit(t, clone, env, "git", "log", "--format=%s", "origin/main"); !strings.HasPrefix(got, "two") {
t.Fatalf("fetched %q", got)
}
// A push is the forge's to refuse (ADR 0365 §5): the token reads, and nothing writes with it.
commit(t, clone, "three")
if out, err := runErr(clone, env, "git", "push", "origin", "main"); err == nil {
t.Fatalf("a push with the agent's credential went through:\n%s", out)
}
// No credential in what the session saw, its command lines, its environment or the module's words.
remote := runGit(t, clone, env, "git", "remote", "get-url", "origin")
for what, text := range map[string]string{"the clone's output": cloneOut, "the fetch's output": fetchOut,
"the remote's url": remote, "the managed settings": managed, "the session's environment": strings.Join(env, "\n"),
"what the module said": said, "the clone's configuration": readOrEmpty(filepath.Join(clone, ".git", "config"))} {
if strings.Contains(text, agentToken) {
t.Errorf("the token appears in %s", what)
}
}
}
func readOrEmpty(path string) string {
raw, _ := os.ReadFile(path)
return string(raw)
}
func TestWhatGitChangedInTheFileIsPutBackAndAValueNotIssuedIsNeverPlaced(t *testing.T) {
p := forgePaths(t)
// A value the mesh made while the forge had not been asked: not credential lines, never placed.
if err := os.WriteFile(p.forgeSecret(), []byte("Zm9yZ2VkLWJ5LXRoZS1tZXNo\n"), 0o600); err != nil {
t.Fatal(err)
}
if said, err := PlaceForgeCredential(p); err != nil || said != "" {
t.Fatalf("%q %v", said, err)
}
if _, err := os.Stat(p.forgeCredentials()); err == nil {
t.Fatal("a value the forge did not issue was placed")
}
if ForgeCredentialHeld(p) || strings.Contains(managedFor(p), "GIT_CONFIG") {
t.Fatal("the sessions' git is pointed at a credential that is not held")
}
issued := "https://agent-anchor:" + agentToken + "@git.example.test\n"
if err := os.WriteFile(p.forgeSecret(), []byte(issued), 0o600); err != nil {
t.Fatal(err)
}
if _, err := PlaceForgeCredential(p); err != nil {
t.Fatal(err)
}
// git's store helper erased the line after a rejection, and a person's password for another host was stored.
if err := os.WriteFile(p.forgeCredentials(), []byte("https://me:mine@example.org\n"), 0o644); err != nil {
t.Fatal(err)
}
if said, err := PlaceForgeCredential(p); err != nil || !strings.Contains(said, "placed") {
t.Fatalf("%q %v", said, err)
}
if got := readOrEmpty(p.forgeCredentials()); got != issued {
t.Fatalf("the file holds %q", strings.ReplaceAll(got, agentToken, "<token>"))
}
if info, _ := os.Stat(p.forgeCredentials()); info.Mode().Perm() != 0o600 {
t.Fatalf("mode %v", info.Mode().Perm())
}
// The credential withdrawn: taken back, and the sessions' git no longer pointed at it.
if err := os.Remove(p.forgeSecret()); err != nil {
t.Fatal(err)
}
if said, err := PlaceForgeCredential(p); err != nil || !strings.Contains(said, "taken back") || strings.Contains(said, agentToken) {
t.Fatalf("%q %v", said, err)
}
if _, err := os.Stat(p.forgeCredentials()); err == nil {
t.Fatal("a withdrawn credential stayed in the agent's home")
}
}
func TestTheSessionsGitIsPointedAtTheFileUnderTheOperatorsOwnEnvironment(t *testing.T) {
managed := map[string]any{"env": map[string]any{"FOO": "bar", "GIT_CONFIG_COUNT": "7"}}
forgeEnv(managed, "/home/agent/.config/mesh/forge.credentials")
env := managed["env"].(map[string]any)
if env["FOO"] != "bar" || env["GIT_CONFIG_COUNT"] != "1" || env["GIT_CONFIG_KEY_0"] != "credential.helper" ||
env["GIT_CONFIG_VALUE_0"] != "store --file='/home/agent/.config/mesh/forge.credentials'" {
t.Fatalf("%v", env)
}
none := map[string]any{}
forgeEnv(none, "")
if _, set := none["env"]; set {
t.Fatal("an environment was set with no credential held")
}
}
func TestForgeCredentialLinesAreReadStrictly(t *testing.T) {
for _, bad := range []string{"", "\n", "token", "https://git.example.test", "https://agent-anchor@git.example.test",
"https://agent-anchor:t@git.example.test/novox/hq.git", "ssh://agent-anchor:t@git.example.test",
"https://agent-anchor:t@git.example.test\nnot a line"} {
if _, err := ForgeCredentialLines(bad); err == nil {
t.Errorf("%q was taken", bad)
} else if strings.Contains(err.Error(), "t@git") {
t.Errorf("the refusal said the value: %v", err)
}
}
}
@@ -585,7 +585,25 @@ func run(p Paths, view *ServerView, configView *ConfigView) {
report()
var last string
// The forge's read credential (forge.go, ADR 0365 §4): placed as held, and put back when git or a person changed
// it; the sessions' environment names it only while one is held.
forgeHeld := ForgeCredentialHeld(p)
placeForge := func() {
if line, err := PlaceForgeCredential(p); err != nil {
say("%v", err)
} else if line != "" {
say("%s", line)
}
if held := ForgeCredentialHeld(p); held != forgeHeld {
forgeHeld = held
if _, err := RenderNow(p, writeManaged); err != nil {
say("rendering after the forge's read credential changed: %v", err)
}
}
}
placeForge()
for range time.Tick(5 * time.Second) {
placeForge()
info, err := fsys.Stat(p.credentials())
now := "absent"
if err == nil {
@@ -216,6 +216,9 @@ func RenderNow(p Paths, write WriteManaged) ([]string, error) {
if !readJSON(p.Facts, &facts) || facts.Console == "" {
return nil, fmt.Errorf("the mesh has not rendered %s yet; nothing to write", p.Facts)
}
if ForgeCredentialHeld(p) {
facts.ForgeCredentials = p.forgeCredentials()
}
var settings Settings
readJSON(p.Settings, &settings)
var binding *Binding
@@ -34,6 +34,9 @@ const meshEntry = "mesh"
type Facts struct {
Node string `json:"node"`
Console string `json:"console"`
// ForgeCredentials is the forge's read credential placed in the agent's home, when this machine holds one
// (forge.go): its path, which every session's git is pointed at. Never rendered by the mesh.
ForgeCredentials string `json:"-"`
}
// Settings are the operator's, for the mesh or this node.
@@ -139,6 +142,7 @@ func RenderWithMesh(facts Facts, settings Settings, binding *Binding, helperPath
}
managed[key] = entries
}
forgeEnv(managed, facts.ForgeCredentials)
if binding != nil && binding.Kind == "api-key" {
managed["apiKeyHelper"] = helperPath
}
@@ -19,11 +19,12 @@ const agentAccountFile = "/etc/claude-code/agent-account"
type manifestShell struct {
Resources []map[string]any `json:"resources"`
Shell []struct {
For string `json:"for"`
Slot string `json:"slot"`
Code string `json:"code"`
} `json:"shell"`
// The zsh code is an item of the collection shell:zsh:<slot> (novox/hq ADR 0349 clause 13), its text the
// template file beside the manifest.
Items []struct {
Collection string `json:"collection"`
Template string `json:"template"`
} `json:"items"`
}
func readManifestShell(t *testing.T) manifestShell {
@@ -54,9 +55,13 @@ func resourceByID(t *testing.T, m manifestShell, id string) map[string]any {
func zshCode(t *testing.T, m manifestShell) string {
t.Helper()
var code []string
for _, s := range m.Shell {
if s.For == "zsh" {
code = append(code, s.Code)
for _, it := range m.Items {
if strings.HasPrefix(it.Collection, "shell:zsh:") {
text, err := os.ReadFile(filepath.Join("..", "..", it.Template))
if err != nil {
t.Fatal(err)
}
code = append(code, string(text))
}
}
if len(code) != 1 {
@@ -71,8 +76,8 @@ func TestTheAccountAgentsRunAsIsWrittenForTheShell(t *testing.T) {
if r["type"] != "file" || r["path"] != agentAccountFile || r["mode"] != "0644" {
t.Fatalf("the account's file is %v", r)
}
if r["content"] != "${machine:agent-account}\n" {
t.Fatalf("the account's file holds %q", r["content"])
if text := templateText(t, r); text != "${machine:agent-account}\n" {
t.Fatalf("the account's file holds %q", text)
}
if _, has := r["owner"]; has {
t.Fatal("the account's file must be root's, as claude-agent is")
@@ -163,7 +168,7 @@ func TestClaudeAgentRunsTheRealClaudeUnderTheAlias(t *testing.T) {
if err != nil {
t.Fatal(err)
}
start, _ := resourceByID(t, readManifestShell(t), "start")["content"].(string)
start := templateText(t, resourceByID(t, readManifestShell(t), "start"))
if !strings.Contains(start, "exec claude \"$@\"") {
t.Fatalf("claude-agent no longer runs claude itself:\n%s", start)
}
@@ -0,0 +1,64 @@
package main
import (
"encoding/json"
"os"
"path/filepath"
"testing"
)
// The module's directory, where its manifest and its files/ are.
const moduleDir = "../.."
// templateText is the text a file resource places on the machine: its template's, in the module's files/
// (novox/hq ADR 0349 §2). A resource that names no template, or a template that is not there, fails the test.
func templateText(t *testing.T, r map[string]any) string {
t.Helper()
name, _ := r["template"].(string)
if name == "" {
t.Fatalf("resource %v names no template", r["id"])
}
raw, err := os.ReadFile(filepath.Join(moduleDir, filepath.FromSlash(name)))
if err != nil {
t.Fatalf("resource %v names %s: %v", r["id"], name, err)
}
return string(raw)
}
// No file resource carries its text inline: each names its template in files/, and the build packs files/
// into the one archive the controller reads the templates back from (novox/hq ADR 0349 §2).
func TestEveryFileTakesItsTextFromATemplate(t *testing.T) {
raw, err := os.ReadFile(filepath.Join(moduleDir, "module.json"))
if err != nil {
t.Fatal(err)
}
var m struct {
Resources []map[string]any `json:"resources"`
Build struct {
Artifacts []map[string]any `json:"artifacts"`
} `json:"build"`
}
if err := json.Unmarshal(raw, &m); err != nil {
t.Fatal(err)
}
for _, r := range m.Resources {
if _, inline := r["content"]; inline {
t.Errorf("resource %v keeps its text inline: keep it in files/ and name it as the template", r["id"])
}
if r["type"] == "file" {
templateText(t, r)
}
}
packs := 0
for _, a := range m.Build.Artifacts {
if a["kind"] == "archive" && a["from"] == "files" {
packs++
if a["name"] != "files" {
t.Errorf("the templates archive is named %v, not files", a["name"])
}
}
}
if packs != 1 {
t.Errorf("the build packs files/ into %d archives, not one", packs)
}
}
+1
View File
@@ -0,0 +1 @@
${machine:agent-account}
+13
View File
@@ -0,0 +1,13 @@
#!/bin/sh
# The mesh's (module claude-code, novox/hq ADR 0266): start the agent as the account agents run as on this
# machine. Written whole at every push: an edit here is overwritten.
agent='${machine:agent-account}'
if [ "$(id -un)" = "$agent" ]; then
exec claude "$@"
fi
# Another account, the operator's, becomes the agent's through its own sudo: the person is the authority. The
# operator's override of the guard travels only when it is set in this shell, as it would reach claude.
if [ -n "$MESH_GUARD_OVERRIDE" ]; then
exec sudo -iu "$agent" env MESH_GUARD_OVERRIDE="$MESH_GUARD_OVERRIDE" claude "$@"
fi
exec sudo -iu "$agent" claude "$@"
+4
View File
@@ -0,0 +1,4 @@
{
"node": "${machine:name}",
"console": "http://127.0.0.1:${bound:mcp-endpoint:port}/mcp"
}
+10
View File
@@ -0,0 +1,10 @@
{
"role": "",
"mcp_servers": {},
"managed_settings": {},
"instructions": {},
"output_styles": {},
"skills": {},
"commands": {},
"agents": {}
}
+12
View File
@@ -0,0 +1,12 @@
# The agent's session (module claude-code, novox/hq ADR 0266): where this machine names an account of the
# agents' own, claude in an interactive zsh is claude-agent, which starts the session as that account. Where
# agents run as the operator's account the file names that account, and nothing is added. claude-agent runs
# the real claude: exec, sudo and its sh see no alias.
if [[ -r /etc/claude-code/agent-account ]]; then
() {
local agent=$(</etc/claude-code/agent-account)
if [[ -n $agent && $agent != $USERNAME ]]; then
alias claude=claude-agent
fi
}
fi
+73 -9
View File
@@ -1,16 +1,24 @@
{
"module": "claude-code",
"version": "1",
"moved": true,
"slug": "agent",
"capabilities": [
"package-manager"
],
"requires": [
"mcp-endpoint"
"mcp-endpoint",
{
"name": "gitea-read",
"optional": true
}
],
"binds": {
"mcp-endpoint": "${dir:state}/mcp-endpoint.json"
},
"secrets": {
"gitea-read": "${dir:state}/gitea-read.secret"
},
"uses": [
"operator-channel"
],
@@ -85,6 +93,57 @@
}
]
},
"config": {
"role": {
"type": "string",
"description": "What this machine is for, in a sentence, written into the instructions every Claude Code session on it reads; empty says nothing.",
"trusted": true,
"default": ""
},
"mcp_servers": {
"type": "object",
"description": "The tool servers every session on this machine is given beside the mesh's own, by name, each as Claude Code's managed MCP configuration takes it; empty adds none.",
"trusted": true,
"default": {}
},
"managed_settings": {
"type": "object",
"description": "Keys of Claude Code's managed settings every session on this machine obeys, such as spinnerTipsEnabled, laid over the mesh's own; empty adds none. Falls back to agent-managed-settings, the mesh's or this node's.",
"trusted": true,
"predefined": "agent-managed-settings",
"default": {}
},
"instructions": {
"type": "object",
"description": "The instructions every session on this machine reads, by name, each a text as one line or a list of its lines; empty adds none.",
"trusted": true,
"default": {}
},
"output_styles": {
"type": "object",
"description": "The output styles offered to every session on this machine, by name, each a text as one line or a list of its lines; empty adds none.",
"trusted": true,
"default": {}
},
"skills": {
"type": "object",
"description": "The skills offered to every session on this machine, by name, each its files by path, a text as one line or a list of its lines; a skill without its SKILL.md is left out; empty adds none.",
"trusted": true,
"default": {}
},
"commands": {
"type": "object",
"description": "The slash commands offered to every session on this machine, by name, each a text as one line or a list of its lines; empty adds none.",
"trusted": true,
"default": {}
},
"agents": {
"type": "object",
"description": "The agents offered to every session on this machine, by name, each a text as one line or a list of its lines; empty adds none.",
"trusted": true,
"default": {}
}
},
"resources": [
{
"id": "package",
@@ -102,14 +161,14 @@
"type": "file",
"path": "/usr/local/bin/claude-agent",
"mode": "0755",
"content": "#!/bin/sh\n# The mesh's (module claude-code, novox/hq ADR 0266): start the agent as the account agents run as on this\n# machine. Written whole at every push: an edit here is overwritten.\nagent='${machine:agent-account}'\nif [ \"$(id -un)\" = \"$agent\" ]; then\n\texec claude \"$@\"\nfi\n# Another account, the operator's, becomes the agent's through its own sudo: the person is the authority. The\n# operator's override of the guard travels only when it is set in this shell, as it would reach claude.\nif [ -n \"$MESH_GUARD_OVERRIDE\" ]; then\n\texec sudo -iu \"$agent\" env MESH_GUARD_OVERRIDE=\"$MESH_GUARD_OVERRIDE\" claude \"$@\"\nfi\nexec sudo -iu \"$agent\" claude \"$@\"\n"
"template": "files/claude-agent"
},
{
"id": "agent-account-name",
"type": "file",
"path": "/etc/claude-code/agent-account",
"mode": "0644",
"content": "${machine:agent-account}\n"
"template": "files/agent-account"
},
{
"id": "agent-account",
@@ -138,7 +197,7 @@
"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"
"template": "files/facts.json"
},
{
"id": "settings",
@@ -146,15 +205,15 @@
"path": "${dir:state}/settings.json",
"mode": "0600",
"owner": "${machine:account}",
"takes-config": ["role", "mcp_servers", "managed_settings", "instructions", "output_styles", "skills", "commands", "agents"],
"merge": "json",
"content": "{\n \"role\": \"\",\n \"mcp_servers\": {},\n \"managed_settings\": {},\n \"instructions\": {},\n \"output_styles\": {},\n \"skills\": {},\n \"commands\": {},\n \"agents\": {}\n}\n"
"template": "files/settings.json"
}
],
"shell": [
"items": [
{
"for": "zsh",
"slot": "normal",
"code": "# The agent's session (module claude-code, novox/hq ADR 0266): where this machine names an account of the\n# agents' own, claude in an interactive zsh is claude-agent, which starts the session as that account. Where\n# agents run as the operator's account the file names that account, and nothing is added. claude-agent runs\n# the real claude: exec, sudo and its sh see no alias.\nif [[ -r /etc/claude-code/agent-account ]]; then\n\t() {\n\t\tlocal agent=$(</etc/claude-code/agent-account)\n\t\tif [[ -n $agent && $agent != $USERNAME ]]; then\n\t\t\talias claude=claude-agent\n\t\tfi\n\t}\nfi\n"
"collection": "shell:zsh:normal",
"template": "files/zsh-normal"
}
],
"build": {
@@ -174,6 +233,11 @@
"MESH_CLAUDE_CODE_FACTS": "${dir:state}/facts.json",
"MESH_CLAUDE_CODE_SETTINGS": "${dir:state}/settings.json"
}
},
{
"name": "files",
"kind": "archive",
"from": "files"
}
]
}
@@ -0,0 +1,64 @@
package main
import (
"encoding/json"
"os"
"path/filepath"
"testing"
)
// The module's directory, where its manifest and its files/ are.
const moduleDir = "../.."
// templateText is the text a file resource places on the machine: its template's, in the module's files/
// (novox/hq ADR 0349 §2). A resource that names no template, or a template that is not there, fails the test.
func templateText(t *testing.T, r map[string]any) string {
t.Helper()
name, _ := r["template"].(string)
if name == "" {
t.Fatalf("resource %v names no template", r["id"])
}
raw, err := os.ReadFile(filepath.Join(moduleDir, filepath.FromSlash(name)))
if err != nil {
t.Fatalf("resource %v names %s: %v", r["id"], name, err)
}
return string(raw)
}
// No file resource carries its text inline: each names its template in files/, and the build packs files/
// into the one archive the controller reads the templates back from (novox/hq ADR 0349 §2).
func TestEveryFileTakesItsTextFromATemplate(t *testing.T) {
raw, err := os.ReadFile(filepath.Join(moduleDir, "module.json"))
if err != nil {
t.Fatal(err)
}
var m struct {
Resources []map[string]any `json:"resources"`
Build struct {
Artifacts []map[string]any `json:"artifacts"`
} `json:"build"`
}
if err := json.Unmarshal(raw, &m); err != nil {
t.Fatal(err)
}
for _, r := range m.Resources {
if _, inline := r["content"]; inline {
t.Errorf("resource %v keeps its text inline: keep it in files/ and name it as the template", r["id"])
}
if r["type"] == "file" {
templateText(t, r)
}
}
packs := 0
for _, a := range m.Build.Artifacts {
if a["kind"] == "archive" && a["from"] == "files" {
packs++
if a["name"] != "files" {
t.Errorf("the templates archive is named %v, not files", a["name"])
}
}
}
if packs != 1 {
t.Errorf("the build packs files/ into %d archives, not one", packs)
}
}
@@ -0,0 +1 @@
postgresql://${bound:postgres-database:as}:${secret:postgres-database}@${bound:postgres-database:at}:${bound:postgres-database:port}/${bound:postgres-database:as}
@@ -0,0 +1,7 @@
{
"cadence_minutes": 240,
"floor_minutes": 60,
"failures_to_notify": 3,
"cooldown_hours": 24,
"refresh_warn_days": 3
}
+45 -2
View File
@@ -76,6 +76,43 @@
"reads": [
"claude-code.holdings"
],
"config": {
"cadence_minutes": {
"type": "number",
"description": "How old a licence's grant may grow before it is rotated, in minutes; 0 keeps 240.",
"minimum": 0,
"trusted": true,
"default": 240
},
"floor_minutes": {
"type": "number",
"description": "How little time a grant may have left before it is refreshed in any case, in minutes; 0 keeps 60.",
"minimum": 0,
"trusted": true,
"default": 60
},
"failures_to_notify": {
"type": "number",
"description": "How many refreshes of a licence fail in a row before the operator is told, a count; 0 keeps 3.",
"minimum": 0,
"trusted": true,
"default": 3
},
"cooldown_hours": {
"type": "number",
"description": "How long after telling the operator about a licence nothing more is said about it, in hours; 0 keeps 24.",
"minimum": 0,
"trusted": true,
"default": 24
},
"refresh_warn_days": {
"type": "number",
"description": "How long before a licence's refresh token itself expires the operator is warned, in days; 0 keeps 3.",
"minimum": 0,
"trusted": true,
"default": 3
}
},
"resources": [
{
"id": "state",
@@ -88,15 +125,16 @@
"type": "file",
"path": "${dir:state}/database.url",
"mode": "0600",
"content": "postgresql://${bound:postgres-database:as}:${secret:postgres-database}@${bound:postgres-database:at}:${bound:postgres-database:port}/${bound:postgres-database:as}\n"
"template": "files/database.url"
},
{
"id": "settings",
"type": "file",
"path": "${dir:state}/settings.json",
"mode": "0600",
"takes-config": ["cadence_minutes", "floor_minutes", "failures_to_notify", "cooldown_hours", "refresh_warn_days"],
"merge": "json",
"content": "{\n \"cadence_minutes\": 240,\n \"floor_minutes\": 60,\n \"failures_to_notify\": 3,\n \"cooldown_hours\": 24,\n \"refresh_warn_days\": 3\n}\n"
"template": "files/settings.json"
},
{
"id": "prepare",
@@ -134,6 +172,11 @@
"MESH_LICENCE_KEY_FILE": "${dir:state}/grant.key",
"MESH_LICENCE_SETTINGS": "${dir:state}/settings.json"
}
},
{
"name": "files",
"kind": "archive",
"from": "files"
}
]
}
+13 -7
View File
@@ -7,9 +7,15 @@ The clipboard manager as a module (novox/hq ADR 0208, research 026/04).
- 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.
- Declares `clipmenud.service` as a **user unit of the operator's account**, enabled and running
(`scope: user`, ADR 0177; issue 743), so `$mod+.` shows the history again. It is the only starter: the
session's `xinitrc` line is gone. A drop-in (`~/.config/systemd/user/clipmenud.service.d/mesh.conf`)
replaces `ExecStart` with the module's launcher, which finds the X display from its socket (the account's
service manager has no `DISPLAY`) and puts the text-only `xsel` first on the `PATH`; until the display is up
it waits (a quick exit would run into the start limit, which the drop-in also switches off, with `RestartSec=2s`). `DISPLAY` and `XAUTHORITY` come from the user manager when the session's xinitrc has handed them over (module xorg); with several X servers and no `DISPLAY` yet, it waits for them. The store is in the account's runtime directory (mode 0700),
so no other account reads it.
**After delivery the operator logs in again**: the running session keeps the `clipmenud` its xinitrc
started, and the new unit waits on clipmenud's session lock until that one ends.
- 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
@@ -23,11 +29,11 @@ The clipboard manager as a module (novox/hq ADR 0208, research 026/04).
| 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 |
| `node-clipboard.history` | the history, newest first: count, time and length of each entry, and whether the daemon collects. **Never a text, a first line or an id** (issue 750); the text is read through clipmenu on the operator's own screen |
| `node-clipboard.copy` | put text on the clipboard, then read it back; an error with the reason (no `DISPLAY`, `xsel` exited, other contents) when it does not hold the text. It enters the history like any copy |
| `clipmenu_paste` | how many bytes of text the clipboard holds now, never the text |
| `clipmenu_clear` | forget the history; the daemon's locks stay |
| `clipmenu_delete` | forget one entry, by id or first line |
| `clipmenu_delete` | forget one entry, by its place in the history (1 is the newest) and the time the history gave it, refused if the history changed since; not by text or id |
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
@@ -15,8 +15,7 @@ import (
)
const (
mostCopy = 1 << 20
mostPaste = 64 << 10
mostCopy = 1 << 20
// majorVersion is clipmenu's store layout: <dir>/clipmenu.<major>.<user>/.
majorVersion = 6
)
@@ -71,15 +70,15 @@ func cksum(data []byte) string {
func entryID(line string) string { return cksum([]byte(line + "\n")) }
// Entry is one clip in the history.
// Entry is one clip in the history as a caller sees it: when and how long, never what. The text of a
// clip, its first line and its id (a checksum of that line) stay in clipmenu's store, readable on the
// operator's own screen through clipmenu (issue 750).
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
At string `json:"at"`
Bytes int `json:"bytes"`
id string
line string
at int64
}
// HistoryResult is what node-clipboard.history answers.
@@ -119,14 +118,15 @@ func readStore(dir string) ([]Entry, error) {
}
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)})
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) {
// History is the clipboard's history, newest first: how many entries, when each was copied and how
// long each is, and whether the daemon is collecting. It never returns a copied text.
func History(limit int) (HistoryResult, error) {
dir, err := storeDir(findEnvironment())
if err != nil {
return HistoryResult{}, err
@@ -140,15 +140,8 @@ func History(limit, maxBytes int) (HistoryResult, error) {
if i == limit {
break
}
if info, err := os.Stat(filepath.Join(dir, e.ID)); err == nil {
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)
}
@@ -159,6 +152,40 @@ func History(limit, maxBytes int) (HistoryResult, error) {
type CopyResult struct {
Bytes int `json:"bytes"`
Unit string `json:"unit"`
// Verified says the clipboard was read back and held exactly the text.
Verified bool `json:"verified"`
}
// readBackWait is the pause between reads of the clipboard while the owner starts; read-back tries 15 times.
var readBackWait = 200 * time.Millisecond
// readBack waits for the clipboard to hold text and answers why when it does not.
func readBack(s Session, unit, text string) error {
var last string
for i := 0; i < 15; i++ {
time.Sleep(readBackWait)
r, err := s.run(3*time.Second, "", "xsel", "--output", "--clipboard")
switch {
case err != nil:
last = err.Error()
case r.Code != 0:
last = "xsel: " + strings.TrimSpace(r.Stderr)
case r.Stdout == text:
return nil
default:
last = fmt.Sprintf("it holds %d bytes of something else", len(r.Stdout))
}
}
why := ""
if s.Display == "" {
why = "; the session has no DISPLAY, so the owner could not reach the screen"
}
if j, err := s.run(5*time.Second, "", "journalctl", "--user", "-u", unit+".service", "-n", "5", "--no-pager", "-o", "cat"); err == nil {
if tail := strings.TrimSpace(j.Stdout); tail != "" {
why += "; the owner said: " + tail
}
}
return fmt.Errorf("the clipboard does not hold the text (%d bytes): %s%s", len(text), last, why)
}
// Copy puts text on the clipboard. The clipboard is owned by a process until another copies, so the
@@ -194,17 +221,20 @@ func Copy(text string) (CopyResult, error) {
os.Remove(f.Name())
return CopyResult{}, err
}
return CopyResult{Bytes: len(text), Unit: unit + ".service"}, nil
if err := readBack(s, unit, text); err != nil {
return CopyResult{}, err
}
return CopyResult{Bytes: len(text), Unit: unit + ".service", Verified: true}, nil
}
// PasteResult is what clipmenu_paste answers.
// PasteResult is what clipmenu_paste answers: how much text the clipboard holds, never the text. The
// clipboard is the newest history entry, and a copied secret must not reach a caller (issue 750).
type PasteResult struct {
Text string `json:"text"`
Bytes int `json:"bytes"`
Truncated bool `json:"truncated,omitempty"`
Bytes int `json:"bytes"`
Text bool `json:"holds_text"`
}
// Paste reads the clipboard now.
// Paste reads the clipboard now and says how long it is.
func Paste() (PasteResult, error) {
s, err := findSession()
if err != nil {
@@ -217,11 +247,7 @@ func Paste() (PasteResult, error) {
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
return PasteResult{Bytes: len(r.Stdout), Text: len(r.Stdout) > 0}, nil
}
// ChangeResult is what clear and delete answer.
@@ -298,10 +324,12 @@ func Clear() (ChangeResult, error) {
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")
// Delete forgets one entry, by its place in the history as node-clipboard.history lists it (1 is the
// newest) together with the time it reports, so a copy arriving since the listing cannot shift the
// place onto another entry. Not by its text or id: either would let a caller confirm a guess of a copied line.
func Delete(place int, at string) (ChangeResult, error) {
if place < 1 {
return ChangeResult{}, errors.New("give the entry's place in the history, 1 for the newest")
}
dir, err := storeOf()
if err != nil {
@@ -309,6 +337,17 @@ func Delete(id, line string) (ChangeResult, error) {
}
var out ChangeResult
err = withStoreLock(dir, func() error {
entries, err := readStore(dir)
if err != nil {
return err
}
if place > len(entries) {
return fmt.Errorf("no entry %d: the clipboard history holds %d", place, len(entries))
}
if entries[place-1].At != at {
return fmt.Errorf("entry %d was copied at %s, not at %s: the history changed since it was listed; list it again", place, entries[place-1].At, at)
}
line := entries[place-1].line
raw, err := os.ReadFile(filepath.Join(dir, "line_cache"))
if err != nil && !errors.Is(err, os.ErrNotExist) {
return err
@@ -319,7 +358,7 @@ func Delete(id, line string) (ChangeResult, error) {
continue
}
_, text, _ := strings.Cut(l, " ")
if text == line || (id != "" && entryID(text) == id) {
if text == line {
out.Removed++
_ = os.Remove(filepath.Join(dir, entryID(text)))
continue
@@ -327,7 +366,7 @@ func Delete(id, line string) (ChangeResult, error) {
kept = append(kept, l)
}
if out.Removed == 0 {
return fmt.Errorf("no entry %s%s in the clipboard history", id, line)
return fmt.Errorf("no entry %d in the clipboard history", place)
}
content := strings.Join(kept, "\n")
if content != "" {
@@ -1,6 +1,7 @@
package main
import (
"encoding/json"
"errors"
"os"
"os/exec"
@@ -51,46 +52,84 @@ func store(t *testing.T, clips map[string]string, order ...string) string {
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")
func TestTheHistoryAnswersCountTimesAndLengthsNeverContent(t *testing.T) {
store(t, map[string]string{"first": "first", "sk-ant-api03-SECRETKEY (2 lines)": "sk-ant-api03-SECRETKEY\nline two"}, "first", "sk-ant-api03-SECRETKEY (2 lines)", "first")
fakeProcess(t, nobody, "clipmenud")
h, err := History(10, 4096)
h, err := History(10)
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 {
if !h.Collecting || h.Total != 2 || len(h.Entries) != 2 || h.Entries[0].Bytes != 5 || h.Entries[1].Bytes != 31 || h.Entries[0].At == "" {
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)
raw, _ := json.Marshal(h)
for _, leak := range []string{"first", "SECRETKEY", "line two"} {
if strings.Contains(string(raw), leak) {
t.Fatalf("the history answers %q: %s", leak, raw)
}
}
if h, _ := History(10, 0); h.Entries[0].Text != "" || h.Entries[0].Bytes != 5 {
t.Fatalf("without text: %+v", h)
if h, _ := History(1); len(h.Entries) != 1 {
t.Fatalf("limited: %+v", h)
}
}
func TestAnEntryIsDeletedByIdOrLineWithItsText(t *testing.T) {
// at is the time History reports for the entry at place.
func at(t *testing.T, place int) string {
t.Helper()
h, err := History(100)
if err != nil || place > len(h.Entries) {
t.Fatalf("%+v, %v", h, err)
}
return h.Entries[place-1].At
}
func TestADeleteWhoseListingIsStaleIsRefused(t *testing.T) {
dir := store(t, map[string]string{"a": "a", "b": "b"}, "a", "b")
listed := at(t, 1) // b
f, _ := os.OpenFile(filepath.Join(dir, "line_cache"), os.O_APPEND|os.O_WRONLY, 0o600)
f.WriteString("1800000000000000000 a\n") // a copy arrives: place 1 is now a
f.Close()
if _, err := Delete(1, listed); err == nil || !strings.Contains(err.Error(), "list it again") {
t.Fatalf("the wrong entry was deleted: %v", err)
}
if h, _ := History(10); h.Total != 2 {
t.Fatalf("%+v", h)
}
}
func TestAnEntryIsDeletedByItsPlaceWithItsTextAndNeverByItsText(t *testing.T) {
dir := store(t, map[string]string{"a": "a", "b": "b", "c": "c"}, "a", "b", "c", "a")
r, err := Delete(entryID("a"), "")
// newest first: a, c, b
r, err := Delete(1, at(t, 1))
if err != nil || r.Removed != 2 || r.Remaining != 2 {
t.Fatalf("by id, both copies: %+v, %v", r, err)
t.Fatalf("by place, 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 r, err := Delete(2, at(t, 2)); err != nil || r.Removed != 1 || r.Remaining != 1 {
t.Fatalf("second place (b): %+v, %v", r, err)
}
if _, err := Delete("", "zzz"); err == nil {
t.Fatal("a missing entry was reported deleted")
if _, err := Delete(5, "x"); err == nil {
t.Fatal("a missing place was reported deleted")
}
if _, err := Delete("x", "y"); err == nil {
t.Fatal("both an id and a line were accepted")
if _, err := Delete(0, "x"); err == nil {
t.Fatal("place 0 was 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)
}
for _, tool := range tools() {
if tool.Name == "clipmenu_delete" {
if _, ok := tool.Input["line"]; ok {
t.Fatal("delete still takes a line")
}
if _, ok := tool.Input["id"]; ok {
t.Fatal("delete still takes an id")
}
}
}
}
func TestClearForgetsEverythingButKeepsTheDaemonsLocks(t *testing.T) {
@@ -112,13 +151,21 @@ func TestClearForgetsEverythingButKeepsTheDaemonsLocks(t *testing.T) {
}
}
func TestCopyHandsTheTextToAnOwnerUnderTheAccountsServiceManager(t *testing.T) {
func copyBed(t *testing.T, xsel string) string {
t.Helper()
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"`})
bin := fakeBinaries(t, map[string]string{"systemctl": "true", "xsel": xsel,
"systemd-run": `echo "$*" > "$LOG"; for last; do :; done; cat "$last" > "$LOG.text"`})
t.Setenv("LOG", filepath.Join(bin, "log"))
readBackWait = 0
return bin
}
func TestCopyHandsTheTextToAnOwnerUnderTheAccountsServiceManagerAndReadsItBack(t *testing.T) {
bin := copyBed(t, `cat "$LOG.text"`)
r, err := Copy("secret-free text")
if err != nil || r.Bytes != 16 || !strings.HasPrefix(r.Unit, "clipmenu-copy-") {
if err != nil || r.Bytes != 16 || !r.Verified || !strings.HasPrefix(r.Unit, "clipmenu-copy-") {
t.Fatalf("%+v, %v", r, err)
}
asked, _ := os.ReadFile(filepath.Join(bin, "log"))
@@ -134,7 +181,23 @@ func TestCopyHandsTheTextToAnOwnerUnderTheAccountsServiceManager(t *testing.T) {
}
}
func TestPasteReadsTheClipboardOrSaysThereIsNoSession(t *testing.T) {
func TestCopyFailsWhenTheClipboardHoldsSomethingElse(t *testing.T) {
copyBed(t, `printf 'old contents'`)
_, err := Copy("new text")
if err == nil || !strings.Contains(err.Error(), "does not hold") || strings.Contains(err.Error(), "old contents") {
t.Fatalf("a copy that did not land was reported: %v", err)
}
}
func TestCopyFailsSayingWhyWhenTheOwnerCannotReachTheDisplay(t *testing.T) {
copyBed(t, `echo "xsel: Can't open display: (null)" >&2; exit 1`)
_, err := Copy("new text")
if err == nil || !strings.Contains(err.Error(), "Can't open display") {
t.Fatalf("the reason was not given: %v", err)
}
}
func TestPasteSaysHowMuchTheClipboardHoldsNeverWhat(t *testing.T) {
fakeMachine(t)
if _, err := Paste(); !errors.Is(err, ErrNoSession) {
t.Fatal(err)
@@ -142,8 +205,9 @@ func TestPasteReadsTheClipboardOrSaysThereIsNoSession(t *testing.T) {
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)
raw, _ := json.Marshal(p)
if err != nil || p.Bytes != 16 || strings.Contains(string(raw), "on the clipboard") {
t.Fatalf("%s, %v", raw, err)
}
}
@@ -157,7 +221,26 @@ func TestWithoutAStoreTheChangesSayWhy(t *testing.T) {
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 {
if h, err := History(5); err != nil || h.Total != 0 || h.Collecting {
t.Fatalf("an empty history: %+v, %v", h, err)
}
}
// clipmenud is a bash script: its process is named "bash" and only its command line names clipmenud
// (issue 860: collecting was false while the daemon ran).
func TestCollectingSeesClipmenudRunAsABashScript(t *testing.T) {
store(t, map[string]string{"first": "first"}, "first")
fakeProcess(t, nobody, "bash")
cmdline := filepath.Join(procRoot, strconv.Itoa(nobody), "cmdline")
if err := os.WriteFile(cmdline, []byte("bash\x00/usr/bin/clipmenud\x00"), 0o644); err != nil {
t.Fatal(err)
}
if h, err := History(10); err != nil || !h.Collecting {
t.Fatalf("clipmenud runs as a bash script: %+v %v", h, err)
}
// another bash script is not the daemon
os.WriteFile(cmdline, []byte("bash\x00/usr/bin/other\x00"), 0o644)
if h, _ := History(10); h.Collecting {
t.Fatalf("a bash script that is not clipmenud counted: %+v", h)
}
}
+16 -18
View File
@@ -22,28 +22,23 @@ 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.",
Description: "What the operator copied, newest first: how many entries, when each was copied and how long " +
"it is, never what it says. Whether the clipboard daemon is collecting. The text is read on the operator's own screen, through clipmenu.",
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)"},
"limit": map[string]any{"type": "integer", "description": "at most this many entries (default 20, at most 500)"},
},
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)
return History(limit)
},
},
{
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.",
"like any copy. Reads the clipboard back and answers an error with the reason when it does not hold the text.",
Input: map[string]any{
"type": "object",
"properties": map[string]any{
@@ -61,7 +56,7 @@ func tools() []stdio.Tool {
},
{
Name: "clipmenu_paste",
Description: "What the operator's clipboard holds right now, as text (cut at 64 KiB, said in truncated).",
Description: "How many bytes of text the operator's clipboard holds right now, never the text itself.",
Run: func(map[string]any) (any, error) { return Paste() },
},
{
@@ -71,19 +66,22 @@ func tools() []stdio.Tool {
},
{
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.",
Description: "Forget one entry of the clipboard history, by its place in node-clipboard.history's list " +
"(1 is the newest) and its time as the history gives it; refused when the two no longer match.",
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"},
"place": map[string]any{"type": "integer", "description": "the entry's place in the history, 1 for the newest"},
"at": map[string]any{"type": "string", "description": "the entry's time, as the history reports it"},
},
Run: func(args map[string]any) (any, error) {
id, err := text(args, "id", false)
place, err := whole(args, "place", 0, 0, 100000)
if err != nil {
return nil, err
}
line, _ := args["line"].(string)
return Delete(id, line)
at, err := text(args, "at", true)
if err != nil {
return nil, err
}
return Delete(place, at)
},
},
}
@@ -14,6 +14,8 @@ import (
type manifest struct {
Module string `json:"module"`
Version string `json:"version"`
Moved bool `json:"moved"`
Items []item `json:"items"`
Capabilities []string `json:"capabilities"`
Requires []string `json:"requires"`
Claims []claim `json:"claims"`
@@ -23,7 +25,7 @@ type manifest struct {
Shell []shellCode `json:"shell"`
Resources []map[string]any `json:"resources"`
// Lines for other modules' seats (novox/hq ADR 0212): the window manager's, here.
Contributions []contribution `json:"contributions"`
Contributions []any `json:"contributions"`
Build struct {
Artifacts []map[string]any `json:"artifacts"`
} `json:"build"`
@@ -46,6 +48,53 @@ type shellCode struct {
Code string `json:"code"`
}
// An item of a collection (novox/hq ADR 0349 clause 13): its text is the template file beside the manifest.
type item struct {
Collection string `json:"collection"`
Name string `json:"name"`
Attributes map[string]string `json:"attributes"`
Template string `json:"template"`
}
// shell is the code the module adds to a shell's or the session's start, read from the templates of its items of
// the collections shell:<file>:<slot>, in the order they are declared.
func (m manifest) shell(t *testing.T) []shellCode {
t.Helper()
var out []shellCode
for _, it := range m.Items {
parts := strings.Split(it.Collection, ":")
if len(parts) != 3 || parts[0] != "shell" {
continue
}
text, err := os.ReadFile(filepath.Join("..", "..", it.Template))
if err != nil {
t.Fatal(err)
}
out = append(out, shellCode{For: parts[1], Slot: parts[2], Code: string(text)})
}
return out
}
// environmentOf is the variables the module sets, read from the templates of its items of the environment.
func (m manifest) environmentOf(t *testing.T) *environment {
t.Helper()
var env *environment
for _, it := range m.Items {
if it.Collection != "environment" || it.Attributes["kind"] != "variable" {
continue
}
text, err := os.ReadFile(filepath.Join("..", "..", it.Template))
if err != nil {
t.Fatal(err)
}
if env == nil {
env = &environment{Variables: map[string]string{}}
}
env.Variables[it.Attributes["name"]] = strings.TrimSuffix(string(text), "\n")
}
return env
}
func readManifest(t *testing.T) manifest {
t.Helper()
raw, err := os.ReadFile(filepath.Join("..", "..", "module.json"))
@@ -58,6 +107,7 @@ func readManifest(t *testing.T) manifest {
if err := dec.Decode(&m); err != nil {
t.Fatalf("module.json: %v", err)
}
m.Environment = m.environmentOf(t)
return m
}
@@ -85,26 +135,66 @@ func (m manifest) packages() (present, absent []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) {
// fromTemplate checks that a file resource takes its text from its template file in the module's
// repository (novox/hq ADR 0349 §2): it names the template, carries no text of its own beside it, and the
// template is there. The readable file in the repository is the one place the machine's text is written.
func (m manifest) fromTemplate(t *testing.T, id, template 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["template"] != template {
t.Fatalf("resource %s does not take its text from %s: template %v", id, template, r["template"])
}
if _, inline := r["content"]; inline {
t.Fatalf("resource %s keeps its text inline beside its template: a file's text is in one place", id)
}
if _, err := os.Stat(filepath.Join("..", "..", template)); err != nil {
t.Fatalf("resource %s names %s: %v", id, template, err)
}
if r["owner"] != "${machine:account}" && !strings.HasPrefix(r["path"].(string), "/etc/") {
t.Fatalf("%s under the home is the account's", id)
}
}
// everyFileIsATemplate checks that no file resource carries its text inline, and that the build packs
// files/ into the one archive the controller reads the templates back from (novox/hq ADR 0349 §2).
func (m manifest) everyFileIsATemplate(t *testing.T) {
t.Helper()
for _, r := range m.Resources {
if _, inline := r["content"]; inline {
t.Errorf("resource %v keeps its text inline: keep it in files/ and name it as the template", r["id"])
}
}
packs := 0
for _, a := range m.Build.Artifacts {
if a["kind"] == "archive" && a["from"] == "files" {
packs++
if a["name"] != "files" {
t.Errorf("the templates archive is named %v, not files", a["name"])
}
}
}
if packs != 1 {
t.Errorf("the build packs files/ into %d archives, not one", packs)
}
}
// text is the text a file resource places on the machine: its template file's.
func (m manifest) text(t *testing.T, id string) string {
t.Helper()
name, _ := m.resource(t, id)["template"].(string)
if name == "" {
t.Fatalf("resource %s names no template", id)
}
raw, err := os.ReadFile(filepath.Join("..", "..", name))
if err != nil {
t.Fatal(err)
}
return string(raw)
}
// 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) {
@@ -176,31 +266,42 @@ func checkNoSecretsOrInstallationNames(t *testing.T) {
}
}
type contribution struct {
Seat string `json:"seat"`
Kind string `json:"kind"`
Content string `json:"content"`
}
// displaySession is the collection the module's lines for the window manager are an item of.
const displaySession = "contribution:node-display-session:config"
// i3Lines is what the module contributes to the window manager.
// i3Lines is what the module contributes to the window manager: the text of its item of the collection
// contribution:node-display-session:config (design 57 L3, issue 848).
func (m manifest) i3Lines() string {
var out string
for _, c := range m.Contributions {
if c.Seat == "node-display-session" && c.Kind == "config" {
out += c.Content
for _, it := range m.Items {
if it.Collection == displaySession {
text, err := os.ReadFile(filepath.Join("..", "..", it.Template))
if err != nil {
return ""
}
out += string(text)
}
}
return out
}
// i3LinesAreSource checks the window-manager contribution is the source file it is written from.
// i3LinesAreSource checks the window-manager lines are one item whose template is the source file, and that no
// old-form contribution to node-display-session is left.
func (m manifest) i3LinesAreSource(t *testing.T, source string) {
t.Helper()
want, err := os.ReadFile(filepath.Join("..", "..", source))
if err != nil {
t.Fatal(err)
if len(m.Contributions) != 0 {
t.Fatalf("the module still holds contributions: the lines are an item of %s", displaySession)
}
if m.i3Lines() != string(want) {
t.Fatalf("the contribution to node-display-session is not %s: edit the source and copy it into module.json", source)
var templates []string
for _, it := range m.Items {
if it.Collection == displaySession {
templates = append(templates, it.Template)
}
}
if len(templates) != 1 || templates[0] != source {
t.Fatalf("the items of %s are %v, want the one template %s", displaySession, templates, source)
}
if m.i3Lines() == "" {
t.Fatalf("the template %s holds no line", source)
}
}
@@ -1,9 +1,16 @@
package main
import (
"context"
"net"
"os"
"os/exec"
"reflect"
"strings"
"sync"
"syscall"
"testing"
"time"
)
// clipmenu's shape (novox/hq ADR 0208, research 026/04): it claims node-clipboard serving history
@@ -27,20 +34,48 @@ func TestItClaimsTheClipboardSeatServingHistoryAndCopy(t *testing.T) {
}
}
func TestTheDaemonStartsOnceFromTheSessionsStart(t *testing.T) {
func TestTheDaemonIsTheOperatorsUserUnitEnabledAndRunning(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)
if len(m.shell(t)) != 0 {
t.Fatalf("a second start from the session: %+v", m.shell(t))
}
code := m.Shell[0].Code
// Started once, through the module's text-only xsel (a selection holding an image is never read).
if strings.Count(code, "clipmenud &\n") != 1 || !strings.Contains(code, `PATH="/usr/local/lib/mesh-clipmenu:$PATH" clipmenud &`) ||
strings.Contains(code, "greenclip") {
t.Fatalf("%q", code)
svc := m.resource(t, "daemon")
want := map[string]any{"id": "daemon", "type": "service", "unit": "clipmenud.service", "scope": "user",
"user": "${machine:account}", "state": "running", "boot": "enabled", "reload-on": nil}
for k, v := range want {
if k != "reload-on" && svc[k] != v {
t.Fatalf("daemon %s = %v, want %v: %v", k, svc[k], v, svc)
}
}
for _, r := range m.Resources {
if r["type"] == "service" || r["type"] == "process" {
t.Fatalf("a second start: %v", r)
// ADR 0240 rule 8: a long-running resource says how it is ready. The launcher keeps the unit active while
// it waits for the display and execs clipmenud, so the unit's state is the daemon's own liveness.
if h, _ := svc["health"].(map[string]any); h["kind"] != "unit" {
t.Fatalf("daemon health = %v, want kind unit", svc["health"])
}
dropin := m.resource(t, "daemon-dropin")
content := m.text(t, "daemon-dropin")
if dropin["path"] != "${machine:account-home}/.config/systemd/user/clipmenud.service.d/mesh.conf" ||
dropin["owner"] != "${machine:account}" ||
!strings.Contains(content, "ExecStart=/usr/local/lib/mesh-clipmenu/clipmenud-session") ||
strings.Contains(content, "greenclip") {
t.Fatalf("drop-in: %v", dropin)
}
launcher := m.text(t, "launcher")
if !strings.Contains(launcher, "DISPLAY") || !strings.Contains(launcher, "/tmp/.X11-unix") ||
!strings.Contains(launcher, `PATH="/usr/local/lib/mesh-clipmenu:$PATH"`) || !strings.Contains(launcher, "exec clipmenud") {
t.Fatalf("launcher: %q", launcher)
}
// Waits for the display instead of exiting into the start limit; DISPLAY from the manager wins.
if !strings.Contains(launcher, "until [ -n \"$DISPLAY\" ]") || !strings.Contains(launcher, "sleep 1") ||
strings.Contains(launcher, "exit 1") || strings.Contains(launcher, "exit 0") {
t.Fatalf("the launcher must wait, not exit: %q", launcher)
}
if !strings.Contains(content, "StartLimitIntervalSec=0") || !strings.Contains(content, "RestartSec=2s") {
t.Fatalf("drop-in lacks the safety net: %q", content)
}
for k, v := range m.environmentOf(t).Variables {
if !strings.Contains(content, "Environment="+k+"="+v) {
t.Fatalf("the unit lacks %s=%s", k, v)
}
}
}
@@ -64,3 +99,131 @@ func TestTheToolsAgreeWithTheManifest(t *testing.T) {
checkTheToolsAgree(t, m)
checkNoSecretsOrInstallationNames(t)
}
// No file resource carries its text inline (novox/hq ADR 0349 §2).
func TestEveryFileTakesItsTextFromATemplate(t *testing.T) {
readManifest(t).everyFileIsATemplate(t)
}
// Run for real: DISPLAY from the manager is used at once; several sockets and no DISPLAY wait.
func TestTheLauncherUsesTheManagersDisplayAndWaitsOtherwise(t *testing.T) {
m := readManifest(t)
script := m.text(t, "launcher")
dir := t.TempDir()
file := dir + "/launcher"
// clipmenud and the socket directory are faked: the script's own paths are rewritten.
body := strings.ReplaceAll(script, "/tmp/.X11-unix", dir+"/x11")
body = strings.Replace(body, "exec clipmenud", `echo "DISPLAY=$DISPLAY"`, 1)
if err := os.WriteFile(file, []byte(body), 0o755); err != nil {
t.Fatal(err)
}
os.MkdirAll(dir+"/x11", 0o755)
fakeBin := dir + "/bin"
os.MkdirAll(fakeBin, 0o755)
// the manager's environment: empty until the file appears
os.WriteFile(fakeBin+"/systemctl", []byte("#!/bin/sh\n[ -f \""+dir+"/env\" ] && cat \""+dir+"/env\"\nexit 0\n"), 0o755)
run := func(env ...string) (string, bool) {
ctx, cancel := context.WithTimeout(context.Background(), 2500*time.Millisecond)
defer cancel()
cmd := exec.CommandContext(ctx, "sh", file)
cmd.Env = append([]string{"PATH=" + fakeBin + ":" + os.Getenv("PATH"), "HOME=" + dir}, env...)
out, _ := cmd.Output()
return strings.TrimSpace(string(out)), ctx.Err() == nil
}
if out, ok := run("DISPLAY=:7"); !ok || out != "DISPLAY=:7" {
t.Fatalf("with DISPLAY: %q %v", out, ok)
}
if _, ok := run(); ok {
t.Fatal("it exited with no X server")
}
for _, n := range []string{"X0", "X1"} {
l, _ := net.Listen("unix", dir+"/x11/"+n)
defer l.Close()
}
if _, ok := run(); ok {
t.Fatal("it chose one of two servers")
}
// the session hands the manager its display while the launcher waits
go func() {
time.Sleep(1200 * time.Millisecond)
os.WriteFile(dir+"/env", []byte("DISPLAY=:1\nOTHER=x\n"), 0o644)
}()
if out, ok := run(); !ok || out != "DISPLAY=:1" {
t.Fatalf("the display that arrived while waiting: %q %v", out, ok)
}
}
// Run for real: while another clipmenud of the account holds the session lock the launcher waits without
// a word, and starts clipmenud once the lock is free (issue 862).
func TestTheLauncherWaitsQuietlyWhileAnotherClipmenudHoldsTheLock(t *testing.T) {
if _, err := exec.LookPath("flock"); err != nil {
t.Skip("flock is missing here")
}
m := readManifest(t)
script := m.text(t, "launcher")
dir := t.TempDir()
body := strings.Replace(script, "exec clipmenud", `echo "started"`, 1)
file := dir + "/launcher"
if err := os.WriteFile(file, []byte(body), 0o755); err != nil {
t.Fatal(err)
}
store := dir + "/cm/clipmenu.6.op"
os.MkdirAll(store, 0o700)
lock := store + "/session_lock"
// the older clipmenud: it holds the lock the way clipmenud does, until it is killed
holder := exec.Command("flock", "-x", "-n", lock, "sleep", "60")
holder.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
if err := holder.Start(); err != nil {
t.Fatal(err)
}
// flock's child shares the lock, so the whole group goes
release := func() { syscall.Kill(-holder.Process.Pid, syscall.SIGKILL); holder.Wait() }
defer release()
for i := 0; i < 50; i++ {
if probe := exec.Command("flock", "-n", lock, "true"); probe.Run() != nil {
break
}
time.Sleep(50 * time.Millisecond)
}
var out syncBuffer
cmd := exec.Command("sh", file)
cmd.Env = []string{"PATH=" + os.Getenv("PATH"), "HOME=" + dir, "DISPLAY=:7", "USER=op", "CM_DIR=" + dir + "/cm"}
cmd.Stdout, cmd.Stderr = &out, &out
if err := cmd.Start(); err != nil {
t.Fatal(err)
}
done := make(chan error, 1)
go func() { done <- cmd.Wait() }()
select {
case <-done:
t.Fatalf("the launcher went on while the lock was held: %q", out.String())
case <-time.After(2500 * time.Millisecond):
}
if out.Len() != 0 {
t.Fatalf("the launcher spoke while it waited: %q", out.String())
}
release()
select {
case <-done:
case <-time.After(5 * time.Second):
cmd.Process.Kill()
t.Fatal("the launcher did not start clipmenud after the lock was freed")
}
if got := strings.TrimSpace(out.String()); got != "started" {
t.Fatalf("after the lock was freed: %q", got)
}
}
type syncBuffer struct {
mu sync.Mutex
b strings.Builder
}
func (s *syncBuffer) Write(p []byte) (int, error) {
s.mu.Lock()
defer s.mu.Unlock()
return s.b.Write(p)
}
func (s *syncBuffer) Len() int { s.mu.Lock(); defer s.mu.Unlock(); return s.b.Len() }
func (s *syncBuffer) String() string { s.mu.Lock(); defer s.mu.Unlock(); return s.b.String() }
+19 -1
View File
@@ -403,7 +403,7 @@ func processesOf(comm string) []int {
if owner, ok := ownerOf(dir); !ok || owner != uid {
continue
}
if readTrimmed(filepath.Join(dir, "comm")) == comm {
if readTrimmed(filepath.Join(dir, "comm")) == comm || runsScript(dir, comm) {
pids = append(pids, pid)
}
}
@@ -411,6 +411,24 @@ func processesOf(comm string) []int {
return pids
}
// runsScript is whether the process is an interpreter running the script named name: clipmenud is a
// bash script, so its comm is "bash" and only its command line, "bash /usr/bin/clipmenud", names it.
func runsScript(procDir, name string) bool {
raw, err := os.ReadFile(filepath.Join(procDir, "cmdline"))
if err != nil {
return false
}
args := strings.Split(strings.TrimRight(string(raw), "\x00"), "\x00")
if len(args) < 2 {
return false
}
switch filepath.Base(args[0]) {
case "bash", "sh", "dash", "zsh", "env":
return filepath.Base(args[1]) == name
}
return false
}
// 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
@@ -0,0 +1,41 @@
#!/bin/sh
# clipmenud as the operator's user unit starts it, written by the mesh (module clipmenu). Replaced at every push.
#
# The account's service manager starts before the X server does, and has a DISPLAY only once the session's
# xinitrc has handed it over (module xorg). So this waits, quietly, until the display is known, and exits
# only when the session ends with it: a quick exit would run the unit into its start limit. DISPLAY, when
# the manager has it, wins. Without it, exactly one X socket names the display; none or several (a
# second server beside the session) wait for the session's own to arrive.
# A running process does not see what the session hands the manager later, so each pass asks the manager.
until [ -n "$DISPLAY" ]; do
for pair in $(systemctl --user show-environment 2>/dev/null | grep -E '^(DISPLAY|XAUTHORITY)='); do
export "$pair"
done
[ -n "$DISPLAY" ] && break
set -- /tmp/.X11-unix/X*
if [ $# -eq 1 ] && [ -S "$1" ]; then
DISPLAY=":${1##*/X}"
export DISPLAY
else
sleep 1
fi
done
if [ -z "$XAUTHORITY" ] && [ -r "$HOME/.Xauthority" ]; then
XAUTHORITY="$HOME/.Xauthority"
export XAUTHORITY
fi
# clipmenud allows one daemon per account: it takes an exclusive flock on its session_lock file and exits
# with status 2 when that is held. After a delivery without a new login, the session's older clipmenud (from
# its xinitrc) still holds it, and a start would run the unit round its restart loop. So this waits, quietly,
# until the lock is free: it looks at the same file clipmenud locks (the directory is clipmenud's own rule)
# and lets go at once. Exit status 1 is "held" for util-linux and BusyBox flock alike (no -E: BusyBox lacks it); any other outcome starts clipmenud, which says its own
# error. Another daemon winning the gap between look and start costs one restart, not a loop.
lock="${CM_DIR:-${XDG_RUNTIME_DIR-${TMPDIR-/tmp}}}/clipmenu.6.$USER/session_lock"
while [ -e "$lock" ]; do
flock -n "$lock" true
[ $? -eq 1 ] || break
sleep 2
done
PATH="/usr/local/lib/mesh-clipmenu:$PATH"
export PATH
exec clipmenud
+20
View File
@@ -0,0 +1,20 @@
#!/bin/sh
# xsel as clipmenud sees it, written by the mesh (module clipmenu). Replaced at every push.
#
# clipmenud records text, and reads a selection with `timeout 1 xsel -o`. A selection holding an
# image (a screenshot copied as image/png) is sent in pieces; one second is too short for megabytes,
# timeout kills xsel half way, and the program owning the image waits for ever for a reader that is
# gone. From then on nothing can ask the clipboard anything: pastes hang, and an Electron app that
# asks on its main thread freezes. So a read goes ahead only when the selection offers text.
case " $* " in
*" -o "* | *" --output "*)
selection=clipboard
case " $* " in
*" --primary "* | *" -p "*) selection=primary ;;
*" --secondary "* | *" -s "*) selection=secondary ;;
esac
targets=$(timeout 1 xclip -selection "$selection" -t TARGETS -o 2>/dev/null) || exit 1
printf '%s\n' "$targets" | grep -qxE 'UTF8_STRING|STRING|TEXT|text/plain(;charset=utf-8)?' || exit 1
;;
esac
exec /usr/bin/xsel "$@"
+1
View File
@@ -0,0 +1 @@
15
+1
View File
@@ -0,0 +1 @@
500
+1
View File
@@ -0,0 +1 @@
clipboard
@@ -0,0 +1,14 @@
# clipmenud in the operator's account (module clipmenu, novox/hq ADR 0208). Owned by the mesh: replaced at every push.
# The package's unit starts clipmenud with no DISPLAY; the module's launcher finds the display and puts the
# text-only xsel first on the PATH. The settings repeat the module's environment, which the account's
# service manager does not read.
[Unit]
StartLimitIntervalSec=0
[Service]
RestartSec=2s
Environment=CM_SELECTIONS=clipboard
Environment=CM_MAX_CLIPS=500
Environment=CM_HISTLENGTH=15
ExecStart=
ExecStart=/usr/local/lib/mesh-clipmenu/clipmenud-session
+80 -20
View File
@@ -1,6 +1,7 @@
{
"module": "clipmenu",
"version": "1",
"moved": true,
"capabilities": [
"package-manager"
],
@@ -22,18 +23,38 @@
"clipmenu_clear",
"clipmenu_delete"
],
"environment": {
"variables": {
"CM_SELECTIONS": "clipboard",
"CM_MAX_CLIPS": "500",
"CM_HISTLENGTH": "15"
}
},
"shell": [
"items": [
{
"for": "xinitrc",
"slot": "normal",
"code": "# The clipboard's history (module clipmenu, novox/hq ADR 0208): clipmenud collects every copy from\n# here on, once per session. It keeps the history in the account's runtime directory, so a reboot\n# forgets it, and with it every password that was ever copied.\n# Through the module's xsel, which reads only a selection offering text (see the README).\nPATH=\"/usr/local/lib/mesh-clipmenu:$PATH\" clipmenud &\n"
"collection": "environment",
"name": "cm-selections",
"attributes": {
"kind": "variable",
"name": "CM_SELECTIONS"
},
"template": "files/env-cm-selections"
},
{
"collection": "environment",
"name": "cm-max-clips",
"attributes": {
"kind": "variable",
"name": "CM_MAX_CLIPS"
},
"template": "files/env-cm-max-clips"
},
{
"collection": "environment",
"name": "cm-histlength",
"attributes": {
"kind": "variable",
"name": "CM_HISTLENGTH"
},
"template": "files/env-cm-histlength"
},
{
"collection": "contribution:node-display-session:config",
"name": "display-session",
"template": "files/i3/50-clipmenu.conf"
}
],
"resources": [
@@ -53,7 +74,48 @@
"type": "file",
"path": "/usr/local/lib/mesh-clipmenu/xsel",
"mode": "0755",
"content": "#!/bin/sh\n# xsel as clipmenud sees it, written by the mesh (module clipmenu). Replaced at every push.\n#\n# clipmenud records text, and reads a selection with `timeout 1 xsel -o`. A selection holding an\n# image (a screenshot copied as image/png) is sent in pieces; one second is too short for megabytes,\n# timeout kills xsel half way, and the program owning the image waits for ever for a reader that is\n# gone. From then on nothing can ask the clipboard anything: pastes hang, and an Electron app that\n# asks on its main thread freezes. So a read goes ahead only when the selection offers text.\ncase \" $* \" in\n*\" -o \"* | *\" --output \"*)\n\tselection=clipboard\n\tcase \" $* \" in\n\t*\" --primary \"* | *\" -p \"*) selection=primary ;;\n\t*\" --secondary \"* | *\" -s \"*) selection=secondary ;;\n\tesac\n\ttargets=$(timeout 1 xclip -selection \"$selection\" -t TARGETS -o 2>/dev/null) || exit 1\n\tprintf '%s\\n' \"$targets\" | grep -qxE 'UTF8_STRING|STRING|TEXT|text/plain(;charset=utf-8)?' || exit 1\n\t;;\nesac\nexec /usr/bin/xsel \"$@\"\n"
"template": "files/bin/xsel"
},
{
"id": "user-units-dir",
"type": "directory",
"path": "${machine:account-home}/.config/systemd/user",
"owner": "${machine:account}",
"mode": "0755"
},
{
"id": "daemon-dropins",
"type": "directory",
"path": "${machine:account-home}/.config/systemd/user/clipmenud.service.d",
"owner": "${machine:account}",
"mode": "0755"
},
{
"id": "daemon-dropin",
"type": "file",
"path": "${machine:account-home}/.config/systemd/user/clipmenud.service.d/mesh.conf",
"owner": "${machine:account}",
"mode": "0644",
"template": "files/systemd/clipmenud-mesh.conf"
},
{
"id": "launcher",
"type": "file",
"path": "/usr/local/lib/mesh-clipmenu/clipmenud-session",
"mode": "0755",
"template": "files/bin/clipmenud-session"
},
{
"id": "daemon",
"type": "service",
"unit": "clipmenud.service",
"scope": "user",
"user": "${machine:account}",
"state": "running",
"boot": "enabled",
"health": {
"kind": "unit"
}
}
],
"build": {
@@ -68,14 +130,12 @@
"loads": [
"clipmenu-tools"
]
},
{
"name": "files",
"kind": "archive",
"from": "files"
}
]
},
"contributions": [
{
"seat": "node-display-session",
"kind": "config",
"content": "# The clipboard's history key (module clipmenu, novox/hq ADR 0208). Owned by the mesh: replaced at\n# every push. clipmenu shows the history through `dmenu`, the node's dmenu-compatible command, which\n# the holder of node-launcher answers (rofi on the workstations); the chosen entry is put back on the\n# clipboard.\nbindsym $mod+period exec --no-startup-id clipmenu -p Clipboard\n"
}
]
}
}
+1
View File
@@ -0,0 +1 @@
{}
+31 -2
View File
@@ -5,6 +5,20 @@
"own-secrets": {
"token": "${dir:state}/token"
},
"config": {
"atlassian-url": {
"type": "string",
"description": "The address of the operator's Atlassian site the confluence tools call, as a URL such as https://example.atlassian.net, laid into the file as ATLASSIAN_URL; unset, the code falls back to its environment and answers no calls until it is given.",
"trusted": true,
"default": ""
},
"atlassian-email": {
"type": "string",
"description": "The email address of the Atlassian account the confluence tools sign in as with its API token, laid into the file as ATLASSIAN_EMAIL; unset, the code falls back to its environment and answers no calls until it is given.",
"trusted": true,
"default": ""
}
},
"resources": [
{
"id": "state",
@@ -17,8 +31,18 @@
"type": "file",
"path": "${dir:state}/config.json",
"merge": "json",
"content": "{}",
"mode": "0600"
"template": "files/config.json",
"mode": "0600",
"takes-config": [
{
"key": "atlassian-url",
"as": "ATLASSIAN_URL"
},
{
"key": "atlassian-email",
"as": "ATLASSIAN_EMAIL"
}
]
}
],
"capabilities": [
@@ -40,6 +64,11 @@
"MESH_CONFLUENCE_TOKEN_FILE": "${dir:state}/token",
"MESH_CONFLUENCE_CONFIG_FILE": "${dir:state}/config.json"
}
},
{
"name": "files",
"kind": "archive",
"from": "files"
}
]
}
+2
View File
@@ -0,0 +1,2 @@
SMTP_AUTH_USER=${secret:smtp-user}
SMTP_AUTH_PASS=${secret:smtp-pass}
+11 -2
View File
@@ -42,7 +42,7 @@
"type": "file",
"path": "${dir:state}/server.env",
"mode": "0600",
"content": "SMTP_AUTH_USER=${secret:smtp-user}\nSMTP_AUTH_PASS=${secret:smtp-pass}\n"
"template": "files/server.env"
},
{
"id": "net",
@@ -66,5 +66,14 @@
"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)"
}
}
]
],
"build": {
"artifacts": [
{
"name": "files",
"kind": "archive",
"from": "files"
}
]
}
}
@@ -0,0 +1,64 @@
package main
import (
"encoding/json"
"os"
"path/filepath"
"testing"
)
// The module's directory, where its manifest and its files/ are.
const moduleDir = "../.."
// templateText is the text a file resource places on the machine: its template's, in the module's files/
// (novox/hq ADR 0349 §2). A resource that names no template, or a template that is not there, fails the test.
func templateText(t *testing.T, r map[string]any) string {
t.Helper()
name, _ := r["template"].(string)
if name == "" {
t.Fatalf("resource %v names no template", r["id"])
}
raw, err := os.ReadFile(filepath.Join(moduleDir, filepath.FromSlash(name)))
if err != nil {
t.Fatalf("resource %v names %s: %v", r["id"], name, err)
}
return string(raw)
}
// No file resource carries its text inline: each names its template in files/, and the build packs files/
// into the one archive the controller reads the templates back from (novox/hq ADR 0349 §2).
func TestEveryFileTakesItsTextFromATemplate(t *testing.T) {
raw, err := os.ReadFile(filepath.Join(moduleDir, "module.json"))
if err != nil {
t.Fatal(err)
}
var m struct {
Resources []map[string]any `json:"resources"`
Build struct {
Artifacts []map[string]any `json:"artifacts"`
} `json:"build"`
}
if err := json.Unmarshal(raw, &m); err != nil {
t.Fatal(err)
}
for _, r := range m.Resources {
if _, inline := r["content"]; inline {
t.Errorf("resource %v keeps its text inline: keep it in files/ and name it as the template", r["id"])
}
if r["type"] == "file" {
templateText(t, r)
}
}
packs := 0
for _, a := range m.Build.Artifacts {
if a["kind"] == "archive" && a["from"] == "files" {
packs++
if a["name"] != "files" {
t.Errorf("the templates archive is named %v, not files", a["name"])
}
}
}
if packs != 1 {
t.Errorf("the build packs files/ into %d archives, not one", packs)
}
}
+3
View File
@@ -0,0 +1,3 @@
{
"desktop-machines": []
}
+15 -1
View File
@@ -45,6 +45,14 @@
"tools": [
"desk_channel_status"
],
"config": {
"desktop-machines": {
"type": "list",
"description": "The machines, by node name, whose desktop shows the operator's questions and notifications, in the order they are tried; empty shows them on none.",
"trusted": true,
"default": []
}
},
"resources": [
{
"id": "account",
@@ -66,8 +74,9 @@
"path": "${dir:state}/settings.json",
"mode": "0600",
"owner": "desk-channel",
"takes-config": ["desktop-machines"],
"merge": "json",
"content": "{\n \"desktop-machines\": []\n}\n"
"template": "files/settings.json"
}
],
"build": {
@@ -85,6 +94,11 @@
"env": {
"MESH_DESK_SETTINGS": "${dir:state}/settings.json"
}
},
{
"name": "files",
"kind": "archive",
"from": "files"
}
]
}
+5 -2
View File
@@ -3,7 +3,9 @@
How loaded a machine's storage is, read from `/proc` and `/sys` and nothing else: the share of CPU time
spent waiting on I/O, I/O pressure stall, each disk's busy share, throughput, IOPS, wait and queue
depth, the filesystems' space, and the units, containers and processes doing the I/O (novox/hq issue
312). It changes nothing and declares no resources, so it can be assigned to every machine.
312). It also reads a machine's CPU use by systemd unit and by container, from each cgroup's cpu.stat,
so an overload outside every container can be named (novox/hq issue 741). It changes nothing and
declares no resources, so it can be assigned to every machine.
## Why it exists, and why here
@@ -22,7 +24,7 @@ machine's resources a seat, these tools become its verbs.
## Tools
All four only read.
All five only read.
| tool | what | replaces |
|---|---|---|
@@ -30,6 +32,7 @@ All four only read.
| `disk_top` | Over a window. `by=unit` (the default) ranks systemd units and containers by bytes moved, from their cgroups. `by=container` ranks containers by name, and `by=process` ranks processes | `iotop` |
| `disk_filesystems` | Each filesystem that stores data, said once however many places it is mounted: size, used and free GiB, used % as `df` counts it, inodes used % | `df -h`, `df -i` |
| `disk_devices` | Each disk without sampling: model, size, whether it spins, scheduler, queue limit, what it is built on, and since boot the GiB read and written, the busy share and the wait per read and per write | `lsblk`, `cat /proc/diskstats` |
| `cpu_top` | Samples a window (`seconds`, default 3, at most 10). It answers the machine's CPU in use (% of all CPUs and as CPUs busy, from `/proc/stat`), then `by=unit` (the default) ranks systemd units and containers by CPU, from their cgroups' cpu.stat, busiest first (`limit`, default 15), with the busy time no unit accounts for; `by=container` ranks containers by name | `top`, `systemd-cgtop` |
### How each number is read
+15 -6
View File
@@ -68,9 +68,10 @@ func (m *Machine) bottomDevices() map[string]bool {
return out
}
// unitIO is every unit's storage I/O, by its cgroup's path: the innermost .service or .scope, because
// io.stat counts a cgroup's descendants too and an outer unit would count them twice.
func (m *Machine) unitIO(counted map[string]bool) map[string]cgroupIO {
// unitPaths is every systemd unit's cgroup path under /sys/fs/cgroup: the innermost .service or
// .scope, because a cgroup's own stat file counts its descendants too and an outer unit would count
// them twice. Shared by every reader that walks the cgroup tree (unitIO here, unitCPU in cpu.go).
func (m *Machine) unitPaths() []string {
base := m.path("/sys/fs/cgroup")
var units []string
filepath.WalkDir(base, func(p string, d fs.DirEntry, err error) error {
@@ -89,11 +90,19 @@ func (m *Machine) unitIO(counted map[string]bool) map[string]cgroupIO {
outer[dir] = true
}
}
out := map[string]cgroupIO{}
out := make([]string, 0, len(units))
for _, u := range units {
if outer[u] {
continue
if !outer[u] {
out = append(out, u)
}
}
return out
}
// unitIO is every unit's storage I/O, by its cgroup's path.
func (m *Machine) unitIO(counted map[string]bool) map[string]cgroupIO {
out := map[string]cgroupIO{}
for _, u := range m.unitPaths() {
text := m.read("/sys/fs/cgroup/" + u + "/io.stat")
if text == "" {
continue
+176
View File
@@ -0,0 +1,176 @@
package main
import (
"path/filepath"
"sort"
"strconv"
"strings"
"time"
)
// cgroupCPU is a cgroup's processor time, from its cpu.stat.
type cgroupCPU struct {
usageUsec uint64
}
// parseCPUStat reads a cgroup's cpu.stat ("usage_usec 123\nuser_usec …\nsystem_usec …"), readable by
// every account, unlike /proc/<pid>/stat for another account's process.
func parseCPUStat(s string) cgroupCPU {
var c cgroupCPU
for _, line := range strings.Split(s, "\n") {
k, v, ok := strings.Cut(line, " ")
if !ok {
continue
}
if k == "usage_usec" {
c.usageUsec, _ = strconv.ParseUint(strings.TrimSpace(v), 10, 64)
}
}
return c
}
// unitCPU is every unit's processor time, by its cgroup's path. A cgroup whose cpu.stat cannot be
// read is left out, never an error for the whole answer.
func (m *Machine) unitCPU() map[string]cgroupCPU {
out := map[string]cgroupCPU{}
for _, u := range m.unitPaths() {
text := m.read("/sys/fs/cgroup/" + u + "/cpu.stat")
if text == "" {
continue
}
out[u] = parseCPUStat(text)
}
return out
}
// MachineCPU is the machine's CPU in use over the window, from /proc/stat's cpu line. CPUsBusy is
// rounded only for display; raw, unexported, is the same count unrounded, for topCPU's share and
// unaccounted math (review round 1 of novox/hq issue 741: a small busy time that rounds CPUsBusy to
// 0.0 must not zero out every unit's share along with it).
type MachineCPU struct {
CPUs int `json:"cpus"`
BusyPct float64 `json:"busy_pct"` // % of all CPUs together
CPUsBusy float64 `json:"cpus_busy"` // as a count of CPUs, e.g. 7.5 of 12, rounded for display
raw float64
}
// machineCPUOver is the machine's CPU in use between two readings of /proc/stat: everything but idle
// and iowait, counted against the window's total ticks over every CPU.
func machineCPUOver(a, b cpuTimes) MachineCPU {
total := float64(b.total() - a.total())
if total <= 0 {
return MachineCPU{CPUs: b.cpus}
}
idle := sub(b.idle, a.idle) + sub(b.iowait, a.iowait)
busy := total - idle
if busy < 0 {
busy = 0
}
raw := busy / total * float64(b.cpus)
return MachineCPU{CPUs: b.cpus, BusyPct: round1(busy / total * 100), CPUsBusy: round1(raw), raw: raw}
}
// CPUUsage is a unit's or a container's share of CPU between two readings of its cgroup's cpu.stat.
// Cgroup is its cgroup's path, relative to /sys/fs/cgroup, said beside Unit always (review round 1 of
// novox/hq issue 741): two cgroups can share a leaf name (an outer "init.scope" and a user session's
// "user.slice/user@1000.service/init.scope"), and the path is how they are told apart.
type CPUUsage struct {
Unit string `json:"unit,omitempty"`
Cgroup string `json:"cgroup,omitempty"`
Container string `json:"container,omitempty"`
CPUPct float64 `json:"cpu_pct"` // % of one CPU: 100 = one CPU busy the whole window
SharePct float64 `json:"share_pct"` // this unit's share of the machine's busy time
raw float64
}
// topCPU ranks the units, or the containers, with the most CPU time between two readings of
// cpu.stat, busiest first. unaccountedPct is the machine's busy time no unit accounts for, as a
// share of it (never below 0): the sum is always taken over every unit, whichever by ranks by, so a
// limit on the list never changes it.
func (m *Machine) topCPU(a, b map[string]cgroupCPU, window time.Duration, by string, limit int, cpusBusy float64) ([]CPUUsage, float64) {
windowUsec := window.Seconds() * 1e6
type entry struct {
path, unit, container string
pct float64
}
var entries []entry
var names map[string]string
var sumPct float64
for path, end := range b {
start, ok := a[path]
if !ok {
continue
}
d := sub(end.usageUsec, start.usageUsec)
if d == 0 {
continue
}
pct := d / windowUsec * 100
sumPct += pct
unit := filepath.Base(path)
var container string
if id := containerID.FindStringSubmatch("/" + path); id != nil {
if names == nil {
names = m.containerNames()
}
container = id[1][:12]
if n, ok := names[id[1]]; ok {
container = n
}
}
entries = append(entries, entry{path: path, unit: unit, container: container, pct: pct})
}
// Units are keyed by their cgroup's own path, never by its leaf name (filepath.Base): two cgroups can
// share a leaf name (an outer "init.scope" and a user session's nested one), and keying by the name
// alone would merge them into one row (review round 1 of novox/hq issue 741). Containers stay keyed
// by their name, as before.
groups := map[string]*CPUUsage{}
for _, e := range entries {
key := e.path
if by == "container" {
if e.container == "" {
continue
}
key = e.container
}
g, ok := groups[key]
if !ok {
g = &CPUUsage{Unit: e.unit, Cgroup: e.path, Container: e.container}
if by == "container" {
g.Unit, g.Cgroup = "", ""
}
groups[key] = g
}
g.raw += e.pct
}
out := make([]CPUUsage, 0, len(groups))
for _, g := range groups {
out = append(out, *g)
}
sort.Slice(out, func(i, j int) bool {
if out[i].raw != out[j].raw {
return out[i].raw > out[j].raw
}
return out[i].Unit+out[i].Cgroup+out[i].Container < out[j].Unit+out[j].Cgroup+out[j].Container
})
if len(out) > limit {
out = out[:limit]
}
for i := range out {
out[i].CPUPct = round1(out[i].raw)
if cpusBusy > 0 {
out[i].SharePct = round1(out[i].raw / (cpusBusy * 100) * 100)
}
}
unaccounted := cpusBusy*100 - sumPct
if unaccounted < 0 {
unaccounted = 0
}
unaccountedPct := 0.0
if cpusBusy > 0 {
unaccountedPct = round1(unaccounted / (cpusBusy * 100) * 100)
}
return out, unaccountedPct
}
+182
View File
@@ -0,0 +1,182 @@
package main
import (
"strings"
"testing"
"time"
)
// aCPUHog is a machine whose CPU is 75 % busy (1.5 of 2 CPUs) over a 2 s window: a unit, another unit,
// and a container, each with a known usage_usec delta, leaving part of the machine's busy time
// unaccounted — the shape of issue 739, where 750 % of 12 CPUs went to processes outside containers.
func aCPUHog(t *testing.T) *fake {
f := newFake(t)
f.file("/proc/stat", "cpu 0 0 0 0 0 0 0 0 0 0\ncpu0 0 0 0 0 0 0 0 0 0 0\ncpu1 0 0 0 0 0 0 0 0 0 0\nintr 1\n")
f.then("/proc/stat", "cpu 100 0 50 50 0 0 0 0 0 0\ncpu0 50 0 25 25 0 0 0 0 0 0\ncpu1 50 0 25 25 0 0 0 0 0 0\nintr 2\n")
zero := "usage_usec 0\nuser_usec 0\nsystem_usec 0\n"
cg := func(path string, usec int) {
f.file("/sys/fs/cgroup/"+path+"/cpu.stat", zero)
f.then("/sys/fs/cgroup/"+path+"/cpu.stat", "usage_usec "+itoa(usec)+"\nuser_usec "+itoa(usec)+"\nsystem_usec 0\n")
}
cg("system.slice/alpha.service", 1000000) // 50 % of one CPU over 2 s
cg("system.slice/beta.service", 400000) // 20 %
cg("system.slice/docker-"+store+".scope", 600000) // 30 %, named "cpu-hog" by the runtime
f.answers["docker ps --no-trunc --format {{.ID}}\t{{.Names}}"] = store + "\tcpu-hog\n"
return f
}
func TestCPUTopRanksUnitsAndContainersByCPUOverTheWindow(t *testing.T) {
f := aCPUHog(t)
out, err := tool(t, f.machine(), "cpu_top").Run(map[string]any{"seconds": 2.0})
if err != nil {
t.Fatal(err)
}
m := out.(map[string]any)
if m["by"] != "unit" || f.waited != 2*time.Second {
t.Errorf("the default: %+v, waited %s", m, f.waited)
}
cpu := m["cpu"].(MachineCPU)
if cpu.CPUs != 2 || cpu.BusyPct != 75 || cpu.CPUsBusy != 1.5 {
t.Errorf("machine cpu %+v", cpu)
}
top := m["top"].([]CPUUsage)
if len(top) != 3 {
t.Fatalf("three units moved: %+v", top)
}
if top[0].Unit != "alpha.service" || top[0].CPUPct != 50 || top[0].SharePct != 33.3 {
t.Errorf("busiest first: %+v", top[0])
}
if top[1].Container != "cpu-hog" || top[1].CPUPct != 30 || top[1].SharePct != 20 {
t.Errorf("the container, named by the runtime: %+v", top[1])
}
if top[2].Unit != "beta.service" || top[2].CPUPct != 20 || top[2].SharePct != 13.3 {
t.Errorf("the least busy: %+v", top[2])
}
if m["unaccounted_pct"] != 33.3 {
t.Errorf("unaccounted: %+v (machine busy 150%% of one CPU, units sum to 100%%, 50%% of 150%% left over)", m["unaccounted_pct"])
}
}
func TestCPUTopByContainerNamesOnlyContainers(t *testing.T) {
out, err := tool(t, aCPUHog(t).machine(), "cpu_top").Run(map[string]any{"by": "container", "seconds": 2.0})
if err != nil {
t.Fatal(err)
}
top := out.(map[string]any)["top"].([]CPUUsage)
if len(top) != 1 || top[0].Container != "cpu-hog" || top[0].Unit != "" || top[0].CPUPct != 30 {
t.Errorf("by container: %+v", top)
}
if _, err := tool(t, aCPUHog(t).machine(), "cpu_top").Run(map[string]any{"by": "process"}); err == nil {
t.Error("by=process was accepted; cpu_top only ranks units and containers")
}
}
func TestCPUTopLimitsHowManyAreNamed(t *testing.T) {
out, err := tool(t, aCPUHog(t).machine(), "cpu_top").Run(map[string]any{"limit": 1.0, "seconds": 2.0})
if err != nil {
t.Fatal(err)
}
m := out.(map[string]any)
top := m["top"].([]CPUUsage)
if len(top) != 1 || top[0].Unit != "alpha.service" {
t.Errorf("limit 1: %+v", top)
}
// The limit narrows the list, never the unaccounted share, which is taken over every unit.
if m["unaccounted_pct"] != 33.3 {
t.Errorf("unaccounted with limit 1: %+v", m["unaccounted_pct"])
}
}
func TestCPUTopLeavesOutACgroupThatAppearsOrVanishesWithinTheWindow(t *testing.T) {
f := aCPUHog(t)
f.then("/sys/fs/cgroup/system.slice/short-lived.service/cpu.stat", "usage_usec 500000\n")
out, err := tool(t, f.machine(), "cpu_top").Run(map[string]any{"seconds": 2.0})
if err != nil {
t.Fatal(err)
}
top := out.(map[string]any)["top"].([]CPUUsage)
for _, u := range top {
if u.Unit == "short-lived.service" {
t.Errorf("a unit only in the second reading was counted: %+v", top)
}
}
if !strings.Contains(strings.Join(f.calls, ","), "docker ps") {
t.Errorf("the container runtime was never asked: %v", f.calls)
}
}
// aTinyLoad is a machine with 12 CPUs, only 0.04 of them busy over a 2 s window (busy 1 of 300 total
// ticks): rounded to one decimal for display, CPUsBusy reads 0.0, but a unit using half of that busy
// time still has a non-zero share — review round 1's decision that share_pct and unaccounted_pct are
// computed from the unrounded busy time, never from the rounded CPUsBusy.
func aTinyLoad(t *testing.T) *fake {
f := newFake(t)
cpus := "cpu0 0 0 0 0 0 0 0 0 0 0\ncpu1 0 0 0 0 0 0 0 0 0 0\ncpu2 0 0 0 0 0 0 0 0 0 0\ncpu3 0 0 0 0 0 0 0 0 0 0\n" +
"cpu4 0 0 0 0 0 0 0 0 0 0\ncpu5 0 0 0 0 0 0 0 0 0 0\ncpu6 0 0 0 0 0 0 0 0 0 0\ncpu7 0 0 0 0 0 0 0 0 0 0\n" +
"cpu8 0 0 0 0 0 0 0 0 0 0\ncpu9 0 0 0 0 0 0 0 0 0 0\ncpu10 0 0 0 0 0 0 0 0 0 0\ncpu11 0 0 0 0 0 0 0 0 0 0\n"
f.file("/proc/stat", "cpu 0 0 0 0 0 0 0 0 0 0\n"+cpus)
f.then("/proc/stat", "cpu 1 0 0 299 0 0 0 0 0 0\n"+cpus)
f.file("/sys/fs/cgroup/system.slice/tiny.service/cpu.stat", "usage_usec 0\n")
f.then("/sys/fs/cgroup/system.slice/tiny.service/cpu.stat", "usage_usec 60000\n") // 3 % of one CPU over 2 s
return f
}
func TestCPUTopSharesAreNonZeroEvenWhenRoundedCPUsBusyIsZero(t *testing.T) {
out, err := tool(t, aTinyLoad(t).machine(), "cpu_top").Run(map[string]any{"seconds": 2.0})
if err != nil {
t.Fatal(err)
}
m := out.(map[string]any)
cpu := m["cpu"].(MachineCPU)
if cpu.CPUs != 12 || cpu.CPUsBusy != 0 {
t.Fatalf("the machine 0.04 CPUs busy, rounded for display: %+v", cpu)
}
top := m["top"].([]CPUUsage)
if len(top) != 1 || top[0].Unit != "tiny.service" || top[0].CPUPct != 3 || top[0].SharePct != 75 {
t.Fatalf("a non-zero share from the unrounded busy time: %+v", top)
}
if m["unaccounted_pct"] != 25.0 {
t.Fatalf("unaccounted from the unrounded busy time: %+v (want 25, so shares plus unaccounted add to 100)",
m["unaccounted_pct"])
}
}
// TestCPUTopKeepsCgroupsWithTheSameUnitNameAsSeparateRows is review round 1's other decision: units are keyed
// by their cgroup path, never by filepath.Base, so two cgroups that both end in "init.scope" — one at the
// root, one under a user's session — are two rows, not one merged by their shared leaf name.
func TestCPUTopKeepsCgroupsWithTheSameUnitNameAsSeparateRows(t *testing.T) {
f := aCPUHog(t)
cg := func(path string, usec int) {
f.file("/sys/fs/cgroup/"+path+"/cpu.stat", "usage_usec 0\n")
f.then("/sys/fs/cgroup/"+path+"/cpu.stat", "usage_usec "+itoa(usec)+"\n")
}
cg("init.scope", 1000000) // the root init, 50 % of one CPU over 2 s
cg("user.slice/user@1000.service/init.scope", 400000) // a user session's init, 20 %
out, err := tool(t, f.machine(), "cpu_top").Run(map[string]any{"seconds": 2.0, "limit": 10.0})
if err != nil {
t.Fatal(err)
}
top := out.(map[string]any)["top"].([]CPUUsage)
var inits []CPUUsage
for _, u := range top {
if u.Unit == "init.scope" {
inits = append(inits, u)
}
}
if len(inits) != 2 {
t.Fatalf("two cgroups named init.scope, merged into %d row(s): %+v", len(inits), top)
}
byCgroup := map[string]CPUUsage{}
for _, u := range inits {
byCgroup[u.Cgroup] = u
}
if u, ok := byCgroup["init.scope"]; !ok || u.CPUPct != 50 {
t.Errorf("the root init.scope: %+v", byCgroup)
}
if u, ok := byCgroup["user.slice/user@1000.service/init.scope"]; !ok || u.CPUPct != 20 {
t.Errorf("the user session's init.scope: %+v", byCgroup)
}
}

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