Compare commits

Author SHA1 Message Date
jschoubben ed10d96048 The derived-value record is ADR 0202 (was 0201)
mesh/merge-gate fail: builds minio, sent nowhere; no bus step; 1 wait(s) for a person; a manifest the change touches fails the module check: minio grants s…
mesh/repo-check fail: mesh/repo-check is required here and this head defines no merge-check.sh — a person adds one, or lifts the requirement from the branc…
mesh/delivery stopped: the pull request closed unmerged
2026-10-04 04:33:58 +02:00
1505 changed files with 5639 additions and 274329 deletions
-19
View File
@@ -1,21 +1,2 @@
node_modules/
dist/
# Go tool bundles built in place (go build in a module's cmd/<name>-tools) are build output.
modules/slack/cmd/slack-tools/slack-tools
modules/jetbrains-toolbox/cmd/toolbox-tools/toolbox-tools
modules/messenger/cmd/messenger/messenger
modules/mesh-watcher/cmd/mesh-watcher/mesh-watcher
modules/claude-code/cmd/claude-code/claude-code
modules/dnsmasq/cmd/dnsmasq-tools/dnsmasq-tools
modules/mesh-delivery/cmd/mesh-delivery/mesh-delivery
# ...and the same built from the module's root (go build ./cmd/<name>), which names it after the module.
modules/messenger/messenger
modules/claude-code/claude-code
modules/mesh-watcher/mesh-watcher
modules/dnsmasq/dnsmasq
modules/mesh-delivery/mesh-delivery
modules/mesh-issues/cmd/mesh-issues/mesh-issues
modules/mounts/cmd/mounts/mounts
modules/mesh-knowledge/cmd/mesh-knowledge/mesh-knowledge
modules/mesh-knowledge/mesh-knowledge
-3
View File
@@ -1,3 +0,0 @@
module git.novox.be/novox/mesh-catalog/checks/agenthome
go 1.22
-128
View File
@@ -1,128 +0,0 @@
// Command agenthome refuses, in every module's manifest, a resource placed under the agent account's home
// that the node-engine cannot write without a gap (novox/hq ADR 0266).
//
// The agent account owns its home and everything in it, so it can put a symbolic or hard link anywhere
// there. The node-engine, as root, writes a whole file or a directory below a home through descriptors it
// opened itself and refuses a link it finds; it unpacks an archive, and writes into an existing file (`into`:
// json or block), only after checking the path, so a link swapped in between the check and the write would
// point root's write at any file. No module does either today. This check keeps it so: a resource of type
// `archive`, or a `file` with `into`, whose path is under ${machine:agent-home} — written so, reached through
// one of the module's own directories (${dir:<id>}), or written as a literal /home path — is refused. The
// node-engine refuses the same below the home of any account declared never to become root, whatever the path.
//
// go run . <catalogue root>
//
// Exits 1 on a finding.
package main
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"regexp"
"sort"
"strings"
)
// AgentHome is the machine fact naming the agent account's home.
const AgentHome = "${machine:agent-home}"
// ofDir is where a manifest names one of its own directories: ${dir:<id>}.
var ofDir = regexp.MustCompile(`\$\{dir:([a-z0-9][a-z0-9_-]*)\}`)
// resolve replaces each ${dir:<id>} with that resource's own path, as far as the manifest says it, so a path
// reaching the agent's home through one of the module's directories is seen for what it is.
func resolve(p string, dirs map[string]string) string {
for i := 0; i < 8 && ofDir.MatchString(p); i++ {
p = ofDir.ReplaceAllStringFunc(p, func(word string) string {
if to, ok := dirs[ofDir.FindStringSubmatch(word)[1]]; ok {
return to
}
return word
})
}
return p
}
// underAHome says whether a resolved path lies under the agent's home: by its machine fact, or written as a
// literal home, which no definition may carry anyway (ADR 0112) and which may well be the agent's.
func underAHome(p string) bool {
// Cleaned first, so //home/x or /./home/x is seen as the /home/x it is.
if p != "" {
p = filepath.Clean(p)
}
return strings.HasPrefix(p, AgentHome) || strings.HasPrefix(p, "/home/") || p == "/home"
}
// Finding is one resource refused.
type Finding struct {
Module, ID, Why string
}
// Check reads every manifest under root/modules and answers what it refuses.
func Check(root string) ([]Finding, error) {
manifests, err := filepath.Glob(filepath.Join(root, "modules", "*", "module.json"))
if err != nil {
return nil, err
}
sort.Strings(manifests)
var out []Finding
for _, path := range manifests {
raw, err := os.ReadFile(path)
if err != nil {
return nil, err
}
var m struct {
Module string `json:"module"`
Resources []map[string]any `json:"resources"`
}
if err := json.Unmarshal(raw, &m); err != nil {
return nil, fmt.Errorf("%s: %w", path, err)
}
dirs := map[string]string{}
for _, r := range m.Resources {
id, _ := r["id"].(string)
if p, _ := r["path"].(string); id != "" && p != "" {
dirs[id] = p
}
}
for _, r := range m.Resources {
p, _ := r["path"].(string)
if !underAHome(resolve(p, dirs)) {
continue
}
id, _ := r["id"].(string)
kind, _ := r["type"].(string)
into, _ := r["into"].(string)
switch {
case kind == "archive":
out = append(out, Finding{m.Module, id, "an archive unpacked under the agent's home"})
case kind == "file" && into != "":
out = append(out, Finding{m.Module, id, "a file written into (" + into + ") under the agent's home"})
}
}
}
return out, nil
}
func main() {
root := "."
if len(os.Args) > 1 {
root = os.Args[1]
}
found, err := Check(root)
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
for _, f := range found {
fmt.Printf("%s: %s is %s: the node-engine writes it as root after checking the path, and the agent "+
"account could swap a link in between (novox/hq ADR 0266); place a whole file or a directory instead\n",
f.Module, f.ID, f.Why)
}
if len(found) > 0 {
os.Exit(1)
}
fmt.Println("agent-home: no archive and no file written into under the agent's home")
}
-51
View File
@@ -1,51 +0,0 @@
package main
import (
"os"
"path/filepath"
"testing"
)
func manifest(t *testing.T, root, module, resources string) {
t.Helper()
dir := filepath.Join(root, "modules", module)
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatal(err)
}
body := `{"module":"` + module + `","resources":[` + resources + `]}`
if err := os.WriteFile(filepath.Join(dir, "module.json"), []byte(body), 0o644); err != nil {
t.Fatal(err)
}
}
func TestAnArchiveOrAFileWrittenIntoUnderTheAgentsHomeIsRefused(t *testing.T) {
root := t.TempDir()
manifest(t, root, "a", `{"id":"theme","type":"archive","path":"${machine:agent-home}/.local/share/x"}`)
manifest(t, root, "b", `{"id":"cfg","type":"file","path":"${machine:agent-home}/.claude.json","into":"json","content":"{}"}`)
manifest(t, root, "c", `{"id":"dir","type":"directory","path":"${machine:agent-home}/.claude"},`+
`{"id":"whole","type":"file","path":"${machine:agent-home}/x","content":"y"},`+
`{"id":"op","type":"archive","path":"${machine:account-home}/.local/share/x"}`)
manifest(t, root, "d", `{"id":"home","type":"directory","path":"${machine:agent-home}/.claude"},`+
`{"id":"inner","type":"directory","path":"${dir:home}/inner"},`+
`{"id":"via-dir","type":"archive","path":"${dir:inner}/theme"}`)
manifest(t, root, "e", `{"id":"literal","type":"file","path":"/home/agent/.bashrc","into":"block","content":"x"}`)
manifest(t, root, "f", `{"id":"unclean","type":"archive","path":"//./home/agent/x"}`)
found, err := Check(root)
if err != nil {
t.Fatal(err)
}
if len(found) != 5 || found[0].Module != "a" || found[1].Module != "b" || found[2].ID != "via-dir" ||
found[3].ID != "literal" || found[4].ID != "unclean" {
t.Fatalf("refused %+v", found)
}
}
func TestTheCatalogueHasNone(t *testing.T) {
found, err := Check("../..")
if err != nil {
t.Fatal(err)
}
if len(found) != 0 {
t.Fatalf("%+v", found)
}
}
-3
View File
@@ -1,3 +0,0 @@
module git.novox.be/novox/mesh-catalog/checks/deps
go 1.22
-255
View File
@@ -1,255 +0,0 @@
// Command deps makes a module's dependencies available to the catalogue's check, or says why it cannot
// (novox/hq issue 385).
//
// A module's tests run only when its dependencies can be fetched. The build seat clones what a check
// needs with the forge credential and forgets that credential before the check runs, so a module pinning
// an SDK commit the seat's module cache does not hold cannot fetch it from the forge. The SDK is cloned
// beside the check all the same (MESH_CHECK_BESIDE, mesh-controller's besideRefs): every requirement of the
// mesh's own forge is taken from the clone of its repository there, at the commit or the tag its version
// names, through a go.mod of its own (-modfile) — the module's own files are not changed, and no forge
// credential is added.
//
// Usage: deps -beside <dir> -work <dir> <module dir>. It prints the go.mod to build the module with, or
// nothing when the module fetches what it needs; when it cannot, it prints why and exits 1, and the check
// fails: a module whose tests could not run is a failure, never a note.
package main
import (
"archive/tar"
"bytes"
"encoding/json"
"errors"
"flag"
"fmt"
"io"
"os"
"os/exec"
"path/filepath"
"regexp"
"strings"
)
// forge is the prefix of the Go modules of the mesh's own forge: <forge><repository>[/<directory>].
const forge = "git.novox.be/novox/"
func main() {
beside := flag.String("beside", "", "where the repositories beside the check are cloned (MESH_CHECK_BESIDE)")
work := flag.String("work", os.TempDir(), "where the go.mod and the sources taken from beside are written")
flag.Parse()
if flag.NArg() != 1 {
fmt.Println("usage: deps -beside <dir> -work <dir> <module dir>")
os.Exit(2)
}
modfile, err := resolve(flag.Arg(0), *beside, *work)
if err != nil {
fmt.Println(err)
os.Exit(1)
}
if modfile != "" {
fmt.Println(modfile)
}
}
// resolve is the go.mod the module at dir builds with: "" when its own fetches everything, else one
// beside it in work that takes every requirement of the mesh's forge from its clone beside the check.
func resolve(dir, beside, work string) (string, error) {
out, err := goIn(dir, "mod", "download")
if err == nil {
return "", nil
}
fetching := lastLine(out)
required, err := forgeRequirements(dir)
if err != nil {
return "", err
}
if len(required) == 0 {
return "", fmt.Errorf("its dependencies cannot be fetched: %s", fetching)
}
if beside == "" {
return "", fmt.Errorf("its dependencies cannot be fetched (%s), and nothing was cloned beside the check to "+
"take %s from", fetching, repositories(required))
}
into, err := os.MkdirTemp(work, filepath.Base(dir)+"-")
if err != nil {
return "", err
}
modfile := filepath.Join(into, "go.mod")
if err := copyFile(filepath.Join(dir, "go.mod"), modfile); err != nil {
return "", err
}
if err := copyFile(filepath.Join(dir, "go.sum"), filepath.Join(into, "go.sum")); err != nil &&
!errors.Is(err, os.ErrNotExist) {
return "", err
}
for _, r := range required {
source, err := export(beside, r, into)
if err != nil {
return "", fmt.Errorf("its dependencies cannot be fetched (%s), and %s@%s cannot be taken from the "+
"clone beside the check: %w", fetching, r.Path, r.Version, err)
}
if out, err := goIn(dir, "mod", "edit", "-replace", r.Path+"@"+r.Version+"="+source, modfile); err != nil {
return "", fmt.Errorf("%s@%s cannot be taken from the clone beside the check: %s", r.Path, r.Version,
lastLine(out))
}
}
if out, err := goIn(dir, "mod", "download", "-modfile="+modfile); err != nil {
return "", fmt.Errorf("its dependencies cannot be fetched even with %s taken from the clone beside the "+
"check: %s", repositories(required), lastLine(out))
}
return modfile, nil
}
// requirement is one module the module requires.
type requirement struct {
Path string
Version string
}
// forgeRequirements is every requirement of the module at dir that comes from the mesh's own forge.
func forgeRequirements(dir string) ([]requirement, error) {
out, err := goIn(dir, "mod", "edit", "-json")
if err != nil {
return nil, fmt.Errorf("its go.mod cannot be read: %s", lastLine(out))
}
var mod struct{ Require []requirement }
if err := json.Unmarshal(out, &mod); err != nil {
return nil, fmt.Errorf("its go.mod cannot be read: %w", err)
}
var on []requirement
for _, r := range mod.Require {
if strings.HasPrefix(r.Path, forge) {
on = append(on, r)
}
}
return on, nil
}
// repositoryOf splits a module path of the forge into its repository and the directory in it.
func repositoryOf(path string) (string, string) {
repo, sub, _ := strings.Cut(strings.TrimPrefix(path, forge), "/")
return repo, sub
}
func repositories(rs []requirement) string {
var names []string
for _, r := range rs {
repo, _ := repositoryOf(r.Path)
names = append(names, repo)
}
return strings.Join(names, ", ")
}
// pseudo is a pseudo-version's commit: v0.1.14-0.20261012082113-9747f3e1b1ac → 9747f3e1b1ac.
var pseudo = regexp.MustCompile(`[-.]\d{14}-([0-9a-f]{12})$`)
// refOf is the ref a version names in its repository: a pseudo-version's commit, else the tag a module in
// a directory is released under (<directory>/<version>).
func refOf(sub, version string) string {
version = strings.TrimSuffix(version, "+incompatible")
if m := pseudo.FindStringSubmatch(version); m != nil {
return m[1]
}
if sub != "" {
return sub + "/" + version
}
return version
}
// export writes the module r names, at its version, from its repository's clone beside the check into
// into, and answers its directory.
func export(beside string, r requirement, into string) (string, error) {
repo, sub := repositoryOf(r.Path)
clone := filepath.Join(beside, repo)
if _, err := os.Stat(filepath.Join(clone, ".git")); err != nil {
return "", fmt.Errorf("no clone of %s is beside the check", repo)
}
ref := refOf(sub, r.Version)
commit, err := exec.Command("git", "-C", clone, "rev-parse", "--verify", "--quiet", ref+"^{commit}").Output()
if err != nil {
return "", fmt.Errorf("the clone of %s holds no %s (a commit or tag not pushed to the forge?)", repo, ref)
}
at := strings.TrimSpace(string(commit))
args := []string{"-C", clone, "archive", "--format=tar", at}
if sub != "" {
args = append(args, "--", sub)
}
var stderr bytes.Buffer
archive := exec.Command("git", args...)
archive.Stderr = &stderr
raw, err := archive.Output()
if err != nil {
return "", fmt.Errorf("git archive of %s at %s: %v %s", repo, ref, err, strings.TrimSpace(stderr.String()))
}
root := filepath.Join(into, repo+"@"+at[:12])
if err := untar(raw, root); err != nil {
return "", err
}
source := filepath.Join(root, filepath.FromSlash(sub))
if _, err := os.Stat(filepath.Join(source, "go.mod")); err != nil {
return "", fmt.Errorf("%s at %s has no go.mod in %q", repo, ref, sub)
}
return source, nil
}
// untar writes a tar's directories and regular files under root.
func untar(raw []byte, root string) error {
r := tar.NewReader(bytes.NewReader(raw))
for {
h, err := r.Next()
if err == io.EOF {
return nil
}
if err != nil {
return err
}
name := filepath.Clean(filepath.FromSlash(h.Name))
if name == "." || strings.HasPrefix(name, "..") || filepath.IsAbs(name) {
continue
}
path := filepath.Join(root, name)
switch h.Typeflag {
case tar.TypeDir:
if err := os.MkdirAll(path, 0o755); err != nil {
return err
}
case tar.TypeReg:
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return err
}
body, err := io.ReadAll(r)
if err != nil {
return err
}
if err := os.WriteFile(path, body, 0o644); err != nil {
return err
}
}
}
}
func copyFile(from, to string) error {
raw, err := os.ReadFile(from)
if err != nil {
return err
}
return os.WriteFile(to, raw, 0o644)
}
// goIn runs the go command in dir.
func goIn(dir string, args ...string) ([]byte, error) {
cmd := exec.Command("go", args...)
cmd.Dir = dir
var stderr bytes.Buffer
cmd.Stderr = &stderr
out, err := cmd.Output()
if err != nil {
return append(out, stderr.Bytes()...), err
}
return out, nil
}
// lastLine is the last line a command said, which is where go says what failed.
func lastLine(out []byte) string {
lines := strings.Split(strings.TrimSpace(string(out)), "\n")
return strings.TrimSpace(lines[len(lines)-1])
}
-177
View File
@@ -1,177 +0,0 @@
package main
import (
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
)
// offline is a Go that fetches nothing: what the build seat is once the forge credential is gone, for a pin
// its module cache does not hold (novox/hq issue 385).
func offline(t *testing.T) {
t.Helper()
t.Setenv("GOPROXY", "off")
t.Setenv("GOFLAGS", "")
t.Setenv("GOWORK", "off")
// Not GOPRIVATE: a private module is fetched directly by git, around GOPROXY, with whatever credential
// the machine running the test holds.
t.Setenv("GOPRIVATE", "")
t.Setenv("GONOPROXY", "")
t.Setenv("GOSUMDB", "off")
t.Setenv("GOMODCACHE", t.TempDir())
}
func git(t *testing.T, dir string, args ...string) string {
t.Helper()
cmd := exec.Command("git", 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_AUTHOR_DATE=2026-10-12T08:21:13Z", "GIT_COMMITTER_DATE=2026-10-12T08:21:13Z")
out, err := cmd.CombinedOutput()
if err != nil {
t.Fatalf("git %s: %v\n%s", strings.Join(args, " "), err, out)
}
return strings.TrimSpace(string(out))
}
func write(t *testing.T, path, content string) {
t.Helper()
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
t.Fatal(err)
}
}
// sdkBeside is the SDK's repository cloned beside a check, its Go module under go/, one commit tagged
// go/v0.1.7. It answers the beside root and the commit.
func sdkBeside(t *testing.T) (string, string) {
t.Helper()
beside := t.TempDir()
repo := filepath.Join(beside, "mesh-sdk")
write(t, filepath.Join(repo, "go", "go.mod"), "module git.novox.be/novox/mesh-sdk/go\n\ngo 1.22\n")
write(t, filepath.Join(repo, "go", "sdk", "sdk.go"), "package sdk\n\nfunc Hello() string { return \"hello\" }\n")
write(t, filepath.Join(repo, "README.md"), "the SDK\n")
git(t, repo, "init", "--quiet")
git(t, repo, "add", ".")
git(t, repo, "commit", "--quiet", "-m", "sdk")
git(t, repo, "tag", "go/v0.1.7")
return beside, git(t, repo, "rev-parse", "HEAD")
}
// moduleRequiring is a catalogue module whose code and test use the SDK at the version given.
func moduleRequiring(t *testing.T, version string) string {
t.Helper()
dir := filepath.Join(t.TempDir(), "messenger")
write(t, filepath.Join(dir, "go.mod"), "module git.novox.be/novox/mesh-catalog/modules/messenger\n\ngo 1.22\n\n"+
"require git.novox.be/novox/mesh-sdk/go "+version+"\n")
write(t, filepath.Join(dir, "main.go"), "package main\n\nimport \"git.novox.be/novox/mesh-sdk/go/sdk\"\n\n"+
"func main() { println(sdk.Hello()) }\n")
return dir
}
func builds(t *testing.T, dir, modfile string) {
t.Helper()
args := []string{"vet"}
if modfile != "" {
args = append(args, "-modfile="+modfile)
}
cmd := exec.Command("go", append(args, "./...")...)
cmd.Dir = dir
if out, err := cmd.CombinedOutput(); err != nil {
t.Fatalf("the module does not build with %q: %v\n%s", modfile, err, out)
}
}
// A module that needs nothing it cannot fetch is left as it is: no other go.mod.
func TestAModuleThatFetchesItsDependenciesNeedsNothingBeside(t *testing.T) {
offline(t)
dir := filepath.Join(t.TempDir(), "plain")
write(t, filepath.Join(dir, "go.mod"), "module example.com/plain\n\ngo 1.22\n")
write(t, filepath.Join(dir, "main.go"), "package main\n\nfunc main() {}\n")
modfile, err := resolve(dir, "", t.TempDir())
if err != nil || modfile != "" {
t.Fatalf("resolve = %q, %v; want nothing", modfile, err)
}
}
// The case of issue 385: the module pins an SDK commit the build seat cannot fetch. It is taken from the
// SDK's clone beside the check, and the module builds against it.
func TestAPinnedSDKCommitIsTakenFromTheCloneBeside(t *testing.T) {
offline(t)
beside, commit := sdkBeside(t)
dir := moduleRequiring(t, "v0.1.14-0.20261012082113-"+commit[:12])
modfile, err := resolve(dir, beside, t.TempDir())
if err != nil {
t.Fatalf("resolve: %v", err)
}
if modfile == "" {
t.Fatal("resolve said the module fetches its dependencies, with nothing to fetch them from")
}
builds(t, dir, modfile)
if raw, _ := os.ReadFile(filepath.Join(dir, "go.mod")); strings.Contains(string(raw), "replace") {
t.Fatalf("the module's own go.mod was changed:\n%s", raw)
}
}
// A released SDK is found by the tag it is released under: go/<version>.
func TestATaggedSDKIsTakenFromTheCloneBeside(t *testing.T) {
offline(t)
beside, _ := sdkBeside(t)
dir := moduleRequiring(t, "v0.1.7")
modfile, err := resolve(dir, beside, t.TempDir())
if err != nil {
t.Fatalf("resolve: %v", err)
}
builds(t, dir, modfile)
}
// A commit the clone beside does not hold (a branch never pushed) cannot be tested, and says which.
func TestACommitTheCloneBesideDoesNotHoldIsSaid(t *testing.T) {
offline(t)
beside, _ := sdkBeside(t)
dir := moduleRequiring(t, "v0.1.14-0.20261010132341-4f3efc98e0c0")
_, err := resolve(dir, beside, t.TempDir())
if err == nil {
t.Fatal("resolve found a commit nothing holds")
}
for _, want := range []string{"git.novox.be/novox/mesh-sdk/go@v0.1.14-0.20261010132341-4f3efc98e0c0", "4f3efc98e0c0"} {
if !strings.Contains(err.Error(), want) {
t.Errorf("the reason %q does not name %s", err, want)
}
}
}
// With nothing cloned beside (a check by hand without the forge), the reason says that.
func TestNoCloneBesideIsSaid(t *testing.T) {
offline(t)
_, commit := sdkBeside(t)
dir := moduleRequiring(t, "v0.1.14-0.20261012082113-"+commit[:12])
for _, beside := range []string{"", t.TempDir()} {
_, err := resolve(dir, beside, t.TempDir())
if err == nil {
t.Fatalf("resolve with %q beside found what is not there", beside)
}
if !strings.Contains(err.Error(), "mesh-sdk") {
t.Errorf("the reason %q does not name the repository it needed", err)
}
}
}
func TestTheRefOfAVersion(t *testing.T) {
cases := []struct{ sub, version, want string }{
{"go", "v0.1.14-0.20261012082113-9747f3e1b1ac", "9747f3e1b1ac"},
{"go", "v0.0.0-20261012082113-9747f3e1b1ac", "9747f3e1b1ac"},
{"go", "v0.1.7", "go/v0.1.7"},
{"", "v1.2.0", "v1.2.0"},
{"", "v2.0.0+incompatible", "v2.0.0"},
}
for _, c := range cases {
if got := refOf(c.sub, c.version); got != c.want {
t.Errorf("refOf(%q, %q) = %q, want %q", c.sub, c.version, got, c.want)
}
}
}
-3
View File
@@ -1,3 +0,0 @@
module git.novox.be/novox/mesh-catalog/checks/paths
go 1.22
-6
View File
@@ -1,6 +0,0 @@
# Findings standing when the check took /etc and /usr/local/lib in (2026-10-08). Each is a module reading
# another's file instead of asking over the bus. The list only shrinks: a finding that no longer occurs
# must leave it. Format: <module> <path> <file> # why it is still here
asus-zephyrus-g14 /etc/mesh-power modules/asus-zephyrus-g14/cmd/zephyrus/check.go # its check reads the power module's moment file; should ask power_hooks
asus-zephyrus-g14 /etc/mesh-power/moments modules/asus-zephyrus-g14/cmd/zephyrus/check.go # the same
asus-zephyrus-g14 /etc/mesh-power/moments/after-wake modules/asus-zephyrus-g14/cmd/zephyrus/check.go # the same
-278
View File
@@ -1,278 +0,0 @@
// Command paths holds the catalogue's modules to talking over the bus (novox/hq ADR 0260): no module
// names a path that another module's manifest places under /run, /var/lib, /usr/local/lib or /etc.
// Such a path is that module's state or configuration on its machine, and a second module reading or
// writing it is two modules talking through a file, which the bus exists to replace.
//
// A module owns every `path` of its resources under those roots. Every other module's manifest, and
// every file of its own but its documentation, its tests and its binaries, is searched for each as a
// whole path: followed by nothing, a slash, or a character no path takes. A path a container sees
// inside itself (the target of a `volumes` entry) is the container's, and is not searched.
//
// **A drop-in directory is no module's.** A directory whose name ends in `.d` (`/etc/sysctl.d`,
// `/etc/systemd/system/<unit>.d`, `/etc/X11/xorg.conf.d`) is a place several modules each put their own
// file, so naming it is not reaching into one module's state; the files in it are each their placer's.
//
// Two lists beside this file narrow it, each line a path or a finding and its reason after a `#`:
//
// - shared: paths every program on a machine reads as the machine's, not as a message from their
// owner — /etc/hosts, which every resolver reads.
// - known: the findings standing on the day the roots were widened (2026-10-08). The check fails on a
// finding not listed, and on a listed one that no longer occurs, so the list only shrinks.
//
// **Neither list may grow unseen.** With `-base <dir>` holding the base branch's copies of both, a line
// the change adds is refused unless its reason says `allow:` and why — so an addition is a decision a
// reviewer reads in the diff, not a way past the check.
//
// **What it cannot see:** a path built in code from pieces (`filepath.Join("/etc", name)`), one read
// from the environment or a setting, and one reached through a shell the module runs. It reads text,
// not what a program does; review and the runtime's grants cover the rest.
//
// go run . <catalogue root>
//
// Exits 1 on a finding.
package main
import (
"bufio"
"bytes"
"encoding/json"
"fmt"
"io/fs"
"os"
"path/filepath"
"regexp"
"sort"
"strings"
)
// stateRoots are where a machine keeps a module's state and configuration.
var stateRoots = []string{"/run/", "/var/lib/", "/usr/local/lib/", "/etc/"}
// Finding is one module naming another's path.
type Finding struct {
Module, File, Path, Owner string
}
func (f Finding) String() string {
return fmt.Sprintf("%s names %s in %s, and the path is %s's; modules talk over the bus (novox/hq ADR 0260)",
f.Module, f.Path, f.File, f.Owner)
}
// Key is how a finding is listed in `known`: the module, the path and the file.
func (f Finding) Key() string { return f.Module + " " + f.Path + " " + f.File }
// containerTargets drops what a container sees inside itself from a manifest before it is searched:
// the target half of every `volumes` entry of a container resource.
func containerTargets(m map[string]any) {
resources, _ := m["resources"].([]any)
for _, raw := range resources {
r, ok := raw.(map[string]any)
if !ok || r["type"] != "container" {
continue
}
volumes, _ := r["volumes"].([]any)
for i, v := range volumes {
if s, ok := v.(string); ok {
if src, _, two := strings.Cut(s, ":"); two {
volumes[i] = src
}
}
if o, ok := v.(map[string]any); ok {
delete(o, "target")
delete(o, "container")
}
}
}
}
// Check is every finding in the catalogue at root, before the lists narrow it.
func Check(root string) ([]Finding, error) {
files, err := filepath.Glob(filepath.Join(root, "modules", "*", "module.json"))
if err != nil {
return nil, err
}
owner := map[string]string{}
modules := map[string]string{}
for _, f := range files {
raw, err := os.ReadFile(f)
if err != nil {
return nil, err
}
var m struct {
Module string `json:"module"`
Resources []map[string]any `json:"resources"`
}
if err := json.Unmarshal(raw, &m); err != nil {
return nil, fmt.Errorf("%s: %w", f, err)
}
modules[m.Module] = filepath.Dir(f)
for _, r := range m.Resources {
p, _ := r["path"].(string)
if r["type"] == "directory" && strings.HasSuffix(filepath.Base(p), ".d") {
continue // a drop-in directory: several modules' place, no one module's
}
for _, root := range stateRoots {
if strings.HasPrefix(p, root) && len(p) > len(root) {
owner[strings.TrimRight(p, "/")] = m.Module
}
}
}
}
paths := make([]string, 0, len(owner))
for p := range owner {
paths = append(paths, p)
}
sort.Strings(paths)
patterns := map[string]*regexp.Regexp{}
for _, p := range paths {
patterns[p] = regexp.MustCompile(regexp.QuoteMeta(p) + `(?:$|[^A-Za-z0-9._-])`)
}
names := make([]string, 0, len(modules))
for n := range modules {
names = append(names, n)
}
sort.Strings(names)
var out []Finding
for _, module := range names {
err := filepath.WalkDir(modules[module], func(path string, d fs.DirEntry, err error) error {
if err != nil || d.IsDir() {
return err
}
name := d.Name()
if strings.HasSuffix(name, ".md") || strings.HasSuffix(name, "_test.go") ||
name == "go.sum" || name == "package-lock.json" {
return nil
}
raw, err := os.ReadFile(path)
if err != nil {
return err
}
if bytes.IndexByte(raw, 0) >= 0 {
return nil // a binary: what it says is in its source
}
if name == "module.json" {
var m map[string]any
if json.Unmarshal(raw, &m) == nil {
containerTargets(m)
raw, _ = json.Marshal(m)
}
}
text := string(raw)
for _, p := range paths {
if owner[p] != module && patterns[p].MatchString(text) {
rel, _ := filepath.Rel(root, path)
out = append(out, Finding{Module: module, File: rel, Path: p, Owner: owner[p]})
}
}
return nil
})
if err != nil {
return nil, err
}
}
return out, nil
}
// list reads one of the lists beside this check: each line's entry before a `#`, trimmed, with its
// reason after it.
func list(path string) (map[string]bool, map[string]string, error) {
out, why := map[string]bool{}, map[string]string{}
f, err := os.Open(path)
if err != nil {
return nil, nil, err
}
defer f.Close()
s := bufio.NewScanner(f)
for s.Scan() {
line, reason, _ := strings.Cut(s.Text(), "#")
if line = strings.TrimSpace(line); line != "" {
out[line] = true
why[line] = strings.TrimSpace(reason)
}
}
return out, why, s.Err()
}
// Grown is every entry a list has that its base copy does not, and whose reason does not say `allow:`.
func Grown(name string, now map[string]bool, why map[string]string, base map[string]bool) []string {
var out []string
for entry := range now {
if !base[entry] && !strings.Contains(why[entry], "allow:") {
out = append(out, fmt.Sprintf("checks/paths/%s gains %q, which the base branch's copy does not hold; "+
"say why after `# allow:` on its line, or keep it out", name, entry))
}
}
sort.Strings(out)
return out
}
// Judge narrows the findings by the lists: what is left to refuse, and the known findings that no
// longer occur, which must leave the list.
func Judge(found []Finding, shared, known map[string]bool) (refused []Finding, gone []string) {
seen := map[string]bool{}
for _, f := range found {
if shared[f.Path] {
continue
}
seen[f.Key()] = true
if !known[f.Key()] {
refused = append(refused, f)
}
}
for k := range known {
if !seen[k] {
gone = append(gone, k)
}
}
sort.Strings(gone)
return refused, gone
}
func main() {
root, baseDir := ".", ""
args := os.Args[1:]
if len(args) >= 2 && args[0] == "-base" {
baseDir, args = args[1], args[2:]
}
if len(args) > 0 {
root = args[0]
}
found, err := Check(root)
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(2)
}
here := filepath.Join(root, "checks", "paths")
shared, sharedWhy, err1 := list(filepath.Join(here, "shared"))
known, knownWhy, err2 := list(filepath.Join(here, "known"))
if err1 != nil || err2 != nil {
fmt.Fprintln(os.Stderr, "the lists beside the check cannot be read:", err1, err2)
os.Exit(2)
}
var grown []string
if baseDir == "" {
fmt.Println("NOT COMPARED: no base branch's lists were given, so additions to them were not judged")
} else {
baseShared, _, err1 := list(filepath.Join(baseDir, "shared"))
baseKnown, _, err2 := list(filepath.Join(baseDir, "known"))
if err1 != nil || err2 != nil {
fmt.Fprintln(os.Stderr, "the base branch's lists cannot be read:", err1, err2)
os.Exit(2)
}
grown = append(Grown("shared", shared, sharedWhy, baseShared), Grown("known", known, knownWhy, baseKnown)...)
}
refused, gone := Judge(found, shared, known)
for _, g := range grown {
fmt.Println(g)
}
for _, f := range refused {
fmt.Println(f)
}
for _, k := range gone {
fmt.Printf("%q is in checks/paths/known and no longer occurs: take it out, so it cannot come back\n", k)
}
if len(refused) > 0 || len(gone) > 0 || len(grown) > 0 {
os.Exit(1)
}
fmt.Printf("paths: no module names another's state on its machine (%d known, listed to go)\n", len(known))
}
-135
View File
@@ -1,135 +0,0 @@
package main
import (
"os"
"path/filepath"
"strings"
"testing"
)
func catalogue(t *testing.T, files map[string]string) string {
t.Helper()
root := t.TempDir()
for p, c := range files {
full := filepath.Join(root, p)
if err := os.MkdirAll(filepath.Dir(full), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(full, []byte(c), 0o644); err != nil {
t.Fatal(err)
}
}
return root
}
const owner = `{"module":"power","resources":[{"id":"d","type":"directory","path":"/run/mesh-power"}]}`
func TestAModuleReadingAnothersStateIsFound(t *testing.T) {
root := catalogue(t, map[string]string{
"modules/power/module.json": owner,
"modules/power/cmd/power/draw.go": `const f = "/run/mesh-power/draw"`,
"modules/bar/module.json": `{"module":"bar","resources":[{"id":"x","type":"file","path":"/etc/x","content":"cat /run/mesh-power/draw"}]}`,
"modules/other/module.json": `{"module":"other"}`,
"modules/other/files/bin/read": "#!/bin/sh\ncat /run/mesh-power\n",
"modules/other/README.md": "it once read /run/mesh-power/draw",
"modules/other/cmd/x/x_test.go": `"/run/mesh-power"`,
"modules/near/module.json": `{"module":"near","resources":[{"id":"y","type":"file","path":"/etc/y","content":"/run/mesh-power-other/x"}]}`,
})
found, err := Check(root)
if err != nil {
t.Fatal(err)
}
var got []string
for _, f := range found {
got = append(got, f.Module+" "+f.File)
}
want := []string{"bar modules/bar/module.json", "other modules/other/files/bin/read"}
if strings.Join(got, "|") != strings.Join(want, "|") {
t.Fatalf("found %v, want %v", got, want)
}
if !strings.Contains(found[0].String(), "power's") {
t.Fatalf("%s", found[0])
}
}
func TestACatalogueWhereEachKeepsToItsOwnIsClean(t *testing.T) {
root := catalogue(t, map[string]string{
"modules/power/module.json": owner,
"modules/power/cmd/power/draw.go": `const f = "/run/mesh-power/draw"`,
})
if found, err := Check(root); err != nil || len(found) != 0 {
t.Fatalf("%v %v", found, err)
}
}
func TestEtcAndUsrLocalLibAreAModulesTooButNotInsideAContainer(t *testing.T) {
root := catalogue(t, map[string]string{
"modules/power/module.json": `{"module":"power","resources":[{"id":"m","type":"directory","path":"/etc/mesh-power/moments"},` +
`{"id":"s","type":"archive","path":"/usr/local/lib/mesh-power"}]}`,
"modules/reader/module.json": `{"module":"reader"}`,
"modules/reader/cmd/r/r.go": `const a, b = "/etc/mesh-power/moments/after-wake", "/usr/local/lib/mesh-power/bin/x"`,
// A container that mounts something at that path inside itself names its own world.
"modules/boxed/module.json": `{"module":"boxed","resources":[{"id":"c","type":"container",` +
`"volumes":["/srv/boxed:/etc/mesh-power/moments",{"source":"/srv/b","target":"/usr/local/lib/mesh-power"}]}]}`,
// A binary says what its source says.
"modules/boxed/bin/tool": "\x00\x01/etc/mesh-power/moments\x00",
})
found, err := Check(root)
if err != nil {
t.Fatal(err)
}
var got []string
for _, f := range found {
got = append(got, f.Module+" "+f.Path)
}
want := []string{"reader /etc/mesh-power/moments", "reader /usr/local/lib/mesh-power"}
if strings.Join(got, "|") != strings.Join(want, "|") {
t.Fatalf("found %v, want %v", got, want)
}
}
func TestSharedPathsPassAndKnownFindingsOnlyShrink(t *testing.T) {
found := []Finding{
{Module: "dns", File: "modules/dns/module.json", Path: "/etc/hosts", Owner: "hostname"},
{Module: "laptop", File: "modules/laptop/k.go", Path: "/etc/triggers", Owner: "keys"},
{Module: "new", File: "modules/new/n.go", Path: "/run/mesh-power", Owner: "power"},
}
shared := map[string]bool{"/etc/hosts": true}
known := map[string]bool{
"laptop /etc/triggers modules/laptop/k.go": true,
"old /run/x modules/old/o.go": true,
}
refused, gone := Judge(found, shared, known)
if len(refused) != 1 || refused[0].Module != "new" {
t.Fatalf("refused %v", refused)
}
if len(gone) != 1 || gone[0] != "old /run/x modules/old/o.go" {
t.Fatalf("gone %v", gone)
}
}
func TestADropInDirectoryIsNoModulesButItsFilesAre(t *testing.T) {
root := catalogue(t, map[string]string{
"modules/memory/module.json": `{"module":"memory","resources":[{"id":"d","type":"directory","path":"/etc/sysctl.d"},` +
`{"id":"f","type":"file","path":"/etc/sysctl.d/90-memory.conf"}]}`,
"modules/other/module.json": `{"module":"other","resources":[{"id":"f","type":"file","path":"/etc/sysctl.d/80-other.conf"}]}`,
"modules/other/cmd/o/o.go": `const a, b = "/etc/sysctl.d", "/etc/sysctl.d/90-memory.conf"`,
})
found, err := Check(root)
if err != nil {
t.Fatal(err)
}
if len(found) != 1 || found[0].Path != "/etc/sysctl.d/90-memory.conf" || found[0].Module != "other" {
t.Fatalf("%v", found)
}
}
func TestAListGrowsOnlyWithAReasonTheDiffShows(t *testing.T) {
base := map[string]bool{"/etc/hosts": true}
now := map[string]bool{"/etc/hosts": true, "/etc/a": true, "/etc/b": true}
why := map[string]string{"/etc/a": "because", "/etc/b": "allow: every program reads it"}
got := Grown("shared", now, why, base)
if len(got) != 1 || !strings.Contains(got[0], `"/etc/a"`) {
t.Fatalf("%v", got)
}
}
-3
View File
@@ -1,3 +0,0 @@
# Paths every program on a machine reads as the machine's own, not as a message from the module that
# writes them (checks/paths, novox/hq ADR 0260). One per line, with why.
/etc/hosts # every resolver on the machine reads it; the hostname module writes it for all of them
-3
View File
@@ -1,3 +0,0 @@
module git.novox.be/novox/mesh-catalog/checks/words
go 1.22
-73
View File
@@ -1,73 +0,0 @@
package main
import (
"fmt"
"regexp"
"sort"
"strings"
)
// The daemons' lines (novox/hq ADR 0272, its insight of 2026-10-09): the operator types one command, `nox`
// (`mesh-cli`). The node-engine (`nox-mesh-host`) and the controller (`mesh-controller`) are daemons, and a
// person never types their names — so no text a module can show names a line of theirs. The list is the
// copy in retired-lines at the catalogue's root, which novox/hq's words.py prints (`--list lines`) and
// compares; one line each, `<daemon> <command>`, and a trailing ` …` for a command that is a line only with
// an argument after it (`mesh-controller module check` is a line; "the mesh-controller module" is prose).
// retiredLine is one daemon's line: the daemon, its command, and whether an argument must follow.
type retiredLine struct {
daemon, command string
withArgument bool
}
// readLines reads retired-lines: one line a line; blank lines and lines starting with # are not lines.
func readLines(path string) ([]retiredLine, error) {
entries, err := readList(path)
if err != nil {
return nil, err
}
var out []retiredLine
for _, e := range entries {
withArgument := strings.HasSuffix(e, " …")
e = strings.TrimSuffix(e, " …")
parts := strings.Fields(e)
if len(parts) != 2 {
return nil, fmt.Errorf("retired-lines: %q is not `<daemon> <command>`", e)
}
out = append(out, retiredLine{daemon: parts[0], command: parts[1], withArgument: withArgument})
}
return out, nil
}
// linePattern matches the line whole: the daemon, white space, the command. A command that is a line only
// with an argument is matched as a line is written — at the start of a line or a code span, a prompt allowed,
// with an argument after it — so prose that names the daemon beside the word ("the mesh-controller module is
// assigned") is not one.
func linePattern(l retiredLine) *regexp.Regexp {
daemon, command := regexp.QuoteMeta(l.daemon), regexp.QuoteMeta(l.command)
if l.withArgument {
tick := "`"
return regexp.MustCompile(`(?m)(?:^|` + tick + `)[ \t]*(?:\$[ \t]*)?(` + daemon + `\s+` + command + `\s+[^\s` + tick + `])`)
}
return regexp.MustCompile(`(?:^|[^\w-])(` + daemon + `\s+` + command + `)(?:$|[^\w-])`)
}
// checkLines finds every daemon's line in the texts.
func checkLines(lines []retiredLine, texts []text) []string {
var out []string
for _, l := range lines {
rx := linePattern(l)
for _, t := range texts {
if m := rx.FindStringSubmatchIndex(t.s); m != nil {
line := t.line
if line > 0 {
line += strings.Count(t.s[:m[2]], "\n")
}
out = append(out, fmt.Sprintf("%s:%d: `%s %s` is a daemon's line — the operator types `nox …` (novox/hq "+
"ADR 0272) — in %q", t.file, line, l.daemon, l.command, excerpt(t.s, m[2], m[3])))
}
}
}
sort.Strings(out)
return out
}
-351
View File
@@ -1,351 +0,0 @@
// Command words holds what the catalogue's modules say to an agent to the glossary's words (novox/hq ADR
// 0244): no word the glossary retired for the tools' descriptions — the copy in retired-words at the
// catalogue's root — in any text a module's tools can show. That text is every string literal in a module's
// own code that is not a test (a tool's description, the notes and errors it answers with) and every
// description in its manifest. Comments are not read: an agent never sees them. And no daemon's line
// (novox/hq ADR 0272, lines.go): the copy in retired-lines, the lines a person would otherwise be told to
// type of the node-engine's or the controller's binary, where the operator's command is `nox`.
//
// A word is matched whole and in any case, a space in it matching any run of white space, and its plural
// (`s` or `es` on its last part) is matched as the word. A vendor word is
// never on the list (the glossary does not retire one for the tools), so a wrapped program's own objects —
// an identity provider's users, a media manager's releases — are never findings.
//
// go run . <catalogue root>
//
// Exits 1 on a finding, 2 when the list cannot be read.
package main
import (
"bufio"
"encoding/json"
"fmt"
"go/scanner"
"go/token"
"io/fs"
"os"
"path/filepath"
"regexp"
"sort"
"strconv"
"strings"
)
// text is one piece of text an agent can be shown, and where it starts.
type text struct {
file string
line int
s string
}
func main() {
root := "."
if len(os.Args) > 1 {
root = os.Args[1]
}
words, err := readList(filepath.Join(root, "retired-words"))
if err != nil {
fmt.Fprintln(os.Stderr, "words:", err)
os.Exit(2)
}
if len(words) == 0 {
fmt.Fprintln(os.Stderr, "words: retired-words lists no word — the check would pass on anything")
os.Exit(2)
}
var texts []text
files := 0
err = filepath.WalkDir(filepath.Join(root, "modules"), func(path string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
if d.IsDir() {
switch d.Name() {
case "node_modules", "dist", "vendor", "test", "tests", "testdata", ".git":
return filepath.SkipDir
}
return nil
}
rel, _ := filepath.Rel(root, path)
name := d.Name()
var found []text
switch {
case strings.HasSuffix(name, "_test.go"), strings.Contains(name, ".test."), strings.Contains(name, ".spec."):
return nil
case strings.HasSuffix(name, ".go"):
found, err = goStrings(path, rel)
case strings.HasSuffix(name, ".ts"), strings.HasSuffix(name, ".js"), strings.HasSuffix(name, ".mjs"):
found, err = scriptStrings(path, rel)
case name == "module.json":
found, err = jsonStrings(path, rel)
default:
return nil
}
if err != nil {
return fmt.Errorf("%s: %w", rel, err)
}
files++
texts = append(texts, found...)
return nil
})
if err != nil {
fmt.Fprintln(os.Stderr, "words:", err)
os.Exit(2)
}
findings := check(words, texts)
for _, f := range findings {
fmt.Println(f)
}
if len(findings) > 0 {
fmt.Printf("words: %d use(s) of a word the glossary retired (novox/hq ADR 0244) — say it in the glossary's word\n", len(findings))
os.Exit(1)
}
// And no daemon's line (novox/hq ADR 0272): what a module tells the operator to type is a `nox` line.
lines, err := readLines(filepath.Join(root, "retired-lines"))
if err != nil {
fmt.Fprintln(os.Stderr, "words:", err)
os.Exit(2)
}
if len(lines) == 0 {
fmt.Fprintln(os.Stderr, "words: retired-lines lists no line — the check would pass on anything")
os.Exit(2)
}
if findings := checkLines(lines, texts); len(findings) > 0 {
for _, f := range findings {
fmt.Println(f)
}
fmt.Printf("words: %d daemon's line(s) in what a module shows — the operator types one command, nox (novox/hq ADR 0272)\n",
len(findings))
os.Exit(1)
}
fmt.Printf("words: %d retired words and %d daemons' lines, none in the %d strings of %d files\n", len(words), len(lines),
len(texts), files)
}
// readList reads retired-words: one word or phrase a line; blank lines and lines starting with # are not words.
func readList(path string) ([]string, error) {
f, err := os.Open(path)
if err != nil {
return nil, err
}
defer f.Close()
var words []string
sc := bufio.NewScanner(f)
for sc.Scan() {
line := strings.TrimSpace(sc.Text())
if line == "" || strings.HasPrefix(line, "#") {
continue
}
words = append(words, line)
}
return words, sc.Err()
}
func pattern(word string) *regexp.Regexp {
parts := strings.Fields(word)
for i, p := range parts {
parts[i] = regexp.QuoteMeta(p)
}
// The plural is the word too: `s` or `es` on its last part, so "control planes" is found as "control
// plane" is. Anything else joined on is another word.
return regexp.MustCompile(`(?i)(?:^|[^\w-])(` + strings.Join(parts, `\s+`) + `(?:e?s)?)(?:$|[^\w-])`)
}
func check(words []string, texts []text) []string {
var out []string
for _, w := range words {
rx := pattern(w)
for _, t := range texts {
if m := rx.FindStringSubmatchIndex(t.s); m != nil {
line := t.line
if line > 0 {
line += strings.Count(t.s[:m[2]], "\n")
}
out = append(out, fmt.Sprintf("%s:%d: %q is retired — in %q", t.file, line, w, excerpt(t.s, m[2], m[3])))
}
}
}
sort.Strings(out)
return out
}
func excerpt(s string, from, to int) string {
a, b := from-50, to+50
if a < 0 {
a = 0
}
if b > len(s) {
b = len(s)
}
return strings.Join(strings.Fields(s[a:b]), " ")
}
// goStrings returns a Go file's string literals, adjacent ones joined across `+` as the program joins them.
func goStrings(path, rel string) ([]text, error) {
src, err := os.ReadFile(path)
if err != nil {
return nil, err
}
fset := token.NewFileSet()
file := fset.AddFile(rel, -1, len(src))
var s scanner.Scanner
var bad error
s.Init(file, src, func(pos token.Position, msg string) { bad = fmt.Errorf("%s: %s", pos, msg) }, 0)
var out []text
var cur *text
joining := false
for {
pos, tok, lit := s.Scan()
if tok == token.EOF {
break
}
switch {
case tok == token.STRING:
v, err := strconv.Unquote(lit)
if err != nil {
v = lit
}
if cur != nil && joining {
cur.s += v
} else {
out = append(out, text{file: rel, line: fset.Position(pos).Line, s: v})
cur = &out[len(out)-1]
}
joining = false
case tok == token.ADD && cur != nil:
joining = true
default:
cur, joining = nil, false
}
}
return out, bad
}
// scriptStrings returns a TypeScript or JavaScript file's string literals, comments skipped, adjacent ones
// joined across `+`. A template literal's `${…}` parts are left out of the text around them.
func scriptStrings(path, rel string) ([]text, error) {
b, err := os.ReadFile(path)
if err != nil {
return nil, err
}
src := string(b)
var out []text
line := 1
lastWasString, joining := false, false
for i := 0; i < len(src); i++ {
c := src[i]
switch {
case c == '\n':
line++
case c == '/' && i+1 < len(src) && src[i+1] == '/':
for i < len(src) && src[i] != '\n' {
i++
}
line++
case c == '/' && i+1 < len(src) && src[i+1] == '*':
end := strings.Index(src[i+2:], "*/")
if end < 0 {
end = len(src) - i - 2
}
line += strings.Count(src[i:i+2+end], "\n")
i += end + 3
case c == '"' || c == '\'' || c == '`':
start := line
var sb strings.Builder
depth := 0
j := i + 1
for ; j < len(src); j++ {
d := src[j]
if d == '\n' {
line++
}
if depth > 0 {
if d == '\n' {
sb.WriteByte('\n')
}
if d == '{' {
depth++
} else if d == '}' {
depth--
}
continue
}
if d == '\\' && j+1 < len(src) {
j++
sb.WriteByte(src[j])
continue
}
if c == '`' && d == '$' && j+1 < len(src) && src[j+1] == '{' {
depth = 1
j++
sb.WriteByte(' ')
continue
}
if d == c || (c != '`' && d == '\n') {
break
}
sb.WriteByte(d)
}
if lastWasString && joining && len(out) > 0 {
out[len(out)-1].s += sb.String()
} else {
out = append(out, text{file: rel, line: start, s: sb.String()})
}
lastWasString, joining = true, false
i = j
continue
case c == '+' && lastWasString:
joining = true
continue
case c == ' ' || c == '\t' || c == '\r':
continue
default:
lastWasString, joining = false, false
}
if c == '\n' {
continue
}
}
return out, nil
}
// jsonStrings returns the descriptions in a manifest — every string under a key named "description", at any
// depth. The rest of a manifest is names, paths and the contents of files it places, none of which a tool
// shows an agent.
func jsonStrings(path, rel string) ([]text, error) {
b, err := os.ReadFile(path)
if err != nil {
return nil, err
}
var v any
if err := json.Unmarshal(b, &v); err != nil {
return nil, err
}
var out []text
var walk func(any, bool)
walk = func(v any, described bool) {
switch x := v.(type) {
case string:
if described {
out = append(out, text{file: rel, line: lineOf(string(b), x), s: x})
}
case []any:
for _, e := range x {
walk(e, described)
}
case map[string]any:
for k, e := range x {
walk(e, k == "description")
}
}
}
walk(v, false)
return out, nil
}
func lineOf(src, s string) int {
q, _ := json.Marshal(s)
if i := strings.Index(src, string(q)); i >= 0 {
return strings.Count(src[:i], "\n") + 1
}
return 0
}
-89
View File
@@ -1,89 +0,0 @@
package main
import (
"os"
"testing"
)
// A retired word's plural is the word; anything else joined on is another word.
func TestPatternMatchesTheWordAndItsPlural(t *testing.T) {
cases := []struct {
word, text string
want bool
}{
{"control plane", "ask the control plane", true},
{"control plane", "two control planes", true},
{"control plane", "two control\n\tplanes", true},
{"substrate", "the substrates", true},
{"flavor", "flavors of a module", true},
{"flavor", "flavored", false},
{"node tools", "node toolsets", false},
{"mesh-console", "the mesh-consoles", true},
{"mesh-console", "mesh-console-x", false},
}
for _, c := range cases {
if got := pattern(c.word).MatchString(c.text); got != c.want {
t.Errorf("pattern(%q) on %q = %v, want %v", c.word, c.text, got, c.want)
}
}
}
// A daemon's line is found whole — the daemon, its command, and an argument where the command is a line only with
// one — and prose that merely names the daemon beside a word is not one (novox/hq ADR 0272).
func TestADaemonsLineIsFoundWhole(t *testing.T) {
push := retiredLine{daemon: "mesh-controller", command: "push"}
module := retiredLine{daemon: "mesh-controller", command: "module", withArgument: true}
handOver := retiredLine{daemon: "nox-mesh-host", command: "hand-over"}
cases := []struct {
line retiredLine
text string
want bool
}{
{push, "run `mesh-controller push laptop` after", true},
{push, "mesh-controller push", true},
{push, "mesh-controller\n push", true},
{push, "mesh-controller pushed it", false},
{push, "the mesh-controller pushes", false},
{push, "mesh-controller-push", false},
{module, "`mesh-controller module check modules/*/module.json`", true},
{module, "run\n $ mesh-controller module check x", true},
{module, "the mesh-controller module is assigned", false},
{module, "mesh-controller module", false},
{module, "`mesh-controller module`", false},
{handOver, "`nox-mesh-host hand-over /srv/x` at the machine", true},
{handOver, "`nox node hand-over laptop /srv/x`", false},
{retiredLine{daemon: "mesh-host", command: "hand-over"}, "nox-mesh-host hand-over", false},
}
for _, c := range cases {
if got := linePattern(c.line).MatchString(c.text); got != c.want {
t.Errorf("%s %s on %q = %v, want %v", c.line.daemon, c.line.command, c.text, got, c.want)
}
}
found := checkLines([]retiredLine{push, module}, []text{{file: "m/a.go", line: 3, s: "say\n`mesh-controller push`"},
{file: "m/b.go", line: 1, s: "the mesh-controller module"}})
if len(found) != 1 || found[0] != "m/a.go:4: `mesh-controller push` is a daemon's line — the operator types `nox …` (novox/hq ADR 0272) — in \"say `mesh-controller push`\"" {
t.Fatalf("found %q", found)
}
}
// The list's lines read as `<daemon> <command>`, a trailing ` …` meaning an argument must follow; anything else is
// refused rather than read as some line.
func TestRetiredLinesAreReadAsDaemonAndCommand(t *testing.T) {
dir := t.TempDir()
path := dir + "/retired-lines"
if err := os.WriteFile(path, []byte("# the list\nmesh-controller push\nmesh-controller module …\n\nnox-mesh-host hand-over\n"), 0o644); err != nil {
t.Fatal(err)
}
lines, err := readLines(path)
if err != nil || len(lines) != 3 || lines[0] != (retiredLine{daemon: "mesh-controller", command: "push"}) ||
lines[1] != (retiredLine{daemon: "mesh-controller", command: "module", withArgument: true}) ||
lines[2] != (retiredLine{daemon: "nox-mesh-host", command: "hand-over"}) {
t.Fatalf("read %+v, %v", lines, err)
}
if err := os.WriteFile(path, []byte("mesh-controller\n"), 0o644); err != nil {
t.Fatal(err)
}
if _, err := readLines(path); err == nil {
t.Fatal("a line with no command was read")
}
}
-5
View File
@@ -1,5 +0,0 @@
69
The long-running resources of this catalogue that do not say how they are ready (`health`, novox/hq ADR 0240
rule 8), as `module check` counts them. It may only go down: merge-check.sh fails a change that raises it, and
one that lowers it writes the new number on the first line. From 2026-11-18, or once it is 0, a long-running
resource without `health` is refused.
-100
View File
@@ -1,100 +0,0 @@
#!/bin/sh
# The catalogue's TypeScript part of its own check (novox/hq issue 302's `mesh-check-also`), declared in
# merge-check.sh's header and run by the build seat in the TypeScript toolchain, after the Go part. The media
# catalogue's twin is issue 311. Before this, a TypeScript module's change ran nothing of its own before it
# merged: the Go part said "NOT TESTED HERE", and a type error in the forge module would first have shown in
# its walk.
#
# For every touched module that has a package.json, in a copy of it outside the clone:
#
# 1. its dependencies are the toolchain's own (/app/node_modules: the mesh's SDK, Node's types and what the
# runtime carries), copied beside it, never fetched: the check holds no credential for the package
# registry. A module that depends on a package the toolchain does not hold **fails the check**, by name:
# it could not be type-checked or tested, and what could not run is a failure, never a note (novox/hq
# issue 385);
# 2. every source that is not a test is type-checked with the toolchain's compiler and the flags a build
# compiles with — the same check the build makes, before the merge rather than in the walk;
# 3. its tests (test/*.test.ts) run under Node's own runner with types stripped, as its own `npm test` does
# after its build: the sources are compiled to dist/ and beside themselves first, so a test that imports
# the compiled module, or a source that imports its sibling by its .js name, finds it.
#
# Said, never passed silently: a module with no tests is said NOT TESTED on every run that touches it —
# nothing of it could fail. A module whose tests exist and could not run fails (novox/hq issue 385).
#
# By hand: MESH_CHECK_MODULES=gitea sh merge-check-typescript.sh, in the toolchain image (it reads
# /app/node_modules), or with TOOLCHAIN_MODULES pointing at a node_modules that holds the same.
set -eu
deps="${TOOLCHAIN_MODULES:-/app/node_modules}"
tsc="$deps/typescript/bin/tsc"
if [ ! -f "$tsc" ]; then
echo "NOT CHECKED: no TypeScript compiler at $tsc — this part runs in the TypeScript toolchain"
exit 1
fi
# The modules the change reaches: the planner's answer (hq ADR 0238), and every module a changed file is in —
# a change to a module's tests alone moves no build (hq ADR 0267), and its tests must still run.
touched=$({
printf '%s\n' "${MESH_CHECK_MODULES:-}" | tr ',' '\n' | sed -e 's#^modules/##' -e '/\//d'
printf '%s\n' "${MESH_CHECK_CHANGED:-}" | tr ',' '\n' | sed -n 's#^modules/\([^/]*\)/.*#\1#p'
} | sed '/^$/d' | sort -u)
work=$(mktemp -d)
trap 'rm -rf "$work"' EXIT
failed=""
for m in $touched; do
d="modules/$m"
[ -f "$d/package.json" ] || continue
copy="$work/$m"
mkdir -p "$copy"
(cd "$d" && tar --exclude=./node_modules --exclude=./dist -cf - .) | (cd "$copy" && tar -xf -)
# What it depends on, against what the toolchain holds.
missing=$(node -e '
const p = require(process.argv[1]); const have = require("fs").readdirSync(process.argv[2]);
const scoped = (n) => n.startsWith("@") ? n.split("/")[0] : n;
const names = Object.keys({ ...(p.dependencies || {}), ...(p.devDependencies || {}) });
const held = (n) => have.includes(n) || (n.startsWith("@") && have.includes(scoped(n)) &&
require("fs").existsSync(process.argv[2] + "/" + n));
console.log(names.filter((n) => n !== "typescript" && !held(n)).join(" "));' "$copy/package.json" "$deps")
if [ -n "$missing" ]; then
echo "FAILED: $d could not be checked — it depends on $missing, which the toolchain does not hold and the check cannot fetch (novox/hq issue 385)"
failed="$failed $m"
continue
fi
mkdir -p "$copy/node_modules"
for p in "$deps"/*; do
case "$(basename "$p")" in typescript | esbuild | @esbuild) continue ;; esac
cp -r "$p" "$copy/node_modules/"
done
sources=$(cd "$copy" && find . -name '*.ts' ! -name '*.d.ts' ! -path './test/*' ! -path './node_modules/*' ! -path './dist/*' | sort)
if [ -n "$sources" ]; then
echo "type-checking $d (TypeScript)"
# shellcheck disable=SC2086 # the module's sources, none with a space in its name
if ! (cd "$copy" && node "$tsc" --noEmit --module NodeNext --moduleResolution NodeNext --target ES2022 \
--types node $sources); then
failed="$failed $m"
continue
fi
else
echo "$d has no TypeScript sources of its own to type-check: its tests read its files"
fi
if ! ls "$copy"/test/*.test.ts >/dev/null 2>&1; then
echo "NOT TESTED: $d is TypeScript and has no tests of its own"
continue
fi
if [ -n "$sources" ]; then
# shellcheck disable=SC2086
(cd "$copy" && node "$tsc" --module NodeNext --moduleResolution NodeNext --target ES2022 --types node \
--rootDir . --outDir dist $sources && node "$tsc" --module NodeNext --moduleResolution NodeNext \
--target ES2022 --types node --rootDir . --outDir . $sources)
fi
echo "testing $d (TypeScript)"
if ! (cd "$copy" && node --test --test-reporter=spec --experimental-strip-types \
--no-warnings=ExperimentalWarning test/*.test.ts); then
failed="$failed $m"
fi
done
if [ -n "$failed" ]; then
echo "TypeScript modules that failed:$failed"
exit 1
fi
-117
View File
@@ -1,117 +0,0 @@
#!/bin/sh
# mesh-check-toolchain: go
# mesh-check-also: typescript merge-check-typescript.sh
#
# The catalogue's own check (novox/hq ADR 0237 as amended): the second layer of a pull request's merge
# check, `mesh/repo-check`, run by the build seat in the mesh's Go toolchain.
#
# The gate — the manifests the change touches through the running controller's module check, every machine
# of the facts snapshot composed with them and validated by the node-engine's own validator, the replays
# against this tree — is the build seat's first layer (`mesh/merge-gate`), run because the mesh's module
# graph builds these modules from here. It is not repeated here. This is the repository's own:
#
# 1. every manifest through the controller's module check, so one module's change cannot leave another
# it shares a rule with refused (MESH_GATE is the controller the mesh runs) — and the count of
# long-running resources without `health` held to the number in health-undeclared, which only goes down;
# 2. the Go tests of every module the change touches that has them, under the race detector when the
# toolchain has a C compiler. A requirement of the mesh's own forge the module cache does not hold (a
# pinned SDK commit) is taken from its repository's clone beside the check (checks/deps); a module whose
# dependencies still cannot be had **fails the check**, by name and why — its tests could not run, and a
# test that could not run is a failure, never a note (novox/hq issue 385). A module written in
# TypeScript is type-checked and tested by
# merge-check-typescript.sh, declared above, in the TypeScript toolchain (novox/hq issues 302, 311);
# 3. the words (novox/hq ADR 0244): no word the glossary retired for the tools, as copied in
# retired-words, in any text a module's tools can show an agent — every string in its own code that is
# not a test, and every description in its manifest. Run over every module, not only the touched ones,
# so a word newly retired is found everywhere it stands. The check's own test runs first.
# 4. the paths (novox/hq ADR 0260): no module names a path under /run, /var/lib, /usr/local/lib or /etc
# that another module's manifest places — modules talk over the bus, not through each other's files.
# Over every module; checks/paths/shared and checks/paths/known narrow it, and known only shrinks.
# 5. the agent's home (novox/hq ADR 0266): no module unpacks an archive, or writes into a file, under
# ${machine:agent-home} — the node-engine does both after checking the path, and the agent account owns
# that home and could swap a link in between.
set -eu
(cd checks/words && go test -count=1 . && go run . ../..)
# The agent's home (novox/hq ADR 0266): no archive, and no file written into, under it.
(cd checks/agenthome && go test -count=1 . && go run . ../..)
# The lists that narrow the paths check may not grow unseen: the base branch's copies are what they are
# judged against (MESH_CHECK_BASE, main when unset), read from the clone the check runs in.
base_lists=""
base_ref="origin/${MESH_CHECK_BASE:-main}"
if git rev-parse --verify --quiet "$base_ref^{commit}" >/dev/null 2>&1 &&
git cat-file -e "$base_ref:checks/paths/known" 2>/dev/null && git cat-file -e "$base_ref:checks/paths/shared" 2>/dev/null; then
base_lists=$(mktemp -d)
git show "$base_ref:checks/paths/known" >"$base_lists/known"
git show "$base_ref:checks/paths/shared" >"$base_lists/shared"
fi
(cd checks/paths && go test -count=1 . && if [ -n "$base_lists" ]; then go run . -base "$base_lists" ../..; else go run . ../..; fi)
if [ -n "${MESH_GATE:-}" ]; then
checked=$("$MESH_GATE" module check modules/*/module.json) || { printf '%s\n' "$checked"; exit 1; }
# **The count only goes down** (novox/hq ADR 0240 rule 8): the long-running resources that do not say how
# they are ready, as the controller counts them, against the number kept in health-undeclared. A change
# that raises it fails; one that lowers it writes the new number there, so it can never rise again.
kept=$(sed -n 's/^\([0-9][0-9]*\).*/\1/p' health-undeclared | head -n 1)
counted=$(printf '%s\n' "$checked" | sed -n 's/^long-running resources without health: \([0-9][0-9]*\)$/\1/p')
if [ -z "$counted" ]; then
echo "NOT COUNTED: the controller the mesh runs is older than the count of resources without health (ADR 0240 Phase B)"
elif [ "$counted" -gt "$kept" ]; then
echo "the long-running resources without health rose from $kept to $counted: declare how each new one is ready (ADR 0240 rule 8)"
exit 1
elif [ "$counted" -lt "$kept" ]; then
echo "the long-running resources without health fell from $kept to $counted: write $counted in health-undeclared, so the count cannot rise again"
exit 1
else
echo "long-running resources without health: $counted, as kept"
fi
else
echo "NOT CHECKED: no controller was built beside this check, so the manifests were not read by one"
fi
race=""
if command -v gcc >/dev/null 2>&1; then race="-race"; else echo "NOT RACE-CHECKED: the toolchain holds no C compiler"; fi
# The modules the change reaches are the controller's planner's answer (MESH_CHECK_MODULES, hq ADR 0238):
# a module by its name, a new one by its directory. By hand, without it, the directories the changed files
# are in.
if [ -n "${MESH_CHECK_MODULES:-}" ]; then
touched=$(printf '%s\n' "$MESH_CHECK_MODULES" | tr ',' '\n' | sed -e 's#^modules/##' -e '/\//d' | sort -u)
else
touched=$(printf '%s\n' "${MESH_CHECK_CHANGED:-}" | tr ',' '\n' | sed -n 's#^modules/\([^/]*\)/.*#\1#p' | sort -u)
fi
# A module's dependencies: fetched, or a forge requirement taken from its clone beside the check
# (MESH_CHECK_BESIDE); its tests run against them (checks/deps, novox/hq issue 385). The helper's own test
# runs first.
(cd checks/deps && go test -count=1 .)
deps=$(mktemp -d)
trap 'rm -rf "$deps"' EXIT
(cd checks/deps && go build -o "$deps/deps" .)
untestable=""
for m in $touched; do
[ -d "modules/$m" ] || continue
if [ ! -f "modules/$m/go.mod" ]; then
[ -f "modules/$m/package.json" ] && echo "modules/$m is TypeScript: merge-check-typescript.sh type-checks and tests it"
continue
fi
if ! modfile=$(GOPRIVATE=git.novox.be "$deps/deps" -beside "${MESH_CHECK_BESIDE:-}" -work "$deps" "modules/$m"); then
echo "FAILED: modules/$m could not be tested — $modfile"
untestable="$untestable modules/$m"
continue
fi
[ -n "$modfile" ] && echo "modules/$m takes its forge dependencies from the clones beside the check"
echo "testing modules/$m"
unformatted=$(cd "modules/$m" && gofmt -l .)
if [ -n "$unformatted" ]; then
echo "modules/$m is not gofmt'd: $unformatted"
exit 1
fi
cgo=0
[ -n "$race" ] && cgo=1
(cd "modules/$m" && CGO_ENABLED=$cgo GOPRIVATE=git.novox.be go vet ${modfile:+"-modfile=$modfile"} ./... &&
CGO_ENABLED=$cgo GOPRIVATE=git.novox.be go test ${modfile:+"-modfile=$modfile"} $race -count=1 ./...)
done
if [ -n "$untestable" ]; then
echo "the check fails: the tests of$untestable could not run (novox/hq issue 385)"
exit 1
fi
-103
View File
@@ -1,103 +0,0 @@
# adwaita
The desktop's theme as a module (novox/hq ADR 0208, research 026, to-be 42 phase 2, step 6): Adwaita
for GTK, Qt, the portal and the cursor, dark by default. It claims no seat, because themes coexist.
- **Packages:** `gnome-themes-extra` (Adwaita-dark for GTK 2 and 3), `adwaita-icon-theme`,
`adwaita-cursors`, `qt6ct`, `xdg-desktop-portal-gtk`.
- **Environment** (ADR 0203), the five words today's `~/.xinitrc` exported plus the cursor:
- `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`.
- **Session code** (ADR 0208 §4):
- in the `xinitrc` slot `normal`, the GSettings keys the portal serves (dark, the GTK and icon theme,
the cursor, the UI and monospace fonts), set at every session start. This replaces the
predecessor's `~/scripts/xdg-appearance`;
- in the `xresources` slot `normal`, `Xcursor.theme` and `Xcursor.size`.
## What it owns
| 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-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 |
**`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
the operator's (issue 168).
## What it improves
- **No package from the user repository.** Today's Qt style, `adwaita-dark` (`QT_STYLE_OVERRIDE` and
qt6ct's `Adwaita-Dark`), comes from the user repository's `adwaita-qt5`/`adwaita-qt6-git`, a
project that is no longer developed. The module uses Qt's own **Fusion** style with qt6ct's
**`darker`** palette, both shipped with Qt and qt6ct. **This is the one visible change:** Qt
programs keep a dark palette, drawn by Fusion instead of Adwaita-Qt.
- **One Qt tool.** `qt5ct` is gone (installed on the desktop only, with a configuration on both).
`QT_SELECT=6` and `qt6ct` cover the Qt 6 programs. A Qt 5 program gets Fusion through
`QT_STYLE_OVERRIDE`, but not the palette.
- **The fonts research 026/04 chose:** Inter 11 for GTK, Qt and GSettings' interface font, and
JetBrains Mono Nerd Font for Qt's fixed font and GSettings' monospace. Today these are Noto Sans 12,
Adwaita Sans 11 and nothing.
- **The cursor said everywhere**: GTK's settings, GSettings, `XCURSOR_*`, the X resources and the
default theme. Today only the resources and GSettings said it.
- **The portal answers secrets.** `portals.conf` routes `org.freedesktop.impl.portal.Secret` to
gnome-keyring. Today `adwaita_portal_check` shows no backend answering it: gtk does not implement
it, and gnome-keyring's backend names only GNOME.
## Tools
| tool | | what |
|---|---|---|
| `adwaita_appearance` | r/a | dark or light as each audience sees it (GSettings, the portal's own answer, the GTK files, Qt's style and palette, the theme words in the user manager). `mode` switches GSettings for the session, and the answer says what follows live (programs asking the portal) and what stays dark (GTK 3 under `GTK_THEME`, the declared files) |
| `adwaita_cursor` | r/a | the cursor in GSettings, the resources and the environment, and the cursor themes installed; set theme or size for new windows (GSettings, and the resources when a session runs) |
| `adwaita_icons` | r | icon themes installed (where, what each inherits, whether it has cursors), and the one GSettings, GTK and Qt use |
| `adwaita_portal_check` | r | the backends installed and what each implements, which `portals.conf` decides (the first that exists, in xdg-desktop-portal's order), the backend answering each interface, what runs on the bus, and the colour scheme the portal answers |
Each reaches GSettings, the portal and the user manager on the account's bus. Only the cursor's X
resources need the session. From the user manager it reads only the theme's own words.
**The appearance is a session's choice.** A persistent dark or light, for the files too, is a setting
and waits for issue 168. Until then the module's default is dark, and `mode` lasts until the next
login.
## What it leaves found
`~/.config/qt5ct/`, `~/.config/gtk-3.0/bookmarks` and everything else in those directories,
`~/scripts/xdg-appearance`, and the user repository's `adwaita-qt*` packages.
## The one-off migration (ADR 0182)
**Once `adwaita` is assigned:**
1. In `~/.xinitrc`, the theme exports and `~/scripts/xdg-appearance || true` go (see `xorg`'s list).
2. Delete `~/scripts/xdg-appearance`.
3. In `~/.Xresources`, delete the two `Xcursor` lines.
4. Delete `~/.config/qt5ct/`.
5. Remove the user repository's packages: `sudo pacman -Rns adwaita-qt5-git adwaita-qt6-git`
(laptop), `sudo pacman -Rns adwaita-qt5 adwaita-qt6-git adwaita-dark qt5ct` (desktop). Check first
that nothing else needs them (`pacman -Qi`).
## What changes when it is assigned
| | g14 | shanks |
|---|---|---|
| packages | none (all present) | the same |
| GTK settings, `portals.conf` | the found files kept once, then the module's: adds the cursor and Inter, keeps Adwaita dark; `portals.conf` adds the Secret line | the same |
| `qt6ct.conf` | Fusion with the `darker` palette, Inter and JetBrains Mono, instead of Adwaita-Dark with Noto Sans | the same |
| `~/.icons/default/index.theme` | new | new |
| environment | `QT_STYLE_OVERRIDE` becomes `Fusion`; `XCURSOR_*` added; the rest as `~/.xinitrc` exported them | the same |
| running programs | **nothing**: settings are read at a program's start, and GSettings is set at the next login | the same |
| next login | GSettings' fonts become Inter and JetBrains Mono. A secret request through the portal finds gnome-keyring | the same |
## Blockers
- **`fonts` first**, for Inter and JetBrains Mono. Without them, GTK and Qt fall back to the nearest
installed face.
- **Light is a session's choice only**, until settings (issue 168).
@@ -1,246 +0,0 @@
package main
import (
"context"
"encoding/json"
"os"
"path/filepath"
"strings"
"testing"
"adwaita/internal/desktop"
)
type manifest struct {
Capabilities []string `json:"capabilities"`
Claims []any `json:"claims"`
Tools []string `json:"tools"`
Environment struct {
Variables map[string]string `json:"variables"`
} `json:"environment"`
Shell []struct {
For, Slot, Code string
} `json:"shell"`
Resources []map[string]any `json:"resources"`
}
func readManifest(t *testing.T) manifest {
raw, err := os.ReadFile(filepath.Join("..", "..", "module.json"))
if err != nil {
t.Fatal(err)
}
var m manifest
if err := json.Unmarshal(raw, &m); err != nil {
t.Fatal(err)
}
return m
}
func TestItContributesTheThemesWordsAndClaimsNoSeat(t *testing.T) {
m := readManifest(t)
if m.Claims != nil {
t.Fatal("a theme is not a seat: several coexist")
}
want := map[string]string{"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"}
if len(m.Environment.Variables) != len(want) {
t.Fatalf("%v", m.Environment.Variables)
}
for k, v := range want {
if m.Environment.Variables[k] != v {
t.Errorf("%s=%q", k, m.Environment.Variables[k])
}
}
served := map[string]bool{}
for _, tool := range tools(adwaita{}) {
served[tool.Name] = true
}
if len(served) != len(m.Tools) {
t.Fatalf("%v %v", served, m.Tools)
}
for _, n := range m.Tools {
if !served[n] {
t.Errorf("%s", n)
}
}
}
func TestTheSessionLinesSetGSettingsAndTheResourcesTheCursor(t *testing.T) {
m := readManifest(t)
if len(m.Shell) != 2 || m.Shell[0].For != "xresources" || m.Shell[1].For != "xinitrc" || m.Shell[0].Slot != "normal" || m.Shell[1].Slot != "normal" {
t.Fatalf("%+v", m.Shell)
}
if m.Shell[0].Code != "! adwaita: the cursor, for X programs that take it from the resources.\nXcursor.theme: Adwaita\nXcursor.size: 24\n" {
t.Fatalf("%q", m.Shell[0].Code)
}
x := m.Shell[1].Code
for _, want := range []string{"color-scheme 'prefer-dark'", "gtk-theme 'Adwaita'", "icon-theme 'Adwaita'", "cursor-theme 'Adwaita'", "font-name 'Inter 11'", "monospace-font-name 'JetBrainsMono Nerd Font 11'"} {
if !strings.Contains(x, want) {
t.Errorf("lacks %s", want)
}
}
for _, l := range strings.Split(strings.TrimSpace(x), "\n") {
if !strings.HasPrefix(l, "#") && !strings.HasSuffix(l, "|| true") {
t.Errorf("a session line that can stop the session's start: %q", l)
}
}
}
func TestItOwnsTheFilesItsSourcesHoldAndNoQt5Duplicate(t *testing.T) {
m := readManifest(t)
sources := map[string]string{"gtk3": "gtk-settings.ini", "gtk4": "gtk-settings.ini", "qt6ct": "qt6ct.conf", "portals": "portals.conf", "cursor": "cursor-index.theme"}
pkgs := []string{}
for _, r := range m.Resources {
id := r["id"].(string)
if r["type"] == "package" {
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])
}
if strings.Contains(r["path"].(string), "qt5ct") {
t.Error("qt5ct: both workstations run Qt 6 programs through qt6ct; a second tool is a duplicate")
}
}
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"))
if qt["Appearance"]["style"] != "Fusion" || qt["Appearance"]["custom_palette"] != "true" || !strings.Contains(qt["Fonts"]["general"], "Inter,11") {
t.Fatalf("%v", qt)
}
if _, err := os.Stat("/usr/share/qt6ct/colors"); err == nil {
if _, err := os.Stat(qt["Appearance"]["color_scheme_path"]); err != nil {
t.Fatalf("qt6ct ships no %s", qt["Appearance"]["color_scheme_path"])
}
}
gtk := readINI(filepath.Join("..", "..", "config", "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)
}
}
func TestKeyFilesAreReadWithTheirComments(t *testing.T) {
ini := ParseINI("# c\n[A]\nk = v\n; also a comment\n[B]\nx=1=2\n")
if ini["A"]["k"] != "v" || ini["B"]["x"] != "1=2" || len(ini) != 2 {
t.Fatalf("%v", ini)
}
}
func TestThePortalsAnswerIsResolvedAsXdgDesktopPortalDoes(t *testing.T) {
dir := t.TempDir()
os.WriteFile(filepath.Join(dir, "gtk.portal"), []byte("[portal]\nDBusName=org.freedesktop.impl.portal.desktop.gtk\nInterfaces=org.freedesktop.impl.portal.FileChooser;org.freedesktop.impl.portal.Settings;\nUseIn=gnome\n"), 0o644)
os.WriteFile(filepath.Join(dir, "gnome-keyring.portal"), []byte("[portal]\nDBusName=org.freedesktop.secrets\nInterfaces=org.freedesktop.impl.portal.Secret;\nUseIn=gnome\n"), 0o644)
os.WriteFile(filepath.Join(dir, "kde.portal"), []byte("[portal]\nDBusName=org.freedesktop.impl.portal.desktop.kde\nInterfaces=org.freedesktop.impl.portal.FileChooser;\nUseIn=KDE\n"), 0o644)
b := Backends([]string{dir})
if len(b) != 3 || b[0].Name != "gnome-keyring" || len(b[1].Interfaces) != 2 {
t.Fatalf("%+v", b)
}
got := Resolve(b, map[string]string{"default": "gtk", "org.freedesktop.impl.portal.Secret": "gnome-keyring"}, []string{"i3"})
if got["org.freedesktop.impl.portal.FileChooser"] != "gtk" || got["org.freedesktop.impl.portal.Secret"] != "gnome-keyring" || got["org.freedesktop.impl.portal.Settings"] != "gtk" {
t.Fatalf("%v", got)
}
got = Resolve(b, map[string]string{"default": "gtk"}, []string{"i3"})
if got["org.freedesktop.impl.portal.Secret"] != "(none)" {
t.Fatalf("gtk does not implement secrets: %v", got)
}
got = Resolve(b, map[string]string{"default": "none;gtk"}, nil)
if got["org.freedesktop.impl.portal.FileChooser"] != "(none)" {
t.Fatalf("none stops the list: %v", got)
}
got = Resolve(b, nil, []string{"KDE"})
if got["org.freedesktop.impl.portal.FileChooser"] != "kde" || got["org.freedesktop.impl.portal.Settings"] != "(none)" {
t.Fatalf("with no configuration, UseIn decides: %v", got)
}
c := PortalConfigs("/h", []string{"i3", "GNOME"})
if c[0] != "/h/.config/xdg-desktop-portal/i3-portals.conf" || c[1] != "/h/.config/xdg-desktop-portal/gnome-portals.conf" || c[2] != "/h/.config/xdg-desktop-portal/portals.conf" {
t.Fatalf("%v", c[:3])
}
}
func TestThemesAreFoundOnceEachWithCursorsAndIcons(t *testing.T) {
a, b := t.TempDir(), t.TempDir()
os.MkdirAll(filepath.Join(a, "Adwaita", "cursors"), 0o755)
os.MkdirAll(filepath.Join(b, "Adwaita"), 0o755)
os.WriteFile(filepath.Join(b, "Adwaita", "index.theme"), []byte("[Icon Theme]\nName=Adwaita\nInherits=hicolor\nDirectories=16x16\n"), 0o644)
os.MkdirAll(filepath.Join(b, "hicolor"), 0o755)
os.WriteFile(filepath.Join(b, "hicolor", "index.theme"), []byte("[Icon Theme]\nName=Hicolor\nDirectories=16x16\n"), 0o644)
os.MkdirAll(filepath.Join(b, "empty"), 0o755)
got := Themes([]string{a, b})
if len(got) != 2 || got[0].Name != "Adwaita" || !got[0].Cursors || got[0].Icons || got[0].Dir != filepath.Join(a, "Adwaita") || got[1].Name != "hicolor" {
t.Fatalf("the first directory's Adwaita hides the second's: %+v", got)
}
}
type fake struct {
ran []string
get map[string]string
}
func (f *fake) desk() desktop.Desk {
return desktop.Desk{
Find: func() (*desktop.Session, error) { return nil, &desktop.NoSession{Reason: "none"} },
Run: func(_ context.Context, env []string, _ []byte, name string, args ...string) desktop.Result {
line := strings.Join(append([]string{name}, args...), " ")
f.ran = append(f.ran, line)
switch {
case name == "gsettings" && args[0] == "get":
return desktop.Result{Stdout: "'" + f.get[args[2]] + "'\n"}
case name == "gsettings" && args[0] == "set":
f.get[args[2]] = args[3]
case name == "systemctl":
return desktop.Result{Stdout: "GTK_THEME=Adwaita:dark\nNPM_TOKEN=secret\nXDG_CURRENT_DESKTOP=i3\n"}
case name == "busctl" && args[1] == "call":
return desktop.Result{Stdout: "v u 1\n"}
}
return desktop.Result{}
},
}
}
func TestAppearanceSwitchesGSettingsAndSaysWhatFollowsAndWhatStays(t *testing.T) {
f := &fake{get: map[string]string{"color-scheme": "prefer-dark", "gtk-theme": "Adwaita"}}
a := adwaita{d: f.desk(), home: t.TempDir()}
got, err := a.appearance(context.Background(), desktop.Args{"mode": "light"})
if err != nil {
t.Fatal(err)
}
j := asJSON(got)
if f.get["color-scheme"] != "prefer-light" || !strings.Contains(j, `"switched":"light"`) || !strings.Contains(j, "GTK_THEME=Adwaita:dark") || !strings.Contains(j, `"lasts"`) {
t.Fatalf("%s", j)
}
if strings.Contains(j, "secret") || strings.Contains(j, "NPM_TOKEN") {
t.Fatal("only the theme's words are read back from the user manager")
}
if _, err := a.appearance(context.Background(), desktop.Args{"mode": "blue"}); err == nil {
t.Fatal("mode is dark or light")
}
got, _ = a.appearance(context.Background(), desktop.Args{})
if strings.Contains(asJSON(got), "switched") {
t.Fatal("reading changes nothing")
}
}
func TestACursorThemeMustBeInstalledAndWithoutASessionOnlyGSettingsChanges(t *testing.T) {
dir := t.TempDir()
os.MkdirAll(filepath.Join(dir, "Adwaita", "cursors"), 0o755)
f := &fake{get: map[string]string{}}
a := adwaita{d: f.desk(), home: t.TempDir(), iconDirs: []string{dir}}
if _, err := a.cursor(context.Background(), desktop.Args{"theme": "Bibata"}); err == nil {
t.Fatal("a theme that is not installed")
}
got, err := a.cursor(context.Background(), desktop.Args{"theme": "Adwaita", "size": float64(32)})
if err != nil {
t.Fatal(err)
}
if f.get["cursor-theme"] != "Adwaita" || f.get["cursor-size"] != "32" || !strings.Contains(asJSON(got), "no graphical session") {
t.Fatalf("%v %s", f.get, asJSON(got))
}
}
func asJSON(v any) string {
b, _ := json.Marshal(v)
return string(b)
}
-84
View File
@@ -1,84 +0,0 @@
// adwaita's tools (novox/hq ADR 0208, research 026/05): appearance (dark or light for GTK, Qt and the
// portal at once), cursor, icons and portal-check. The predecessor's appearance script is folded into
// the first, and into the module's session line.
//
// None needs the display except setting the cursor's X resources: GSettings, the portal and the user
// manager are reached on the account's own bus, which exists whenever the operator's user manager
// runs.
package main
import (
"context"
"fmt"
"os"
"path/filepath"
"time"
stdio "git.novox.be/novox/mesh-sdk/go"
"adwaita/internal/desktop"
)
func main() {
home := desktop.Home()
a := adwaita{
d: desktop.Machine("i3", "sway"), home: home,
iconDirs: []string{filepath.Join(home, ".local", "share", "icons"), filepath.Join(home, ".icons"), "/usr/local/share/icons", "/usr/share/icons"},
portalDirs: []string{"/usr/share/xdg-desktop-portal/portals"},
uid: os.Getuid(),
}
if err := stdio.Serve("", tools(a)); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
}
func call(run func(ctx context.Context, a desktop.Args) (any, error)) func(map[string]any) (any, error) {
return func(args map[string]any) (any, error) {
ctx, cancel := context.WithTimeout(context.Background(), 25*time.Second)
defer cancel()
return run(ctx, desktop.Args(args))
}
}
func tools(a adwaita) []stdio.Tool {
return []stdio.Tool{
{
Name: "adwaita_appearance",
Description: "Dark or light, as each audience sees it now: GSettings (which the portal serves to " +
"Electron, Firefox and flatpaks), the portal's own answer, the GTK settings files, Qt's palette " +
"and the theme words in the user manager's environment. With mode, switch GSettings for the " +
"running session and answer which audiences follow live and which keep the module's dark " +
"default until it is a setting.",
Input: desktop.Schema(map[string]any{"mode": desktop.Enum("switch to (optional)", "dark", "light")}),
Run: call(a.appearance),
},
{
Name: "adwaita_cursor",
Description: "The cursor theme and size in force (GSettings, the X resources, XCURSOR_* in the user " +
"manager) and the cursor themes installed. With theme and/or size, set them for windows opened " +
"from now on; the module's defaults return at the next login.",
Input: desktop.Schema(map[string]any{
"theme": desktop.Str("an installed cursor theme"),
"size": desktop.Int("pixels, 8 to 256"),
}),
Run: call(a.cursor),
},
{
Name: "adwaita_icons",
Description: "The icon themes installed (name, where, what each inherits, whether it carries cursors) " +
"and the one GTK and Qt are set to use.",
Input: desktop.Schema(map[string]any{}),
Run: call(a.icons),
},
{
Name: "adwaita_portal_check",
Description: "Which xdg-desktop-portal backend answers which interface for this desktop: the backends " +
"installed and what they implement, the portals.conf that decides (and which one won), the " +
"resulting backend per interface, whether the portal and each backend are running on the " +
"account's bus, and the colour scheme the portal answers.",
Input: desktop.Schema(map[string]any{}),
Run: call(a.portalCheck),
},
}
}
-434
View File
@@ -1,434 +0,0 @@
package main
import (
"bufio"
"context"
"fmt"
"os"
"path/filepath"
"regexp"
"sort"
"strconv"
"strings"
"adwaita/internal/desktop"
)
type adwaita struct {
d desktop.Desk
home string
iconDirs []string
portalDirs []string
uid int
}
const iface = "org.gnome.desktop.interface"
// ParseINI reads a key file (GTK's settings.ini, qt6ct.conf, a .portal, index.theme): sections of
// key=value, `#` and `;` comments.
func ParseINI(text string) map[string]map[string]string {
out := map[string]map[string]string{}
section := ""
sc := bufio.NewScanner(strings.NewReader(text))
for sc.Scan() {
l := strings.TrimSpace(sc.Text())
if l == "" || strings.HasPrefix(l, "#") || strings.HasPrefix(l, ";") {
continue
}
if strings.HasPrefix(l, "[") && strings.HasSuffix(l, "]") {
section = l[1 : len(l)-1]
continue
}
if k, v, ok := strings.Cut(l, "="); ok {
if out[section] == nil {
out[section] = map[string]string{}
}
out[section][strings.TrimSpace(k)] = strings.TrimSpace(v)
}
}
return out
}
func readINI(path string) map[string]map[string]string {
b, err := os.ReadFile(path)
if err != nil {
return nil
}
return ParseINI(string(b))
}
// gsetting is one GSettings value, unquoted.
func (a adwaita) gsetting(ctx context.Context, schema, key string) (string, error) {
r := a.d.AsUser(ctx, "gsettings", "get", schema, key)
if !r.OK() {
return "", r.Err()
}
return strings.Trim(strings.TrimSpace(r.Stdout), "'"), nil
}
// themeWords are the words of the user manager's environment the theme is about; nothing else of it
// is read back.
var themeWords = []string{"GTK_THEME", "GTK2_RC_FILES", "QT_QPA_PLATFORMTHEME", "QT_STYLE_OVERRIDE", "QT_SELECT",
"XCURSOR_THEME", "XCURSOR_SIZE", "XDG_CURRENT_DESKTOP"}
func (a adwaita) userEnvironment(ctx context.Context) map[string]string {
r := a.d.AsUser(ctx, "systemctl", "--user", "show-environment")
all := desktop.ParseProperties(r.Stdout)
out := map[string]string{}
for _, w := range themeWords {
if v, ok := all[w]; ok {
out[w] = v
}
}
return out
}
var portalScheme = regexp.MustCompile(`^v u (\d)`)
// portalColourScheme asks the portal what it tells applications: 0 no preference, 1 dark, 2 light.
func (a adwaita) portalColourScheme(ctx context.Context) string {
r := a.d.AsUser(ctx, "busctl", "--user", "call", "org.freedesktop.portal.Desktop", "/org/freedesktop/portal/desktop",
"org.freedesktop.portal.Settings", "ReadOne", "ss", "org.freedesktop.appearance", "color-scheme")
m := portalScheme.FindStringSubmatch(strings.TrimSpace(r.Stdout))
if !r.OK() || m == nil {
return "unanswered"
}
return map[string]string{"0": "no-preference", "1": "dark", "2": "light"}[m[1]]
}
func (a adwaita) appearance(ctx context.Context, args desktop.Args) (any, error) {
mode := args.Opt("mode", "")
if mode != "" && mode != "dark" && mode != "light" {
return nil, fmt.Errorf("mode is dark or light")
}
if mode != "" {
scheme := map[string]string{"dark": "prefer-dark", "light": "prefer-light"}[mode]
if r := a.d.AsUser(ctx, "gsettings", "set", iface, "color-scheme", scheme); !r.OK() {
return nil, r.Err()
}
}
scheme, err := a.gsetting(ctx, iface, "color-scheme")
if err != nil {
return nil, fmt.Errorf("GSettings does not answer on the account's bus: %w", err)
}
gtkTheme, _ := a.gsetting(ctx, iface, "gtk-theme")
gtk3 := readINI(filepath.Join(a.home, ".config", "gtk-3.0", "settings.ini"))["Settings"]
gtk4 := readINI(filepath.Join(a.home, ".config", "gtk-4.0", "settings.ini"))["Settings"]
qt := readINI(filepath.Join(a.home, ".config", "qt6ct", "qt6ct.conf"))["Appearance"]
env := a.userEnvironment(ctx)
answer := map[string]any{
"gsettings": map[string]string{"color-scheme": scheme, "gtk-theme": gtkTheme},
"portal": a.portalColourScheme(ctx),
"gtk3": map[string]string{"theme": gtk3["gtk-theme-name"], "prefer-dark": gtk3["gtk-application-prefer-dark-theme"]},
"gtk4": map[string]string{"theme": gtk4["gtk-theme-name"], "prefer-dark": gtk4["gtk-application-prefer-dark-theme"]},
"qt": map[string]string{"style": qt["style"], "palette": filepath.Base(qt["color_scheme_path"])},
"environment": env,
}
if mode != "" {
var stays []string
if strings.HasSuffix(env["GTK_THEME"], ":dark") && mode == "light" {
stays = append(stays, "GTK 3 programs: GTK_THEME="+env["GTK_THEME"]+" in the environment pins them dark")
}
if mode == "light" {
stays = append(stays, "the GTK settings files and Qt's palette, which the module declares dark")
}
answer["switched"] = mode
answer["follows_live"] = "programs asking the portal: Electron, Chromium, Firefox, libadwaita and flatpaks"
if len(stays) > 0 {
answer["stays"] = stays
}
answer["lasts"] = "until the next login, which sets the module's default (dark) again; a persistent choice waits for settings (hq issue 168)"
}
return answer, nil
}
// Theme is one installed icon or cursor theme.
type Theme struct {
Name string `json:"name"`
Dir string `json:"dir"`
Title string `json:"title,omitempty"`
Inherits []string `json:"inherits,omitempty"`
Cursors bool `json:"cursors"`
Icons bool `json:"icons"`
}
// Themes lists the themes in dirs; a name found earlier hides the same name later, as lookups do.
func Themes(dirs []string) []Theme {
seen := map[string]bool{}
var out []Theme
for _, d := range dirs {
entries, _ := os.ReadDir(d)
for _, e := range entries {
if !e.IsDir() && e.Type()&os.ModeSymlink == 0 || seen[e.Name()] {
continue
}
dir := filepath.Join(d, e.Name())
t := Theme{Name: e.Name(), Dir: dir}
if info, err := os.Stat(filepath.Join(dir, "cursors")); err == nil && info.IsDir() {
t.Cursors = true
}
if ini := readINI(filepath.Join(dir, "index.theme")); ini != nil {
th := ini["Icon Theme"]
t.Title = th["Name"]
if th["Directories"] != "" {
t.Icons = true
}
for _, i := range strings.Split(th["Inherits"], ",") {
if i = strings.TrimSpace(i); i != "" {
t.Inherits = append(t.Inherits, i)
}
}
}
if !t.Cursors && !t.Icons && t.Title == "" {
continue
}
seen[e.Name()] = true
out = append(out, t)
}
}
sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name })
return out
}
var cursorName = regexp.MustCompile(`^[A-Za-z0-9._ -]{1,64}$`)
func (a adwaita) cursor(ctx context.Context, args desktop.Args) (any, error) {
theme := args.Opt("theme", "")
size, err := args.Whole("size", 0, 8, 256)
if err != nil {
return nil, err
}
var cursors []string
installed := map[string]bool{}
for _, t := range Themes(a.iconDirs) {
if t.Cursors {
cursors = append(cursors, t.Name)
installed[t.Name] = true
}
}
changed := theme != "" || size != 0
if theme != "" && (!cursorName.MatchString(theme) || !installed[theme]) {
return nil, fmt.Errorf("%q is not an installed cursor theme; installed: %s", theme, strings.Join(cursors, ", "))
}
var resources []string
if theme != "" {
if r := a.d.AsUser(ctx, "gsettings", "set", iface, "cursor-theme", theme); !r.OK() {
return nil, r.Err()
}
resources = append(resources, "Xcursor.theme: "+theme)
}
if size != 0 {
if r := a.d.AsUser(ctx, "gsettings", "set", iface, "cursor-size", strconv.Itoa(size)); !r.OK() {
return nil, r.Err()
}
resources = append(resources, "Xcursor.size: "+strconv.Itoa(size))
}
answer := map[string]any{"installed": cursors}
gTheme, _ := a.gsetting(ctx, iface, "cursor-theme")
gSize, _ := a.gsetting(ctx, iface, "cursor-size")
answer["gsettings"] = map[string]string{"cursor-theme": gTheme, "cursor-size": gSize}
env := a.userEnvironment(ctx)
answer["environment"] = map[string]string{"XCURSOR_THEME": env["XCURSOR_THEME"], "XCURSOR_SIZE": env["XCURSOR_SIZE"]}
if s, err := a.d.Find(); err == nil {
senv := s.Env(a.d.Base)
if len(resources) > 0 {
if r := a.d.Run(ctx, senv, []byte(strings.Join(resources, "\n")+"\n"), "xrdb", "-nocpp", "-merge", "-"); !r.OK() {
return nil, r.Err()
}
}
q := a.d.Run(ctx, senv, nil, "xrdb", "-query")
x := map[string]string{}
for _, l := range strings.Split(q.Stdout, "\n") {
if k, v, ok := strings.Cut(l, ":"); ok && strings.HasPrefix(k, "Xcursor.") {
x[k] = strings.TrimSpace(v)
}
}
answer["x_resources"] = x
} else {
answer["x_resources"] = nil
if changed {
answer["note"] = "no graphical session: GSettings changed, the X resources not"
}
}
if changed {
answer["lasts"] = "for windows opened from now on, until the next login"
}
return answer, nil
}
func (a adwaita) icons(ctx context.Context, args desktop.Args) (any, error) {
var themes []Theme
for _, t := range Themes(a.iconDirs) {
if t.Icons {
themes = append(themes, t)
}
}
gtk3 := readINI(filepath.Join(a.home, ".config", "gtk-3.0", "settings.ini"))["Settings"]
qt := readINI(filepath.Join(a.home, ".config", "qt6ct", "qt6ct.conf"))["Appearance"]
g, _ := a.gsetting(ctx, iface, "icon-theme")
return map[string]any{
"installed": themes,
"in_use": map[string]string{"gsettings": g, "gtk": gtk3["gtk-icon-theme-name"], "qt": qt["icon_theme"]},
}, nil
}
// Backend is one installed portal backend.
type Backend struct {
Name string `json:"name"`
DBusName string `json:"dbus_name"`
Interfaces []string `json:"interfaces"`
UseIn []string `json:"use_in,omitempty"`
Running bool `json:"running"`
}
func splitList(v string) []string {
var out []string
for _, x := range strings.Split(v, ";") {
if x = strings.TrimSpace(x); x != "" {
out = append(out, x)
}
}
return out
}
// Backends reads the installed `.portal` files.
func Backends(dirs []string) []Backend {
var out []Backend
for _, d := range dirs {
files, _ := filepath.Glob(filepath.Join(d, "*.portal"))
sort.Strings(files)
for _, f := range files {
p := readINI(f)["portal"]
out = append(out, Backend{Name: strings.TrimSuffix(filepath.Base(f), ".portal"), DBusName: p["DBusName"],
Interfaces: splitList(p["Interfaces"]), UseIn: splitList(p["UseIn"])})
}
}
return out
}
// PortalConfigs are the files xdg-desktop-portal looks for, in its order (portals.conf(5)): for each
// directory, `<desktop>-portals.conf` for each of the desktops named, then `portals.conf`. The first
// that exists decides everything.
func PortalConfigs(home string, desktops []string) []string {
dirs := []string{
filepath.Join(home, ".config", "xdg-desktop-portal"), "/etc/xdg/xdg-desktop-portal", "/etc/xdg-desktop-portal",
filepath.Join(home, ".local", "share", "xdg-desktop-portal"), "/usr/local/share/xdg-desktop-portal", "/usr/share/xdg-desktop-portal",
}
var out []string
for _, d := range dirs {
for _, desk := range desktops {
out = append(out, filepath.Join(d, strings.ToLower(desk)+"-portals.conf"))
}
out = append(out, filepath.Join(d, "portals.conf"))
}
return out
}
// Resolve says which backend answers each interface the backends implement, given the deciding
// file's [preferred] section: an interface's own key first, else `default`; each a list of backend
// names, the first one that implements the interface wins; `none` answers nothing, `*` any.
// With no file, a backend whose UseIn names the desktop answers.
func Resolve(backends []Backend, preferred map[string]string, desktops []string) map[string]string {
out := map[string]string{}
implements := func(b Backend, i string) bool {
for _, x := range b.Interfaces {
if x == i {
return true
}
}
return false
}
var all []string
seen := map[string]bool{}
for _, b := range backends {
for _, i := range b.Interfaces {
if !seen[i] {
seen[i] = true
all = append(all, i)
}
}
}
sort.Strings(all)
for _, i := range all {
var want []string
if preferred != nil {
if v, ok := preferred[i]; ok {
want = splitList(v)
} else {
want = splitList(preferred["default"])
}
} else {
for _, b := range backends {
for _, u := range b.UseIn {
for _, d := range desktops {
if strings.EqualFold(u, d) {
want = append(want, b.Name)
}
}
}
}
}
out[i] = "(none)"
pick:
for _, w := range want {
if w == "none" {
break
}
for _, b := range backends {
if (w == "*" || w == b.Name) && implements(b, i) {
out[i] = b.Name
break pick
}
}
}
}
return out
}
func (a adwaita) portalCheck(ctx context.Context, args desktop.Args) (any, error) {
env := a.userEnvironment(ctx)
desktops := splitColon(env["XDG_CURRENT_DESKTOP"])
backends := Backends(a.portalDirs)
names := map[string]bool{}
if r := a.d.AsUser(ctx, "busctl", "--user", "list", "--no-legend", "--no-pager"); r.OK() {
for _, l := range strings.Split(r.Stdout, "\n") {
if f := strings.Fields(l); len(f) > 1 && f[1] != "-" {
names[f[0]] = true
}
}
}
for i := range backends {
backends[i].Running = names[backends[i].DBusName]
}
var decided string
var preferred map[string]string
looked := PortalConfigs(a.home, desktops)
for _, f := range looked {
if ini := readINI(f); ini != nil {
decided, preferred = f, ini["preferred"]
if preferred == nil {
preferred = map[string]string{}
}
break
}
}
return map[string]any{
"desktop": env["XDG_CURRENT_DESKTOP"],
"portal": map[string]bool{"running": names["org.freedesktop.portal.Desktop"]},
"backends": backends,
"decided_by": decided,
"preferred": preferred,
"answers": Resolve(backends, preferred, desktops),
"colour_scheme": a.portalColourScheme(ctx),
}, nil
}
func splitColon(v string) []string {
var out []string
for _, x := range strings.Split(v, ":") {
if x = strings.TrimSpace(x); x != "" {
out = append(out, x)
}
}
return out
}
@@ -1,5 +0,0 @@
# Written by the mesh (module adwaita, novox/hq ADR 0208): the default cursor theme, for programs that
# read neither XCURSOR_THEME nor the X resources.
[Icon Theme]
Name=Default
Inherits=Adwaita
-9
View File
@@ -1,9 +0,0 @@
# Written by the mesh (module adwaita, novox/hq ADR 0208), for GTK 3 and GTK 4 alike. Replaced at
# every push; adwaita_appearance switches dark and light for the running session.
[Settings]
gtk-theme-name=Adwaita
gtk-icon-theme-name=Adwaita
gtk-cursor-theme-name=Adwaita
gtk-cursor-theme-size=24
gtk-font-name=Inter 11
gtk-application-prefer-dark-theme=1
-11
View File
@@ -1,11 +0,0 @@
# Written by the mesh (module adwaita, novox/hq ADR 0208). Replaced at every push.
#
# Which portal backend answers each interface. i3 is not a desktop xdg-desktop-portal knows, so with
# no preference it uses whichever backend happens to be installed: fine while gtk is the only one,
# wrong the day another arrives as somebody else's dependency. Named instead. gtk also serves
# org.freedesktop.appearance (dark or light) from GSettings, which the session's start sets.
[preferred]
default=gtk
# Secrets for sandboxed programs come from the keyring's backend. Without this line no backend answers
# the interface: gtk does not implement it, and gnome-keyring's names only GNOME as its desktop.
org.freedesktop.impl.portal.Secret=gnome-keyring
-29
View File
@@ -1,29 +0,0 @@
[Appearance]
color_scheme_path=/usr/share/qt6ct/colors/darker.conf
custom_palette=true
icon_theme=Adwaita
standard_dialogs=default
style=Fusion
[Fonts]
fixed="JetBrainsMono Nerd Font,11,-1,5,50,0,0,0,0,0"
general="Inter,11,-1,5,50,0,0,0,0,0"
[Interface]
activate_item_on_single_click=1
buttonbox_layout=0
cursor_flash_time=1000
dialog_buttons_have_icons=1
double_click_interval=400
gui_effects=@Invalid()
keyboard_scheme=2
menus_have_icons=true
show_shortcuts_in_context_menus=true
stylesheets=@Invalid()
toolbutton_style=4
underline_shortcut=1
wheel_scroll_lines=3
[Troubleshooting]
force_raster_widgets=1
ignored_applications=@Invalid()
-5
View File
@@ -1,5 +0,0 @@
module adwaita
go 1.22
require git.novox.be/novox/mesh-sdk/go v0.1.7
-2
View File
@@ -1,2 +0,0 @@
git.novox.be/novox/mesh-sdk/go v0.1.7 h1:C0sTQmtTiyYH7bnqZb7PusXnqA37gKuT7Nqjn9gG47w=
git.novox.be/novox/mesh-sdk/go v0.1.7/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY=
-160
View File
@@ -1,160 +0,0 @@
package desktop
import (
"fmt"
"math"
"os"
"path/filepath"
"strings"
)
// Args reads a tool's arguments as JSON decoded them: strings, float64 numbers, booleans.
type Args map[string]any
// Text is a required string, trimmed.
func (a Args) Text(name string) (string, error) {
v, ok := a[name].(string)
if !ok || strings.TrimSpace(v) == "" {
return "", fmt.Errorf("%s is required, as text", name)
}
return strings.TrimSpace(v), nil
}
// Opt is an optional string, trimmed, or def.
func (a Args) Opt(name, def string) string {
if v, ok := a[name].(string); ok && strings.TrimSpace(v) != "" {
return strings.TrimSpace(v)
}
return def
}
// Has is whether the caller gave the argument at all.
func (a Args) Has(name string) bool {
v, ok := a[name]
return ok && v != nil
}
// Bool is an optional boolean: its value, and whether it was given.
func (a Args) Bool(name string) (bool, bool, error) {
v, ok := a[name]
if !ok || v == nil {
return false, false, nil
}
b, isBool := v.(bool)
if !isBool {
return false, false, fmt.Errorf("%s is true or false", name)
}
return b, true, nil
}
// Number is an optional number: its value, and whether it was given.
func (a Args) Number(name string) (float64, bool, error) {
v, ok := a[name]
if !ok || v == nil {
return 0, false, nil
}
f, isNum := v.(float64)
if !isNum || math.IsNaN(f) || math.IsInf(f, 0) {
return 0, false, fmt.Errorf("%s is a number", name)
}
return f, true, nil
}
// Whole is an optional whole number within [lo, hi], or def.
func (a Args) Whole(name string, def, lo, hi int) (int, error) {
f, given, err := a.Number(name)
if err != nil {
return 0, err
}
if !given {
return def, nil
}
if f != math.Trunc(f) || f < float64(lo) || f > float64(hi) {
return 0, fmt.Errorf("%s is a whole number from %d to %d", name, lo, hi)
}
return int(f), nil
}
// OneOf is an optional string that must be one of choices, or def.
func (a Args) OneOf(name, def string, choices ...string) (string, error) {
v := a.Opt(name, def)
for _, c := range choices {
if v == c {
return v, nil
}
}
return "", fmt.Errorf("%s is one of %s", name, strings.Join(choices, ", "))
}
// Strings is an optional list of strings.
func (a Args) Strings(name string) ([]string, error) {
v, ok := a[name]
if !ok || v == nil {
return nil, nil
}
list, isList := v.([]any)
if !isList {
return nil, fmt.Errorf("%s is a list of text", name)
}
out := make([]string, 0, len(list))
for _, x := range list {
s, isText := x.(string)
if !isText {
return nil, fmt.Errorf("%s is a list of text", name)
}
out = append(out, s)
}
return out, nil
}
// Home is the operator account's home: the runtime's word for it, else this process's.
func Home() string {
if h := os.Getenv("MESH_OPERATOR_HOME"); h != "" {
return h
}
if h, err := os.UserHomeDir(); err == nil {
return h
}
return "/"
}
// InHome resolves a path the caller gave: `~/x` and a relative path are under the home. A path
// that leaves the home through `..` is refused, so a tool that writes never writes outside it.
func InHome(path string) (string, error) {
home := Home()
switch {
case path == "~":
path = home
case strings.HasPrefix(path, "~/"):
path = filepath.Join(home, path[2:])
case !filepath.IsAbs(path):
path = filepath.Join(home, path)
}
path = filepath.Clean(path)
if path != home && !strings.HasPrefix(path, home+string(filepath.Separator)) {
return "", fmt.Errorf("%s is outside the account's home", path)
}
return path, nil
}
// Schema builds a tool's input schema from property descriptions; required names those that must
// be given. A property is a string unless its description object says otherwise.
func Schema(props map[string]any, required ...string) map[string]any {
s := map[string]any{"type": "object", "properties": props}
if len(required) > 0 {
s["required"] = required
}
return s
}
// Str, Num, Flag, List and Enum describe one property.
func Str(desc string) map[string]any { return map[string]any{"type": "string", "description": desc} }
func Num(desc string) map[string]any { return map[string]any{"type": "number", "description": desc} }
func Int(desc string) map[string]any { return map[string]any{"type": "integer", "description": desc} }
func Flag(desc string) map[string]any { return map[string]any{"type": "boolean", "description": desc} }
func List(desc string) map[string]any {
return map[string]any{"type": "array", "items": map[string]any{"type": "string"}, "description": desc}
}
func Enum(desc string, values ...string) map[string]any {
return map[string]any{"type": "string", "enum": values, "description": desc}
}
@@ -1,42 +0,0 @@
package desktop
import (
"bytes"
"os"
"path/filepath"
"testing"
)
// The desktop modules that carry this package. Each builds alone, so each has its own copy; this
// test, itself one of the copied files, holds them to one text wherever the siblings are present.
var carriers = []string{"xorg", "lemurs", "i3", "xterm", "adwaita"}
func TestEveryDesktopModuleCarriesTheSameCopy(t *testing.T) {
mine, err := filepath.Glob("*.go")
if err != nil || len(mine) == 0 {
t.Fatal("no files of this package found", err)
}
compared := 0
for _, module := range carriers {
dir := filepath.Join("..", "..", "..", module, "internal", "desktop")
if _, err := os.Stat(dir); err != nil {
continue
}
theirs, _ := filepath.Glob(filepath.Join(dir, "*.go"))
if len(theirs) != len(mine) {
t.Errorf("%s carries %d files of this package, this copy %d", module, len(theirs), len(mine))
continue
}
for _, f := range mine {
a, _ := os.ReadFile(f)
b, err := os.ReadFile(filepath.Join(dir, f))
if err != nil || !bytes.Equal(a, b) {
t.Errorf("%s's copy of %s differs from this one: change every copy together", module, f)
}
}
compared++
}
if compared == 0 {
t.Log("no sibling copies beside this module")
}
}
-232
View File
@@ -1,232 +0,0 @@
package desktop
import (
"bytes"
"context"
"crypto/rand"
"encoding/hex"
"errors"
"fmt"
"os"
"os/exec"
"strings"
"syscall"
"time"
)
// Bounds on a command a tool runs: well below the runtime's 30 s call limit, and an answer that
// fits in a tool's reply.
const (
DefaultTimeout = 10 * time.Second
MostOutput = 256 << 10
)
// Result is what one command did.
type Result struct {
Command []string `json:"command"`
Code int `json:"exit_code"`
Stdout string `json:"stdout,omitempty"`
Stderr string `json:"stderr,omitempty"`
Truncated bool `json:"truncated,omitempty"`
TimedOut bool `json:"timed_out,omitempty"`
}
// OK is whether the command ran and exited 0.
func (r Result) OK() bool { return r.Code == 0 && !r.TimedOut }
// Err is the command's failure as an error naming it and what it said, or nil.
func (r Result) Err() error {
if r.OK() {
return nil
}
said := strings.TrimSpace(r.Stderr)
if said == "" {
said = strings.TrimSpace(r.Stdout)
}
if r.TimedOut {
return fmt.Errorf("%s did not finish in time", strings.Join(r.Command, " "))
}
return fmt.Errorf("%s exited %d: %s", strings.Join(r.Command, " "), r.Code, said)
}
// Runner runs a command with an environment and answers what it did. Tools take one, so their
// tests replace the machine with a table of answers.
type Runner func(ctx context.Context, env []string, stdin []byte, name string, args ...string) Result
// Exec is the machine's Runner: the command in its own process group, ended with everything it
// started at the deadline, each stream cut at MostOutput.
func Exec(ctx context.Context, env []string, stdin []byte, name string, args ...string) Result {
if _, ok := ctx.Deadline(); !ok {
var cancel context.CancelFunc
ctx, cancel = context.WithTimeout(ctx, DefaultTimeout)
defer cancel()
}
res := Result{Command: append([]string{name}, args...)}
cmd := exec.Command(name, args...)
cmd.Env = env
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
if stdin != nil {
cmd.Stdin = bytes.NewReader(stdin)
}
out, errb := &capped{}, &capped{}
cmd.Stdout, cmd.Stderr = out, errb
if err := cmd.Start(); err != nil {
res.Code = 127
res.Stderr = err.Error()
return res
}
done := make(chan error, 1)
go func() { done <- cmd.Wait() }()
var err error
select {
case err = <-done:
case <-ctx.Done():
_ = syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL)
err = <-done
res.TimedOut = true
}
res.Stdout, res.Stderr = out.String(), errb.String()
res.Truncated = out.cut || errb.cut
var exit *exec.ExitError
switch {
case err == nil:
case errors.As(err, &exit):
res.Code = exit.ExitCode()
if res.Code < 0 {
res.Code = 128
}
default:
res.Code = 1
if res.Stderr == "" {
res.Stderr = err.Error()
}
}
return res
}
// capped keeps the first MostOutput bytes written to it. Its buffer is a field, not embedded: an
// embedded bytes.Buffer brings ReadFrom along, and io.Copy would use it and never call Write.
type capped struct {
buf bytes.Buffer
cut bool
}
func (c *capped) Write(p []byte) (int, error) {
if room := MostOutput - c.buf.Len(); room < len(p) {
if room > 0 {
c.buf.Write(p[:room])
}
c.cut = true
return len(p), nil
}
return c.buf.Write(p)
}
func (c *capped) String() string { return c.buf.String() }
// Desk is what a desktop tool needs: how to find the session, and how to run a command.
type Desk struct {
Find func() (*Session, error)
Run Runner
// Base is the environment a command starts from, before the session's words.
Base []string
}
// Machine is the real Desk, preferring the named processes as the session's.
func Machine(prefer ...string) Desk {
return Desk{
Find: func() (*Session, error) { return Find(prefer...) },
Run: Exec,
Base: os.Environ(),
}
}
// InSession runs a command in the operator's session, or answers NoSession.
func (d Desk) InSession(ctx context.Context, name string, args ...string) (Result, *Session, error) {
s, err := d.Find()
if err != nil {
return Result{}, nil, err
}
return d.Run(ctx, s.Env(d.Base), nil, name, args...), s, nil
}
// InSessionWith is InSession with standard input.
func (d Desk) InSessionWith(ctx context.Context, stdin []byte, name string, args ...string) (Result, *Session, error) {
s, err := d.Find()
if err != nil {
return Result{}, nil, err
}
return d.Run(ctx, s.Env(d.Base), stdin, name, args...), s, nil
}
// Plain runs a command with the base environment: for what needs no session.
func (d Desk) Plain(ctx context.Context, name string, args ...string) Result {
return d.Run(ctx, d.Base, nil, name, args...)
}
// AsUser runs a command with the account's own runtime directory and bus, and no display.
func (d Desk) AsUser(ctx context.Context, name string, args ...string) Result {
return d.Run(ctx, UserEnv(d.Base, os.Getuid()), nil, name, args...)
}
// Launched is how a program was started in the session.
type Launched struct {
Unit string `json:"unit,omitempty"`
PID int `json:"pid,omitempty"`
How string `json:"how"`
}
// Launch starts a program in the operator's session that outlives the call and the runtime.
//
// **Not as a child of this process.** The runtime is a system service; everything it starts is in
// its control group, and the service manager ends that group whenever the runtime restarts — which
// is every push that changes it. So the program is handed to the account's own service manager as a
// transient unit (`systemd-run --user`), with the session's words set on it, and lives as long as the
// operator's user manager does. Without a user manager it is started detached as a last resort, and
// the answer says it will end with the runtime.
func (d Desk) Launch(ctx context.Context, s *Session, name string, argv ...string) (Launched, error) {
if len(argv) == 0 {
return Launched{}, errors.New("nothing to launch")
}
env := s.Env(d.Base)
unit := "mesh-" + name + "-" + token()
args := []string{"--user", "--collect", "--quiet", "--unit=" + unit}
for _, w := range []string{"DISPLAY", "WAYLAND_DISPLAY", "XAUTHORITY", "XDG_SESSION_TYPE", "XDG_CURRENT_DESKTOP", "XDG_SESSION_DESKTOP", "I3SOCK", "SWAYSOCK"} {
if v := lookup(env, w); v != "" {
args = append(args, "--setenv="+w+"="+v)
}
}
args = append(args, "--")
args = append(args, argv...)
res := d.Run(ctx, env, nil, "systemd-run", args...)
if res.OK() {
return Launched{Unit: unit, How: "a transient unit of the account's service manager; ends when it exits or when the operator logs out"}, nil
}
if s.Bus != "" {
return Launched{}, res.Err()
}
cmd := exec.Command(argv[0], argv[1:]...)
cmd.Env = env
cmd.SysProcAttr = &syscall.SysProcAttr{Setsid: true}
if err := cmd.Start(); err != nil {
return Launched{}, err
}
pid := cmd.Process.Pid
go func() { _ = cmd.Wait() }()
return Launched{PID: pid, How: "detached from the runtime with no user manager to hand it to; it ends when the runtime restarts"}, nil
}
func lookup(env []string, name string) string {
for i := len(env) - 1; i >= 0; i-- {
if k, v, ok := strings.Cut(env[i], "="); ok && k == name {
return v
}
}
return ""
}
func token() string {
b := make([]byte, 4)
_, _ = rand.Read(b)
return hex.EncodeToString(b)
}
-445
View File
@@ -1,445 +0,0 @@
// Package desktop is how a desktop module's tools act in the operator's graphical session
// (novox/hq ADR 0208, research 026/05).
//
// **One question, answered once for every desktop tool.** A tool runs inside the node's runtime: a
// process of node-tools.service, started by the system's service manager as the operator account,
// with no session around it — no DISPLAY, no XAUTHORITY, no session bus. The session it must act in
// was started elsewhere, by the login manager, and the only place its values are written down is
// the environment of the processes it started. So this package finds the session the way a person
// would: it looks at the operator account's own processes, takes the one that is plainly the
// session's (the window manager, or the oldest process carrying a display), confirms with logind
// that its session is a live local one, and checks that the display's socket is really there.
//
// **Only the session's own words are read.** A session's processes also carry whatever its start
// script exported — on the workstations that was a file of secrets — so the environment is filtered
// to a fixed list of names while it is read, and nothing else ever leaves /proc.
//
// The D-Bus address handed on is the user manager's socket, `unix:path=$XDG_RUNTIME_DIR/bus`,
// whenever it exists, because that is where the portal, the notifier and every user service
// listen. A session started on a private bus (a stale session, measured on one workstation) is
// reported as `session_bus` beside it, so the difference is visible rather than guessed at.
//
// The same copy of this package is vendored into every desktop module (xorg, lemurs, i3, xterm,
// adwaita); the catalogue builds each module alone, so it cannot be imported across them. Change
// every copy together — the modules' tests compare them.
package desktop
import (
"bufio"
"bytes"
"encoding/json"
"errors"
"fmt"
"os"
"os/exec"
"os/user"
"path/filepath"
"sort"
"strconv"
"strings"
"syscall"
"time"
)
// SessionWords are the only environment words read from a session's process: the ones that say
// where the session is. Everything else in that environment is the operator's, and is never read.
var SessionWords = []string{
"DISPLAY", "WAYLAND_DISPLAY", "XAUTHORITY",
"XDG_SESSION_ID", "XDG_SESSION_TYPE", "XDG_SESSION_DESKTOP", "XDG_CURRENT_DESKTOP",
"XDG_RUNTIME_DIR", "DBUS_SESSION_BUS_ADDRESS", "XDG_SEAT", "XDG_VTNR",
"I3SOCK", "SWAYSOCK",
}
// Session is the operator's running graphical session, as a tool needs it.
type Session struct {
UID int `json:"uid"`
ID string `json:"session_id,omitempty"`
Type string `json:"type"`
Display string `json:"display,omitempty"`
WaylandDisplay string `json:"wayland_display,omitempty"`
XAuthority string `json:"xauthority,omitempty"`
RuntimeDir string `json:"runtime_dir"`
Bus string `json:"bus,omitempty"`
SessionBus string `json:"session_bus,omitempty"`
Desktop string `json:"desktop,omitempty"`
// FoundIn is the process whose environment named the session.
FoundIn Process `json:"found_in"`
// Active is logind's word on the session, when logind answered.
Active *bool `json:"active,omitempty"`
words map[string]string
}
// Process is one process the search looked at.
type Process struct {
PID int `json:"pid"`
Command string `json:"command"`
start uint64
}
// NoSession is the answer when there is no graphical session to act in. Its text is JSON, so a tool
// that returns it as its error still answers structured data.
type NoSession struct {
Reason string `json:"reason"`
Looked []string `json:"looked"`
}
func (e *NoSession) Error() string {
b, _ := json.Marshal(map[string]any{"error": "no-graphical-session", "reason": e.Reason, "looked": e.Looked})
return string(b)
}
// IsNoSession is whether err says there is no session.
func IsNoSession(err error) bool {
var n *NoSession
return errors.As(err, &n)
}
// Finder holds where the search looks, so a test can point it at a tree of its own.
type Finder struct {
Proc string // the process table: /proc
X11Sockets string // where X servers listen: /tmp/.X11-unix
RuntimeBase string // the parent of every XDG_RUNTIME_DIR: /run/user
UID int // whose session
// Prefer names the processes that are the session's own, best first: the session's holder.
Prefer []string
// Logind answers `loginctl show-session` for one id; nil skips the check.
Logind func(id string) (map[string]string, error)
}
// DefaultFinder is the machine's: the account this process runs as, or — when it runs as root — the
// operator account the runtime names (MESH_OPERATOR_ACCOUNT).
func DefaultFinder(prefer ...string) Finder {
uid := os.Getuid()
if uid == 0 {
if name := os.Getenv("MESH_OPERATOR_ACCOUNT"); name != "" {
if u, err := user.Lookup(name); err == nil {
if n, err := strconv.Atoi(u.Uid); err == nil {
uid = n
}
}
}
}
return Finder{
Proc: "/proc", X11Sockets: "/tmp/.X11-unix", RuntimeBase: "/run/user",
UID: uid, Prefer: prefer, Logind: loginctl,
}
}
// Find is the operator's session on this machine, preferring a process named in prefer.
func Find(prefer ...string) (*Session, error) {
return DefaultFinder(prefer...).Find()
}
type candidate struct {
proc Process
words map[string]string
rank int
logind map[string]string
}
// Find looks for the session.
func (f Finder) Find() (*Session, error) {
entries, err := os.ReadDir(f.Proc)
if err != nil {
return nil, &NoSession{Reason: "the process table cannot be read: " + err.Error(), Looked: []string{f.Proc}}
}
looked := []string{fmt.Sprintf("the processes of uid %d in %s", f.UID, f.Proc)}
var found []candidate
stale := 0
for _, e := range entries {
pid, err := strconv.Atoi(e.Name())
if err != nil {
continue
}
dir := filepath.Join(f.Proc, e.Name())
info, err := os.Stat(dir)
if err != nil {
continue
}
if st, ok := info.Sys().(*syscall.Stat_t); !ok || int(st.Uid) != f.UID {
continue
}
words := readWords(filepath.Join(dir, "environ"))
if words["DISPLAY"] == "" && words["WAYLAND_DISPLAY"] == "" {
continue
}
if !f.reachable(words) {
stale++
continue
}
found = append(found, candidate{proc: Process{PID: pid, Command: comm(dir), start: startTime(dir)}, words: words})
}
if len(found) == 0 {
reason := fmt.Sprintf("no process of uid %d carries a display", f.UID)
if stale > 0 {
reason = fmt.Sprintf("%d process(es) of uid %d name a display whose socket is gone: the session they belonged to has ended", stale, f.UID)
}
return nil, &NoSession{Reason: reason, Looked: append(looked, f.X11Sockets, f.RuntimeBase)}
}
// logind's word on each session the candidates name, asked once per session.
asked := map[string]map[string]string{}
for i := range found {
id := found[i].words["XDG_SESSION_ID"]
if f.Logind == nil || id == "" {
found[i].rank = 1
continue
}
props, done := asked[id]
if !done {
props, _ = f.Logind(id)
asked[id] = props
}
found[i].logind = props
switch {
case props == nil:
found[i].rank = 1
case props["Remote"] == "yes":
found[i].rank = 3
case props["Active"] == "yes" && props["State"] != "closing":
found[i].rank = 0
case props["State"] == "closing":
found[i].rank = 3
default:
found[i].rank = 2
}
}
if f.Logind != nil {
looked = append(looked, "logind's sessions")
}
preferred := func(c candidate) int {
for i, p := range f.Prefer {
if c.proc.Command == p {
return i
}
}
return len(f.Prefer)
}
sort.SliceStable(found, func(i, j int) bool {
a, b := found[i], found[j]
if a.rank != b.rank {
return a.rank < b.rank
}
if pa, pb := preferred(a), preferred(b); pa != pb {
return pa < pb
}
if a.proc.start != b.proc.start {
return a.proc.start < b.proc.start
}
return a.proc.PID < b.proc.PID
})
best := found[0]
if best.rank == 3 {
return nil, &NoSession{Reason: "the only sessions found are remote or closing", Looked: looked}
}
return f.session(best), nil
}
func (f Finder) session(c candidate) *Session {
w := c.words
s := &Session{
UID: f.UID, ID: w["XDG_SESSION_ID"], Display: w["DISPLAY"], WaylandDisplay: w["WAYLAND_DISPLAY"],
XAuthority: w["XAUTHORITY"], RuntimeDir: w["XDG_RUNTIME_DIR"], FoundIn: c.proc, words: w,
}
s.Desktop = w["XDG_CURRENT_DESKTOP"]
if s.Desktop == "" {
s.Desktop = w["XDG_SESSION_DESKTOP"]
}
switch {
case w["XDG_SESSION_TYPE"] != "":
s.Type = w["XDG_SESSION_TYPE"]
case s.WaylandDisplay != "":
s.Type = "wayland"
default:
s.Type = "x11"
}
if s.RuntimeDir == "" {
s.RuntimeDir = filepath.Join(f.RuntimeBase, strconv.Itoa(f.UID))
}
if isSocket(filepath.Join(s.RuntimeDir, "bus")) {
s.Bus = "unix:path=" + filepath.Join(s.RuntimeDir, "bus")
}
if own := w["DBUS_SESSION_BUS_ADDRESS"]; own != "" && own != s.Bus {
s.SessionBus = own
}
if c.logind != nil {
active := c.logind["Active"] == "yes"
s.Active = &active
}
return s
}
// reachable is whether the display a process names is still served: the X server's socket, or the
// Wayland compositor's. A process outliving its session still carries the session's words.
func (f Finder) reachable(w map[string]string) bool {
if d := w["WAYLAND_DISPLAY"]; d != "" {
path := d
if !filepath.IsAbs(d) {
dir := w["XDG_RUNTIME_DIR"]
if dir == "" {
dir = filepath.Join(f.RuntimeBase, strconv.Itoa(f.UID))
}
path = filepath.Join(dir, d)
}
if isSocket(path) {
return true
}
}
n, ok := DisplayNumber(w["DISPLAY"])
return ok && isSocket(filepath.Join(f.X11Sockets, "X"+strconv.Itoa(n)))
}
// DisplayNumber is the server number of a local X display (":1", ":1.0", "unix:1"); a display on
// another host — an ssh session's forwarded one — is not the local session and answers false.
func DisplayNumber(display string) (int, bool) {
host, rest, ok := strings.Cut(display, ":")
if !ok || (host != "" && host != "unix") {
return 0, false
}
num, _, _ := strings.Cut(rest, ".")
n, err := strconv.Atoi(num)
if err != nil || n < 0 {
return 0, false
}
return n, true
}
// Word is one of the session's words as its process had it ("" when it had none).
func (s *Session) Word(name string) string { return s.words[name] }
// Env is base with the session's words in place of whatever base said for them.
func (s *Session) Env(base []string) []string {
drop := map[string]bool{}
for _, w := range SessionWords {
drop[w] = true
}
out := make([]string, 0, len(base)+8)
for _, kv := range base {
k, _, _ := strings.Cut(kv, "=")
if !drop[k] {
out = append(out, kv)
}
}
bus := s.Bus
if bus == "" {
bus = s.SessionBus
}
for _, kv := range [][2]string{
{"DISPLAY", s.Display}, {"WAYLAND_DISPLAY", s.WaylandDisplay}, {"XAUTHORITY", s.XAuthority},
{"XDG_RUNTIME_DIR", s.RuntimeDir}, {"DBUS_SESSION_BUS_ADDRESS", bus},
{"XDG_SESSION_TYPE", s.Type}, {"XDG_SESSION_ID", s.ID},
{"XDG_CURRENT_DESKTOP", s.words["XDG_CURRENT_DESKTOP"]},
{"XDG_SESSION_DESKTOP", s.words["XDG_SESSION_DESKTOP"]},
{"I3SOCK", s.words["I3SOCK"]}, {"SWAYSOCK", s.words["SWAYSOCK"]},
} {
if kv[1] != "" {
out = append(out, kv[0]+"="+kv[1])
}
}
return out
}
// UserEnv is base with the account's own runtime directory and bus, for a tool that talks to the
// user manager or the session bus and needs no display — it works with no session at all.
func UserEnv(base []string, uid int) []string {
dir := filepath.Join("/run/user", strconv.Itoa(uid))
out := make([]string, 0, len(base)+2)
for _, kv := range base {
k, _, _ := strings.Cut(kv, "=")
if k != "XDG_RUNTIME_DIR" && k != "DBUS_SESSION_BUS_ADDRESS" {
out = append(out, kv)
}
}
return append(out, "XDG_RUNTIME_DIR="+dir, "DBUS_SESSION_BUS_ADDRESS=unix:path="+filepath.Join(dir, "bus"))
}
// readWords reads a process's environment and keeps only SessionWords.
func readWords(path string) map[string]string {
raw, err := os.ReadFile(path)
if err != nil {
return nil
}
keep := map[string]bool{}
for _, w := range SessionWords {
keep[w] = true
}
out := map[string]string{}
for _, kv := range bytes.Split(raw, []byte{0}) {
k, v, ok := bytes.Cut(kv, []byte{'='})
if ok && keep[string(k)] {
out[string(k)] = string(v)
}
}
return out
}
func comm(dir string) string {
b, err := os.ReadFile(filepath.Join(dir, "comm"))
if err != nil {
return ""
}
return strings.TrimSpace(string(b))
}
// startTime is field 22 of /proc/<pid>/stat: when the process started, in clock ticks since boot.
// Read after the command's closing parenthesis, because the command may hold spaces.
func startTime(dir string) uint64 {
b, err := os.ReadFile(filepath.Join(dir, "stat"))
if err != nil {
return ^uint64(0)
}
i := bytes.LastIndexByte(b, ')')
if i < 0 {
return ^uint64(0)
}
fields := strings.Fields(string(b[i+1:]))
// fields[0] is the state, field 3 of the line; start time is field 22.
if len(fields) < 20 {
return ^uint64(0)
}
n, err := strconv.ParseUint(fields[19], 10, 64)
if err != nil {
return ^uint64(0)
}
return n
}
func isSocket(path string) bool {
info, err := os.Stat(path)
return err == nil && info.Mode()&os.ModeSocket != 0
}
// loginctl asks logind about one session, by its property lines.
func loginctl(id string) (map[string]string, error) {
cmd := exec.Command("loginctl", "show-session", id, "-p", "Active", "-p", "State", "-p", "Remote", "-p", "Type", "-p", "Class")
var out bytes.Buffer
cmd.Stdout = &out
done := make(chan error, 1)
if err := cmd.Start(); err != nil {
return nil, err
}
go func() { done <- cmd.Wait() }()
select {
case err := <-done:
if err != nil {
return nil, err
}
case <-time.After(3 * time.Second):
_ = cmd.Process.Kill()
return nil, errors.New("loginctl did not answer in 3s")
}
return ParseProperties(out.String()), nil
}
// ParseProperties reads `Key=Value` lines, as loginctl and systemctl show print them.
func ParseProperties(text string) map[string]string {
out := map[string]string{}
sc := bufio.NewScanner(strings.NewReader(text))
for sc.Scan() {
if k, v, ok := strings.Cut(sc.Text(), "="); ok {
out[k] = v
}
}
return out
}
@@ -1,255 +0,0 @@
package desktop
import (
"context"
"encoding/json"
"net"
"os"
"path/filepath"
"strconv"
"strings"
"testing"
)
// A machine in a directory: a process table, the X servers' socket directory and a runtime base.
type fakeMachine struct {
t *testing.T
proc, x11, runtime string
uid int
}
func newMachine(t *testing.T) *fakeMachine {
root, err := os.MkdirTemp("", "desk")
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { os.RemoveAll(root) })
m := &fakeMachine{t: t, proc: filepath.Join(root, "p"), x11: filepath.Join(root, "x"), runtime: filepath.Join(root, "r"), uid: os.Getuid()}
for _, d := range []string{m.proc, m.x11, filepath.Join(m.runtime, strconv.Itoa(m.uid))} {
if err := os.MkdirAll(d, 0o755); err != nil {
t.Fatal(err)
}
}
return m
}
func (m *fakeMachine) socket(path string) {
l, err := net.Listen("unix", path)
if err != nil {
m.t.Fatal(err)
}
m.t.Cleanup(func() { l.Close() })
}
func (m *fakeMachine) process(pid int, comm string, start int, env ...string) {
dir := filepath.Join(m.proc, strconv.Itoa(pid))
if err := os.MkdirAll(dir, 0o755); err != nil {
m.t.Fatal(err)
}
os.WriteFile(filepath.Join(dir, "environ"), []byte(strings.Join(env, "\x00")+"\x00"), 0o600)
os.WriteFile(filepath.Join(dir, "comm"), []byte(comm+"\n"), 0o644)
// pid (comm) state ppid pgrp session tty tpgid flags minflt cminflt majflt cmajflt utime stime
// cutime cstime priority nice threads itrealvalue starttime ...
stat := strconv.Itoa(pid) + " (" + comm + ") S 1 1 1 0 -1 0 0 0 0 0 0 0 0 0 20 0 1 0 " + strconv.Itoa(start) + " 0 0"
os.WriteFile(filepath.Join(dir, "stat"), []byte(stat), 0o644)
}
func (m *fakeMachine) finder(logind func(string) (map[string]string, error), prefer ...string) Finder {
return Finder{Proc: m.proc, X11Sockets: m.x11, RuntimeBase: m.runtime, UID: m.uid, Prefer: prefer, Logind: logind}
}
func active(id string) (map[string]string, error) {
return map[string]string{"Active": "yes", "State": "active", "Remote": "no", "Type": "x11"}, nil
}
func TestTheSessionIsFoundInTheWindowManagersEnvironmentAndOnlyItsWordsAreRead(t *testing.T) {
m := newMachine(t)
m.socket(filepath.Join(m.x11, "X1"))
run := filepath.Join(m.runtime, strconv.Itoa(m.uid))
m.socket(filepath.Join(run, "bus"))
m.process(100, "lemurs-child", 5, "DISPLAY=:1", "XDG_SESSION_ID=1")
m.process(200, "i3", 10, "DISPLAY=:1", "XAUTHORITY=/home/op/.Xauthority", "XDG_SESSION_ID=1",
"XDG_SESSION_TYPE=x11", "XDG_CURRENT_DESKTOP=i3", "XDG_RUNTIME_DIR="+run,
"DBUS_SESSION_BUS_ADDRESS=unix:path=/tmp/dbus-private", "NPM_TOKEN=secret", "OPENAI_API_KEY=secret")
m.process(300, "zsh", 50, "TERM=xterm") // no display: not a candidate
s, err := m.finder(active, "i3").Find()
if err != nil {
t.Fatal(err)
}
if s.FoundIn.PID != 200 || s.Display != ":1" || s.XAuthority != "/home/op/.Xauthority" || s.ID != "1" || s.Type != "x11" || s.Desktop != "i3" {
t.Fatalf("session: %+v", s)
}
if s.Bus != "unix:path="+filepath.Join(run, "bus") || s.SessionBus != "unix:path=/tmp/dbus-private" {
t.Fatalf("the user manager's bus first, the session's private one reported beside it: %q %q", s.Bus, s.SessionBus)
}
if s.Active == nil || !*s.Active {
t.Fatal("logind's word is carried")
}
env := strings.Join(s.Env([]string{"PATH=/usr/bin", "DISPLAY=:9", "HOME=/home/op"}), "\n")
for _, want := range []string{"PATH=/usr/bin", "HOME=/home/op", "DISPLAY=:1", "XAUTHORITY=/home/op/.Xauthority", "DBUS_SESSION_BUS_ADDRESS=unix:path=" + filepath.Join(run, "bus"), "XDG_RUNTIME_DIR=" + run} {
if !strings.Contains(env, want) {
t.Errorf("env lacks %s:\n%s", want, env)
}
}
if strings.Contains(env, ":9") || strings.Contains(env, "secret") || strings.Contains(env, "NPM_TOKEN") {
t.Fatalf("the base's display is replaced and no other word of the session's process passes:\n%s", env)
}
b, _ := json.Marshal(s)
if strings.Contains(string(b), "secret") {
t.Fatal("the answer carries a word outside the session's")
}
}
func TestWithoutAPreferenceTheOldestProcessOfTheLiveSessionWins(t *testing.T) {
m := newMachine(t)
m.socket(filepath.Join(m.x11, "X0"))
m.process(410, "xterm", 90, "DISPLAY=:0", "XDG_SESSION_ID=3")
m.process(400, "openbox", 20, "DISPLAY=:0", "XDG_SESSION_ID=3")
s, err := m.finder(nil).Find()
if err != nil || s.FoundIn.PID != 400 {
t.Fatalf("%+v %v", s, err)
}
if s.RuntimeDir != filepath.Join(m.runtime, strconv.Itoa(m.uid)) || s.Bus != "" {
t.Fatalf("an absent runtime directory word falls back to the account's, and no bus socket means no bus: %+v", s)
}
}
func TestALeftoverProcessOfAnEndedSessionIsNotTheSession(t *testing.T) {
m := newMachine(t)
m.process(500, "i3", 10, "DISPLAY=:2", "XDG_SESSION_ID=7") // no X2 socket
_, err := m.finder(active, "i3").Find()
if !IsNoSession(err) || !strings.Contains(err.Error(), "socket is gone") {
t.Fatalf("%v", err)
}
var answer map[string]any
if json.Unmarshal([]byte(err.Error()), &answer) != nil || answer["error"] != "no-graphical-session" {
t.Fatalf("the refusal is structured: %s", err)
}
}
func TestNoProcessWithADisplayIsAClearNoSession(t *testing.T) {
m := newMachine(t)
m.process(600, "sshd", 1, "SSH_CONNECTION=x")
_, err := m.finder(active).Find()
if !IsNoSession(err) || !strings.Contains(err.Error(), "no process of uid") {
t.Fatalf("%v", err)
}
}
func TestAnActiveLocalSessionBeatsAnInactiveOneAndARemoteOneIsRefused(t *testing.T) {
m := newMachine(t)
m.socket(filepath.Join(m.x11, "X0"))
m.socket(filepath.Join(m.x11, "X1"))
m.process(700, "i3", 5, "DISPLAY=:0", "XDG_SESSION_ID=a")
m.process(800, "i3", 9, "DISPLAY=:1", "XDG_SESSION_ID=b")
logind := func(id string) (map[string]string, error) {
if id == "a" {
return map[string]string{"Active": "no", "State": "online", "Remote": "no"}, nil
}
return map[string]string{"Active": "yes", "State": "active", "Remote": "no"}, nil
}
s, err := m.finder(logind, "i3").Find()
if err != nil || s.FoundIn.PID != 800 || s.Display != ":1" {
t.Fatalf("the active session: %+v %v", s, err)
}
remote := func(string) (map[string]string, error) {
return map[string]string{"Active": "yes", "Remote": "yes"}, nil
}
if _, err := m.finder(remote).Find(); !IsNoSession(err) {
t.Fatalf("a remote session is not the operator's desktop: %v", err)
}
}
func TestAWaylandSessionIsFoundByItsCompositorsSocket(t *testing.T) {
m := newMachine(t)
run := filepath.Join(m.runtime, strconv.Itoa(m.uid))
m.socket(filepath.Join(run, "wayland-1"))
m.process(900, "sway", 3, "WAYLAND_DISPLAY=wayland-1", "XDG_RUNTIME_DIR="+run, "SWAYSOCK=/run/x.sock")
s, err := m.finder(nil, "sway").Find()
if err != nil || s.Type != "wayland" || s.WaylandDisplay != "wayland-1" {
t.Fatalf("%+v %v", s, err)
}
if !strings.Contains(strings.Join(s.Env(nil), " "), "SWAYSOCK=/run/x.sock") {
t.Fatal("the compositor's socket word passes")
}
}
func TestADisplayOnAnotherHostIsNotTheLocalSession(t *testing.T) {
for d, want := range map[string]bool{":0": true, ":1.0": true, "unix:2": true, "localhost:10.0": false, "host:0": false, "": false, ":x": false} {
if _, ok := DisplayNumber(d); ok != want {
t.Errorf("%q: %v", d, ok)
}
}
}
func TestACommandIsBoundedAndItsFailureNamed(t *testing.T) {
r := Exec(context.Background(), os.Environ(), []byte("hello"), "cat")
if !r.OK() || r.Stdout != "hello" {
t.Fatalf("%+v", r)
}
r = Exec(context.Background(), os.Environ(), nil, "sh", "-c", "echo no >&2; exit 3")
if r.OK() || r.Code != 3 || !strings.Contains(r.Err().Error(), "exited 3: no") {
t.Fatalf("%+v", r)
}
r = Exec(context.Background(), os.Environ(), nil, "no-such-program-here")
if r.OK() || r.Code != 127 {
t.Fatalf("%+v", r)
}
r = Exec(context.Background(), os.Environ(), nil, "sh", "-c", "head -c 400000 /dev/zero")
if !r.Truncated || len(r.Stdout) != MostOutput {
t.Fatalf("cut at %d: %d %v", MostOutput, len(r.Stdout), r.Truncated)
}
}
func TestArgumentsAreReadStrictly(t *testing.T) {
a := Args{"name": " x ", "n": float64(3), "f": 1.5, "b": true, "l": []any{"a", "b"}}
if v, err := a.Text("name"); err != nil || v != "x" {
t.Fatal(v, err)
}
if _, err := a.Text("missing"); err == nil {
t.Fatal("a missing required text")
}
if n, err := a.Whole("n", 0, 1, 5); err != nil || n != 3 {
t.Fatal(n, err)
}
if _, err := a.Whole("f", 0, 0, 5); err == nil {
t.Fatal("1.5 is not whole")
}
if _, err := a.Whole("n", 0, 4, 5); err == nil {
t.Fatal("out of range")
}
if b, given, err := a.Bool("b"); !b || !given || err != nil {
t.Fatal("bool")
}
if _, _, err := a.Bool("name"); err == nil {
t.Fatal("text is not a bool")
}
if l, err := a.Strings("l"); err != nil || len(l) != 2 {
t.Fatal(l, err)
}
if _, err := a.OneOf("name", "", "y", "z"); err == nil {
t.Fatal("not one of")
}
}
func TestAPathIsKeptInsideTheHome(t *testing.T) {
t.Setenv("MESH_OPERATOR_HOME", "/home/op")
for in, want := range map[string]string{"~/a.png": "/home/op/a.png", "b/c": "/home/op/b/c", "/home/op/d": "/home/op/d", "~": "/home/op"} {
if got, err := InHome(in); err != nil || got != want {
t.Errorf("%s: %s %v", in, got, err)
}
}
for _, out := range []string{"/etc/passwd", "~/../other", "../x"} {
if _, err := InHome(out); err == nil {
t.Errorf("%s was accepted", out)
}
}
}
func TestPropertiesAreParsed(t *testing.T) {
p := ParseProperties("Active=yes\nState=active\nDisplay=\n")
if p["Active"] != "yes" || p["State"] != "active" || p["Display"] != "" {
t.Fatal(p)
}
}
-118
View File
@@ -1,118 +0,0 @@
{
"module": "adwaita",
"version": "1",
"capabilities": [
"package-manager"
],
"tools": [
"adwaita_appearance",
"adwaita_cursor",
"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": [
{
"for": "xresources",
"slot": "normal",
"code": "! adwaita: the cursor, for X programs that take it from the resources.\nXcursor.theme: Adwaita\nXcursor.size: 24\n"
},
{
"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"
}
],
"resources": [
{
"id": "package-gnome-themes-extra",
"type": "package",
"package": "gnome-themes-extra"
},
{
"id": "package-adwaita-icon-theme",
"type": "package",
"package": "adwaita-icon-theme"
},
{
"id": "package-adwaita-cursors",
"type": "package",
"package": "adwaita-cursors"
},
{
"id": "package-qt6ct",
"type": "package",
"package": "qt6ct"
},
{
"id": "package-xdg-desktop-portal-gtk",
"type": "package",
"package": "xdg-desktop-portal-gtk"
},
{
"id": "gtk3",
"type": "file",
"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"
},
{
"id": "gtk4",
"type": "file",
"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"
},
{
"id": "qt6ct",
"type": "file",
"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"
},
{
"id": "portals",
"type": "file",
"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"
},
{
"id": "cursor",
"type": "file",
"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"
}
],
"build": {
"artifacts": [
{
"name": "tools",
"kind": "bundle",
"language": "go",
"system": "arch",
"from": "cmd/adwaita-tools",
"binary": "adwaita-tools",
"loads": [
"adwaita-tools"
]
}
]
}
}
+75
View File
@@ -0,0 +1,75 @@
// The consumer's scheduled run: take the ACCESS token the mesh delivered and write it where the
// Claude CLI reads it, access-token-only (novox/hq ADR 0050). The refresh token is never here to
// strip — the manager holds it, and a holder's delivery has only ever been the access token.
//
// What the host delivers, per the manifest:
// secrets.model-access -> a file holding the sealed-then-unsealed ACCESS token (the host opened it
// with this node's private key; this process reads plaintext).
// binds.model-access -> a JSON file of the non-secret facts the licence serves (which licence,
// model, and — when the control plane carries them — grant expiry/scopes).
//
// Runs as `mesh-tools run` (no broker) on a schedule, so it is idempotent: same token in, same file
// out.
import { readFileSync } from "node:fs";
import { deliver, type DeliveredGrant } from "../credentials.js";
import { readAccountUuid, check } from "../identity.js";
function required(name: string): string {
const v = process.env[name];
if (!v) throw new Error(`${name} is not set — the consumer runtime was deployed without it`);
return v;
}
/** Read optional non-secret grant metadata (expiry, scopes, subscription) from the bound facts file. */
function readBoundMeta(path: string | undefined): Partial<DeliveredGrant> {
if (!path) return {};
try {
const raw = JSON.parse(readFileSync(path, "utf8")) as Record<string, unknown>;
return {
expiresAt: typeof raw.expiresAt === "number" ? raw.expiresAt : null,
refreshTokenExpiresAt: typeof raw.refreshTokenExpiresAt === "number" ? raw.refreshTokenExpiresAt : null,
scopes: Array.isArray(raw.scopes) ? (raw.scopes as string[]) : null,
subscriptionType: typeof raw.subscriptionType === "string" ? raw.subscriptionType : null,
};
} catch {
return {};
}
}
function main(): void {
const accessToken = readFileSync(required("MESH_MODEL_ACCESS_SECRET_FILE"), "utf8").trim();
if (!accessToken) {
// Nothing was delivered — which reads exactly like a credential that never arrived, so it is
// said rather than written as an empty file the CLI would take for a login it should not do.
throw new Error("[anthropic-consumer] the delivered access token is empty; nothing was written");
}
const meta = readBoundMeta(process.env.MESH_MODEL_ACCESS_BIND_FILE);
const grant: DeliveredGrant = { accessToken, ...meta };
const target = process.env.MESH_CLAUDE_CREDENTIALS_FILE ?? `${homedir()}/.claude/.credentials.json`;
deliver(target, grant);
console.error(`[anthropic-consumer] wrote an access-token-only credential to ${target}`);
// The mis-binding guard, best-effort and fail-closed. The expected account uuid is not yet plumbed
// (identity.ts TODO), so this reports what it can see rather than acting on it — it never delivers
// to a wrong account because it never learns one to deliver to.
const identityFile = process.env.MESH_CLAUDE_IDENTITY_FILE ?? `${homedir()}/.claude.json`;
const found = readAccountUuid(identityFile);
const expected = process.env.MESH_MODEL_ACCESS_ACCOUNT_UUID ?? null;
const verdict = check(found, expected);
if (verdict.state === "wrong-account") {
throw new Error(
`[anthropic-consumer] the CLI is logged in as ${verdict.found}, not the licensed ${verdict.expected}; refusing`,
);
}
console.error(`[anthropic-consumer] identity check: ${verdict.state}`);
}
function homedir(): string {
return process.env.HOME ?? "/root";
}
main();
+82
View File
@@ -0,0 +1,82 @@
// Writing the access token where the Claude CLI reads it — the consumer half of model-access
// (novox/hq ADR 0050). A node holds an ACCESS token and nothing else: it cannot rotate, so it is
// never given a refresh token, and this enforces that on every write.
//
// The file shape and the strip are ported byte-exact from the mature implementation (see the port
// map): `~/.claude/.credentials.json` → `{ claudeAiOauth: { accessToken, expiresAt,
// refreshTokenExpiresAt?, scopes?, subscriptionType? } }`, and the refresh token is deleted, not
// merely omitted, so a full grant left by an interactive login is stripped back to access-only.
import { readFileSync, writeFileSync, renameSync, mkdirSync } from "node:fs";
import { dirname } from "node:path";
/** The access-token-only grant the mesh delivered — what the manager submitted, minus the refresh. */
export interface DeliveredGrant {
readonly accessToken: string;
readonly expiresAt?: number | null;
readonly refreshTokenExpiresAt?: number | null;
readonly scopes?: string[] | null;
readonly subscriptionType?: string | null;
}
interface ClaudeOauth {
accessToken?: string;
expiresAt?: number;
refreshTokenExpiresAt?: number;
scopes?: string[];
subscriptionType?: string;
refreshToken?: string;
}
interface Credentials {
claudeAiOauth?: ClaudeOauth;
[key: string]: unknown;
}
/** Read the existing credentials file, or an empty object if there is none or it is unreadable. */
function readLocal(path: string): Credentials {
try {
return JSON.parse(readFileSync(path, "utf8")) as Credentials;
} catch {
return {};
}
}
/**
* Overlay the delivered grant onto whatever is on disk, then STRIP the refresh token — the node
* carve-out. Returns the object to write, so the strip is testable without touching a file.
*/
export function applyGrant(local: Credentials, grant: DeliveredGrant): Credentials {
const oauth = local.claudeAiOauth ?? {};
const next: Credentials = {
...local,
claudeAiOauth: {
...oauth,
accessToken: grant.accessToken,
...(grant.expiresAt != null ? { expiresAt: grant.expiresAt } : {}),
...(grant.refreshTokenExpiresAt != null
? { refreshTokenExpiresAt: grant.refreshTokenExpiresAt }
: {}),
...(grant.scopes ? { scopes: grant.scopes } : {}),
...(grant.subscriptionType ? { subscriptionType: grant.subscriptionType } : {}),
},
};
// A node NEVER holds a refresh token: delete it, so a full grant on disk is reduced to access-only.
delete next.claudeAiOauth!.refreshToken;
return next;
}
/** Atomic write-then-rename at 0600 — a partial credentials file must never be read as a whole one. */
export function writeCredentials(path: string, creds: Credentials): void {
mkdirSync(dirname(path), { recursive: true });
const tmp = `${path}.tmp`;
writeFileSync(tmp, JSON.stringify(creds, null, 2), { mode: 0o600 });
renameSync(tmp, path);
}
/** Read, overlay, strip, write — the whole consumer credential update, in one call. */
export function deliver(path: string, grant: DeliveredGrant): Credentials {
const next = applyGrant(readLocal(path), grant);
writeCredentials(path, next);
return next;
}
+49
View File
@@ -0,0 +1,49 @@
// The mis-binding guard (novox/hq ADR 0050, port map §identity). Account identity is NOT in the
// token or any API — it lives in a sibling CLI state file, `~/.claude.json` →
// `oauthAccount.accountUuid`. The guard compares the account the CLI is actually logged in as to the
// account the licence was recorded against, and FAILS CLOSED: an absent file or an unrecorded licence
// account refuses rather than guesses, because delivering an access token to the wrong account is the
// exact fault this exists to catch.
//
// **Partial first cut, FLAGGED.** Reading the sibling file is implemented; the licence's recorded
// account uuid is not yet plumbed from the control plane to the consumer (the bound `model.json` does
// not carry it today). So `check` returns `licence-not-adopted` when no expected uuid is supplied,
// which is the fail-closed answer, and the wiring of the expected uuid is a TODO below.
import { readFileSync } from "node:fs";
export type IdentityVerdict =
| { state: "verified"; accountUuid: string }
| { state: "no-identity-file" }
| { state: "licence-not-adopted" }
| { state: "wrong-account"; found: string; expected: string };
interface ClaudeJson {
oauthAccount?: { accountUuid?: string; emailAddress?: string; organizationUuid?: string };
}
/** Read `oauthAccount.accountUuid` from `~/.claude.json`, or null if the file or field is absent. */
export function readAccountUuid(path: string): string | null {
try {
const raw = JSON.parse(readFileSync(path, "utf8")) as ClaudeJson;
return raw.oauthAccount?.accountUuid ?? null;
} catch {
return null;
}
}
/**
* Compare the CLI's logged-in account to the one the licence was recorded against. Pure over its
* inputs so the fail-closed logic is tested without a filesystem.
*
* TODO(novox/hq ADR 0050, Phase C): plumb `expected` — the licence's recorded account uuid — from the
* control plane into the consumer's bound `model.json`, then adopt-on-first-sight or refuse per the
* port map's five states. Until then only the two safe verdicts are reachable: verified when an
* expected uuid is provided and matches, refuse otherwise.
*/
export function check(found: string | null, expected: string | null): IdentityVerdict {
if (found === null) return { state: "no-identity-file" };
if (!expected) return { state: "licence-not-adopted" };
if (found === expected) return { state: "verified", accountUuid: found };
return { state: "wrong-account", found, expected };
}
+76
View File
@@ -0,0 +1,76 @@
{
"module": "anthropic-consumer",
"version": "1",
"slug": "claude",
"capabilities": [
"container-runtime"
],
"requires": [
"model-access"
],
"binds": {
"model-access": "${dir:state}/model.json"
},
"secrets": {
"model-access": "${dir:state}/access-token"
},
"emits": [
"usage.session"
],
"resources": [
{
"id": "state",
"type": "directory",
"mode": "0700",
"place": "."
},
{
"id": "claude-home",
"type": "directory",
"path": "/var/lib/anthropic-consumer/claude",
"mode": "0700"
},
{
"id": "out",
"type": "directory",
"mode": "0700"
},
{
"id": "apply",
"type": "process",
"name": "anthropic-consumer-apply",
"artifact": "code",
"run": [
"node",
"apply/index.js"
],
"schedule": "*/5 * * * *",
"env": {
"MESH_MODEL_ACCESS_SECRET_FILE": "${dir:state}/access-token",
"MESH_MODEL_ACCESS_BIND_FILE": "${dir:state}/model.json",
"MESH_CLAUDE_CREDENTIALS_FILE": "${dir:state}/claude/.credentials.json",
"MESH_CLAUDE_IDENTITY_FILE": "${dir:state}/claude/.claude.json"
}
}
],
"build": {
"artifacts": [
{
"name": "code",
"kind": "bundle",
"language": "typescript",
"entrypoints": [
"apply/index.js",
"usage/index.js"
],
"loads": [
"usage/index.js"
],
"env": {
"MESH_CLAUDE_PROJECTS_DIR": "${dir:state}/claude/projects",
"MESH_ANTHROPIC_USAGE_OUT": "${dir:state}/out/session-usage.json"
}
}
]
}
}
+14
View File
@@ -0,0 +1,14 @@
{
"name": "@novox/module-anthropic-consumer",
"version": "0.1.0",
"description": "anthropic-consumer — the consumer side of model-access (ADR 0050): writes the delivered access token to ~/.claude/.credentials.json (access-token-only) and reports session-grain usage from the CLI transcripts (ADR 0054).",
"type": "module",
"private": true,
"dependencies": {
"@novox/mesh-sdk": "^0.1.0"
},
"devDependencies": {
"@types/node": "^22.0.0",
"typescript": "^5.6.0"
}
}
@@ -0,0 +1,32 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { mkdtempSync, readFileSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { applyGrant, deliver } from "../credentials.ts";
test("applyGrant strips the refresh token a full grant on disk left behind", () => {
const local = { claudeAiOauth: { accessToken: "at-old", refreshToken: "rt-must-not-survive" } };
const next = applyGrant(local, { accessToken: "at-new", expiresAt: 123 });
assert.equal(next.claudeAiOauth!.accessToken, "at-new");
assert.equal(next.claudeAiOauth!.expiresAt, 123);
assert.ok(!("refreshToken" in next.claudeAiOauth!), "a node held onto a refresh token");
});
test("deliver writes the port-map shape, access-token-only, and never a refresh token", () => {
const dir = mkdtempSync(join(tmpdir(), "anthropic-consumer-"));
const path = join(dir, ".credentials.json");
// A prior interactive login left a full grant on disk.
writeFileSync(path, JSON.stringify({ claudeAiOauth: { accessToken: "at-old", refreshToken: "rt-login" } }));
deliver(path, { accessToken: "at-delivered", expiresAt: 999, subscriptionType: "max" });
const raw = readFileSync(path, "utf8");
const creds = JSON.parse(raw);
assert.equal(creds.claudeAiOauth.accessToken, "at-delivered");
assert.equal(creds.claudeAiOauth.expiresAt, 999);
assert.equal(creds.claudeAiOauth.subscriptionType, "max");
assert.doesNotMatch(raw, /rt-login/, "the refresh token is still on disk");
assert.ok(!("refreshToken" in creds.claudeAiOauth));
});
@@ -0,0 +1,48 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { foldTranscript } from "../transcript.ts";
// A captured-shape transcript: two assistant turns and a user line, exactly the fields the port map
// names. Not imagined — the field names match the mature implementation's parse.
const TRANSCRIPT = [
JSON.stringify({ type: "user", timestamp: "2026-01-01T00:00:00Z", cwd: "/work/app", gitBranch: "main" }),
JSON.stringify({
type: "assistant",
timestamp: "2026-01-01T00:00:01Z",
costUSD: 0.01,
message: {
model: "claude-opus-4-8",
usage: { input_tokens: 100, cache_creation_input_tokens: 20, cache_read_input_tokens: 5, output_tokens: 40 },
},
}),
JSON.stringify({
type: "assistant",
timestamp: "2026-01-01T00:00:02Z",
costUSD: 0.02,
message: { model: "claude-opus-4-8", usage: { input_tokens: 200, output_tokens: 60 } },
}),
"", // a half-written trailing line is ordinary and must not be fatal.
].join("\n");
test("a transcript sums per-session token counts, cost, and metadata", () => {
const s = foldTranscript("session-abc", TRANSCRIPT);
assert.equal(s.sessionId, "session-abc");
assert.equal(s.turns, 2);
assert.equal(s.inputTokens, 300);
assert.equal(s.cacheCreationTokens, 20);
assert.equal(s.cacheReadTokens, 5);
assert.equal(s.outputTokens, 100);
assert.equal(Math.round(s.costUSD * 100) / 100, 0.03);
assert.equal(s.model, "claude-opus-4-8");
assert.equal(s.gitBranch, "main");
assert.equal(s.cwd, "/work/app");
assert.equal(s.startedAt, "2026-01-01T00:00:00Z");
assert.equal(s.lastActive, "2026-01-01T00:00:02Z");
});
test("a malformed line is skipped, not fatal", () => {
const s = foldTranscript("s", 'not json\n{"type":"assistant","message":{"usage":{"output_tokens":7}}}');
assert.equal(s.outputTokens, 7);
assert.equal(s.turns, 1);
});
+113
View File
@@ -0,0 +1,113 @@
// Session-grain usage from the CLI's own transcripts (novox/hq ADR 0054). The mature implementation
// reads `~/.claude/projects/<projDir>/<sessionId>.jsonl` and sums the token counts each assistant
// message reports; this ports the token extraction and DROPS the per-message account-attribution
// timeline — the nox (node,module) session has a fixed licence binding (port map "don't-map" #3), so
// there is nothing to attribute per message.
//
// The fields are ported from the port map: assistant lines carry
// `message.usage.{input_tokens,cache_creation_input_tokens,cache_read_input_tokens,output_tokens}`,
// `costUSD`, `message.model`, `timestamp`; user lines carry `cwd`, `gitBranch`.
import { createInterface } from "node:readline";
import { createReadStream } from "node:fs";
/** One session's totals — the session-grain usage row ADR 0054 fixes. */
export interface SessionUsage {
sessionId: string;
model: string | null;
gitBranch: string | null;
cwd: string | null;
turns: number;
inputTokens: number;
cacheCreationTokens: number;
cacheReadTokens: number;
outputTokens: number;
costUSD: number;
startedAt: string | null;
lastActive: string | null;
}
interface Line {
type?: string;
timestamp?: string;
cwd?: string;
gitBranch?: string;
costUSD?: number;
message?: {
model?: string;
usage?: {
input_tokens?: number;
cache_creation_input_tokens?: number;
cache_read_input_tokens?: number;
output_tokens?: number;
};
};
}
function empty(sessionId: string): SessionUsage {
return {
sessionId,
model: null,
gitBranch: null,
cwd: null,
turns: 0,
inputTokens: 0,
cacheCreationTokens: 0,
cacheReadTokens: 0,
outputTokens: 0,
costUSD: 0,
startedAt: null,
lastActive: null,
};
}
/** Fold one transcript line into a session's running totals. Pure, so it is tested on fixtures. */
export function foldLine(acc: SessionUsage, raw: string): SessionUsage {
const line = parse(raw);
if (!line) return acc;
if (line.timestamp) {
if (!acc.startedAt || line.timestamp < acc.startedAt) acc.startedAt = line.timestamp;
if (!acc.lastActive || line.timestamp > acc.lastActive) acc.lastActive = line.timestamp;
}
if (line.type === "user") {
if (line.cwd) acc.cwd = line.cwd;
if (line.gitBranch) acc.gitBranch = line.gitBranch;
}
if (line.type === "assistant") {
acc.turns += 1;
const u = line.message?.usage ?? {};
acc.inputTokens += u.input_tokens ?? 0;
acc.cacheCreationTokens += u.cache_creation_input_tokens ?? 0;
acc.cacheReadTokens += u.cache_read_input_tokens ?? 0;
acc.outputTokens += u.output_tokens ?? 0;
acc.costUSD += line.costUSD ?? 0;
if (!acc.model && line.message?.model) acc.model = line.message.model;
}
return acc;
}
function parse(raw: string): Line | null {
const trimmed = raw.trim();
if (!trimmed) return null;
try {
return JSON.parse(trimmed) as Line;
} catch {
// A malformed line is skipped, never fatal: a transcript is an append-only log the CLI owns, and
// a half-written last line is ordinary.
return null;
}
}
/** Sum a whole transcript string into one session's usage — the tested core of the streaming read. */
export function foldTranscript(sessionId: string, text: string): SessionUsage {
return text.split("\n").reduce(foldLine, empty(sessionId));
}
/** Stream one `<sessionId>.jsonl` file line by line, so a large transcript never loads whole. */
export async function readSessionFile(path: string, sessionId: string): Promise<SessionUsage> {
const acc = empty(sessionId);
const rl = createInterface({ input: createReadStream(path), crlfDelay: Infinity });
for await (const line of rl) foldLine(acc, line);
return acc;
}
+18
View File
@@ -0,0 +1,18 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"noEmit": true
},
"include": [
"credentials.ts",
"transcript.ts",
"identity.ts",
"apply/index.ts",
"usage/index.ts"
]
}
+138
View File
@@ -0,0 +1,138 @@
// Session-grain usage emission (novox/hq ADR 0054). On a schedule, read every transcript under
// `~/.claude/projects/*/<sessionId>.jsonl`, sum its tokens, and emit one session-grain usage event
// per session. The consumer IS the (node,module) session's fixed binding, so no per-message account
// attribution is done — just the totals (port map "don't-map" #3).
//
// Runs in the node's runtime (novox/hq ADR 0198), every five minutes, so events are emitted through
// the runtime as this module; the totals are also written to a file so the reading is observable
// without one.
import { readdirSync, statSync, readFileSync, writeFileSync, renameSync, mkdirSync } from "node:fs";
import { join, dirname } from "node:path";
import { emit } from "@novox/mesh-sdk/events";
import { readSessionFile, type SessionUsage } from "../transcript.js";
/** The vendor-neutral usage row ADR 0054 fixes — the shape the model-usage store upserts. Kept local
* to the producer (the normalisation lives in the adapter), so nothing here couples to the SDK. */
interface UsageRow {
licence: string;
consumer: string;
period: string;
metric: string;
value: number;
}
/** The licence this session's usage is charged to. The model-access binding names it; failing that,
* the deployed env; failing that, "unknown" — a reading is never dropped for want of a licence. */
function boundLicence(): string {
const bindFile = process.env.MESH_MODEL_ACCESS_BIND_FILE;
if (bindFile) {
try {
const raw = JSON.parse(readFileSync(bindFile, "utf8")) as { licence?: unknown };
if (typeof raw.licence === "string" && raw.licence) return raw.licence;
} catch {
// A missing or unreadable bind file is not fatal — fall through to the env, then to "unknown".
}
}
return process.env.MESH_ANTHROPIC_LICENCE || "unknown";
}
/** Normalise one session reading into ADR 0054 rows: one row per metric, a row omitted when its
* number is not finite. `consumer` is node/module/session — the session grain. */
function sessionRows(licence: string, node: string, module: string, r: SessionUsage): UsageRow[] {
const consumer = `${node}/${module}/${r.sessionId}`;
const rows: UsageRow[] = [];
const add = (metric: string, value: number): void => {
if (Number.isFinite(value)) rows.push({ licence, consumer, period: "session", metric, value });
};
add("input_tokens", r.inputTokens);
add("output_tokens", r.outputTokens);
add("cache_creation_tokens", r.cacheCreationTokens);
add("cache_read_tokens", r.cacheReadTokens);
add("cost_usd", r.costUSD);
return rows;
}
function projectsDir(): string {
return process.env.MESH_CLAUDE_PROJECTS_DIR ?? `${process.env.HOME ?? "/root"}/.claude/projects`;
}
/** Every `<sessionId>.jsonl` under the projects tree, with the project directory it sits in. */
function transcripts(root: string): { path: string; sessionId: string }[] {
const found: { path: string; sessionId: string }[] = [];
let projects: string[];
try {
projects = readdirSync(root);
} catch {
return found; // no projects yet is not a failure — there is simply nothing to report.
}
for (const proj of projects) {
const dir = join(root, proj);
let entries: string[];
try {
if (!statSync(dir).isDirectory()) continue;
entries = readdirSync(dir);
} catch {
continue;
}
for (const file of entries) {
if (!file.endsWith(".jsonl")) continue;
found.push({ path: join(dir, file), sessionId: file.replace(/\.jsonl$/, "") });
}
}
return found;
}
async function main(): Promise<void> {
const module = process.env.MESH_MODULE ?? "anthropic-consumer";
const node = process.env.MESH_NODE ?? "unknown";
const readings: SessionUsage[] = [];
for (const t of transcripts(projectsDir())) {
try {
readings.push(await readSessionFile(t.path, t.sessionId));
} catch (err) {
console.error(`[anthropic-consumer] could not read ${t.path}: ${err}`);
}
}
// Emit ADR-0054 rows, not a vendor-shaped body: the consumer of module.*.usage.* is the
// vendor-neutral model-usage store, so the session→row normalisation is done HERE. The full
// SessionUsage rides as `raw`, so model, branch, cwd and timestamps are not lost.
const licence = boundLicence();
for (const r of readings) {
await emitUsage({ rows: sessionRows(licence, node, module, r), raw: r });
}
if (process.env.MESH_ANTHROPIC_USAGE_OUT) {
atomicWrite(process.env.MESH_ANTHROPIC_USAGE_OUT, JSON.stringify(readings, null, 2));
}
console.error(`[anthropic-consumer] reported ${readings.length} session(s)`);
}
function atomicWrite(path: string, content: string): void {
mkdirSync(dirname(path), { recursive: true });
const tmp = `${path}.tmp`;
writeFileSync(tmp, content, { mode: 0o600 });
renameSync(tmp, path);
}
/** Emit best-effort through the runtime: a reading that could not be announced is still in the file. */
async function emitUsage(body: Record<string, unknown>): Promise<void> {
try {
await emit("usage.session", body);
} catch (err) {
console.error(`[anthropic-consumer] could not emit usage: ${err}`);
}
}
// The cadence the scheduled container had: once at start, then every five minutes. Not awaited, so the
// runtime's handshake is answered while a long first reading is still under way.
const EVERY_MS = 5 * 60 * 1000;
const tick = (): void => {
void main().catch((err) => console.error(`[anthropic-consumer] usage reading failed: ${err}`));
};
tick();
setInterval(tick, EVERY_MS);
+35
View File
@@ -0,0 +1,35 @@
// Adoption: the ONE time an operator's refresh token enters the mesh, and it enters already sealed.
//
// The refresh token is read here, on the MANAGER NODE, sealed to that node's PUBLIC sealing key, and
// only the sealed box leaves this process (novox/hq ADR 0050). The control plane stores that box via
// `licence set-grant` without ever seeing the refresh token in the clear — the same bound every
// delivery keeps. This is the counterpart to `refresh/index.js`: adoption seals the first box, refresh
// re-seals a rotated one; both use the very anonymous box (`crypto_box_seal`) the mesh seals every
// credential with, so the HOST unseals the stored box to mount the cleartext back — this module is
// never given a private key and opens nothing.
//
// MESH_ANTHROPIC_ADOPT_TOKEN_FILE the operator's refresh token, read once and never written out
// MESH_MODEL_ACCESS_BIND_FILE the manager holder's bound facts, carrying manager_public_key
// MESH_ANTHROPIC_GRANT_OUT where the sealed box is written, for `licence set-grant`
import { readFileSync } from "node:fs";
import { seal } from "../sealedbox.js";
import { managerPublicKey, writeSealedGrant } from "../grantfile.js";
function required(name: string): string {
const v = process.env[name];
if (!v) throw new Error(`${name} is not set — adoption needs it`);
return v;
}
const refreshToken = readFileSync(required("MESH_ANTHROPIC_ADOPT_TOKEN_FILE"), "utf8").trim();
if (!refreshToken) throw new Error("[anthropic-manager] there is no refresh token to adopt");
// The node's PUBLIC sealing key, delivered by the mesh in the manager holder's bound facts. Public,
// so it is safe to hand a module; the private half stays with the host, which is what opens the box.
const nodePub = managerPublicKey(required("MESH_MODEL_ACCESS_BIND_FILE"));
const sealed = seal(new Uint8Array(Buffer.from(refreshToken, "utf8")), nodePub);
writeSealedGrant(required("MESH_ANTHROPIC_GRANT_OUT"), sealed, nodePub);
console.error("[anthropic-manager] sealed the refresh token to this node's key; only the host opens it");
+135
View File
@@ -0,0 +1,135 @@
// The only file that talks to Anthropic — the vendor half of the refreshable-grant adapter
// (novox/hq ADR 0050). Isolated exactly as cloudflare-dns isolates its registrar call, so the
// vendor is swappable and the one place a token endpoint is reached is auditable.
//
// Two endpoints, and they are different hosts (port-map "don't-map" #1): the TOKEN host mints a new
// access token from the refresh token; the USAGE host reports utilisation against an access token.
/** The token endpoint, overridable so the lab can point the whole flow at a stub without a vendor. */
export function tokenEndpoint(env = process.env): string {
return env.MESH_ANTHROPIC_TOKEN_ENDPOINT ?? "https://platform.claude.com/v1/oauth/token";
}
/** The usage endpoint, likewise overridable for the lab. */
export function usageEndpoint(env = process.env): string {
return env.MESH_ANTHROPIC_USAGE_ENDPOINT ?? "https://api.anthropic.com/api/oauth/usage";
}
// The OAuth client id is a hard-won constant, ported byte-exact from the mature implementation: a
// metadata URL in its place yields 400. It is not a secret (it identifies the public Claude Code
// client), so it lives in code.
const CLIENT_ID = "9d1c250a-e61b-44d9-88ed-5944d1962f5e";
/** The vendor's token response, snake_case as the wire has it. */
export interface RefreshedGrant {
readonly access_token?: string;
readonly refresh_token?: string;
readonly expires_in?: number;
readonly refresh_token_expires_in?: number;
readonly scopes?: string[];
readonly subscription_type?: string;
}
/**
* Exchange a refresh token for a fresh grant. Returns null on any non-ok response, surfacing the
* OAuth error body (invalid_grant/invalid_client/…) — the difference between "the token is dead" and
* "the endpoint was unreachable", which a bare status hides.
*/
export async function refreshGrant(
refreshToken: string,
env = process.env,
): Promise<RefreshedGrant | null> {
const resp = await fetch(tokenEndpoint(env), {
method: "POST",
headers: { "content-type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({
grant_type: "refresh_token",
refresh_token: refreshToken,
client_id: CLIENT_ID,
}),
});
if (!resp.ok) {
const body = await resp.text().catch(() => "<unreadable>");
console.error(
`[anthropic-manager] token refresh failed: ${resp.status} ${resp.statusText} — ${body.slice(0, 400)}`,
);
return null;
}
return (await resp.json()) as RefreshedGrant;
}
/** The vendor's usage response — utilisation percentages against several windows. */
export interface UsageLimits {
readonly five_hour?: { utilization: number; resets_at?: string };
readonly seven_day?: { utilization: number; resets_at?: string };
readonly seven_day_sonnet?: { utilization: number; resets_at?: string };
readonly seven_day_opus?: { utilization: number; resets_at?: string };
readonly extra_usage?: { utilization: number };
readonly [key: string]: unknown;
}
/**
* Read utilisation for an access token. Never refreshes here (a 401 is just reported): a second
* refresh source racing the first is the fault the mature implementation warns against.
*/
export async function readUsage(accessToken: string, env = process.env): Promise<UsageLimits | null> {
const resp = await fetch(usageEndpoint(env), {
headers: { authorization: `Bearer ${accessToken}` },
});
if (!resp.ok) {
const body = await resp.text().catch(() => "");
console.error(`[anthropic-manager] usage endpoint returned ${resp.status}: ${body.slice(0, 200)}`);
return null;
}
return (await resp.json()) as UsageLimits;
}
/** The licence-grain reading ADR 0054 fixes, flattened from the vendor's windows. */
export interface UsageReading {
readonly sessionPct: number | null;
readonly sessionResetsAt: string | null;
readonly weeklyPct: number | null;
readonly sonnetPct: number | null;
readonly extraPct: number | null;
readonly raw: UsageLimits;
}
export function flattenUsage(u: UsageLimits): UsageReading {
return {
sessionPct: u.five_hour?.utilization ?? null,
sessionResetsAt: u.five_hour?.resets_at ?? null,
weeklyPct: u.seven_day?.utilization ?? null,
sonnetPct: u.seven_day_sonnet?.utilization ?? null,
extraPct: u.extra_usage?.utilization ?? null,
raw: u,
};
}
/** The access-token-only grant a holder is delivered — the port-map credential-file shape's fields. */
export interface AccessGrant {
readonly accessToken: string;
readonly expiresAt: number | null;
readonly refreshTokenExpiresAt: number | null;
readonly scopes: string[] | null;
readonly subscriptionType: string | null;
}
/**
* Turn a vendor refresh into what the manager submits: the access-token-only grant for holders, and
* the rotated refresh token if the vendor sent one. Never clobbers a good grant from an empty
* response — no access_token means the caller keeps what it had.
*/
export function grantFromRefresh(r: RefreshedGrant, nowMs: number): { access: AccessGrant; rotatedRefresh: string | null } | null {
if (!r.access_token) return null;
return {
access: {
accessToken: r.access_token,
expiresAt: typeof r.expires_in === "number" ? nowMs + r.expires_in * 1000 : null,
refreshTokenExpiresAt:
typeof r.refresh_token_expires_in === "number" ? nowMs + r.refresh_token_expires_in * 1000 : null,
scopes: r.scopes ?? null,
subscriptionType: r.subscription_type ?? null,
},
rotatedRefresh: r.refresh_token ?? null,
};
}
+32
View File
@@ -0,0 +1,32 @@
// Reading the manager node's PUBLIC sealing key out of the bound facts the mesh delivers, and
// writing a sealed refresh token in the wire shape mesh-controller reads.
//
// **The public key is delivered, not derived.** The manager module holds no node key of its own
// (novox/hq ADR 0050) — it is deliberately never given one. To seal a refresh token to this node it
// needs the node's PUBLIC sealing key, and mesh-controller puts that in the manager holder's bound facts
// (`serves.manager_public_key`), safe to disclose because it is public. Both adoption and every
// rotation read it from there.
import { readFileSync, writeFileSync, renameSync, mkdirSync } from "node:fs";
import { dirname } from "node:path";
/** The manager node's public sealing key, from the bound facts file the mesh delivers. */
export function managerPublicKey(boundFile: string): string {
const raw = JSON.parse(readFileSync(boundFile, "utf8")) as { serves?: Record<string, unknown> };
const key = raw.serves?.["manager_public_key"];
if (typeof key !== "string" || key === "") {
throw new Error(
"the bound facts carry no manager_public_key — this node is not the licence's manager, or " +
"the manager holder has not been delivered yet",
);
}
return key;
}
/** Write a sealed refresh token in the {sealed, manager_key} wire shape mesh-controller reads. */
export function writeSealedGrant(path: string, sealed: string, managerKey: string): void {
mkdirSync(dirname(path), { recursive: true });
const tmp = `${path}.tmp`;
writeFileSync(tmp, JSON.stringify({ sealed, manager_key: managerKey }), { mode: 0o600 });
renameSync(tmp, path);
}
+63
View File
@@ -0,0 +1,63 @@
{
"module": "anthropic-manager",
"version": "1",
"slug": "anthmgr",
"capabilities": [
"container-runtime"
],
"requires": [
"model-access"
],
"binds": {
"model-access": "${dir:mesh-state}/model.json"
},
"secrets": {
"model-access": "${dir:mesh-state}/refresh-token"
},
"own-secrets": {
"broker": "${dir:mesh-state}/broker"
},
"emits": [
"usage.read"
],
"resources": [
{
"id": "mesh-state",
"type": "directory",
"mode": "0700",
"place": "mesh"
},
{
"id": "out",
"type": "directory",
"path": "${dir:mesh-state}/out",
"mode": "0700"
},
{
"id": "refresh",
"type": "container",
"name": "mesh-anthropic-manager-refresh",
"image": "mesh-runtime-anthropic-manager@sha256:0000000000000000000000000000000000000000000000000000000000000000",
"network": "host",
"schedule": "*/5 * * * *",
"args": [
"run",
"/app/modules/anthropic-manager/dist/refresh/index.js"
],
"volumes": [
"${dir:mesh-state}/broker:/run/secrets/broker:ro",
"${dir:mesh-state}:/run/state"
],
"env": {
"MESH_BROKER_FILE": "/run/secrets/broker",
"MESH_ANTHROPIC_LICENCE": "personal",
"MESH_MODEL_ACCESS_SECRET_FILE": "/run/state/refresh-token",
"MESH_MODEL_ACCESS_BIND_FILE": "/run/state/model.json",
"MESH_ANTHROPIC_ACCESS_OUT": "/run/state/out/access-token",
"MESH_ANTHROPIC_GRANT_OUT": "/run/state/out/grant.json",
"MESH_ANTHROPIC_USAGE_OUT": "/run/state/out/usage.json",
"MESH_TOOLS_MAIN": "/app/dist/main.js"
}
}
]
}
+16
View File
@@ -0,0 +1,16 @@
{
"name": "@novox/module-anthropic-manager",
"version": "0.1.0",
"description": "anthropic-manager — the manager side of the model-access refreshable-grant (ADR 0050): opens the refresh token on the manager node alone, refreshes it against Anthropic's OAuth endpoint, and submits back only the access token and the re-sealed refresh envelope.",
"type": "module",
"private": true,
"dependencies": {
"@novox/mesh-sdk": "^0.1.0",
"tweetnacl": "^1.0.3",
"tweetnacl-sealedbox-js": "^1.2.0"
},
"devDependencies": {
"@types/node": "^22.0.0",
"typescript": "^5.6.0"
}
}
+157
View File
@@ -0,0 +1,157 @@
// The manager's scheduled run (novox/hq ADR 0050/0053). It is the whole of the carve-out in one
// place, and it runs on the MANAGER NODE, never in the control plane:
//
// 1. read the refresh token as CLEARTEXT — the host unsealed the stored box with THIS node's private
// key and mounted it at the module's bound secret path, exactly as it delivers any credential.
// This module holds no node key and opens nothing itself;
// 2. call the vendor's OAuth token endpoint to mint a fresh access token (and maybe a rotated
// refresh token);
// 3. if the vendor rotated the refresh token, SEAL the new one to this node's PUBLIC sealing key
// (delivered in the bound facts) with the same anonymous box the mesh seals every credential with;
// 4. hand the control plane back ONLY the access token in the clear + the opaque re-sealed box —
// never the refresh token — which it seals per consumer holder and stores;
// 5. poll usage with the fresh access token and record the licence-grain reading.
//
// mesh-controller receives the products of steps 3–4 through `licence submit-refresh` (access token +
// sealed box). The refresh token never leaves this process except as ciphertext, and it never had to
// be opened here at all — the host did that.
//
// This runs as `mesh-tools run`, which connects no broker, so the outputs are written to files the
// host mounts; the submit itself (the transport to mesh-controller) is done by the caller invoking
// `mesh-controller licence submit-refresh`. In the lab that caller is the scenario; in production it is
// an authenticated call the manager node makes. The transport is the one part stubbed here — FLAGGED
// — because a cross-node authenticated command surface is out of this module's scope.
import { readFileSync, writeFileSync, renameSync, mkdirSync } from "node:fs";
import { dirname } from "node:path";
import { readEnv } from "@novox/mesh-sdk/primitives";
import { seal } from "../sealedbox.js";
import { managerPublicKey, writeSealedGrant } from "../grantfile.js";
import { refreshGrant, grantFromRefresh, readUsage, flattenUsage, type UsageReading } from "../client.js";
/** The vendor-neutral usage row ADR 0054 fixes — the shape the model-usage store upserts. Kept local
* to the producer (the normalisation lives in the adapter), so nothing here couples to the SDK. */
interface UsageRow {
licence: string;
consumer: string;
period: string;
metric: string;
value: number;
}
/** Normalise a licence-grain reading into ADR 0054 rows: one utilization row per window, a row
* omitted when its percentage is absent. `consumer` is the holding module — the licence grain. */
function licenceRows(licence: string, consumer: string, reading: UsageReading): UsageRow[] {
const rows: UsageRow[] = [];
const add = (period: string, pct: number | null): void => {
if (pct !== null && pct !== undefined && Number.isFinite(pct)) {
rows.push({ licence, consumer, period, metric: "utilization", value: pct });
}
};
add("5h", reading.sessionPct);
add("7d", reading.weeklyPct);
add("extra", reading.extraPct);
return rows;
}
function required(name: string): string {
const v = process.env[name];
if (!v) throw new Error(`${name} is not set — the manager runtime was deployed without it`);
return v;
}
function atomicWrite(path: string, content: string): void {
mkdirSync(dirname(path), { recursive: true });
const tmp = `${path}.tmp`;
writeFileSync(tmp, content, { mode: 0o600 });
renameSync(tmp, path);
}
async function main(): Promise<void> {
const licence = process.env.MESH_ANTHROPIC_LICENCE ?? "unknown";
// Step 1: the refresh token as cleartext, unsealed and mounted by the HOST. No open here.
const refreshToken = readFileSync(required("MESH_MODEL_ACCESS_SECRET_FILE"), "utf8").trim();
if (!refreshToken) {
// Nothing was delivered — the manager has not adopted a refresh token yet, or the push has not
// landed. Said rather than treated as an empty token the vendor would reject obscurely.
throw new Error("[anthropic-manager] no refresh token was delivered; adopt one first");
}
// The node's PUBLIC sealing key, to re-seal a rotated refresh token. Public, delivered in the facts.
const nodePub = managerPublicKey(required("MESH_MODEL_ACCESS_BIND_FILE"));
// Step 2: the vendor call.
const refreshed = await refreshGrant(refreshToken);
if (!refreshed) {
// A dead endpoint or a rejected token: nothing to publish, and we do not clobber a good grant.
throw new Error(`[anthropic-manager] the refresh of ${licence} produced no grant`);
}
const grant = grantFromRefresh(refreshed, Date.now());
if (!grant) {
throw new Error(`[anthropic-manager] the refresh of ${licence} returned no access token`);
}
// Step 3: re-seal the rotated refresh token, if the vendor rotated it. Nothing to store otherwise.
if (grant.rotatedRefresh) {
const sealed = seal(new Uint8Array(Buffer.from(grant.rotatedRefresh, "utf8")), nodePub);
if (process.env.MESH_ANTHROPIC_GRANT_OUT) {
writeSealedGrant(process.env.MESH_ANTHROPIC_GRANT_OUT, sealed, nodePub);
}
}
// Step 4: the access token in the clear, for the control plane to seal per consumer holder. This is
// all it ever receives that is not ciphertext.
atomicWrite(required("MESH_ANTHROPIC_ACCESS_OUT"), grant.access.accessToken);
console.error(
`[anthropic-manager] refreshed ${licence}: access token minted` +
(grant.rotatedRefresh ? ", refresh token rotated and re-sealed" : ", refresh token unchanged"),
);
// Step 5: licence-grain usage, best-effort — a usage read failing must not fail the refresh.
try {
const usage = await readUsage(grant.access.accessToken);
if (usage) {
const reading = flattenUsage(usage);
if (process.env.MESH_ANTHROPIC_USAGE_OUT) {
atomicWrite(
process.env.MESH_ANTHROPIC_USAGE_OUT,
JSON.stringify({ licence, grain: "licence", ...reading }),
);
}
// Emit ADR-0054 rows, not a vendor-shaped body: the consumer of module.*.usage.* is the
// vendor-neutral model-usage store, so the normalisation is done HERE. The node names the
// holding module; with MESH_NODE unset the consumer is the module alone.
const node = readEnv("MESH_NODE", "");
const consumer = node ? `${node}/anthropic-manager` : "anthropic-manager";
await emitUsage({ rows: licenceRows(licence, consumer, reading), raw: usage });
}
} catch (err) {
console.error(`[anthropic-manager] usage poll for ${licence} failed: ${err}`);
}
}
/**
* Emit a usage event best-effort by shelling out to the sibling mesh-tools `emit` primitive, which
* is the one path that wires a broker from a run-once/scheduled step (which itself connects none).
* A broker hiccup must never fail a refresh that already happened.
*/
async function emitUsage(body: Record<string, unknown>): Promise<void> {
const main = process.env.MESH_TOOLS_MAIN ?? "/app/dist/main.js";
const { spawn } = await import("node:child_process");
await new Promise<void>((resolve) => {
const child = spawn(process.execPath, [main, "emit", "usage.read", JSON.stringify(body)], {
stdio: "inherit",
});
child.on("exit", () => resolve());
child.on("error", (err) => {
console.error(`[anthropic-manager] could not emit usage: ${err}`);
resolve();
});
});
}
await main();
+57
View File
@@ -0,0 +1,57 @@
// A NaCl `crypto_box_seal`, byte-compatible with Go's `box.SealAnonymous`, over the audited
// `tweetnacl-sealedbox-js`.
//
// **Why this file exists, and why it is exactly this.** novox/hq ADR 0050's refreshable-grant
// carve-out delivers the refresh token to the manager module the way the mesh delivers every other
// credential: sealed to the node's key, and unsealed by the *host* — never by the module. The host
// unseals with Go's `golang.org/x/crypto/nacl/box.OpenAnonymous` (mesh-host
// internal/identity/sealing.go), and mesh-controller seals with `box.SealAnonymous`
// (mesh-controller internal/secrets/seal.go). Both are NaCl `crypto_box_seal`:
//
// sealed = ephemeralPub(32) ‖ crypto_box(msg, nonce, recipientPub, ephemeralSecret)
// nonce = blake2b( ephemeralPub ‖ recipientPub , 24 bytes, unkeyed )
//
// When the vendor rotates the refresh token, the manager module must store the new one back the
// same way — sealed to the manager node's own sealing key — so mesh-controller keeps it without ever
// reading it and the host can later unseal it to deliver the cleartext again. That reseal happens
// here, on the manager node, in TypeScript. It therefore has to produce the *identical* byte format
// Go's `Open` accepts, or the host would refuse the delivery.
//
// **The crypto is not ours.** `tweetnacl-sealedbox-js` is `crypto_box_seal` built on the audited
// TweetNaCl (`tweetnacl`) and blakejs — the same construction, and the same libraries, the mesh used
// to validate this seal during Phase C. It generates the ephemeral X25519 key pair, derives the
// nonce as `blake2b(ephemeralPub ‖ recipientPub, 24)`, and produces `ephemeralPub ‖ box`. That is
// exactly what Go's `box.OpenAnonymous` opens: the wire format is unchanged from the hand-transcribed
// version this replaces — only the implementation is now a maintained, reviewed dependency rather
// than a copy of TweetNaCl and blakejs carried inline. The module runtime image bundles it
// (package.json dependencies; novox/hq ADR 0052).
//
// **How it is kept honest.** A cross-language test seals a fixture here and opens it in Go
// (mesh-controller internal/secrets/sealedbox_xcheck_test.go); the fixture is regenerated from this
// `seal()`. A drift between this seal and Go's box surfaces there as a seal Go cannot open, not as a
// refresh token silently mangled in production.
//
// This module SEALS only. It never opens — opening is the host's job, with the node private key the
// module is deliberately never given.
// A default import, not `{ seal }`: the library is a CommonJS UMD bundle, and Node's ESM loader
// cannot statically see its named exports — only its default, which is the whole module object.
import sealedbox from "tweetnacl-sealedbox-js";
const RAW_KEY_LEN = 32;
/**
* Seal a value to a node's public sealing key, producing what Go's `box.OpenAnonymous` opens.
*
* @param value the plaintext (e.g. a rotated refresh token)
* @param recipientPublicB64 the node's raw 32-byte X25519 public key, standard base64
* @returns standard-base64( ephemeralPub ‖ box )
*/
export function seal(value: Uint8Array, recipientPublicB64: string): string {
const recipientPub = Buffer.from(recipientPublicB64, "base64");
if (recipientPub.length !== RAW_KEY_LEN) {
throw new Error(`a sealing public key is 32 bytes, not ${recipientPub.length}`);
}
const sealed = sealedbox.seal(value, new Uint8Array(recipientPub));
return Buffer.from(sealed).toString("base64");
}
@@ -0,0 +1,43 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { grantFromRefresh, flattenUsage } from "../client.ts";
test("an empty refresh response never clobbers a good grant", () => {
assert.equal(grantFromRefresh({}, 1000), null);
});
test("a refresh with an access token yields an access-token-only grant and epoch expiry", () => {
const out = grantFromRefresh(
{ access_token: "at-new", expires_in: 3600, refresh_token: "rt-rotated", subscription_type: "pro" },
1_000_000,
);
assert.ok(out);
assert.equal(out!.access.accessToken, "at-new");
assert.equal(out!.access.expiresAt, 1_000_000 + 3600 * 1000);
assert.equal(out!.access.subscriptionType, "pro");
// The rotated refresh token is reported separately, for the manager to re-seal — never put in the
// holder grant.
assert.equal(out!.rotatedRefresh, "rt-rotated");
assert.ok(!("refreshToken" in (out!.access as object)));
});
test("a refresh that did not rotate the refresh token reports none to re-seal", () => {
const out = grantFromRefresh({ access_token: "at-new" }, 0);
assert.ok(out);
assert.equal(out!.rotatedRefresh, null);
});
test("usage flattens the vendor windows to the ADR 0054 grain", () => {
const r = flattenUsage({
five_hour: { utilization: 42, resets_at: "2026-01-01T00:00:00Z" },
seven_day: { utilization: 10 },
seven_day_sonnet: { utilization: 5 },
extra_usage: { utilization: 1 },
});
assert.equal(r.sessionPct, 42);
assert.equal(r.sessionResetsAt, "2026-01-01T00:00:00Z");
assert.equal(r.weeklyPct, 10);
assert.equal(r.sonnetPct, 5);
assert.equal(r.extraPct, 1);
});
@@ -0,0 +1,36 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { generateKeyPairSync } from "node:crypto";
import { seal } from "../sealedbox.ts";
// The definitive proof that this seal interoperates with Go's box.OpenAnonymous (the host's Unseal
// and mesh-controller's secrets.Seal/Open) is a cross-language test in mesh-controller
// (internal/secrets/sealedbox_xcheck_test.go), which opens a fixture this module's seal() produced.
// These tests hold the TypeScript side: the output has the crypto_box_seal shape, and it is
// randomised so a rotation that changed nothing looks nothing like one that changed everything.
/** A node public key as the mesh records it: raw 32-byte X25519, standard base64. */
function aNodePublicKey(): string {
const kp = generateKeyPairSync("x25519");
const x = (kp.publicKey.export({ format: "jwk" }) as { x: string }).x;
return Buffer.from(x, "base64url").toString("base64");
}
test("a seal has the crypto_box_seal shape: ephemeralPub(32) + tag(16) + ciphertext(len)", () => {
const pub = aNodePublicKey();
const msg = Buffer.from("rt-a-refresh-token", "utf8");
const blob = Buffer.from(seal(new Uint8Array(msg), pub), "base64");
// 32 (ephemeral public key) + 16 (Poly1305 tag) + message length.
assert.equal(blob.length, 32 + 16 + msg.length);
});
test("two seals of the same value differ — a fresh ephemeral key each time", () => {
const pub = aNodePublicKey();
const msg = new Uint8Array(Buffer.from("rt-a-refresh-token", "utf8"));
assert.notEqual(seal(msg, pub), seal(msg, pub));
});
test("a public key that is not 32 bytes is refused before anything is sealed", () => {
assert.throws(() => seal(new Uint8Array([1, 2, 3]), Buffer.from("short").toString("base64")));
});
+19
View File
@@ -0,0 +1,19 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"noEmit": true
},
"include": [
"tweetnacl-sealedbox-js.d.ts",
"sealedbox.ts",
"grantfile.ts",
"client.ts",
"adopt/index.ts",
"refresh/index.ts"
]
}
+13
View File
@@ -0,0 +1,13 @@
// Ambient types for `tweetnacl-sealedbox-js` (crypto_box_seal), which ships without its own.
// The library is a small UMD bundle over `tweetnacl` and `blakejs`; only `seal` is used here.
declare module "tweetnacl-sealedbox-js" {
/** crypto_box_seal: returns ephemeralPub(32) ‖ box, sealed to `recipientPublicKey`. */
export function seal(message: Uint8Array, recipientPublicKey: Uint8Array): Uint8Array;
/** crypto_box_seal_open: returns the plaintext, or null if it does not open. */
export function open(
sealed: Uint8Array,
recipientPublicKey: Uint8Array,
recipientSecretKey: Uint8Array,
): Uint8Array | null;
export const overheadLength: number;
}
-93
View File
@@ -1,93 +0,0 @@
# artifact-store-tools
Tools that say what the mesh's artifact store holds, and what the controller's records say of it
(novox/hq ADR 0251 §1–3, to-be 51). It declares no resources and changes nothing on its machine.
## Why a module beside the store
The store is the `distribution` module. A module that provides the artifact store cannot also build an
artifact: building publishes to the store, so the store would be needed to create itself, and the
controller's module check refuses that manifest. So the store's tools are a second module, as the
controller's refusal says to do. **Assign it to the machine that holds the store**: its tools read the
store's files through the store's own container, and anywhere else they answer that the container is not
on that machine.
## Tools
| tool | | what |
|---|---|---|
| `artifact_store_repositories` | r | every repository: tags and what each names, how many manifests (tagged or not), size |
| `artifact_store_usage` | r | the store's size, the largest repositories, bytes shared between repositories, bytes no manifest marks (what the nightly collector frees next) |
| `artifact_store_references` | r | what the controller's records say of each manifest, counted and sized by state and repository; each manifest listed when one repository is asked; what the records keep that the store does not hold |
| `artifact_store_collect` | a | a dry run unless `dry_run` is false: what the controller would let go of, and the bytes the nightly collector would free then and now. A real run needs `why` and asks the controller's `collect` |
### How the store is read
The store's door lists repositories and tags, but not a manifest no tag names, and the mesh pins every
machine by digest, so that is most of them. So the bundle reads the store's own files through its own
container (`docker exec mesh-registry`, busybox `find`, `stat` and `cat`), and only reads them: every
blob with its size, every manifest each repository holds, what each tag names, and each manifest's
content, four hundred to an exec. What it read it keeps (a manifest is named by its content, so it never
changes), and the next call reads only what is new. A manifest that could not be read is counted and
said; what it marks beyond itself is then unknown, so sizes may read low and freed bytes high, and the
answer says so.
docker runs as the tool runner's account; a socket that refuses it is asked again through `sudo -n`,
never with a prompt, as the container runtime's own tools do.
A manifest *marks* its own content, its configuration and its layers, and an index marks the manifests
it lists. The store's collector removes every blob no manifest marks, so these numbers are its own.
### The states of a manifest
| state | what | removable |
|---|---|---|
| `kept` | a definition names it, or one of the five most recent builds of a module the mesh holds; `why` says which | no |
| `holder-of-kept-archive` | the manifest that keeps a kept archive's blob (hq issue 253) | no |
| `eligible` | the mesh made it and keeps it for no reason | through the controller's `collect` |
| `holder-of-eligible-archive` | the manifest that keeps an eligible archive's blob | with its archive |
| `let-go-yet-present` | the controller recorded letting go of it, and the store still holds it | no — a finding |
| `named-document` | a one-layer manifest the controller keeps under a tag (hq to-be 45 §9) | no |
| `unrecorded` | no record names it | **never**, by any tool (ADR 0189 §3) |
**A platform manifest takes the state of the recorded index naming it** (hq ADR 0257). An upstream
image is an index over several platforms, and each platform manifest is a manifest of the repository in
its own right; the controller lets an index go with the platform manifests only it names, so they are
counted as the index's. Two indexes naming one platform give it the more keeping state. An index no
record names leaves its platform manifests unrecorded with it.
**Unrecorded copies of upstream images** are listed in `unrecorded_mirrors`: each manifest at the top of
a repository only the build's mirror writes (`upstream/<host>/<path>`, or a module's `<module>/on-<argument>`
from before hq ADR 0257) that no record names, with what it and its platforms mark alone. They are the
input to the controller's `mirrors` verb, which records them as the mesh's on a person's word; the sweep
then decides about them as about every other copy. This tool removes none of them.
The records come from the controller's `artifacts` verb, asked with the references already let go of;
when that answer is too large to carry, it is asked without them and the answer says so.
### Collecting
`artifact_store_collect` never deletes anything itself. The controller decides and records what it
lets go of (ADR 0189 §2), so a real run asks its `collect` with `why` and `confirm`; the controller holds
every kept archive first and records the run as a hand-act. Bytes come back at the store's nightly
collection, which runs with the store held still.
An index goes with the platform manifests it names that no kept index of its repository also names, as
the controller's sweep lets them go (hq ADR 0257). The controller reads only its records; the store's
files show what else names a platform, so a dry run lists in `platforms_named_by_what_stays` every
manifest that stays and would lose a platform it names — to be read before a real run.
## Tests
```
go test ./...
```
Against a fake container that prints what the two scripts would: the listing parsed (a repository name
with slashes, a cut listing refused), manifests read with one absent or unreadable said as unread, a
digest never becoming a path outside the blobs, the mark (an index, a shared blob, a blob nothing marks),
every state, what the records keep that the store lacks, bytes freed counting a blob shared with a kept
manifest as kept, a dry run never confirming, a real run without `why` refused before anything is asked,
the fall-back when the references let go of cannot be carried, escalation through `sudo -n`, and that the
tools served are exactly the manifest's `tools` and its `invokes` exactly the two controller verbs.
Any command other than the two read-only scripts fails the test.
@@ -1,374 +0,0 @@
package main
import (
"regexp"
"sort"
)
// The states a manifest the store holds can be in, as the records see it (novox/hq to-be 51).
const (
StateKept = "kept"
StateHolderKept = "holder-of-kept-archive"
StateEligible = "eligible"
StateHolderEligible = "holder-of-eligible-archive"
StateCollectedPresent = "let-go-yet-present"
StateNamedDocument = "named-document"
StateUnrecorded = "unrecorded"
collectionRemovesThese = "eligible and holder-of-eligible-archive, through the controller's collect; nothing else"
)
// Entry is one manifest the store holds, and what the records say of it.
type Entry struct {
Repository string `json:"repository"`
Digest string `json:"digest"`
State string `json:"state"`
Why []string `json:"why,omitempty"`
// Archive is the archive a holder keeps.
Archive string `json:"archive,omitempty"`
Tags []string `json:"tags,omitempty"`
// Bytes are the blobs it marks, its own content included.
Bytes int64 `json:"bytes"`
// Unread says its content could not be read, so what it marks is not known beyond itself.
Unread string `json:"unread,omitempty"`
// Index is the index of the same repository whose state a platform manifest takes (novox/hq ADR
// 0257): the controller lets an index go with the platform manifests it names, so they are its.
Index string `json:"index,omitempty"`
// Mirror says it is an unrecorded copy at the top of a repository the mirror writes — what the
// controller's mirrors verb may record as the mesh's (novox/hq ADR 0257).
Mirror bool `json:"mirror,omitempty"`
}
// mirrorRepository is a repository only the build's mirror writes: one upstream image's repository, or a
// module's own repository of a base from before ADR 0257. The controller's mirrors verb takes the same.
var mirrorRepository = regexp.MustCompile(`^(upstream/[a-z0-9][a-z0-9._/-]*|[a-z0-9][a-z0-9._-]*/on-[a-z0-9_]+)$`)
// keeping orders the states a platform manifest may take from the indexes naming it: the most keeping
// wins, so a platform two indexes name is as kept as the more kept of them.
var keeping = map[string]int{
StateKept: 0, StateNamedDocument: 1, StateHolderKept: 2, StateEligible: 3, StateHolderEligible: 4,
StateCollectedPresent: 5,
}
// recordIndex finds a record by repository and digest.
type recordIndex map[string]*Record
func indexRecords(r *Records) recordIndex {
idx := recordIndex{}
for i := range r.References {
rec := &r.References[i]
if rec.Repository == "" || rec.Digest == "" {
continue
}
idx[rec.Kind+" "+Key(rec.Repository, rec.Digest)] = rec
}
return idx
}
// Classify gives every manifest the store holds one state.
func Classify(v *View, recs *Records) []Entry {
idx := indexRecords(recs)
var out []Entry
for _, name := range v.L.RepoNames() {
r := v.L.Repos[name]
tagsAt := map[string][]string{}
for t, d := range r.Tags {
tagsAt[d] = append(tagsAt[d], t)
}
digests := make([]string, 0, len(r.Revisions))
for d := range r.Revisions {
digests = append(digests, d)
}
sort.Strings(digests)
for _, d := range digests {
e := Entry{Repository: name, Digest: d, State: StateUnrecorded, Tags: tagsAt[d]}
sort.Strings(e.Tags)
blobs := map[string]bool{}
for _, b := range v.Marks(name, d) {
blobs[b] = true
}
e.Bytes = v.Bytes(blobs)
e.Unread = v.Unread[Key(name, d)]
m := v.M[Key(name, d)]
if rec := idx["image "+Key(name, d)]; rec != nil {
e.State, e.Why = stateOf(rec, false), rec.Why
} else if m.Holder() {
archive := m.Layers[0]
if rec := idx["archive "+Key(name, archive)]; rec != nil {
e.State, e.Why, e.Archive = stateOf(rec, true), rec.Why, archive
} else if len(e.Tags) > 0 {
e.State = StateNamedDocument
}
}
out = append(out, e)
}
}
return platformsOf(v, out)
}
// platformsOf gives each unrecorded platform manifest the state of the recorded index of its repository
// that names it, the most keeping when several do, and marks each unrecorded copy at the top of a mirror
// repository. An index no record names leaves its platform manifests unrecorded with it.
func platformsOf(v *View, entries []Entry) []Entry {
at := map[string]int{}
for i, e := range entries {
at[Key(e.Repository, e.Digest)] = i
}
named := map[string]bool{}
for _, e := range entries {
m := v.M[Key(e.Repository, e.Digest)]
if m == nil {
continue
}
for _, c := range m.Children {
named[Key(e.Repository, c)] = true
}
}
for _, e := range entries {
rank, recorded := keeping[e.State]
if !recorded {
continue
}
m := v.M[Key(e.Repository, e.Digest)]
if m == nil {
continue
}
for _, c := range m.Children {
i, held := at[Key(e.Repository, c)]
if !held {
continue
}
child := &entries[i]
if current, was := keeping[child.State]; child.State != StateUnrecorded && (!was || current <= rank) {
continue
}
child.State, child.Why, child.Index = e.State, e.Why, e.Digest
}
}
for i := range entries {
e := &entries[i]
if e.State == StateUnrecorded && !named[Key(e.Repository, e.Digest)] && mirrorRepository.MatchString(e.Repository) {
e.Mirror = true
}
}
return entries
}
func stateOf(rec *Record, holder bool) string {
switch rec.State {
case "kept":
if holder {
return StateHolderKept
}
return StateKept
case "eligible":
if holder {
return StateHolderEligible
}
return StateEligible
case "collected":
return StateCollectedPresent
}
// A state this bundle does not know is kept: never offered as removable.
return StateKept
}
// Missing are the references the records keep that the store does not hold: an image whose manifest
// is not in its repository, an archive whose blob is not in the store.
func Missing(v *View, recs *Records) []string {
var out []string
for _, rec := range recs.References {
if rec.State != "kept" || rec.Repository == "" || rec.Digest == "" {
continue
}
switch rec.Kind {
case "image":
if r := v.L.Repos[rec.Repository]; r == nil || !r.Revisions[rec.Digest] {
out = append(out, rec.Reference)
}
case "archive":
if _, ok := v.L.Blobs[rec.Digest]; !ok {
out = append(out, rec.Reference)
}
}
}
sort.Strings(out)
return out
}
// StateSum is the manifests of one state: how many, the bytes they mark together, and the bytes only
// they mark — what the store would give back if they alone went.
type StateSum struct {
Manifests int `json:"manifests"`
Bytes int64 `json:"bytes"`
OnlyTheirBytes int64 `json:"only_their_bytes"`
}
// Summarise sums entries by state, over the whole store or one repository's entries, the "only theirs"
// always judged against every manifest in the store.
func Summarise(v *View, entries []Entry, all []Entry) map[string]*StateSum {
statesOf := map[string]map[string]bool{}
for _, e := range all {
for _, b := range v.Marks(e.Repository, e.Digest) {
if statesOf[b] == nil {
statesOf[b] = map[string]bool{}
}
statesOf[b][e.State] = true
}
}
sums := map[string]*StateSum{}
blobs := map[string]map[string]bool{}
for _, e := range entries {
s := sums[e.State]
if s == nil {
s = &StateSum{}
sums[e.State] = s
blobs[e.State] = map[string]bool{}
}
s.Manifests++
for _, b := range v.Marks(e.Repository, e.Digest) {
blobs[e.State][b] = true
}
}
for state, set := range blobs {
sums[state].Bytes = v.Bytes(set)
only := map[string]bool{}
for b := range set {
if len(statesOf[b]) == 1 {
only[b] = true
}
}
sums[state].OnlyTheirBytes = v.Bytes(only)
}
return sums
}
// LetGoKeys are the manifests the store would no longer hold once the controller lets go of these
// references: an image's manifest and, for an index, the platform manifests it names that no kept index
// of the repository names too (novox/hq ADR 0257); and an archive's holder. Spared is the manifests a
// kept index names, by Key.
func LetGoKeys(v *View, refs []string, spared map[string]bool) map[string]bool {
out := map[string]bool{}
for _, ref := range refs {
repo, digest, kind := splitReference(ref)
r := v.L.Repos[repo]
if r == nil {
continue
}
switch kind {
case "image":
if r.Revisions[digest] {
out[Key(repo, digest)] = true
if m := v.M[Key(repo, digest)]; m != nil {
for _, c := range m.Children {
if r.Revisions[c] && !spared[Key(repo, c)] {
out[Key(repo, c)] = true
}
}
}
}
case "archive":
for d := range r.Revisions {
if m := v.M[Key(repo, d)]; m.Holder() && m.Layers[0] == digest {
out[Key(repo, d)] = true
}
}
}
}
return out
}
// Spared is every manifest the controller's sweep leaves in place when it lets an index go: each kept
// manifest, and every platform manifest a kept index names (novox/hq ADR 0257).
func Spared(v *View, entries []Entry) map[string]bool {
out := map[string]bool{}
for _, e := range entries {
if e.State != StateKept || e.Index != "" {
continue
}
out[Key(e.Repository, e.Digest)] = true
if m := v.M[Key(e.Repository, e.Digest)]; m != nil {
for _, c := range m.Children {
out[Key(e.Repository, c)] = true
}
}
}
return out
}
// Orphaned are the manifests that stay and name a platform manifest collection would remove: what the
// controller cannot see, because it reads only the records, said before anybody runs it for real.
func Orphaned(v *View, gone map[string]bool) []string {
var out []string
for _, name := range v.L.RepoNames() {
for d := range v.L.Repos[name].Revisions {
if gone[Key(name, d)] {
continue
}
if m := v.M[Key(name, d)]; m != nil {
for _, c := range m.Children {
if gone[Key(name, c)] {
out = append(out, Key(name, d))
break
}
}
}
}
}
sort.Strings(out)
return out
}
// UnrecordedMirror is one unrecorded copy at the top of a mirror repository, with what it and its
// platform manifests mark that nothing else does.
type UnrecordedMirror struct {
Reference string `json:"reference"`
Bytes int64 `json:"bytes"`
OnlyTheir int64 `json:"only_their_bytes"`
}
// UnrecordedMirrors are the copies the controller's mirrors verb may record, largest first.
func UnrecordedMirrors(v *View, entries []Entry) []UnrecordedMirror {
marks := map[string]int{}
for name, r := range v.L.Repos {
for d := range r.Revisions {
for _, b := range v.Marks(name, d) {
marks[b]++
}
}
}
var out []UnrecordedMirror
for _, e := range entries {
if !e.Mirror {
continue
}
family := map[string]bool{}
own := map[string]int{}
add := func(digest string) {
for _, b := range v.Marks(e.Repository, digest) {
family[b] = true
own[b]++
}
}
add(e.Digest)
if m := v.M[Key(e.Repository, e.Digest)]; m != nil {
for _, c := range m.Children {
add(c)
}
}
only := map[string]bool{}
for b, n := range own {
if marks[b] == n {
only[b] = true
}
}
out = append(out, UnrecordedMirror{Reference: "artifact-store://" + e.Repository + "@" + e.Digest,
Bytes: v.Bytes(family), OnlyTheir: v.Bytes(only)})
}
sort.Slice(out, func(i, j int) bool {
if out[i].OnlyTheir != out[j].OnlyTheir {
return out[i].OnlyTheir > out[j].OnlyTheir
}
return out[i].Reference < out[j].Reference
})
return out
}
@@ -1,31 +0,0 @@
// The artifact-store-tools module's Go bundle (novox/hq ADR 0251 §1–3, to-be 51): a process the node's
// runtime launches and speaks MCP over stdio to. It says what the artifact store holds — its
// repositories, its size, and what the controller's records say of each manifest — read from the
// store's own files through the store's own container, and writing nothing. A collection is asked of
// the controller, which decides and records what it lets go of (ADR 0189).
//
// **A module beside the store, not the store's own.** A module that provides the artifact store cannot
// also build an artifact: building publishes to the store, so the store would be needed to create
// itself, and the controller refuses that manifest. So these tools are a second module, assigned to the
// machine that holds the store. stdout is the protocol; what this bundle says, it says on stderr.
package main
import (
"fmt"
"os"
stdio "git.novox.be/novox/mesh-sdk/go"
)
func main() {
s := &Store{
Container: os.Getenv("MESH_ARTIFACT_STORE_CONTAINER"),
Run: ExecRunner,
UID: os.Getuid(),
}
// An empty name serves as the module the runtime names (MESH_SERVED_MODULE): artifact-store-tools.
if err := stdio.Serve("", Tools(s, Controller{Ask: stdio.Ask})); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
}
@@ -1,131 +0,0 @@
package main
import (
"reflect"
"testing"
)
// A copy of an upstream image is an index and the platform manifests it names, each a manifest of the
// repository in its own right (novox/hq ADR 0257). The records name the index; its platforms are its.
// mirrorWorld is two copies of golang in one image's repository and one module's old copy:
//
// upstream/docker.io/library/golang: i-old (eligible) names p-amd and p-arm; i-new (kept) names p-arm and p-new
// route-proxy/on-go_base: i-pre, no record, names p-pre; and a platform manifest no index names (p-lost)
func mirrorWorld() *View {
const up = "upstream/docker.io/library/golang"
const pre = "route-proxy/on-go_base"
l := &Listing{Blobs: map[string]int64{}, Repos: map[string]*Repo{}}
v := &View{L: l, M: map[string]*Manifest{}, Unread: map[string]string{}}
platform := func(repo, name string, size int64) {
l.repo(repo).Revisions[d(name)] = true
l.Blobs[d(name)] = 1
l.Blobs[d(name+"-layer")] = size
v.M[Key(repo, d(name))] = &Manifest{Refs: []string{d(name + "-layer")}, Layers: []string{d(name + "-layer")}}
}
idx := func(repo, name string, children ...string) {
l.repo(repo).Revisions[d(name)] = true
l.Blobs[d(name)] = 1
var cs []string
for _, c := range children {
cs = append(cs, d(c))
}
v.M[Key(repo, d(name))] = &Manifest{Children: cs}
}
platform(up, "p-amd", 100)
platform(up, "p-arm", 200)
platform(up, "p-new", 300)
idx(up, "i-old", "p-amd", "p-arm")
idx(up, "i-new", "p-arm", "p-new")
platform(pre, "p-pre", 1000)
platform(pre, "p-lost", 50)
idx(pre, "i-pre", "p-pre")
return v
}
func mirrorRecords() *Records {
const up = "artifact-store://upstream/docker.io/library/golang@"
return &Records{References: []Record{
{Reference: up + d("i-old"), State: "eligible"},
{Reference: up + d("i-new"), State: "kept", Why: []string{"stood-on"}},
}}
}
func stateByName(entries []Entry) map[string]Entry {
out := map[string]Entry{}
for _, e := range entries {
out[e.Repository+" "+e.Digest] = e
}
return out
}
func TestAPlatformManifestTakesTheStateOfTheIndexNamingIt(t *testing.T) {
v := mirrorWorld()
recs := mirrorRecords()
for i := range recs.References {
fill(&recs.References[i])
}
got := stateByName(Classify(v, recs))
up := "upstream/docker.io/library/golang "
for name, want := range map[string]string{
up + d("p-amd"): StateEligible, // only the eligible index names it
up + d("p-arm"): StateKept, // both do: the more keeping wins
up + d("p-new"): StateKept,
"route-proxy/on-go_base " + d("p-pre"): StateUnrecorded, // its index has no record
"route-proxy/on-go_base " + d("p-lost"): StateUnrecorded,
} {
if got[name].State != want {
t.Errorf("%s reads %s, want %s", name, got[name].State, want)
}
}
if got[up+d("p-arm")].Index != d("i-new") {
t.Errorf("p-arm names its index as %q", got[up+d("p-arm")].Index)
}
// Only the top of an unrecorded copy is offered to the mirrors verb: its index, and a platform
// manifest no index names.
var mirrors []string
for _, e := range got {
if e.Mirror {
mirrors = append(mirrors, e.Digest)
}
}
if len(mirrors) != 2 || !got["route-proxy/on-go_base "+d("i-pre")].Mirror || !got["route-proxy/on-go_base "+d("p-lost")].Mirror {
t.Errorf("offered as unrecorded copies: %v", mirrors)
}
um := UnrecordedMirrors(v, Classify(v, recs))
if len(um) != 2 || um[0].Reference != "artifact-store://route-proxy/on-go_base@"+d("i-pre") || um[0].OnlyTheir != 1+1+1000 {
t.Errorf("unrecorded copies: %+v", um)
}
}
func TestLettingAnIndexGoTakesItsOwnPlatformsAndSaysWhatStaysAndNamesOne(t *testing.T) {
v := mirrorWorld()
recs := mirrorRecords()
for i := range recs.References {
fill(&recs.References[i])
}
entries := Classify(v, recs)
old := "artifact-store://upstream/docker.io/library/golang@" + d("i-old")
gone := LetGoKeys(v, []string{old}, Spared(v, entries))
up := "upstream/docker.io/library/golang"
want := map[string]bool{Key(up, d("i-old")): true, Key(up, d("p-amd")): true}
if !reflect.DeepEqual(gone, want) {
t.Fatalf("would go: %v; want the old index and the platform only it names", gone)
}
if o := Orphaned(v, gone); len(o) != 0 {
t.Fatalf("what stays names something that goes: %v", o)
}
// Spared nothing, the shared platform goes too, and the kept index that names it is said.
all := LetGoKeys(v, []string{old}, nil)
if o := Orphaned(v, all); len(o) != 1 || o[0] != Key(up, d("i-new")) {
t.Fatalf("a kept index losing a platform was not said: %v", o)
}
out := Collected(v, &CollectAnswer{DryRun: true, WouldLetGo: []string{old}, Eligible: 1}, true, entries)
if out["manifests_gone"] != 2 {
t.Fatalf("collect said %v manifests go", out["manifests_gone"])
}
if out["collector_frees_after_bytes"].(int64)-out["collector_frees_now_bytes"].(int64) != 1+1+100 {
t.Fatalf("collect frees %v more, want the index, its own platform and that platform's layer",
out["collector_frees_after_bytes"].(int64)-out["collector_frees_now_bytes"].(int64))
}
}
@@ -1,175 +0,0 @@
package main
import (
"context"
"fmt"
"sort"
"strings"
"sync"
)
// readScript prints each manifest named on its command line, each after a line naming its path. A
// manifest is JSON, which holds no raw line break inside a string, so no line of one starts with the
// marker. One the store does not have is said, not skipped.
const readScript = `cd ` + storageRoot + `
for p in "$@"; do
echo "#manifest $p"
if [ -f "$p" ]; then cat "$p"; echo; else echo "#absent"; fi
done
echo '#end'`
// readBatch is how many manifests one exec reads: well under any command line's limit.
const readBatch = 400
// Store reaches the artifact store's own files, through its own container, and only reads them.
type Store struct {
Container string
Run Runner
UID int
mu sync.Mutex
// cache holds every manifest read: a manifest is named by its content's digest, so it never changes.
cache map[string]*Manifest
}
// List reads the store's files.
func (s *Store) List(ctx context.Context) (*Listing, error) {
if s.Container == "" {
return nil, fmt.Errorf("this bundle was not told the artifact store's container (MESH_ARTIFACT_STORE_CONTAINER)")
}
out, err := docker(ctx, s.Run, s.UID, "exec", s.Container, "sh", "-c", listScript)
if err != nil {
return nil, err
}
return ParseListing(out)
}
// Key names one manifest in one repository.
func Key(repo, digest string) string { return repo + "@" + digest }
// blobPath is where the store keeps a blob, under storageRoot.
func blobPath(digest string) string {
algorithm, hex, _ := strings.Cut(digest, ":")
if algorithm == "" || len(hex) < 2 || strings.ContainsAny(digest, "/ \n") {
return ""
}
return "blobs/" + algorithm + "/" + hex[:2] + "/" + hex + "/data"
}
// Manifests reads every manifest the listing names, from the cache or the store's files. Answers each
// one read, and each one not read with why.
func (s *Store) Manifests(ctx context.Context, l *Listing) (map[string]*Manifest, map[string]string) {
need := map[string]bool{}
s.mu.Lock()
if s.cache == nil {
s.cache = map[string]*Manifest{}
}
for _, name := range l.RepoNames() {
for d := range l.Repos[name].Revisions {
if _, ok := s.cache[d]; !ok {
need[d] = true
}
}
}
s.mu.Unlock()
digests := make([]string, 0, len(need))
for d := range need {
digests = append(digests, d)
}
sort.Strings(digests)
failed := map[string]string{}
for start := 0; start < len(digests); start += readBatch {
batch := digests[start:min(start+readBatch, len(digests))]
read, err := s.read(ctx, batch)
s.mu.Lock()
for _, d := range batch {
switch m := read[d]; {
case err != nil:
failed[d] = err.Error()
case m == nil:
failed[d] = "the store holds no manifest content for it"
default:
s.cache[d] = m
}
}
s.mu.Unlock()
}
got := map[string]*Manifest{}
unread := map[string]string{}
s.mu.Lock()
defer s.mu.Unlock()
for _, name := range l.RepoNames() {
for d := range l.Repos[name].Revisions {
if m, ok := s.cache[d]; ok {
got[Key(name, d)] = m
} else if why, ok := failed[d]; ok {
unread[Key(name, d)] = why
} else {
unread[Key(name, d)] = "not read"
}
}
}
return got, unread
}
// read reads one batch of manifests. A blob that is not a manifest, or not there, is answered as nil;
// an exec that fails fails the batch.
func (s *Store) read(ctx context.Context, digests []string) (map[string]*Manifest, error) {
args := []string{"exec", s.Container, "sh", "-c", readScript, "sh"}
byPath := map[string]string{}
for _, d := range digests {
if p := blobPath(d); p != "" {
byPath[p] = d
args = append(args, p)
}
}
out, err := docker(ctx, s.Run, s.UID, args...)
if err != nil {
return nil, err
}
return parseManifests(out, byPath)
}
// parseManifests reads readScript's output.
func parseManifests(out string, byPath map[string]string) (map[string]*Manifest, error) {
got := map[string]*Manifest{}
current := ""
var body strings.Builder
flush := func() {
if d := byPath[current]; d != "" {
m, err := ParseManifest([]byte(body.String()))
if err != nil {
m = nil
}
got[d] = m
}
current = ""
body.Reset()
}
ended := false
for _, line := range strings.Split(out, "\n") {
switch {
case strings.HasPrefix(line, "#manifest "):
flush()
current = strings.TrimPrefix(line, "#manifest ")
case line == "#absent":
if d := byPath[current]; d != "" {
got[d] = nil
}
current = ""
body.Reset()
case line == "#end":
flush()
ended = true
default:
body.WriteString(line)
body.WriteString("\n")
}
}
if !ended {
return nil, fmt.Errorf("reading the store's manifests was cut short (it never reached its end)")
}
return got, nil
}
@@ -1,38 +0,0 @@
package main
import "testing"
func TestManifestsAreReadFromTheStoresFilesAndAMissingOneIsSaid(t *testing.T) {
got, err := parseManifests("#manifest blobs/sha256/aa/aa1/data\n"+image(d("cfg"), d("l"))+"\n\n#manifest blobs/sha256/bb/bb1/data\n#absent\n#manifest blobs/sha256/cc/cc1/data\nnot json\n#end\n",
map[string]string{"blobs/sha256/aa/aa1/data": "sha256:aa1", "blobs/sha256/bb/bb1/data": "sha256:bb1", "blobs/sha256/cc/cc1/data": "sha256:cc1"})
if err != nil {
t.Fatal(err)
}
if m := got["sha256:aa1"]; m == nil || len(m.Layers) != 1 || m.Layers[0] != d("l") {
t.Errorf("aa1 %+v", m)
}
if got["sha256:bb1"] != nil || got["sha256:cc1"] != nil {
t.Error("an absent or unreadable manifest was read as one")
}
if _, err := parseManifests("#manifest x\n{}\n", map[string]string{"x": "sha256:x"}); err == nil {
t.Error("a cut read was accepted")
}
f := world()
delete(f.manifests, d("m-old"))
v := view(t, f)
if v.Unread[Key("app/server", d("m-old"))] == "" {
t.Error("a manifest whose content is gone was not said as unread")
}
if out := Usage(v, 3); out["unread"] == nil {
t.Error("the answer did not say a manifest was unread")
}
}
func TestADigestNeverBecomesAPathOutsideTheBlobs(t *testing.T) {
for _, bad := range []string{"sha256:", "nocolon", "sha256:a/../../etc", "sha256:ab cd"} {
if p := blobPath(bad); p != "" {
t.Errorf("%q became %q", bad, p)
}
}
}
@@ -1,209 +0,0 @@
package main
import (
"encoding/json"
"errors"
"fmt"
"strconv"
"strings"
)
// What the records say, asked of the controller and never copied (novox/hq ADR 0251 §2). The
// controller decides what the mesh keeps; this bundle only sets that beside what the store holds.
// Asking is how a tool on the mesh is asked: stdio.Ask, or a test's.
type Asking func(key string, body any) (json.RawMessage, error)
// ControllerSeat is the seat whose verbs the controller serves.
const ControllerSeat = "mesh-controller"
// Record is one reference the mesh recorded making, as the controller's `artifacts` answers it.
type Record struct {
Reference string `json:"reference"`
Repository string `json:"repository,omitempty"`
Digest string `json:"digest,omitempty"`
Kind string `json:"kind,omitempty"`
State string `json:"state"`
Why []string `json:"why,omitempty"`
}
// Records is the controller's `artifacts` answer.
type Records struct {
Store string `json:"store"`
KeptBuilds int `json:"kept_builds"`
Counts map[string]int `json:"counts"`
References []Record `json:"references"`
// CollectedAsked is whether the references already let go of were asked for too.
CollectedAsked bool `json:"-"`
// Note says what could not be asked, when something could not.
Note string `json:"-"`
}
// CollectAnswer is the controller's `collect` answer.
type CollectAnswer struct {
DryRun bool `json:"dry_run"`
Store string `json:"store"`
KeptArchives int `json:"kept_archives"`
Held int `json:"held"`
HoldersWritten int `json:"holders_written"`
Missing int `json:"missing"`
Eligible int `json:"eligible"`
WouldLetGo []string `json:"would_let_go"`
LetGo []string `json:"let_go"`
Skipped int `json:"skipped"`
Left int `json:"left"`
Stopped string `json:"stopped"`
// Rest is whatever else the controller said, passed on as it said it.
Rest map[string]json.RawMessage `json:"-"`
}
// Controller asks the controller's seat through the runtime.
type Controller struct{ Ask Asking }
// answerOf reads a verb's answer whatever wraps it: the controller's {output, ok, answer}, a text the
// runtime handed over, or the protocol's content list.
func answerOf(raw json.RawMessage) (json.RawMessage, string, bool) {
var s string
if json.Unmarshal(raw, &s) == nil {
return answerOf(json.RawMessage(s))
}
var m map[string]json.RawMessage
if json.Unmarshal(raw, &m) != nil {
return raw, string(raw), true
}
if content, has := m["content"]; has {
var items []struct {
Text string `json:"text"`
}
var e struct {
IsError bool `json:"isError"`
}
_ = json.Unmarshal(raw, &e)
if json.Unmarshal(content, &items) == nil && len(items) > 0 {
inner, out, ok := answerOf(json.RawMessage(items[0].Text))
return inner, out, ok && !e.IsError
}
}
var v struct {
Output string `json:"output"`
OK *bool `json:"ok"`
Answer json.RawMessage `json:"answer"`
}
if json.Unmarshal(raw, &v) == nil && v.OK != nil {
return v.Answer, v.Output, *v.OK
}
return raw, string(raw), true
}
func (c Controller) verb(name string, args map[string]any) (json.RawMessage, error) {
if c.Ask == nil {
return nil, errors.New("this bundle has no way to ask the controller")
}
raw, err := c.Ask("seat:"+ControllerSeat+"."+name, args)
if err != nil {
return nil, fmt.Errorf("the controller's %s could not be asked: %w", name, err)
}
answer, output, ok := answerOf(raw)
if !ok {
return nil, fmt.Errorf("the controller refused %s: %s", name, lastLine(output))
}
if len(answer) == 0 || string(answer) == "null" {
return nil, fmt.Errorf("the controller's %s answered no data: %s", name, lastLine(output))
}
return answer, nil
}
func lastLine(s string) string {
lines := strings.Split(strings.TrimSpace(s), "\n")
return strings.TrimSpace(lines[len(lines)-1])
}
// Artifacts asks what the records say, with the references already let go of when the answer can carry
// them; when it cannot, without, and says so.
func (c Controller) Artifacts(repository string) (*Records, error) {
args := map[string]any{"collected": "true"}
if repository != "" {
args["repository"] = repository
}
raw, err := c.verb("artifacts", args)
collected := true
note := ""
if err != nil {
delete(args, "collected")
var again error
raw, again = c.verb("artifacts", args)
if again != nil {
return nil, again
}
collected = false
note = "the references the mesh already let go of could not be asked for (" + err.Error() +
"), so a manifest the mesh let go of that the store still holds is counted as unrecorded"
}
var r Records
if err := json.Unmarshal(raw, &r); err != nil {
return nil, fmt.Errorf("the controller's artifacts answer is not readable: %v", err)
}
for i := range r.References {
fill(&r.References[i])
}
r.CollectedAsked, r.Note = collected, note
return &r, nil
}
// Collect asks the controller's sweep: a dry run unless confirm, which needs why.
func (c Controller) Collect(why string, confirm bool, most int) (*CollectAnswer, error) {
args := map[string]any{}
if confirm {
if strings.TrimSpace(why) == "" {
return nil, errors.New("a real collection needs why: nothing was asked")
}
args["why"] = why
args["confirm"] = "true"
}
if most > 0 {
args["most"] = strconv.Itoa(most)
}
raw, err := c.verb("collect", args)
if err != nil {
return nil, err
}
var a CollectAnswer
if err := json.Unmarshal(raw, &a); err != nil {
return nil, fmt.Errorf("the controller's collect answer is not readable: %v", err)
}
_ = json.Unmarshal(raw, &a.Rest)
if !confirm && !a.DryRun && len(a.LetGo) > 0 {
return nil, fmt.Errorf("the controller let go of %d artifacts when only a dry run was asked", len(a.LetGo))
}
return &a, nil
}
// fill reads the repository, digest and kind out of a reference when the answer left them out.
func fill(r *Record) {
repo, digest, kind := splitReference(r.Reference)
if r.Repository == "" {
r.Repository = repo
}
if r.Digest == "" {
r.Digest = digest
}
if r.Kind == "" {
r.Kind = kind
}
}
// splitReference reads `artifact-store://<repo>@sha256:…` (an image) and
// `artifact-store://<repo>/blobs/sha256:…` (an archive).
func splitReference(ref string) (repo, digest, kind string) {
path := ref
if _, after, ok := strings.Cut(ref, "://"); ok {
path = after
}
if before, after, ok := strings.Cut(path, "/blobs/sha256:"); ok {
return before, "sha256:" + after, "archive"
}
if before, after, ok := strings.Cut(path, "@sha256:"); ok {
return before, "sha256:" + after, "image"
}
return "", "", ""
}
@@ -1,96 +0,0 @@
package main
import (
"bytes"
"context"
"errors"
"fmt"
"os/exec"
"regexp"
"strings"
"time"
)
// Ran is what a command did: its output, its exit status, and why it never ran to an answer.
type Ran struct {
Stdout string
Stderr string
Status int
// Err is "ENOENT" when the program is not installed, or says it was ended for taking too long.
Err string
}
// Runner runs one command, so every tool can be tested without a daemon.
type Runner func(ctx context.Context, name string, args ...string) Ran
// ListTimeout is how long listing the store's files may take: below the runtime's thirty-second call
// limit, so a store that hangs is answered as such rather than as a call the runtime gave up on.
const ListTimeout = 15 * time.Second
// ExecRunner runs a command on this machine, bounded by ListTimeout.
func ExecRunner(ctx context.Context, name string, args ...string) Ran {
ctx, cancel := context.WithTimeout(ctx, ListTimeout)
defer cancel()
cmd := exec.CommandContext(ctx, name, args...)
var out, errb bytes.Buffer
cmd.Stdout, cmd.Stderr = &out, &errb
err := cmd.Run()
r := Ran{Stdout: out.String(), Stderr: errb.String()}
var exitErr *exec.ExitError
switch {
case errors.Is(ctx.Err(), context.DeadlineExceeded):
r.Status, r.Err = 124, fmt.Sprintf("no answer within %d s", int(ListTimeout/time.Second))
case errors.Is(err, exec.ErrNotFound):
r.Status, r.Err = 127, "ENOENT"
case errors.As(err, &exitErr):
r.Status = exitErr.ExitCode()
case err != nil:
r.Status, r.Err = 1, err.Error()
}
return r
}
var socketRefused = regexp.MustCompile(`(?i)permission denied.*docker.*sock|docker\.sock.*permission denied`)
// docker runs one docker command as this account and answers its stdout. A socket that refuses the
// account is asked again through `sudo -n`, as the container runtime's own tools do; never as root
// otherwise, and never with a prompt.
func docker(ctx context.Context, run Runner, uid int, args ...string) (string, error) {
r := run(ctx, "docker", args...)
program := "docker"
if r.Status != 0 && r.Err == "" && uid != 0 && socketRefused.MatchString(r.Stderr) {
program = "sudo"
r = run(ctx, "sudo", append([]string{"-n", "docker"}, args...)...)
}
if r.Status == 0 && r.Err == "" {
return r.Stdout, nil
}
said := strings.TrimSpace(r.Stderr + "\n" + r.Stdout)
switch {
case r.Err == "ENOENT" && program == "sudo":
return "", errors.New("the runtime's socket refused this account, and sudo is not installed here to escalate with")
case r.Err == "ENOENT":
return "", errors.New("docker is not installed on this machine, so the store's files cannot be listed")
case r.Err != "":
return "", fmt.Errorf("listing the store's files did not answer: %s", r.Err)
case program == "sudo" && strings.HasPrefix(said, "sudo:"):
return "", fmt.Errorf("the runtime's socket refused this account and it may not escalate without a prompt: %s", firstLine(said))
case strings.Contains(said, "No such container"):
return "", fmt.Errorf("the store's container is not on this machine: %s", firstLine(said))
case strings.Contains(said, "is not running"):
return "", fmt.Errorf("the store's container is not running: %s", firstLine(said))
}
if l := firstLine(said); l != "" {
return "", fmt.Errorf("listing the store's files failed (%d): %s", r.Status, l)
}
return "", fmt.Errorf("listing the store's files failed with status %d", r.Status)
}
func firstLine(s string) string {
for _, l := range strings.Split(s, "\n") {
if l = strings.TrimSpace(l); l != "" {
return l
}
}
return ""
}
@@ -1,180 +0,0 @@
package main
import (
"encoding/json"
"fmt"
"sort"
"strconv"
"strings"
)
// What the artifact store holds, read from its own files, and nothing written to them.
//
// **Why its files.** The store's door lists repositories and the tags in each, but not a manifest no tag
// names — and since the mesh pins every machine by digest, that is almost every manifest the store
// holds. The files list them all, with every blob's size, and hold every manifest's content. They are
// read through the store's own container, which is the only process that has them, and only read
// (novox/hq ADR 0251 §1). So this module runs on the machine that holds the store; anywhere else its
// tools say the container is not there.
// storageRoot is where the registry keeps its files, inside its container.
const storageRoot = "/var/lib/registry/docker/registry/v2"
// listScript lists, in three sections, every blob with its size, every manifest each repository holds,
// and what each tag names now. Busybox's find and stat (the store's image is Alpine): no -printf. An
// upload in progress is not listed, and nor are a tag's former targets.
const listScript = `set -e
cd ` + storageRoot + `
echo '#blobs'
if [ -d blobs ]; then find blobs -type f -name data | xargs -r stat -c '%s %n'; fi
echo '#revisions'
if [ -d repositories ]; then find repositories -type f -path '*/_manifests/revisions/sha256/*/link'; fi
echo '#tags'
if [ -d repositories ]; then find repositories -type f -path '*/_manifests/tags/*/current/link' -exec grep -H . {} + || true; fi
echo '#end'`
// Repo is one repository as the store's files list it.
type Repo struct {
Name string
// Revisions are the digests of every manifest it holds, tagged or not.
Revisions map[string]bool
// Tags are what each tag names now.
Tags map[string]string
}
// Listing is the store's files: every blob with its size, and every repository.
type Listing struct {
Blobs map[string]int64
Repos map[string]*Repo
}
func (l *Listing) repo(name string) *Repo {
r := l.Repos[name]
if r == nil {
r = &Repo{Name: name, Revisions: map[string]bool{}, Tags: map[string]string{}}
l.Repos[name] = r
}
return r
}
// RepoNames are the repositories in order.
func (l *Listing) RepoNames() []string {
out := make([]string, 0, len(l.Repos))
for n := range l.Repos {
out = append(out, n)
}
sort.Strings(out)
return out
}
// ParseListing reads listScript's output. A section that never ended means the listing was cut short,
// which is an error: a partial listing would make held things look absent.
func ParseListing(out string) (*Listing, error) {
l := &Listing{Blobs: map[string]int64{}, Repos: map[string]*Repo{}}
section := ""
ended := false
for _, line := range strings.Split(out, "\n") {
line = strings.TrimSpace(line)
if line == "" {
continue
}
if strings.HasPrefix(line, "#") {
section = line
ended = line == "#end"
continue
}
switch section {
case "#blobs":
size, path, ok := strings.Cut(line, " ")
n, err := strconv.ParseInt(size, 10, 64)
if !ok || err != nil {
return nil, fmt.Errorf("the store's listing has a blob line it cannot read: %q", line)
}
// blobs/sha256/ab/<hex>/data
parts := strings.Split(strings.TrimPrefix(path, "./"), "/")
if len(parts) != 5 || parts[0] != "blobs" || parts[4] != "data" {
return nil, fmt.Errorf("the store's listing has a blob at an unexpected place: %q", path)
}
l.Blobs[parts[1]+":"+parts[3]] = n
case "#revisions":
// repositories/<repo>/_manifests/revisions/sha256/<hex>/link
name, rest, ok := strings.Cut(strings.TrimPrefix(strings.TrimPrefix(line, "./"), "repositories/"), "/_manifests/revisions/")
parts := strings.Split(rest, "/")
if !ok || len(parts) != 3 || parts[2] != "link" {
return nil, fmt.Errorf("the store's listing has a manifest at an unexpected place: %q", line)
}
l.repo(name).Revisions[parts[0]+":"+parts[1]] = true
case "#tags":
// repositories/<repo>/_manifests/tags/<tag>/current/link:sha256:<hex>
path, digest, ok := strings.Cut(line, ":sha256:")
name, rest, ok2 := strings.Cut(strings.TrimPrefix(strings.TrimPrefix(path, "./"), "repositories/"), "/_manifests/tags/")
tag, _, ok3 := strings.Cut(rest, "/current/link")
if !ok || !ok2 || !ok3 || tag == "" {
return nil, fmt.Errorf("the store's listing has a tag line it cannot read: %q", line)
}
l.repo(name).Tags[tag] = "sha256:" + digest
default:
return nil, fmt.Errorf("the store's listing has a line outside any section: %q", line)
}
}
if !ended {
return nil, fmt.Errorf("the store's listing was cut short (it never reached its end)")
}
return l, nil
}
// Manifest is what one manifest names.
type Manifest struct {
MediaType string
ConfigMedia string
// Refs are the blobs it names: its configuration and its layers.
Refs []string
// Layers are its layers alone, in order.
Layers []string
// Children are the manifests an index names.
Children []string
}
// Holder is the shape of a manifest that keeps one blob: an empty configuration and one layer. The
// controller holds every archive the mesh keeps this way (novox/hq issue 253), and keeps a named
// document the same way, under a tag.
func (m *Manifest) Holder() bool {
return m != nil && m.ConfigMedia == "application/vnd.oci.empty.v1+json" && len(m.Layers) == 1
}
// ParseManifest reads a manifest's content, of any of the shapes a registry holds.
func ParseManifest(raw []byte) (*Manifest, error) {
type desc struct {
MediaType string `json:"mediaType"`
Digest string `json:"digest"`
}
var v struct {
MediaType string `json:"mediaType"`
Config *desc `json:"config"`
Layers []desc `json:"layers"`
Manifests []desc `json:"manifests"`
FSLayers []struct {
BlobSum string `json:"blobSum"`
} `json:"fsLayers"`
}
if err := json.Unmarshal(raw, &v); err != nil {
return nil, fmt.Errorf("not a manifest: %v", err)
}
m := &Manifest{MediaType: v.MediaType}
if v.Config != nil && v.Config.Digest != "" {
m.ConfigMedia = v.Config.MediaType
m.Refs = append(m.Refs, v.Config.Digest)
}
for _, d := range v.Layers {
m.Refs = append(m.Refs, d.Digest)
m.Layers = append(m.Layers, d.Digest)
}
for _, d := range v.FSLayers {
m.Refs = append(m.Refs, d.BlobSum)
m.Layers = append(m.Layers, d.BlobSum)
}
for _, d := range v.Manifests {
m.Children = append(m.Children, d.Digest)
}
return m, nil
}
@@ -1,427 +0,0 @@
package main
import (
"context"
"encoding/json"
"fmt"
"os"
"reflect"
"sort"
"strings"
"sync"
"testing"
)
// d is a digest made from a short name, so a test reads as names.
func d(name string) string {
return fmt.Sprintf("sha256:%064x", []byte(name))[:71]
}
func hexOf(digest string) string { return strings.TrimPrefix(digest, "sha256:") }
// fakeStore is a store's files and its manifests: what listScript and readScript would print.
type fakeStore struct {
blobs map[string]int64
revisions map[string][]string // repo -> digests
tags map[string]map[string]string
manifests map[string]string // digest -> content
}
func (f *fakeStore) listing() string {
var b strings.Builder
b.WriteString("#blobs\n")
for dg, n := range f.blobs {
fmt.Fprintf(&b, "%d blobs/sha256/%s/%s/data\n", n, hexOf(dg)[:2], hexOf(dg))
}
b.WriteString("#revisions\n")
for repo, ds := range f.revisions {
for _, dg := range ds {
fmt.Fprintf(&b, "repositories/%s/_manifests/revisions/sha256/%s/link\n", repo, hexOf(dg))
}
}
b.WriteString("#tags\n")
for repo, ts := range f.tags {
for t, dg := range ts {
fmt.Fprintf(&b, "repositories/%s/_manifests/tags/%s/current/link:%s\n", repo, t, dg)
}
}
b.WriteString("#end\n")
return b.String()
}
// run is the store's container as docker exec reaches it: the listing, or the manifests asked for, as
// the two scripts print them. Anything else is a test failure: these tools only read.
func (f *fakeStore) run(t *testing.T) Runner {
return func(_ context.Context, name string, args ...string) Ran {
if name != "docker" || len(args) < 5 || args[0] != "exec" || args[1] != "mesh-registry" || args[2] != "sh" || args[3] != "-c" {
t.Fatalf("ran %s %v", name, args)
}
switch args[4] {
case listScript:
return Ran{Stdout: f.listing()}
case readScript:
var b strings.Builder
for _, p := range args[6:] {
fmt.Fprintf(&b, "#manifest %s\n", p)
found := false
for dg, body := range f.manifests {
if blobPath(dg) == p {
b.WriteString(body + "\n")
found = true
}
}
if !found {
b.WriteString("#absent\n")
}
}
b.WriteString("#end\n")
return Ran{Stdout: b.String()}
}
t.Fatalf("ran a script these tools do not have: %q", args[4])
return Ran{}
}
}
func image(config string, layers ...string) string {
var ls []string
for _, l := range layers {
ls = append(ls, fmt.Sprintf(`{"mediaType":"application/vnd.oci.image.layer.v1.tar+gzip","digest":%q,"size":1}`, l))
}
return fmt.Sprintf(`{"schemaVersion":2,"mediaType":"application/vnd.oci.image.manifest.v1+json","config":{"mediaType":"application/vnd.oci.image.config.v1+json","digest":%q,"size":1},"layers":[%s]}`,
config, strings.Join(ls, ","))
}
func holder(blob string) string {
return fmt.Sprintf(`{"schemaVersion":2,"mediaType":"application/vnd.oci.image.manifest.v1+json","config":{"mediaType":"application/vnd.oci.empty.v1+json","digest":%q,"size":2},"layers":[{"mediaType":"application/vnd.oci.image.layer.v1.tar+gzip","digest":%q,"size":1}]}`,
d("empty"), blob)
}
func index(children ...string) string {
var cs []string
for _, c := range children {
cs = append(cs, fmt.Sprintf(`{"mediaType":"application/vnd.oci.image.manifest.v1+json","digest":%q,"size":1}`, c))
}
return fmt.Sprintf(`{"schemaVersion":2,"mediaType":"application/vnd.oci.image.index.v1+json","manifests":[%s]}`, strings.Join(cs, ","))
}
// world is one store with every state in it:
//
// app/server: m-kept (definition), m-old (eligible), m-stranger (unrecorded, tagged), an index naming m-kept
// app/tools: h-kept holds a-kept (kept archive), h-old holds a-old (eligible archive)
// mesh/facts: doc (a named document under the tag latest)
// base layer "shared" is marked by m-kept and m-old; "old-only" only by m-old.
func world() *fakeStore {
f := &fakeStore{
blobs: map[string]int64{},
revisions: map[string][]string{},
tags: map[string]map[string]string{},
manifests: map[string]string{},
}
put := func(repo, name, content string) {
f.manifests[d(name)] = content
f.blobs[d(name)] = int64(len(content))
f.revisions[repo] = append(f.revisions[repo], d(name))
}
for name, size := range map[string]int64{"cfg": 10, "shared": 1000, "old-only": 500, "stranger-layer": 300, "a-kept": 2000, "a-old": 4000, "empty": 2, "doc-body": 50, "orphan": 7000} {
f.blobs[d(name)] = size
}
put("app/server", "m-kept", image(d("cfg"), d("shared")))
put("app/server", "m-old", image(d("cfg"), d("shared"), d("old-only")))
put("app/server", "m-stranger", image(d("cfg"), d("stranger-layer")))
put("app/server", "idx", index(d("m-kept")))
put("app/tools", "h-kept", holder(d("a-kept")))
put("app/tools", "h-old", holder(d("a-old")))
put("mesh/facts", "doc", holder(d("doc-body")))
f.tags["app/server"] = map[string]string{"latest": d("m-stranger")}
f.tags["mesh/facts"] = map[string]string{"latest": d("doc")}
return f
}
func records() *Records {
return &Records{Store: "store:5000", KeptBuilds: 5, Counts: map[string]int{"kept": 3, "eligible": 2},
References: []Record{
{Reference: "artifact-store://app/server@" + d("m-kept"), State: "kept", Why: []string{"definition"}},
{Reference: "artifact-store://app/server@" + d("m-old"), State: "eligible"},
{Reference: "artifact-store://app/tools/blobs/" + d("a-kept"), State: "kept", Why: []string{"recent-build"}},
{Reference: "artifact-store://app/tools/blobs/" + d("a-old"), State: "eligible"},
{Reference: "artifact-store://app/server@" + d("m-gone"), State: "kept", Why: []string{"definition"}},
}}
}
func view(t *testing.T, f *fakeStore) *View {
t.Helper()
s := &Store{Container: "mesh-registry", UID: 1000, Run: f.run(t)}
v, err := s.View(context.Background())
if err != nil {
t.Fatal(err)
}
return v
}
func withFill(r *Records) *Records {
for i := range r.References {
fill(&r.References[i])
}
return r
}
func TestTheListingIsRead(t *testing.T) {
l, err := ParseListing("#blobs\n12 blobs/sha256/ab/abcd/data\n#revisions\nrepositories/a/b/c/_manifests/revisions/sha256/ff/link\n#tags\nrepositories/a/b/c/_manifests/tags/v1/current/link:sha256:ff\n#end\n")
if err != nil {
t.Fatal(err)
}
if l.Blobs["sha256:abcd"] != 12 {
t.Errorf("blobs %v", l.Blobs)
}
r := l.Repos["a/b/c"]
if r == nil || !r.Revisions["sha256:ff"] || r.Tags["v1"] != "sha256:ff" {
t.Errorf("repository with slashes misread: %+v", r)
}
}
func TestACutListingIsRefused(t *testing.T) {
for _, out := range []string{"#blobs\n12 blobs/sha256/ab/abcd/data\n", "#blobs\nnot a line\n#end\n", "stray\n#end\n"} {
if _, err := ParseListing(out); err == nil {
t.Errorf("accepted %q", out)
}
}
}
func TestTheMarkIsTheCollectorsIncludingAnIndexAndSharedBlobs(t *testing.T) {
v := view(t, world())
marked := v.Marked(nil)
for _, name := range []string{"cfg", "shared", "old-only", "stranger-layer", "a-kept", "a-old", "doc-body", "empty", "m-kept", "idx"} {
if !marked[d(name)] {
t.Errorf("%s not marked", name)
}
}
if marked[d("orphan")] {
t.Error("a blob no manifest names is marked")
}
freed, n := v.Unmarked(nil)
if freed != 7000 || n != 1 {
t.Errorf("the collector frees %d in %d, want 7000 in 1", freed, n)
}
// The index alone gone frees its own content and nothing m-kept still marks.
_, n = v.Unmarked(map[string]bool{Key("app/server", d("idx")): true})
if n != 2 {
t.Errorf("without the index %d blobs unmarked, want 2 (orphan and the index itself)", n)
}
if sh := v.Shared(); !sh[d("empty")] || sh[d("cfg")] {
t.Error("shared misjudged")
}
}
func TestEveryStateIsGiven(t *testing.T) {
v := view(t, world())
got := map[string]string{}
for _, e := range Classify(v, withFill(records())) {
got[e.Repository+" "+e.Digest] = e.State
}
want := map[string]string{
"app/server " + d("m-kept"): StateKept,
"app/server " + d("m-old"): StateEligible,
"app/server " + d("m-stranger"): StateUnrecorded,
"app/server " + d("idx"): StateUnrecorded,
"app/tools " + d("h-kept"): StateHolderKept,
"app/tools " + d("h-old"): StateHolderEligible,
"mesh/facts " + d("doc"): StateNamedDocument,
}
if !reflect.DeepEqual(got, want) {
t.Errorf("got %v\nwant %v", got, want)
}
if m := Missing(v, withFill(records())); len(m) != 1 || !strings.Contains(m[0], hexOf(d("m-gone"))) {
t.Errorf("missing %v", m)
}
}
func TestALetGoReferenceStillPresentIsSaidSo(t *testing.T) {
v := view(t, world())
r := records()
r.References = append(r.References, Record{Reference: "artifact-store://app/server@" + d("m-stranger"), State: "collected"})
for _, e := range Classify(v, withFill(r)) {
if e.Digest == d("m-stranger") && e.State != StateCollectedPresent {
t.Errorf("state %s", e.State)
}
}
}
func TestAStateThisBundleDoesNotKnowIsKept(t *testing.T) {
if stateOf(&Record{State: "something-new"}, false) != StateKept {
t.Fatal("an unknown state was not kept")
}
}
func TestBytesFreedCountASharedBlobAsKept(t *testing.T) {
v := view(t, world())
a := &CollectAnswer{DryRun: true, Eligible: 2, WouldLetGo: []string{
"artifact-store://app/server@" + d("m-old"), "artifact-store://app/tools/blobs/" + d("a-old")}}
out := Collected(v, a, true, nil)
now, after := out["collector_frees_now_bytes"].(int64), out["collector_frees_after_bytes"].(int64)
// m-old's own content, old-only and a-old and its holder go; shared and cfg stay with m-kept.
want := int64(7000) + 500 + 4000 + v.L.Blobs[d("m-old")] + v.L.Blobs[d("h-old")]
if now != 7000 || after != want {
t.Errorf("now %d after %d, want 7000 and %d", now, after, want)
}
if out["manifests_gone"].(int) != 2 {
t.Errorf("gone %v", out["manifests_gone"])
}
}
func TestOnlyTheirBytesLeaveOutWhatAnotherStateMarks(t *testing.T) {
v := view(t, world())
entries := Classify(v, withFill(records()))
sums := Summarise(v, entries, entries)
el := sums[StateEligible]
if el.OnlyTheirBytes != 500+v.L.Blobs[d("m-old")] {
t.Errorf("eligible only-theirs %d", el.OnlyTheirBytes)
}
}
// asked records every ask, and answers as the controller would.
type asked struct {
mu sync.Mutex
calls []string
args []map[string]any
fail map[string]bool
}
func (a *asked) ask(key string, body any) (json.RawMessage, error) {
a.mu.Lock()
defer a.mu.Unlock()
args, _ := body.(map[string]any)
a.calls = append(a.calls, key)
a.args = append(a.args, args)
if args["collected"] == "true" && a.fail["collected"] {
return nil, fmt.Errorf("maximum payload exceeded")
}
var answer any
switch key {
case "seat:mesh-controller.artifacts":
answer = records()
case "seat:mesh-controller.collect":
if args["confirm"] == "true" {
answer = CollectAnswer{LetGo: []string{"artifact-store://app/server@" + d("m-old")}}
} else {
answer = CollectAnswer{DryRun: true, Eligible: 1, WouldLetGo: []string{"artifact-store://app/server@" + d("m-old")}}
}
default:
return nil, fmt.Errorf("no such verb %s", key)
}
raw, _ := json.Marshal(answer)
return json.Marshal(map[string]any{"ok": true, "output": "", "answer": json.RawMessage(raw)})
}
func tool(t *testing.T, a *asked, name string) func(map[string]any) (any, error) {
f := world()
s := &Store{Container: "mesh-registry", Run: f.run(t)}
for _, tl := range Tools(s, Controller{Ask: a.ask}) {
if tl.Name == name {
return tl.Run
}
}
t.Fatalf("no tool %s", name)
return nil
}
func TestADryRunNeverConfirms(t *testing.T) {
a := &asked{}
for _, args := range []map[string]any{{}, {"dry_run": true}, {"dry_run": true, "why": "tidy"}} {
if _, err := tool(t, a, "artifact_store_collect")(args); err != nil {
t.Fatal(err)
}
}
for _, args := range a.args {
if args["confirm"] != nil || args["why"] != nil {
t.Errorf("a dry run asked %v", args)
}
}
}
func TestARealRunWithoutWhyIsRefusedAndAsksNothing(t *testing.T) {
a := &asked{}
if _, err := tool(t, a, "artifact_store_collect")(map[string]any{"dry_run": false}); err == nil {
t.Fatal("a real run without why was accepted")
}
if len(a.calls) != 0 {
t.Errorf("asked %v", a.calls)
}
out, err := tool(t, a, "artifact_store_collect")(map[string]any{"dry_run": false, "why": "the store is full", "most": float64(10)})
if err != nil {
t.Fatal(err)
}
if a.args[0]["confirm"] != "true" || a.args[0]["why"] != "the store is full" || a.args[0]["most"] != "10" {
t.Errorf("asked %v", a.args[0])
}
if out.(map[string]any)["dry_run"] != false {
t.Error("a real run answered as a dry run")
}
}
func TestReferencesFallBackWhenCollectedCannotBeCarried(t *testing.T) {
a := &asked{fail: map[string]bool{"collected": true}}
out, err := tool(t, a, "artifact_store_references")(map[string]any{"repository": "app/server"})
if err != nil {
t.Fatal(err)
}
m := out.(map[string]any)
if m["note"] == nil || m["manifests"] == nil {
t.Errorf("answer %v", m)
}
if _, err := tool(t, a, "artifact_store_references")(map[string]any{"repository": "nope"}); err == nil {
t.Error("an unknown repository was answered")
}
}
func TestTheToolsServedAreTheToolsTheManifestNames(t *testing.T) {
raw, err := os.ReadFile("../../module.json")
if err != nil {
t.Fatal(err)
}
var m struct {
Tools []string `json:"tools"`
Invokes []string `json:"invokes"`
}
if err := json.Unmarshal(raw, &m); err != nil {
t.Fatal(err)
}
var served []string
for _, tl := range Tools(&Store{}, Controller{}) {
if !strings.HasPrefix(tl.Name, "artifact_store_") || tl.Description == "" || tl.Run == nil {
t.Errorf("tool %q", tl.Name)
}
served = append(served, tl.Name)
}
sort.Strings(served)
listed := append([]string{}, m.Tools...)
sort.Strings(listed)
if !reflect.DeepEqual(served, listed) {
t.Fatalf("served %v, manifest %v", served, listed)
}
want := []string{"seat:mesh-controller.artifacts", "seat:mesh-controller.collect"}
if !reflect.DeepEqual(m.Invokes, want) {
t.Errorf("invokes %v", m.Invokes)
}
}
func TestASocketRefusalIsAskedAgainThroughSudo(t *testing.T) {
var ran []string
run := func(_ context.Context, name string, args ...string) Ran {
ran = append(ran, name)
if name == "docker" {
return Ran{Status: 1, Stderr: "permission denied while trying to connect to the Docker daemon socket at unix:///var/run/docker.sock"}
}
return Ran{Stdout: "#end\n"}
}
if _, err := docker(context.Background(), run, 1000, "exec", "x"); err != nil {
t.Fatal(err)
}
if !reflect.DeepEqual(ran, []string{"docker", "sudo"}) {
t.Errorf("ran %v", ran)
}
if _, err := docker(context.Background(), func(context.Context, string, ...string) Ran {
return Ran{Status: 1, Stderr: "Error: No such container: mesh-registry"}
}, 1000, "exec"); err == nil || !strings.Contains(err.Error(), "not on this machine") {
t.Errorf("err %v", err)
}
}
@@ -1,381 +0,0 @@
package main
import (
"context"
"fmt"
"math"
"sort"
"strconv"
"strings"
stdio "git.novox.be/novox/mesh-sdk/go"
)
// Tools are the artifact store's tools (novox/hq ADR 0251 §1). The first three only read; artifact_store_collect
// only reads too, unless it is a real run, and then it asks the controller, which decides and deletes.
func Tools(s *Store, c Controller) []stdio.Tool {
ctx := context.Background()
repoArg := map[string]any{"type": "string", "description": "one repository, as the store names it (<module>/<artifact>)"}
return []stdio.Tool{
{
Name: "artifact_store_repositories",
Description: "Every repository the artifact store holds: its tags and what each names, how many manifests it holds " +
"(tagged or not — the mesh pins by digest, so most are untagged) and its size, the blobs its manifests mark. " +
"A blob two repositories share is counted in each, and shared_bytes says how much of a size that is. Read from " +
"the store's own files; changes nothing. Replaces curl /v2/_catalog and /v2/<name>/tags/list. (r)",
Input: map[string]any{"repository": repoArg},
Run: func(args map[string]any) (any, error) {
v, err := s.View(ctx)
if err != nil {
return nil, err
}
return Repositories(v, text(args, "repository"))
},
},
{
Name: "artifact_store_usage",
Description: "How large the artifact store is: every blob's bytes together, the largest repositories, the bytes " +
"more than one repository's manifests mark, and the bytes no manifest marks — what the store's nightly collector " +
"frees next. Read from the store's own files; changes nothing. Replaces du on the store's directory. (r)",
Input: map[string]any{"top": map[string]any{"type": "integer", "description": "how many of the largest repositories to name (default 10, at most 200)"}},
Run: func(args map[string]any) (any, error) {
top, err := count(args, "top", 10, 1, 200)
if err != nil {
return nil, err
}
v, err := s.View(ctx)
if err != nil {
return nil, err
}
return Usage(v, top), nil
},
},
{
Name: "artifact_store_references",
Description: "What the controller's records say of each manifest the artifact store holds: kept (a definition " +
"names it, or one of the five most recent builds of a module the mesh holds), the holder of a kept archive, " +
"eligible (the mesh made it and keeps it for no reason), the holder of an eligible archive, let go yet present, " +
"a named document the controller keeps under a tag, or unrecorded — no record names it. A platform manifest " +
"takes the state of the recorded index naming it. Counted and sized by " +
"state and by repository; each manifest listed when one repository is asked. Then what the records keep that " +
"the store does not hold, and the unrecorded copies of upstream images the controller's mirrors verb may " +
"record (novox/hq ADR 0257). Unrecorded manifests are never removed by any tool (novox/hq ADR 0189 §3). (r)",
Input: map[string]any{"repository": repoArg},
Run: func(args map[string]any) (any, error) {
repo := text(args, "repository")
recs, err := c.Artifacts("")
if err != nil {
return nil, err
}
v, err := s.View(ctx)
if err != nil {
return nil, err
}
return References(v, recs, repo)
},
},
{
Name: "artifact_store_collect",
Description: "Collect what the mesh made and keeps for no reason (novox/hq ADR 0189, ADR 0251 §3). A dry run unless " +
"dry_run is false: it asks the controller's collect what it would let go of, and works out how many bytes the " +
"store's nightly collector would free once those are gone, beside what it frees tonight anyway. A real run needs " +
"why: it asks the controller's collect to hold every kept archive and let go of the eligible ones, recorded as a " +
"hand-act with the why; the bytes come back at the nightly collection. Never removes an unrecorded manifest, and " +
"never deletes through the store's door itself. (a)",
Input: map[string]any{
"dry_run": map[string]any{"type": "boolean", "description": "true (the default): say what would go, change nothing"},
"why": map[string]any{"type": "string", "description": "why — required for a real run, and recorded as the hand-act's reason"},
"most": map[string]any{"type": "integer", "description": "the most artifacts one real run lets go of (the controller's default when absent)"},
},
Run: func(args map[string]any) (any, error) {
dry := true
if b, given := args["dry_run"].(bool); given {
dry = b
} else if t := text(args, "dry_run"); t == "false" {
dry = false
}
why := text(args, "why")
if !dry && why == "" {
return nil, fmt.Errorf("a real collection needs why: nothing was asked of the controller")
}
most, err := count(args, "most", 0, 1, 5000)
if err != nil {
return nil, err
}
answer, err := c.Collect(why, !dry, most)
if err != nil {
return nil, err
}
v, err := s.View(ctx)
if err != nil {
return map[string]any{"controller": answer, "bytes_not_worked_out": err.Error()}, nil
}
// What a kept index names stays when an index goes (novox/hq ADR 0257): read from the
// records, as the controller's sweep reads it.
recs, err := c.Artifacts("")
if err != nil {
return map[string]any{"controller": answer, "bytes_not_worked_out": "the records could not be read: " + err.Error()}, nil
}
return Collected(v, answer, dry, Classify(v, recs)), nil
},
},
}
}
// View reads the store now.
func (s *Store) View(ctx context.Context) (*View, error) {
l, err := s.List(ctx)
if err != nil {
return nil, err
}
m, unread := s.Manifests(ctx, l)
return &View{L: l, M: m, Unread: unread}, nil
}
// unreadNote says what manifests that could not be read do to the numbers, when there are any.
func unreadNote(v *View) map[string]any {
if len(v.Unread) == 0 {
return nil
}
keys := make([]string, 0, len(v.Unread))
for k := range v.Unread {
keys = append(keys, k)
}
sort.Strings(keys)
first := keys[0] + ": " + v.Unread[keys[0]]
return map[string]any{
"manifests": len(keys),
"first": first,
"means": "what these mark beyond themselves is not known, so sizes may be low and the bytes said to be " +
"freed may be high; ask again — what was read is kept, so the next call reads only the rest",
}
}
// Repositories answers artifact_store_repositories.
func Repositories(v *View, only string) (map[string]any, error) {
shared := v.Shared()
var repos []map[string]any
for _, name := range v.L.RepoNames() {
if only != "" && name != only {
continue
}
r := v.L.Repos[name]
blobs := v.RepoBlobs(name)
sharedHere := map[string]bool{}
for b := range blobs {
if shared[b] {
sharedHere[b] = true
}
}
repos = append(repos, map[string]any{
"repository": name, "tags": tagsOf(r), "manifests": len(r.Revisions),
"bytes": v.Bytes(blobs), "size": human(v.Bytes(blobs)), "shared_bytes": v.Bytes(sharedHere),
})
}
if only != "" && len(repos) == 0 {
return nil, fmt.Errorf("the store holds no repository %q", only)
}
out := map[string]any{"count": len(repos), "repositories": repos,
"note": "a repository's size is the blobs its manifests mark; a blob shared with another repository is counted in each (shared_bytes)"}
if u := unreadNote(v); u != nil {
out["unread"] = u
}
return out, nil
}
// Usage answers artifact_store_usage.
func Usage(v *View, top int) map[string]any {
type sized struct {
name string
bytes int64
n int
}
var all []sized
manifests := 0
for _, name := range v.L.RepoNames() {
n := len(v.L.Repos[name].Revisions)
manifests += n
all = append(all, sized{name, v.Bytes(v.RepoBlobs(name)), n})
}
sort.SliceStable(all, func(i, j int) bool { return all[i].bytes > all[j].bytes })
var largest []map[string]any
for i, s := range all {
if i >= top {
break
}
largest = append(largest, map[string]any{"repository": s.name, "bytes": s.bytes, "size": human(s.bytes), "manifests": s.n})
}
total := v.Total()
sharedBytes := v.Bytes(v.Shared())
freed, freedBlobs := v.Unmarked(nil)
out := map[string]any{
"total_bytes": total, "total": human(total), "blobs": len(v.L.Blobs), "manifests": manifests,
"repositories": len(v.L.Repos), "largest": largest,
"shared_bytes": sharedBytes, "collector_frees_next_bytes": freed, "collector_frees_next_blobs": freedBlobs,
"said": []string{
fmt.Sprintf("the store holds %s in %d blobs, %d manifests in %d repositories", human(total), len(v.L.Blobs), manifests, len(v.L.Repos)),
fmt.Sprintf("%s is marked by more than one repository's manifests", human(sharedBytes)),
fmt.Sprintf("the nightly collector frees %s in %d blobs no manifest marks", human(freed), freedBlobs),
},
}
if u := unreadNote(v); u != nil {
out["unread"] = u
}
return out
}
// References answers artifact_store_references.
func References(v *View, recs *Records, only string) (map[string]any, error) {
if only != "" && v.L.Repos[only] == nil {
return nil, fmt.Errorf("the store holds no repository %q", only)
}
entries := Classify(v, recs)
byRepo := map[string][]Entry{}
for _, e := range entries {
byRepo[e.Repository] = append(byRepo[e.Repository], e)
}
var repos []map[string]any
for _, name := range v.L.RepoNames() {
if only != "" && name != only {
continue
}
repos = append(repos, map[string]any{"repository": name, "states": Summarise(v, byRepo[name], entries)})
}
whole := Summarise(v, entries, entries)
said := []string{}
for _, state := range []string{StateKept, StateHolderKept, StateEligible, StateHolderEligible, StateCollectedPresent, StateNamedDocument, StateUnrecorded} {
if s := whole[state]; s != nil {
said = append(said, fmt.Sprintf("%s: %d manifests marking %s, %s of it marked by nothing else", state, s.Manifests, human(s.Bytes), human(s.OnlyTheirBytes)))
}
}
missing := Missing(v, recs)
if len(missing) > 0 {
said = append(said, fmt.Sprintf("%d references the records keep are not in the store", len(missing)))
}
mirrors := UnrecordedMirrors(v, entries)
if len(mirrors) > 0 {
var only int64
for _, m := range mirrors {
only += m.OnlyTheir
}
said = append(said, fmt.Sprintf("%d unrecorded copies of upstream images, %s marked by them alone: the "+
"controller's mirrors verb may record them as the mesh's (novox/hq ADR 0257), listed in unrecorded_mirrors",
len(mirrors), human(only)))
}
out := map[string]any{
"store": recs.Store, "kept_builds": recs.KeptBuilds, "records": recs.Counts,
"states": whole, "repositories": repos, "missing": nonNil(missing), "said": said,
"removable": collectionRemovesThese, "unrecorded_mirrors": mirrorsOrEmpty(mirrors),
}
if only != "" {
out["manifests"] = byRepo[only]
}
if recs.Note != "" {
out["note"] = recs.Note
}
if u := unreadNote(v); u != nil {
out["unread"] = u
}
return out, nil
}
// Collected answers artifact_store_collect: the controller's answer, and what the store's collector frees.
func Collected(v *View, a *CollectAnswer, dry bool, entries []Entry) map[string]any {
refs := a.LetGo
if dry {
refs = a.WouldLetGo
}
gone := LetGoKeys(v, refs, Spared(v, entries))
orphaned := Orphaned(v, gone)
now, nowBlobs := v.Unmarked(nil)
after, afterBlobs := v.Unmarked(gone)
said := []string{}
if dry {
said = append(said, fmt.Sprintf("dry run: the controller would let go of %d of %d eligible artifacts; nothing was changed", len(a.WouldLetGo), a.Eligible))
} else {
said = append(said, fmt.Sprintf("the controller let go of %d artifacts (%d left, %d skipped)", len(a.LetGo), a.Left, a.Skipped))
if a.Stopped != "" {
said = append(said, "it stopped: "+a.Stopped)
}
}
said = append(said,
fmt.Sprintf("the nightly collector frees %s tonight as the store is now", human(now)),
fmt.Sprintf("and %s once those %d manifests are gone (%s more)", human(after), len(gone), human(after-now)))
if len(orphaned) > 0 {
said = append(said, fmt.Sprintf("%d manifests that stay name a platform manifest this removes, and would "+
"lose it: listed in platforms_named_by_what_stays — read them before a real run", len(orphaned)))
}
if dry && a.Eligible > len(a.WouldLetGo) {
said = append(said, fmt.Sprintf("the controller listed %d of %d eligible: the bytes cover only those listed", len(a.WouldLetGo), a.Eligible))
}
out := map[string]any{
"dry_run": dry, "controller": a,
"collector_frees_now_bytes": now, "collector_frees_now_blobs": nowBlobs,
"collector_frees_after_bytes": after, "collector_frees_after_blobs": afterBlobs,
"manifests_gone": len(gone), "said": said, "platforms_named_by_what_stays": nonNil(orphaned),
"note": "a manifest let go of frees no bytes until the store's nightly collector runs with the store held still (ADR 0189 §4)",
}
if u := unreadNote(v); u != nil {
out["unread"] = u
}
return out
}
func mirrorsOrEmpty(m []UnrecordedMirror) []UnrecordedMirror {
if m == nil {
return []UnrecordedMirror{}
}
return m
}
func nonNil(s []string) []string {
if s == nil {
return []string{}
}
return s
}
func human(n int64) string {
switch {
case n >= 1<<30:
return fmt.Sprintf("%.1f GiB", float64(n)/(1<<30))
case n >= 1<<20:
return fmt.Sprintf("%.1f MiB", float64(n)/(1<<20))
case n >= 1<<10:
return fmt.Sprintf("%.1f KiB", float64(n)/(1<<10))
}
return fmt.Sprintf("%d B", n)
}
func text(args map[string]any, key string) string {
s, _ := args[key].(string)
return strings.TrimSpace(s)
}
// count is a whole-number argument, defaulted, refused below least, held at most.
func count(args map[string]any, key string, fallback, least, most int) (int, error) {
v, given := args[key]
if !given || v == nil {
return fallback, nil
}
var n int
switch x := v.(type) {
case float64:
if x != math.Trunc(x) {
return 0, fmt.Errorf("%s must be a whole number, not %v", key, x)
}
n = int(x)
case string:
i, err := strconv.Atoi(strings.TrimSpace(x))
if err != nil {
return 0, fmt.Errorf("%s must be a whole number, not %q", key, x)
}
n = i
default:
return 0, fmt.Errorf("%s must be a whole number", key)
}
if n < least {
return 0, fmt.Errorf("%s must be at least %d", key, least)
}
return min(n, most), nil
}
@@ -1,129 +0,0 @@
package main
import (
"sort"
)
// View is the store at one moment: its files, and every manifest's content that could be read.
type View struct {
L *Listing
// M is each manifest read, by Key.
M map[string]*Manifest
// Unread is each manifest that could not be read, by Key, with why.
Unread map[string]string
}
// Marks are the blobs one manifest keeps from the store's collector: its own content, its
// configuration and its layers, and for an index the manifests it lists. A manifest that could not be
// read marks only itself here, and the answers that rest on it say so.
func (v *View) Marks(repo, digest string) []string {
out := []string{digest}
if m := v.M[Key(repo, digest)]; m != nil {
out = append(out, m.Refs...)
out = append(out, m.Children...)
}
return out
}
// Marked is every blob some manifest marks, leaving out the manifests named in without — the
// collector's own mark, and the mark it would make once those are gone.
func (v *View) Marked(without map[string]bool) map[string]bool {
marked := map[string]bool{}
for name, r := range v.L.Repos {
for d := range r.Revisions {
if without[Key(name, d)] {
continue
}
for _, b := range v.Marks(name, d) {
marked[b] = true
}
}
}
return marked
}
// Bytes is the size of these blobs, as the store holds them; a blob the store does not hold counts
// nothing.
func (v *View) Bytes(blobs map[string]bool) int64 {
var n int64
for b := range blobs {
n += v.L.Blobs[b]
}
return n
}
// Total is the size of every blob the store holds.
func (v *View) Total() int64 {
var n int64
for _, s := range v.L.Blobs {
n += s
}
return n
}
// Unmarked is the bytes and count of the blobs no manifest marks, once the manifests in without are
// gone: what the nightly collector frees.
func (v *View) Unmarked(without map[string]bool) (int64, int) {
marked := v.Marked(without)
var n int64
count := 0
for b, s := range v.L.Blobs {
if !marked[b] {
n += s
count++
}
}
return n, count
}
// RepoBlobs are the blobs one repository's manifests mark.
func (v *View) RepoBlobs(name string) map[string]bool {
out := map[string]bool{}
if r := v.L.Repos[name]; r != nil {
for d := range r.Revisions {
for _, b := range v.Marks(name, d) {
out[b] = true
}
}
}
return out
}
// Shared are the blobs that the manifests of more than one repository mark.
func (v *View) Shared() map[string]bool {
seen := map[string]string{}
shared := map[string]bool{}
for _, name := range v.L.RepoNames() {
for b := range v.RepoBlobs(name) {
if first, ok := seen[b]; ok && first != name {
shared[b] = true
} else {
seen[b] = name
}
}
}
return shared
}
// tagsOf is a repository's tags, in order, with what each names.
func tagsOf(r *Repo) []map[string]string {
names := make([]string, 0, len(r.Tags))
for t := range r.Tags {
names = append(names, t)
}
sort.Strings(names)
out := make([]map[string]string, 0, len(names))
for _, t := range names {
out = append(out, map[string]string{"tag": t, "digest": r.Tags[t]})
}
return out
}
// taggedDigests are the manifests some tag names, in one repository.
func taggedDigests(r *Repo) map[string]bool {
out := map[string]bool{}
for _, d := range r.Tags {
out[d] = true
}
return out
}
-5
View File
@@ -1,5 +0,0 @@
module artifactstoretools
go 1.22
require git.novox.be/novox/mesh-sdk/go v0.1.7
-2
View File
@@ -1,2 +0,0 @@
git.novox.be/novox/mesh-sdk/go v0.1.7 h1:C0sTQmtTiyYH7bnqZb7PusXnqA37gKuT7Nqjn9gG47w=
git.novox.be/novox/mesh-sdk/go v0.1.7/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY=
-32
View File
@@ -1,32 +0,0 @@
{
"module": "artifact-store-tools",
"version": "1",
"invokes": [
"seat:mesh-controller.artifacts",
"seat:mesh-controller.collect"
],
"tools": [
"artifact_store_repositories",
"artifact_store_usage",
"artifact_store_references",
"artifact_store_collect"
],
"build": {
"artifacts": [
{
"name": "tools-go",
"kind": "bundle",
"language": "go",
"system": "arch",
"from": "cmd/artifact-store-tools",
"binary": "artifact-store-tools",
"loads": [
"artifact-store-tools"
],
"env": {
"MESH_ARTIFACT_STORE_CONTAINER": "mesh-registry"
}
}
]
}
}
-387
View File
@@ -1,387 +0,0 @@
# asus-zephyrus-g14
The hardware module for the **ASUS ROG Zephyrus G14** laptop: its vendor daemon and platform
profiles, the hybrid GPU's mode and driver options, suspend, the lid and power key, low battery,
the backlights, the vendor keys and the touchpad (novox/hq research 027/03 *Power management on
the laptop*, research 026/05, to-be 42 phase 3).
## Why this name
A module is named after the hardware model, never the node (novox/hq ADR 0112; research 026/03:
no flavors, no machine names). `asus-zephyrus-g14` is the model family exactly as the firmware
reports it (`/sys/class/dmi/id/product_family` = `ROG Zephyrus G14`). The module's code checks that
value and its switcher does nothing on any other model, and `zephyrus_check` reports it.
A wider name such as `asus-rog-laptop` would promise what this module cannot keep. Its contents
belong to this family: the vendor-key scan codes, the eDP panel beside an NVIDIA dGPU, and the NVIDIA
D3 workaround. A second G14 is assigned the same module. Another ROG model gets its own.
Written against the GA403 (2024, Ryzen 8945HS, RTX 4070 Laptop, hybrid). Older G14 years have the same
daemons and probably the same keys. Their GPU options are unverified.
## What it owns
| | what | how |
|---|---|---|
| package | `asusctl` (asusd + client) | the distribution's package (`extra`). The machine was found with a local build of 6.4.0. The host only asserts *present*, so the switch to 6.5.0 from `extra` happens at the next `pacman -Syu` (or `pacman -S asusctl`). `zephyrus_check` flags a local build |
| package | `upower`, `playerctl` | what the low-battery drop-in and the media keys use. `xinput` is the `xorg` module's (one package, one module on a node) |
| service | `asusd` running (static unit: no boot state to declare), `supergfxd` running and enabled | |
| archive | `/usr/local/lib/asus-zephyrus-g14/bin/` | the module's scripts, from `files/bin` (below) |
| file ×2 | `~/.config/i3/config.d/10-asus.conf`, `20-g14.conf` | the laptop's lines in i3: the keys the firmware sends as ordinary presses (Fn+F6, Fn+F9), the keyboard-backlight notifier, the panel as primary, the touchpad key. The paths are adopted, because a second file binding the same keys makes i3's configuration check fail, and the `i3` module's watcher then reloads nothing |
| file ×4 | `asus-zephyrus-g14-touchpad-resume.service`, and a drop-in `asus-zephyrus-g14-touchpad.conf` on each sleep service | the touchpad's settings once more after a resume (below) |
| file | `/etc/modprobe.d/g14-nvidia-power.conf` | `NVreg_DynamicPowerManagement=0x00` (runtime D3 off: the ACPI D-Notifier hang) and `NVreg_PreserveVideoMemoryAllocations=1`. The path is adopted (ADR 0182) |
| file | `/etc/modprobe.d/video-brightness-switch.conf` | `video.brightness_switch_enabled=0`, so the ACPI video driver does not also move a backlight on the keys. The file was on the machine and owned by nothing |
| file ×3 | `systemd-{suspend,hibernate,suspend-then-hibernate}.service.d/asus-zephyrus-g14-nvidia.conf` | `Wants=` the matching `nvidia-*` sleep units and `nvidia-resume` (see *suspend units* below) |
| file | `nvidia-powerd.service.d/asus-zephyrus-g14.conf` | `ConditionKernelCommandLine=zephyrus.nvidia-powerd`: Dynamic Boost runs only when the operator opts in at boot |
| file | `/etc/systemd/logind.conf.d/power.conf` | the power key and the lid suspend, on battery, on mains and docked. `systemd-logind` is reloaded, never restarted |
| file | `/etc/udev/rules.d/90-backlight.rules` | backlights writable by the `video` group. `systemd-udevd` is reloaded |
| file | `triggerhappy.service.d/asus-zephyrus-g14.conf` | `thd … --user ${machine:account}`: the triggers run as the operator's account (below) |
| file | `/etc/triggerhappy/triggers.d/asus-g14.conf` | the vendor keys: media (`KEY_PROG1/3/4`), panel brightness, touchpad (`KEY_F21`). The path is adopted, because two trigger files would fire every key twice |
| file | `/etc/UPower/UPower.conf.d/50-asus-zephyrus-g14.conf` | low battery at 15/10/7 %; at 7 % **suspend**, not power off. A drop-in over the package's own file |
| file | `/etc/X11/xorg.conf.d/30-asus-zephyrus-g14-touchpad.conf` | tap to click, natural scrolling, acceleration 0.15, as an X input class |
**What it does not own, on purpose:**
- `/etc/asusd/*.ron` belong to asusd, which rewrites them whenever a setting changes. RON is not a
format the host writes into (ADR 0102 speaks JSON and marked blocks). Owning the file whole would
repeat the predecessor's freeze: the measured file already differs from the one the predecessor
shipped. The settings the module needs are set through asusd, by its code (below).
- `/etc/supergfxd.conf` and `/etc/modprobe.d/supergfxd.conf` belong to supergfxd, which writes both.
- The swap file, its unit and the swap partition are the machine's swap layout (research 027,
question 3). They are not this module's, nor `memory-pressure`'s.
- **Places.** The screen layouts for named places (`$mod+Alt+1…7`, `~/.screenlayout/`,
`~/scripts/.screenlayouts/@*.sh`) name where the operator works, which no module may (ADR 0112).
They are the operator's own lines until autorandr profiles replace them (the `xorg` module).
- **The monitor-hotplug wizard** (`/etc/udev/rules.d/91-monitor-hotplug.rules`,
`~/scripts/.screenlayouts/monitor-wizard.sh`) is any laptop's, not this model's. The predecessor said
so itself (its `laptop` flavor). It is the display server's to replace with autorandr's own hotplug
handling. Until then it stays as found, and it still calls the predecessor's `as-user`.
- **The screenshot script** (`~/.config/i3/scripts/screenshot.sh`) is any machine's. The `i3` module
binds it too. This module only binds the key the firmware sends for it.
- **The bar's battery block, and the power draw's.** A battery is any laptop's, not this model's, so
the `power` module contributes both blocks wherever the machine reports a battery or a power meter
(novox/hq ADR 0255). This module would contribute only what is this model's alone, such as the charge
limit asusd holds; nothing yet. The predecessor's AMD GPU block is no model's either, and waits for a
generic module (the `i3status-rust` README).
**Requires `x11-display`.** The i3 lines and the session scripts need a display, so the module is
assigned where the display server is.
## Software outside the distribution (ADR 0205, research 027 question 1)
`supergfxctl` (5.2.7, from the asus-linux repository, which is no longer configured) and
`triggerhappy` (AUR) are **kept as found, and depended on**. The module declares no package for
either, because the host installs from the official repositories only. It declares their services
(`supergfxd` running, `triggerhappy` running), so on a machine without them the host refuses the
service by name: *does not exist on this machine*. The refusal is loud, never a silent pass.
`zephyrus_check` names both as foreign.
This module does not choose between the options of research 027 question 1. Under the starting
position (P2: the build machine builds AUR packages into a repository the mesh serves), both become
`package` resources here, and a fresh G14 installs them. **Until P2 exists, a fresh G14 is blocked
on installing these two by hand.** ADR 0205's vendored archive (P1) does not fit: supergfxctl is a
daemon with a system-bus policy and udev rules, and triggerhappy is C.
A later option for the keys: the module's own Go code could read the vendor keys from evdev, which
the operator's account may do through the `input` group. That would retire triggerhappy entirely.
It is not done here, because it would put the keys behind the node's runtime, and the runtime
restarts a bundle that dies only on its next call (below).
## The long-running code: the profile switcher (ADR 0198)
The module's Go bundle serves the tools and runs the platform-profile switcher in the same process.
The node's runtime launches the bundle at the runtime's start. It replaces the predecessor's
`auto-profile`, a user unit that woke every five seconds, on battery too.
- **Policy** (constants until settings exist, issue 168): battery → `Quiet`; mains → `Balanced`;
mains with the CPU at or above 50 % for 3 samples of 10 s → `Performance`, back to `Balanced` after
3 samples at or below 20 %. Between the lines nothing moves (hysteresis). iowait counts as idle.
- **Woken by events, not a poll.** The kernel's power-supply uevents (netlink, group 1) wake the
switcher. Any account may listen on that group, and it needs no daemon, bus client or dependency;
upower re-announces the same changes but would need a D-Bus client in the bundle. The CPU is
sampled only on mains, every 10 s, because only there does the answer depend on it. On battery,
a safety re-read every 5 minutes covers an event lost across a suspend. If the uevent socket
cannot be opened, the switcher polls every 10 s and says so in `zephyrus_profile_policy`.
- **The battery decides the source.** A battery that is *discharging* means battery, whatever any
adapter says. The predecessor took any `online` file reading 1 as mains, and on this model the USB-C
ports report `online`. Batteries of `scope=Device` (a mouse, a headset) are ignored.
- **It acts on a change of its decision, never to restore one.** A profile someone chose by hand (the
profile key, asusctl, `zephyrus_profile`) stays until the power source changes or the load crosses
a line. The predecessor re-asserted its choice every five seconds, which made the profile key
useless. **Starting is not a decision**: the runtime restarts the bundle on every push that changes
one, and a push must not reset the operator's profile.
- **A hold.** `zephyrus_profile` holds the profile it sets for 60 min (`hold_minutes`). A change of
power source ends the hold.
- **One assertion at start:** through asusctl, the charge limit (80 %) and asusd's own on-mains and
on-battery profiles (`Balanced`, `Quiet`), each read first and set only if it differs. asusd's own
switching on a change of power source then agrees with the switcher's. A limit set later with
`zephyrus_charge_limit` stands until the bundle next starts. For a one-off full charge, use its
`oneshot`.
- **Events:** `profile.switched` (`profile`, `from`, `reason`, `source`), published through the
runtime.
No root is involved. asusd's and supergfxd's bus policies admit the `users` and `wheel` groups, and the
runtime runs as the operator's account. The one write that may escalate is the panel's backlight,
when the udev rule has not run yet. It uses `sudo -n` and never prompts. Every command is bounded at
20 s.
**Known limit.** The runtime restarts a launched bundle that exits *on its next tool call*, not at
once (mesh-tools `launch.ts`), so a crashed switcher stays down until a tool is called. ADR 0198 §1
says *started again when it exits*. The switcher recovers from a panic and reports it in
`zephyrus_profile_policy` and `zephyrus_check`, but a crash of the process is the runtime's to restart.
## The vendor keys and the scripts
triggerhappy opens the input devices as root, then **drops to the operator's account with its groups**
(`initgroups`: `input`, `video`). The packaged unit already drops to `nobody`, and the module's drop-in
names the account instead. The predecessor replaced the packaged unit with one that ran every trigger
as root, then `su`-ed to a named person with a hard-coded uid and display, and sourced a file of
secrets on the way (research 027 question 2). Now:
- `zephyrus-session CMD…`: runs a command in the account's graphical session. It sets the account's
own bus (`/run/user/<uid>/bus`) and takes the display and its authority from the session's window
manager's own environment, as the desktop modules' session finder does. If i3 is not running, it
asks logind, and then any process of the account that has a display. Nothing is sourced.
triggerhappy's `--user` changes the user and its groups and nothing else: the triggers start with
the service's bare environment, which is why every key that needs the session goes through this.
- `zephyrus-media play-pause | next | previous`: the media keys through MPRIS, with a notification of
what happened and a lock against the key's own repeat.
- `zephyrus-display primary | order`: the internal panel as the primary output, and the display key's
workspace split (odd workspaces on the panel, even ones on the first external output). The panel is
found as the connected `eDP` output. The predecessor named `eDP-1` and used `jq`. This reads i3's
answer without it.
- `zephyrus-kbd-notify`: the keyboard backlight's level, shown when UPower says it changed. One
instance per session, because i3 runs its `exec` lines again on an in-place restart.
- `zephyrus-backlight + | - | N`: the panel in 5 % steps, never below 1 %. **The panel is the
backlight under the eDP connector**, because this model also registers `nvidia_0`, which moves
nothing. The predecessor named `amdgpu_bl1` literally.
- `zephyrus-notify ID TEXT`: one replacing notification, through `busctl` (the service manager's
client, so no libnotify).
- `zephyrus-touchpad reset | toggle`: bound to the touchpad key (`KEY_F21`).
**Media keys** go to MPRIS through `playerctl`. The predecessor's fallback to a media server's local
API needed a token from the secrets file, and is dropped until a module can be handed a secret
(research 027 question 2). A player that does not speak MPRIS is told as "Media: no player".
## The touchpad: an input class instead of a sleep hook
The predecessor re-ran `xinput` from `/etc/systemd/system-sleep/` after every resume, as a named person
on a guessed display, because settings made with `xinput` are lost when the device initialises again.
An X input class is applied by X **every time the device appears**: at login, on hotplug and after a
resume. So the cause is fixed. The class matches any touchpad on the machine, which is the model's, so it
holds across G14 years whose touchpads differ. It takes effect at the next X start.
`zephyrus-touchpad reset` stays as the manual form, on the touchpad key and `$mod+Shift+x`.
**And a backstop after resume.** A resume that does not initialise the device again does not make X
apply the class either, and the predecessor's i3 file says the touchpad "sometimes needs re-init after
sleep". So `asus-zephyrus-g14-touchpad-resume.service` runs `zephyrus-touchpad reset` as the account,
two seconds after the machine is awake. It is never enabled. Each sleep service `Wants=` it through a
drop-in, and it is ordered `After=` them, which is how `nvidia-resume` is started too.
`zephyrus_check` says whether the sleep wants it.
## Suspend units without enabling them
`nvidia-suspend`, `-hibernate`, `-suspend-then-hibernate` and `-resume` are enabled with links in the
sleep services' `.wants` directories. The mesh makes no links (ADR 0012). The host's service shape
cannot declare them either: it may only say *running* or *stopped*, and *running* on a one-shot that
last failed would start `nvidia-sleep.sh suspend` with the machine awake. So the module asks for them
from the other side: a drop-in on each sleep service that `Wants=` them. The units' own
`Before=`/`After=` order them. The found links stay and are harmless.
`suspend-then-hibernate` now also gets `nvidia-suspend-then-hibernate`, which the machine lacked.
The drop-ins take effect at the service manager's next `daemon-reload`. In the same apply, the restart
of `triggerhappy` (whose drop-in changes) performs one.
## Tools
| tool | r/a | what |
|---|---|---|
| `zephyrus_brightness` | r/a | panel (percent or ±step, floor 1 %) and keyboard (off/low/med/high, 0-3, ±) through asusd |
| `zephyrus_battery` | r | charge, energy in Wh, health (full ÷ design), cycles (the firmware reports 0, and this is said), limit, watts, hours left |
| `zephyrus_charge_limit` | r/a | 20-100 through asusd; `oneshot` |
| `zephyrus_gpu_mode` | r/a | mode, supported modes, dGPU power, the pending mode and action; says that asusd switches the mode on every change of power source |
| `zephyrus_profile` | r/a | active, on-mains and on-battery profile, kernel platform profile; set with a hold |
| `zephyrus_thermals` | r | every hwmon temperature and fan, the hottest, the dGPU's temperature **only when it is awake** (nvidia-smi wakes a suspended GPU) |
| `zephyrus_power_draw` | r | battery flow, APU package power (PPT), dGPU draw when awake, power source and why |
| `zephyrus_profile_policy` | r | what the switcher would choose now and why: source, recent load against the thresholds, decision, hold, last switch, what woke it, what it asserted at start, and whether the predecessor's switcher still runs |
| `zephyrus_fan_curves` | r | asusd's curves per profile and fan |
| `zephyrus_keys` | r | every custom key: each triggerhappy trigger, the module's i3 lines and the keys the firmware handles; what runs and the file it is in. Warns when one key is in two trigger files, which fires it twice |
| `zephyrus_check` | r | every expectation: model, packages (local or foreign), daemons, nvidia-powerd, sleep units, the NVIDIA options **in force** (`/proc/driver/nvidia/params`), charge limit, one authority each over the profile and the GPU mode, predecessor leftovers; the touchpad resume unit wanted by the sleep; triggerhappy running as the operator's account; any trigger, udev rule or sleep hook still naming `as-user`. It also lists what it did not check |
`profile` is a candidate verb for a future `node-power-profile` seat (research 027/03). That seat has
no record yet, so this is the module's own tool.
## Found on the laptop, 2026-10-04 (read-only)
- **Two authorities over the GPU mode.** `asusd.ron` has `ac_command: "supergfxctl -m Hybrid"` and
`bat_command: "supergfxctl -m Integrated"`, so asusd switches the GPU mode on every change of power
source. **supergfxd 5.2.7 cannot read logind's sessions** (`manager is an invalid variant`, every
boot), so a switch that needs a logout times out. `zephyrus_check` reports both. The fix is the
operator's, in asusd's file: clear both commands, or update supergfxctl once it can be packaged.
- **`brightness.conf` did nothing.** `HandleBrightnessKey` is not a logind key, and logind logs
*Unknown key … ignoring* at every start. The module does not carry it. The brightness keys were
always triggerhappy's, with the ACPI video switch off.
- **Two profile switchers** would run at once until `auto-profile` is stopped (below).
- **asusctl is a local build** (6.4.0, *Unknown Packager*) beside a foreign `asusctl-debug`.
## When assigned to the laptop: what changes
1. `/usr/local/lib/asus-zephyrus-g14/` appears (seven scripts).
2. Written over found files (each original kept once by the host): `g14-nvidia-power.conf` and
`video-brightness-switch.conf` (same options, so no change until the next boot either),
`logind.conf.d/power.conf` (same keys; logind reloaded), `90-backlight.rules` (same effect;
udevd reloaded), `triggers.d/asus-g14.conf` (now the module's scripts), and i3's `10-asus.conf` and
`20-g14.conf` (the module's lines; the `i3` module's watcher checks and reloads them).
3. New: the three sleep drop-ins (behaviour gained: `nvidia-suspend-then-hibernate`), the
nvidia-powerd drop-in (no effect while it is masked), the triggerhappy drop-in, the UPower drop-in
(same values as today), the touchpad input class (at the next X start), and the resume unit with
its three drop-ins.
4. `daemon-reload` and a `triggerhappy` restart. thd now runs as the account and the keys run the
module's scripts. `upower` restarts.
5. Packages, asusd and supergfxd: already as declared, so nothing changes. asusctl stays the local
6.4.0 until the next upgrade.
6. The node runtime restarts with the new bundle. The switcher asserts the limit (80, already) and
asusd's profiles (Balanced and Quiet, already), so it sets nothing. It takes the current decision
as applied and acts from the first event on.
## Predecessor files this module makes redundant — the operator removes them once (ADR 0182)
**On the laptop:**
1. `systemctl --user disable --now auto-profile.service`, then delete
`~/.config/systemd/user/auto-profile.service` and `~/scripts/auto-profile`. **Do this right after
the push**, or two switchers run at once.
2. **Before the push, keep the place layouts.** The module writes `20-g14.conf` over the found file,
and the found file carries the seven `$mod+Alt+1…7` layout bindings, which name places. Move them,
and the `exec … ~/.screenlayout/@default.sh` line, to a file of your own in the same directory,
e.g. `~/.config/i3/config.d/90-layouts.conf`. i3 reads it the same way, and it stays yours until
autorandr profiles replace it. The host keeps the found `20-g14.conf` once in any case.
3. `~/scripts/asus-bright`, `~/scripts/asusctl-kbd-bright`, `~/scripts/xrandr-bright`,
`~/scripts/media-control`, `~/scripts/xinput-reset-touchpad`,
`~/scripts/.screenlayouts/orden-workspaces.sh` and `~/.config/i3/scripts/kbd-brightness-notify.sh`:
no trigger and no line of the module's uses them any more.
**Keep `~/scripts/as-user`** while `/etc/udev/rules.d/91-monitor-hotplug.rules` exists: that rule
still runs the monitor wizard through it. `zephyrus_check` names every place that still calls it.
4. `/etc/systemd/system/triggerhappy.service`: the predecessor's replacement of the packaged unit. The
module's drop-in works over either, so delete it and `systemctl daemon-reload` to return to the
packaged unit (`Type=notify`, socket).
5. `/etc/systemd/logind.conf.d/brightness.conf`: the unknown key, which does nothing.
6. `/etc/systemd/system-sleep/xinput-reset-touchpad.sh`, if it is still there: replaced by the input
class and the resume unit. (It was already gone on 2026-10-04.)
7. `/etc/UPower/UPower.conf`: the predecessor's replacement of the package's file. Its values are now
the module's drop-in. Restore the package's copy (`rm` it, then `pacman -S upower`).
8. Optional: `systemctl disable nvidia-suspend nvidia-resume nvidia-hibernate` (the drop-ins carry them
now), `/etc/asusd/*.ron-old` and `fan_curves.ron.bak`, the foreign `asusctl-debug` package, and
`pacman -S asusctl` for the distribution's build.
**Stays the machine's:** `/swapfile` and `/etc/systemd/system/swapfile.swap` (the swap layout),
`/etc/udev/rules.d/91-monitor-hotplug.rules` (the display's, phase 2), and the place layouts.
**On the desktop** (the predecessor's G14 flavor reached it; part was removed on 2026-10-04): none of
this module applies there. Still present and to be deleted:
`/etc/systemd/logind.conf.d/brightness.conf`, `/etc/systemd/system-sleep/xinput-reset-touchpad.sh`,
`~/scripts/xinput-reset-touchpad`, `~/scripts/xrandr-bright` and
`~/.config/i3/scripts/kbd-brightness-notify.sh`.
## The predecessor, file by file
The predecessor carried this model in its desktop module's `g14` and `laptop` flavors, and in a
`g14-power` module. Every model-specific file of the desktop module, and where it is now:
| predecessor file | now |
|---|---|
| `i3-asus.conf` → `config.d/10-asus.conf` | **this module**, the same path; the scripts it calls are the module's |
| `i3-g14.conf` → `config.d/20-g14.conf` | **this module**, the same path. The place layouts in it are the operator's own file (migration, step 2) |
| `g14-triggerhappy-asus-g14.conf` | **this module**, the same path; the triggers run the module's scripts |
| `g14-triggerhappy.service` (a replacement of the packaged unit) | **retired**: a drop-in over the packaged unit (`--user`) |
| `as-user` | **retired**: thd runs as the account, and `zephyrus-session` finds the session. Kept on the machine while the monitor wizard's udev rule calls it |
| `asus-bright` | **this module**: `zephyrus-backlight`, the panel found by its connector |
| `media-control` (bound by the `g14` triggers) | **this module**: `zephyrus-media`, without the fallback that needed a secret |
| `xinput-reset-touchpad` | **this module**: the input class, `zephyrus-touchpad`, and the resume unit |
| `g14-system-sleep-xinput-reset-touchpad.sh` | **this module**: the input class, and the resume unit that the sleep services want |
| `kbd-brightness-notify.sh` | **this module**: `zephyrus-kbd-notify`, one instance per session |
| `asusctl-kbd-bright` | **retired**: the keyboard keys are the firmware's, and `zephyrus_brightness` sets it by hand |
| `xrandr-bright` | **retired**: a software dimming; the panel's backlight is `zephyrus-backlight` |
| `screenlayout-orden-workspaces.sh` | **this module**: `zephyrus-display order`, on Fn+F9 |
| `screenlayout-*.sh` (six named places) | **the operator's**, until autorandr profiles (the `xorg` module) replace them |
| `g14-90-backlight.rules` | **this module**, the same path |
| `g14-logind-brightness.conf` | **retired**: an unknown logind key that did nothing |
| `laptop-monitor-wizard.sh`, `laptop-91-monitor-hotplug.rules` | **any laptop's, not this module's**: kept as found, for the `xorg` module's autorandr to replace |
| `bottom-bar.g14.toml` (the battery block) | **the `power` module's**: any machine with a battery gets it (ADR 0255) |
| `razer-basilisk-battery-percentage` | **not this model's**: a mouse. No module carries it (`i3status-rust` README) |
| `screenshot.sh` | **not this model's**: any machine's script, which the `i3` module binds too; this module binds Fn+F6 to it |
| package `triggerhappy` | **depended on, kept as found** (outside the distribution, above) |
`g14-power`'s pieces (the NVIDIA options, logind, UPower, the sleep units, `auto-profile`) are in
*What it owns* and the switcher above. Its memory guard is the `memory-pressure` module's.
## What the predecessor paid for, and what this module does about it
- **One file for every machine overwrote what each machine needed** (the predecessor's split of its i3
configuration into a base, an ASUS and a G14 layer, 2026-03). Here the model's lines are this
module's files, and nothing else writes them.
- **The layers silently stopped applying.** The predecessor's chain *laptop → g14* was stated in two
places, and the one its installer read lacked it. For weeks the G14 received only its 21
G14-tagged files, and the 49 base and laptop files were never seeded. Validation and the pipeline
both reported success (2026-08, found at a cutover). Here a module is one manifest. Its tests assert
that every trigger runs a script it ships, and `zephyrus_check` and `zephyrus_keys` read back what is
on the machine.
- **A hook wrote asusd's own files with sudo,** because configuration sync might not run on install
(2026-04), and froze them. asusd rewrites those files whenever a setting changes. Here the module
sets asusd through its client and never writes the RON files.
- **A profile daemon fought the profile key.** The predecessor turned asusd's own switching off and
re-asserted its choice every five seconds (2026-03). Here the switcher acts on a change of its
decision, never to restore one.
- **The overnight freeze** was an ACPI power-source event hanging the NVIDIA GPU in runtime D3
(2026-06). It is fixed by the driver options, which `zephyrus_check` reads from the running driver
rather than from the file.
- **A unit restarted about 73,000 times, unnoticed** (2026-06). Here every expectation is in
`zephyrus_check`, and so is a list of what it did not check.
- **The vendor keys ran as root and `su`-ed to a named person** on a guessed display, sourcing a file
of secrets. Here thd drops to the account, and nothing is sourced.
- **The touchpad lost its settings after a resume,** because `xinput` settings vanish when the device
initialises again. Here an input class re-applies them, with the resume unit as a backstop.
## Tests
`go test ./...` in this directory. Every tool runs against a tree standing in for `/sys`, `/proc` and
`/etc`, and an injected runner answering with what asusctl 6.4 and supergfxctl 5.2 said on the laptop.
The tests cover:
- the power-source rule;
- battery arithmetic from `charge_*`;
- the eDP panel choice;
- brightness bounds;
- the policy's sustain, relax and hysteresis, with iowait counted as idle;
- the switcher: no act at start, one switch per change of source, a published event, boost from
samples, holds, retry after failure, start-up assertions only where they differ, inert on another
model;
- the uevent filter;
- the manifest: tools listed equal tools served, no machine named, triggers exist, every key runs a
shipped executable script, every script passes `bash -n`;
- `zephyrus_keys` over the module's own triggers and i3 lines, and a key in two trigger files;
- the checks for `as-user`, triggerhappy's account and the resume unit.
## The vendor keys are a contribution (changed 2026-10-04, novox/hq ADR 0212)
The trigger file and triggerhappy's service drop-in are no longer this module's. The `triggerhappy`
module holds `node-hotkeys`, owns the daemon, and reads only the mesh's trigger file. This module
contributes its eight trigger lines (media, panel brightness, touchpad) to that seat, so it depends
on a hotkey holder being assigned beside it. Its keys still run this module's own scripts.
`zephyrus_keys` reads the trigger directory as before.
## The touchpad after waking, and the lid, move to the power module (changed 2026-10-04, novox/hq ADR 0211)
The touchpad resume unit and its three drop-ins on the sleep services are gone. The reset is now this
module's contribution to `node-power`'s `after-wake` moment, so the module depends on the power
module. `logind.conf.d/power.conf` is the power module's. This laptop's values (suspend on the power
key and on the lid in every case) are that module's settings for this machine. The NVIDIA driver's
sleep drop-ins stay here: they must run inside the sleep transaction, which a contribution cannot.
## Its i3 lines are a contribution (changed 2026-10-05, novox/hq ADR 0212)
The module no longer writes a file into i3's `config.d`. Its window-manager lines (the source is still
under `files/i3/` where it had one) are a contribution to `node-display-session`. The i3 module places
them in its own configuration under a `# <module>` line, so this module depends on a window manager
being assigned beside it.
@@ -1,264 +0,0 @@
package main
import (
"context"
"fmt"
"regexp"
"strconv"
"strings"
)
// The vendor daemons are reached through their own command-line clients, which speak to them on the
// system bus. Their bus policy admits the `users` and `wheel` groups, so none of this needs root.
// Profiles are the platform profiles asusd offers on this model, in its spelling.
var Profiles = []string{"Quiet", "Balanced", "Performance"}
// canonicalProfile accepts any case and answers asusd's spelling, or an error naming the choices.
func canonicalProfile(s string) (string, error) {
for _, p := range Profiles {
if strings.EqualFold(strings.TrimSpace(s), p) {
return p, nil
}
}
return "", fmt.Errorf("profile %q is not one of %s", s, strings.Join(Profiles, ", "))
}
// ProfileState is what asusd says about the platform profile.
type ProfileState struct {
Active string `json:"active"`
OnAC string `json:"on_ac,omitempty"`
Battery string `json:"on_battery,omitempty"`
Platform string `json:"platform_profile,omitempty"`
Choices string `json:"platform_profile_choices,omitempty"`
}
var (
activeProfile = regexp.MustCompile(`(?m)^Active profile:\s*(\S+)`)
acProfile = regexp.MustCompile(`(?m)^AC profile\s+(\S+)`)
batteryProfile = regexp.MustCompile(`(?m)^Battery profile\s+(\S+)`)
)
// ParseProfileGet reads `asusctl profile get`.
func ParseProfileGet(out string) (ProfileState, error) {
var p ProfileState
if m := activeProfile.FindStringSubmatch(out); m != nil {
p.Active = m[1]
} else {
return p, fmt.Errorf("asusctl profile get said no active profile: %q", strings.TrimSpace(out))
}
if m := acProfile.FindStringSubmatch(out); m != nil {
p.OnAC = m[1]
}
if m := batteryProfile.FindStringSubmatch(out); m != nil {
p.Battery = m[1]
}
return p, nil
}
// Profile reads the platform profile from asusd and the kernel.
func (m *Machine) Profile(ctx context.Context) (ProfileState, error) {
out, err := m.Run(ctx, "asusctl", "profile", "get")
if err != nil {
return ProfileState{}, vendor("asusctl", err)
}
p, err := ParseProfileGet(out)
if err != nil {
return p, err
}
p.Platform = m.read("/sys/firmware/acpi/platform_profile")
p.Choices = m.read("/sys/firmware/acpi/platform_profile_choices")
return p, nil
}
// SetProfile has asusd switch the active profile.
func (m *Machine) SetProfile(ctx context.Context, profile string) error {
_, err := m.Run(ctx, "asusctl", "profile", "set", profile)
return vendor("asusctl", err)
}
var chargeLimit = regexp.MustCompile(`charge limit:\s*(\d+)\s*%`)
// ChargeLimit is the battery's charge limit as asusd reports it.
func (m *Machine) ChargeLimit(ctx context.Context) (int, error) {
out, err := m.Run(ctx, "asusctl", "battery", "info")
if err != nil {
return 0, vendor("asusctl", err)
}
g := chargeLimit.FindStringSubmatch(out)
if g == nil {
return 0, fmt.Errorf("asusctl battery info said no limit: %q", strings.TrimSpace(out))
}
n, _ := strconv.Atoi(g[1])
return n, nil
}
// Keyboard backlight levels in asusd's spelling, index = the kernel's brightness value.
var KeyboardLevels = []string{"off", "low", "med", "high"}
var ledLevel = regexp.MustCompile(`(?i)brightness:\s*(off|low|med|high)`)
// ParseLeds reads `asusctl leds get`.
func ParseLeds(out string) (string, error) {
g := ledLevel.FindStringSubmatch(out)
if g == nil {
return "", fmt.Errorf("asusctl leds get said no level: %q", strings.TrimSpace(out))
}
return strings.ToLower(g[1]), nil
}
// FanCurve is one fan's curve in one profile: eight points of temperature (°C) and duty (0-255).
type FanCurve struct {
Fan string `json:"fan"`
Enabled bool `json:"enabled"`
Temp []int `json:"temp_c"`
PWM []int `json:"pwm"`
}
var (
fanBlock = regexp.MustCompile(`(?s)fan:\s*(\w+),\s*pwm:\s*\(([^)]*)\),\s*temp:\s*\(([^)]*)\),\s*enabled:\s*(true|false)`)
)
// ParseFanCurves reads `asusctl fan-curve --mod-profile <p>`.
func ParseFanCurves(out string) []FanCurve {
var curves []FanCurve
for _, g := range fanBlock.FindAllStringSubmatch(out, -1) {
curves = append(curves, FanCurve{Fan: g[1], PWM: ints(g[2]), Temp: ints(g[3]), Enabled: g[4] == "true"})
}
return curves
}
func ints(list string) []int {
var out []int
for _, f := range strings.Split(list, ",") {
if n, err := strconv.Atoi(strings.TrimSpace(f)); err == nil {
out = append(out, n)
}
}
return out
}
// GPUState is what supergfxd says about the hybrid GPU.
type GPUState struct {
Mode string `json:"mode"`
Supported []string `json:"supported"`
Power string `json:"dgpu_power,omitempty"`
PendingAction string `json:"pending_action,omitempty"`
PendingMode string `json:"pending_mode,omitempty"`
Vendor string `json:"dgpu_vendor,omitempty"`
}
// ParseSupported reads `supergfxctl -s`: `[Integrated, Hybrid, AsusMuxDgpu]`.
func ParseSupported(out string) []string {
out = strings.Trim(strings.TrimSpace(out), "[]")
var modes []string
for _, f := range strings.Split(out, ",") {
if f = strings.TrimSpace(f); f != "" {
modes = append(modes, f)
}
}
return modes
}
// GPU reads supergfxd.
func (m *Machine) GPU(ctx context.Context) (GPUState, error) {
var g GPUState
mode, err := m.Run(ctx, "supergfxctl", "-g")
if err != nil {
return g, vendor("supergfxctl", err)
}
g.Mode = strings.TrimSpace(mode)
if s, err := m.Run(ctx, "supergfxctl", "-s"); err == nil {
g.Supported = ParseSupported(s)
}
if s, err := m.Run(ctx, "supergfxctl", "-S"); err == nil {
g.Power = strings.TrimSpace(s)
}
if s, err := m.Run(ctx, "supergfxctl", "-p"); err == nil {
g.PendingAction = strings.TrimSpace(s)
}
if s, err := m.Run(ctx, "supergfxctl", "-P"); err == nil {
g.PendingMode = strings.TrimSpace(s)
}
if s, err := m.Run(ctx, "supergfxctl", "-V"); err == nil {
g.Vendor = strings.TrimSpace(s)
}
return g, nil
}
// vendor names a vendor client that is not installed, rather than passing on "executable file not
// found". asusctl is in the distribution's repositories; supergfxctl is not, and the module keeps it as
// it was found until the mesh can build packages from the user repository (research 027, question 1).
func vendor(name string, err error) error {
if err == nil {
return nil
}
if notInstalled(err) {
switch name {
case "supergfxctl":
return fmt.Errorf("supergfxctl is not installed: it is not in the distribution's repositories, " +
"and this module keeps the copy it finds rather than install one (novox/hq research 027, question 1)")
default:
return fmt.Errorf("%s is not installed; the module's package resource installs it", name)
}
}
return err
}
// AsusdConfig is the few settings of asusd's own file that decide what this module's code does. The
// file is asusd's: it rewrites it whenever a setting changes, so the module reads it and never writes
// it.
type AsusdConfig struct {
ChargeLimit *int `json:"charge_control_end_threshold,omitempty"`
ProfileOnAC string `json:"platform_profile_on_ac,omitempty"`
ProfileOnBattery string `json:"platform_profile_on_battery,omitempty"`
ChangesProfileOnAC *bool `json:"change_platform_profile_on_ac,omitempty"`
ChangesProfileOnBatt *bool `json:"change_platform_profile_on_battery,omitempty"`
ACCommand string `json:"ac_command,omitempty"`
BatteryCommand string `json:"bat_command,omitempty"`
DisablesPowerdOnBatt *bool `json:"disable_nvidia_powerd_on_battery,omitempty"`
}
var ronField = regexp.MustCompile(`(?m)^\s{4}([a-z_]+):\s*(.*?),?\s*$`)
// ParseAsusdRon reads the top-level scalar fields of asusd.ron. RON is not a format the mesh
// speaks; these are one line each, and nothing nested is read.
func ParseAsusdRon(text string) AsusdConfig {
var c AsusdConfig
for _, g := range ronField.FindAllStringSubmatch(text, -1) {
key, value := g[1], strings.TrimSuffix(strings.TrimSpace(g[2]), ",")
unquoted := strings.Trim(value, `"`)
boolean := func() *bool { b := value == "true"; return &b }
switch key {
case "charge_control_end_threshold":
if n, err := strconv.Atoi(value); err == nil {
c.ChargeLimit = &n
}
case "platform_profile_on_ac":
c.ProfileOnAC = unquoted
case "platform_profile_on_battery":
c.ProfileOnBattery = unquoted
case "change_platform_profile_on_ac":
c.ChangesProfileOnAC = boolean()
case "change_platform_profile_on_battery":
c.ChangesProfileOnBatt = boolean()
case "ac_command":
c.ACCommand = unquoted
case "bat_command":
c.BatteryCommand = unquoted
case "disable_nvidia_powerd_on_battery":
c.DisablesPowerdOnBatt = boolean()
}
}
return c
}
// Asusd reads asusd's file; nil when it is not there.
func (m *Machine) Asusd() *AsusdConfig {
text := m.read("/etc/asusd/asusd.ron")
if text == "" {
return nil
}
c := ParseAsusdRon(text)
return &c
}
@@ -1,146 +0,0 @@
package main
import (
"context"
"strings"
"testing"
)
// What asusctl 6.4 and supergfxctl 5.2 said on the laptop on 2026-10-04.
const fanCurveQuiet = `
Fan curves for Quiet
[
(
fan: CPU,
pwm: (2, 0, 10, 20, 35, 55, 80, 100),
temp: (35, 45, 50, 55, 60, 65, 70, 80),
enabled: true,
),
(
fan: GPU,
pwm: (0, 0, 10, 20, 35, 65, 90, 115),
temp: (35, 45, 50, 55, 60, 65, 70, 80),
enabled: false,
),
]
`
const asusdRon = `(
charge_control_end_threshold: 80,
base_charge_control_end_threshold: 0,
disable_nvidia_powerd_on_battery: true,
ac_command: "supergfxctl -m Hybrid",
bat_command: "supergfxctl -m Integrated",
platform_profile_linked_epp: true,
platform_profile_on_battery: Quiet,
change_platform_profile_on_battery: true,
platform_profile_on_ac: Balanced,
change_platform_profile_on_ac: true,
ac_profile_tunings: {
Quiet: (
enabled: false,
group: {},
),
},
)`
func TestAsusctlsAnswersAreRead(t *testing.T) {
p, err := ParseProfileGet(profileGetBalanced)
if err != nil || p.Active != "Balanced" || p.OnAC != "Balanced" || p.Battery != "Quiet" {
t.Fatalf("%+v %v", p, err)
}
if _, err := ParseProfileGet("something else"); err == nil {
t.Fatal("an answer with no profile was read as one")
}
if l, err := ParseLeds("Current keyboard led brightness: High\n"); err != nil || l != "high" {
t.Fatalf("%q %v", l, err)
}
curves := ParseFanCurves(fanCurveQuiet)
if len(curves) != 2 || curves[0].Fan != "CPU" || curves[0].PWM[7] != 100 || curves[0].Temp[0] != 35 || curves[1].Enabled {
t.Fatalf("%+v", curves)
}
if got := ParseSupported("[Integrated, Hybrid, AsusMuxDgpu]\n"); strings.Join(got, ",") != "Integrated,Hybrid,AsusMuxDgpu" {
t.Fatalf("%v", got)
}
}
func TestAsusdsFileIsReadForWhatDecidesTheModulesCodeAndNothingNested(t *testing.T) {
c := ParseAsusdRon(asusdRon)
if *c.ChargeLimit != 80 || c.ProfileOnAC != "Balanced" || c.ProfileOnBattery != "Quiet" ||
c.ACCommand != "supergfxctl -m Hybrid" || c.BatteryCommand != "supergfxctl -m Integrated" ||
!*c.ChangesProfileOnAC || !*c.DisablesPowerdOnBatt {
t.Fatalf("%+v", c)
}
}
func TestAMissingVendorClientIsNamedWithWhyItIsMissing(t *testing.T) {
f := newFake(t)
f.fails["supergfxctl"] = notFound
_, err := f.machine().GPU(context.Background())
if err == nil || !strings.Contains(err.Error(), "research 027") {
t.Fatalf("%v", err)
}
f.fails["asusctl"] = notFound
_, err = f.machine().Profile(context.Background())
if err == nil || !strings.Contains(err.Error(), "package resource installs it") {
t.Fatalf("%v", err)
}
}
func TestAGPUModeIsSetOnlyWhenTheMachineSupportsItAndAsusdsSwitchingIsSaid(t *testing.T) {
f := newFake(t)
f.answers["supergfxctl -g"] = "Hybrid\n"
f.answers["supergfxctl -s"] = "[Integrated, Hybrid, AsusMuxDgpu]\n"
f.file("/etc/asusd/asusd.ron", asusdRon)
m := f.machine()
if _, err := GPUModeTool(context.Background(), m, map[string]any{"mode": "Vfio"}); err == nil {
t.Fatal("an unsupported mode was accepted")
}
out, err := GPUModeTool(context.Background(), m, map[string]any{"mode": "integrated"})
if err != nil {
t.Fatal(err)
}
if !f.called("supergfxctl -m Integrated") {
t.Fatalf("calls %v", f.calls)
}
if _, said := out.(map[string]any)["asusd_switches_it"]; !said {
t.Fatalf("asusd's own switching of the mode was not said: %+v", out)
}
}
func TestTheChargeLimitIsBoundedAndSetThroughAsusd(t *testing.T) {
f := newFake(t)
f.answers["asusctl battery info"] = "Current battery charge limit: 60%\n"
m := f.machine()
for _, bad := range []any{float64(10), float64(101), "x", 55.5} {
if _, err := ChargeLimitTool(context.Background(), m, map[string]any{"limit": bad}); err == nil {
t.Errorf("limit %v was accepted", bad)
}
}
out, err := ChargeLimitTool(context.Background(), m, map[string]any{"limit": float64(60)})
if err != nil || !f.called("asusctl battery limit 60") || out.(map[string]any)["asusd_limit_percent"] != 60 {
t.Fatalf("%+v %v %v", out, err, f.calls)
}
}
func TestAProfileSetByToolIsHeldAndAnUnknownOneRefused(t *testing.T) {
f := newFake(t)
f.onMains()
f.answers["asusctl profile get"] = profileGetBalanced
m := f.machine()
sw := NewSwitcher(m, nil)
if _, err := ProfileTool(context.Background(), m, sw, map[string]any{"profile": "Turbo"}); err == nil {
t.Fatal("an unknown profile was accepted")
}
out, err := ProfileTool(context.Background(), m, sw, map[string]any{"profile": "performance", "hold_minutes": float64(30)})
if err != nil || !f.called("asusctl profile set Performance") {
t.Fatalf("%v %v", err, f.calls)
}
if _, held := out.(map[string]any)["held_until"]; !held {
t.Fatalf("not held: %+v", out)
}
if r := sw.Report(); r.Held != "Performance" {
t.Fatalf("%+v", r)
}
}
@@ -1,203 +0,0 @@
package main
import (
"context"
"fmt"
"os"
"path/filepath"
"regexp"
"strings"
)
// Check is one thing the module expects of the machine, and whether it holds.
type Check struct {
Name string `json:"name"`
OK bool `json:"ok"`
Detail string `json:"detail"`
}
// CheckReport is what zephyrus_check answers. NotChecked says what it did not look at, because a
// check that reads as clean while skipping something is the predecessor's verifier again.
type CheckReport struct {
Model string `json:"model"`
Checks []Check `json:"checks"`
Failing int `json:"failing"`
NotChecked []string `json:"not_checked"`
}
var (
pacmanVersion = regexp.MustCompile(`(?m)^Version\s*:\s*(\S+)`)
pacmanPackager = regexp.MustCompile(`(?m)^Packager\s*:\s*(.+)$`)
nvidiaParam = regexp.MustCompile(`(?m)^(\w+):\s*(\S+)`)
)
// ParseNvidiaParams reads /proc/driver/nvidia/params.
func ParseNvidiaParams(text string) map[string]string {
out := map[string]string{}
for _, g := range nvidiaParam.FindAllStringSubmatch(text, -1) {
out[g[1]] = g[2]
}
return out
}
// predecessorProcess finds a running process whose command line names the predecessor's script.
func (m *Machine) predecessorProcess(name string) (int, bool) {
for _, dir := range m.glob("/proc/[0-9]*") {
cmd := strings.ReplaceAll(m.read(dir+"/cmdline"), "\x00", " ")
if strings.Contains(cmd, "/"+name) && !strings.Contains(cmd, "zephyrus") {
var pid int
fmt.Sscanf(filepath.Base(dir), "%d", &pid)
return pid, true
}
}
return 0, false
}
func (m *Machine) unitIs(ctx context.Context, verb, unit string) string {
out, _ := m.Run(ctx, "systemctl", verb, unit)
return strings.TrimSpace(out)
}
// Check reads every expectation and reports each.
func (m *Machine) Check(ctx context.Context, sw *Switcher) CheckReport {
r := CheckReport{Model: m.Model(), NotChecked: []string{
"the fan curves (asusd's own, read them with zephyrus_fan_curves)",
"whether the initramfs carries the NVIDIA options (they are read from the running driver instead)",
"the vendor keys themselves (press them)",
}}
add := func(name string, ok bool, format string, a ...any) {
r.Checks = append(r.Checks, Check{Name: name, OK: ok, Detail: fmt.Sprintf(format, a...)})
if !ok {
r.Failing++
}
}
add("model", m.ThisModel(), "the firmware reports %q; this module is for %q", r.Model, ModelFamily)
// asusctl: present, and from the distribution rather than a local build.
if info, err := m.Run(ctx, "pacman", "-Qi", "asusctl"); err != nil {
add("asusctl package", false, "not installed: %v", err)
} else {
v, p := "", ""
if g := pacmanVersion.FindStringSubmatch(info); g != nil {
v = g[1]
}
if g := pacmanPackager.FindStringSubmatch(info); g != nil {
p = strings.TrimSpace(g[1])
}
local := p == "Unknown Packager"
add("asusctl package", !local, "version %s, packager %s%s", v, p,
map[bool]string{true: "; a local build — the distribution's package replaces it at the next upgrade (pacman -S asusctl)", false: ""}[local])
}
for _, foreign := range []string{"supergfxctl", "triggerhappy"} {
_, err := m.Run(ctx, "pacman", "-Q", foreign)
add(foreign+" package", err == nil, "%s; not in the distribution's repositories, kept as found (novox/hq research 027, question 1)",
map[bool]string{true: "installed", false: "NOT installed"}[err == nil])
}
for _, unit := range []string{"asusd.service", "supergfxd.service", "triggerhappy.service"} {
state := m.unitIs(ctx, "is-active", unit)
add(unit, state == "active", "%s", state)
}
powerd := m.unitIs(ctx, "is-enabled", "nvidia-powerd.service")
add("nvidia-powerd.service", powerd == "masked" || powerd == "disabled" || powerd == "" || strings.Contains(powerd, "not-found"),
"%s; the module's drop-in keeps it from starting unless the kernel command line says zephyrus.nvidia-powerd", orNone(powerd))
wants, _ := m.Run(ctx, "systemctl", "show", "-p", "Wants", "systemd-suspend.service")
add("nvidia suspend and resume", strings.Contains(wants, "nvidia-suspend.service") && strings.Contains(wants, "nvidia-resume.service"),
"systemd-suspend.service %s", strings.TrimSpace(wants))
// The touchpad reset after waking is this module's contribution to node-power's after-wake moment
// (novox/hq ADR 0211), placed in the power module's moment file under a "# asus-zephyrus-g14" line.
afterWake := m.read("/etc/mesh-power/moments/after-wake")
placed := strings.Contains(afterWake, "# asus-zephyrus-g14\n") && strings.Contains(afterWake, "zephyrus-touchpad reset")
add("touchpad after resume", placed,
"the reset is placed in the power module's after-wake moment: %v (it needs the power module on this machine)", placed)
if uid := m.triggerhappyUID(); uid < 0 {
add("triggerhappy as the account", false, "no thd process runs: the vendor keys do nothing")
} else {
add("triggerhappy as the account", uid == operatorUID(), "thd runs as uid %d; the operator's account is %d (the module's drop-in passes --user)", uid, operatorUID())
}
refs := m.asUserReferences()
add("no as-user", len(refs) == 0, "%s", orNone(map[bool]string{true: "", false: "the predecessor's as-user is still named in " + strings.Join(refs, ", ") +
": it su-s to a named person on a guessed display and sources a file of secrets"}[len(refs) == 0]))
params := ParseNvidiaParams(m.read("/proc/driver/nvidia/params"))
if len(params) == 0 {
add("nvidia options", false, "the NVIDIA driver is not loaded (no /proc/driver/nvidia/params)")
} else {
add("nvidia options", params["PreserveVideoMemoryAllocations"] == "1" && params["DynamicPowerManagement"] == "0",
"PreserveVideoMemoryAllocations=%s DynamicPowerManagement=%s (want 1 and 0; a change applies when the driver loads again)",
params["PreserveVideoMemoryAllocations"], params["DynamicPowerManagement"])
}
for _, b := range m.Batteries() {
ok := b.LimitPercent != nil && *b.LimitPercent == ChargeLimitPercent
have := "unknown"
if b.LimitPercent != nil {
have = fmt.Sprintf("%d%%", *b.LimitPercent)
}
add("charge limit", ok, "%s is %s, the module's is %d%%", b.Name, have, ChargeLimitPercent)
}
if c := m.Asusd(); c != nil && (c.ACCommand != "" || c.BatteryCommand != "") {
add("one authority over the GPU mode", false,
"asusd runs %q on mains and %q on battery: it switches the GPU mode on every change of power source, "+
"so a mode set with zephyrus_gpu_mode lasts until the next one. Clear ac_command and bat_command in /etc/asusd/asusd.ron (asusd's file) to make it the operator's alone",
c.ACCommand, c.BatteryCommand)
}
if out, err := m.Run(ctx, "journalctl", "-b", "-u", "supergfxd.service", "-g", "invalid variant", "-n", "1", "-q", "-o", "cat"); err == nil && strings.TrimSpace(out) != "" {
add("supergfxd and logind", false, "supergfxd cannot read logind's sessions this boot (%s): a mode change that needs a logout times out", strings.TrimSpace(out))
}
if pid, ok := m.predecessorProcess("auto-profile"); ok {
add("one profile switcher", false, "the predecessor's auto-profile still runs (pid %d) and switches the profile every five seconds; "+
"stop it: systemctl --user disable --now auto-profile.service", pid)
} else {
add("one profile switcher", true, "no predecessor auto-profile is running")
}
if sw != nil {
rep := sw.Report()
add("profile switcher", rep.Running, "%s", orNone(firstNonEmpty(rep.Disabled, rep.LastError, "woken by "+rep.Watching)))
}
if home := os.Getenv("MESH_OPERATOR_HOME"); home != "" {
var left []string
for _, p := range PredecessorHomeFiles {
if _, err := os.Stat(filepath.Join(m.Root, home, p)); err == nil {
left = append(left, "~/"+p)
}
}
add("predecessor files in the home", len(left) == 0, "%s", orNone(strings.Join(left, ", ")))
} else {
r.NotChecked = append(r.NotChecked, "the predecessor's files in the operator's home (MESH_OPERATOR_HOME is not set)")
}
return r
}
// PredecessorHomeFiles are what the predecessor placed in the operator's home for this model and this
// module replaces. The mesh removes nothing it did not make (novox/hq ADR 0182): the operator does,
// once, and this list is how the check knows.
var PredecessorHomeFiles = []string{
"scripts/auto-profile",
".config/systemd/user/auto-profile.service",
"scripts/asus-bright",
"scripts/asusctl-kbd-bright",
"scripts/xrandr-bright",
"scripts/media-control",
"scripts/xinput-reset-touchpad",
".config/i3/scripts/kbd-brightness-notify.sh",
"scripts/.screenlayouts/orden-workspaces.sh",
}
func orNone(s string) string {
if strings.TrimSpace(s) == "" {
return "none"
}
return s
}
func firstNonEmpty(ss ...string) string {
for _, s := range ss {
if s != "" {
return s
}
}
return ""
}
@@ -1,202 +0,0 @@
package main
import (
"context"
"fmt"
"math"
"os"
"path"
"sort"
"strconv"
"strings"
)
// MinPanelPercent is the floor a brightness change never goes below: a panel at zero is a black
// screen that looks like a dead machine, and the keys cannot be seen to bring it back.
const MinPanelPercent = 1
// Panel is the internal display's backlight.
type Panel struct {
Device string `json:"device"`
Percent float64 `json:"percent"`
Raw int64 `json:"raw"`
Max int64 `json:"max"`
Others []string `json:"other_backlights,omitempty"`
}
// panelDevice chooses the backlight that drives the internal panel.
//
// **This model registers two.** In hybrid mode the integrated GPU drives the panel (amdgpu_bl1,
// beneath the eDP connector) and the discrete GPU's driver registers one of its own (nvidia_0) that
// moves nothing. The predecessor's scripts named amdgpu_bl1 literally, which is right until the GPU
// mode puts the panel on the other GPU. The one that sits under an eDP connector is the panel's; failing
// that, the kernel's own preference: firmware, then platform, then raw.
func (m *Machine) panelDevice() (string, []string, error) {
all := m.glob("/sys/class/backlight/*")
if len(all) == 0 {
return "", nil, fmt.Errorf("this machine has no backlight in /sys/class/backlight")
}
names := make([]string, 0, len(all))
for _, d := range all {
names = append(names, path.Base(d))
}
sort.Strings(names)
rank := func(name string) int {
dir := "/sys/class/backlight/" + name
if target, err := os.Readlink(m.path(dir)); err == nil && strings.Contains(target, "-eDP-") {
return 0
}
switch m.read(dir + "/type") {
case "firmware":
return 1
case "platform":
return 2
}
return 3
}
best := names[0]
for _, n := range names[1:] {
if rank(n) < rank(best) {
best = n
}
}
var others []string
for _, n := range names {
if n != best {
others = append(others, n)
}
}
return best, others, nil
}
// PanelBrightness reads the panel.
func (m *Machine) PanelBrightness() (Panel, error) {
dev, others, err := m.panelDevice()
if err != nil {
return Panel{}, err
}
dir := "/sys/class/backlight/" + dev
raw, ok1 := m.readInt(dir + "/brightness")
max, ok2 := m.readInt(dir + "/max_brightness")
if !ok1 || !ok2 || max <= 0 {
return Panel{}, fmt.Errorf("%s does not say its brightness", dir)
}
return Panel{Device: dev, Raw: raw, Max: max, Percent: round1(float64(raw) / float64(max) * 100), Others: others}, nil
}
// PanelTarget turns a request — "40", "40%", "+5", "-10" — into the percentage to set, clamped to
// [MinPanelPercent, 100].
func PanelTarget(current float64, request string) (float64, error) {
r := strings.TrimSuffix(strings.TrimSpace(request), "%")
if r == "" {
return 0, fmt.Errorf("panel needs a percentage (40) or a step (+5, -5)")
}
n, err := strconv.ParseFloat(r, 64)
if err != nil || math.IsNaN(n) || math.IsInf(n, 0) {
return 0, fmt.Errorf("panel %q is not a percentage or a step", request)
}
target := n
if strings.HasPrefix(r, "+") || strings.HasPrefix(r, "-") {
target = current + n
}
return math.Max(MinPanelPercent, math.Min(100, target)), nil
}
// SetPanel sets the panel to a percentage.
func (m *Machine) SetPanel(ctx context.Context, request string) (Panel, error) {
p, err := m.PanelBrightness()
if err != nil {
return p, err
}
target, err := PanelTarget(p.Percent, request)
if err != nil {
return p, err
}
raw := int64(math.Round(target / 100 * float64(p.Max)))
if raw < 1 {
raw = 1
}
if err := m.write(ctx, "/sys/class/backlight/"+p.Device+"/brightness", strconv.FormatInt(raw, 10)); err != nil {
return p, err
}
return m.PanelBrightness()
}
// Keyboard is the keyboard's backlight.
type Keyboard struct {
Device string `json:"device"`
Level string `json:"level"`
Value int64 `json:"value"`
Max int64 `json:"max"`
}
// KeyboardBrightness reads the keyboard backlight from the kernel.
func (m *Machine) KeyboardBrightness() (Keyboard, error) {
found := m.glob("/sys/class/leds/*kbd_backlight*")
if len(found) == 0 {
return Keyboard{}, fmt.Errorf("this machine has no keyboard backlight in /sys/class/leds")
}
dir := found[0]
v, ok1 := m.readInt(dir + "/brightness")
max, ok2 := m.readInt(dir + "/max_brightness")
if !ok1 || !ok2 {
return Keyboard{}, fmt.Errorf("%s does not say its brightness", dir)
}
k := Keyboard{Device: path.Base(dir), Value: v, Max: max}
if max == int64(len(KeyboardLevels)-1) && v >= 0 && v <= max {
k.Level = KeyboardLevels[v]
}
return k, nil
}
// KeyboardTarget turns a request — off/low/med/high, 0-3, "+", "-" — into asusd's level name.
func KeyboardTarget(current int64, request string) (string, error) {
r := strings.ToLower(strings.TrimSpace(request))
switch r {
case "medium":
r = "med"
case "+", "up":
r = strconv.FormatInt(min64(current+1, int64(len(KeyboardLevels)-1)), 10)
case "-", "down":
r = strconv.FormatInt(max64(current-1, 0), 10)
}
for _, l := range KeyboardLevels {
if r == l {
return l, nil
}
}
if n, err := strconv.Atoi(r); err == nil && n >= 0 && n < len(KeyboardLevels) {
return KeyboardLevels[n], nil
}
return "", fmt.Errorf("keyboard %q is not one of off, low, med, high, 0-3, + or -", request)
}
// SetKeyboard has asusd set the keyboard backlight, so its own record of the level stays true.
func (m *Machine) SetKeyboard(ctx context.Context, request string) (Keyboard, error) {
k, err := m.KeyboardBrightness()
if err != nil {
return k, err
}
level, err := KeyboardTarget(k.Value, request)
if err != nil {
return k, err
}
if _, err := m.Run(ctx, "asusctl", "leds", "set", level); err != nil {
return k, vendor("asusctl", err)
}
return m.KeyboardBrightness()
}
func min64(a, b int64) int64 {
if a < b {
return a
}
return b
}
func max64(a, b int64) int64 {
if a > b {
return a
}
return b
}
@@ -1,91 +0,0 @@
package main
import (
"context"
"os"
"path/filepath"
"strings"
"testing"
)
// backlight makes a backlight the way sysfs does: a link from /sys/class/backlight into the device
// tree, which is where the eDP connector shows.
func (f *fake) backlight(name, device string, raw, max string) {
dev := "/sys/devices/" + device + "/" + name
f.file(dev+"/brightness", raw)
f.file(dev+"/max_brightness", max)
f.file(dev+"/type", "raw")
link := filepath.Join(f.root, "/sys/class/backlight", name)
os.MkdirAll(filepath.Dir(link), 0o755)
if err := os.Symlink(filepath.Join(f.root, dev), link); err != nil {
f.t.Fatal(err)
}
}
func TestThePanelIsTheBacklightUnderTheEDPConnectorNotTheDiscreteGPUs(t *testing.T) {
f := newFake(t)
f.backlight("amdgpu_bl1", "pci0000:00/0000:65:00.0/drm/card1/card1-eDP-1", "199500", "399000")
f.backlight("nvidia_0", "pci0000:00/0000:01:00.0/backlight", "100", "100")
p, err := f.machine().PanelBrightness()
if err != nil {
t.Fatal(err)
}
if p.Device != "amdgpu_bl1" || p.Percent != 50 || len(p.Others) != 1 || p.Others[0] != "nvidia_0" {
t.Fatalf("%+v", p)
}
}
func TestAPanelRequestIsAPercentageOrAStepAndNeverGoesDark(t *testing.T) {
for _, c := range []struct {
cur float64
req string
want float64
}{{50, "40", 40}, {50, "40%", 40}, {50, "+5", 55}, {50, "-10", 40}, {3, "-10", 1}, {98, "+5", 100}, {50, "0", 1}} {
got, err := PanelTarget(c.cur, c.req)
if err != nil || got != c.want {
t.Errorf("%v %q: %v %v, want %v", c.cur, c.req, got, err, c.want)
}
}
for _, bad := range []string{"", "bright", "NaN"} {
if _, err := PanelTarget(50, bad); err == nil {
t.Errorf("%q was accepted", bad)
}
}
}
func TestSettingThePanelWritesTheRawValue(t *testing.T) {
f := newFake(t)
f.backlight("amdgpu_bl1", "card1-eDP-1", "399000", "399000")
p, err := f.machine().SetPanel(context.Background(), "25")
if err != nil {
t.Fatal(err)
}
raw, _ := os.ReadFile(filepath.Join(f.root, "/sys/devices/card1-eDP-1/amdgpu_bl1/brightness"))
if strings.TrimSpace(string(raw)) != "99750" || p.Percent != 25 {
t.Fatalf("wrote %q, read back %+v", raw, p)
}
}
func TestTheKeyboardIsSetThroughAsusdByLevel(t *testing.T) {
f := newFake(t)
f.file("/sys/class/leds/asus::kbd_backlight/brightness", "1")
f.file("/sys/class/leds/asus::kbd_backlight/max_brightness", "3")
k, err := f.machine().KeyboardBrightness()
if err != nil || k.Level != "low" {
t.Fatalf("%+v %v", k, err)
}
if _, err := f.machine().SetKeyboard(context.Background(), "+"); err != nil {
t.Fatal(err)
}
if !f.called("asusctl leds set med") {
t.Fatalf("calls: %v", f.calls)
}
for req, want := range map[string]string{"high": "high", "0": "off", "medium": "med", "-": "off"} {
if got, err := KeyboardTarget(1, req); err != nil || got != want {
t.Errorf("%q: %q %v", req, got, err)
}
}
if _, err := KeyboardTarget(1, "7"); err == nil {
t.Error("level 7 was accepted")
}
}
@@ -1,103 +0,0 @@
package main
import (
"context"
"os"
"os/exec"
"path/filepath"
"strings"
"sync"
"testing"
)
// fake is a machine for a test: a tree standing in for /, and a runner answering from a table and
// recording every command it was asked to run.
type fake struct {
t *testing.T
root string
mu sync.Mutex
answers map[string]string
fails map[string]error
calls []string
}
func newFake(t *testing.T) *fake {
t.Helper()
return &fake{t: t, root: t.TempDir(), answers: map[string]string{}, fails: map[string]error{}}
}
func (f *fake) machine() *Machine { return &Machine{Root: f.root, Run: f.run} }
func (f *fake) run(_ context.Context, name string, args ...string) (string, error) {
line := strings.TrimSpace(name + " " + strings.Join(args, " "))
f.mu.Lock()
defer f.mu.Unlock()
f.calls = append(f.calls, line)
if err, ok := f.fails[line]; ok {
return "", err
}
if out, ok := f.answers[line]; ok {
return out, nil
}
if err, ok := f.fails[name]; ok {
return "", err
}
return "", nil
}
func (f *fake) called(line string) bool {
f.mu.Lock()
defer f.mu.Unlock()
for _, c := range f.calls {
if c == line {
return true
}
}
return false
}
func (f *fake) callsLike(prefix string) []string {
f.mu.Lock()
defer f.mu.Unlock()
var out []string
for _, c := range f.calls {
if strings.HasPrefix(c, prefix) {
out = append(out, c)
}
}
return out
}
// file writes a file under the fake root.
func (f *fake) file(path, content string) {
f.t.Helper()
full := filepath.Join(f.root, path)
if err := os.MkdirAll(filepath.Dir(full), 0o755); err != nil {
f.t.Fatal(err)
}
if err := os.WriteFile(full, []byte(content), 0o644); err != nil {
f.t.Fatal(err)
}
}
// supply writes one power supply's attributes.
func (f *fake) supply(name string, attrs map[string]string) {
for k, v := range attrs {
f.file("/sys/class/power_supply/"+name+"/"+k, v+"\n")
}
}
// onMains and onBattery are this model's two states as measured on 2026-10-04.
func (f *fake) onMains() {
f.supply("ACAD", map[string]string{"type": "Mains", "online": "1"})
f.supply("BAT1", map[string]string{"type": "Battery", "status": "Not charging", "capacity": "80"})
}
func (f *fake) onBattery() {
f.supply("ACAD", map[string]string{"type": "Mains", "online": "0"})
f.supply("BAT1", map[string]string{"type": "Battery", "status": "Discharging", "capacity": "79"})
}
var notFound = &exec.Error{Name: "x", Err: exec.ErrNotFound}
const profileGetBalanced = "Active profile: Balanced\n\nAC profile Balanced\nBattery profile Quiet\n"
@@ -1,192 +0,0 @@
package main
import (
"fmt"
"os"
"path/filepath"
"regexp"
"sort"
"strings"
)
// Key is one custom key on the laptop: what it is, what runs, and where that is defined.
type Key struct {
Key string `json:"key"`
Physical string `json:"physical,omitempty"`
When string `json:"when,omitempty"`
Runs string `json:"runs"`
From string `json:"from"`
}
// KeysReport is what zephyrus_keys answers.
type KeysReport struct {
Keys []Key `json:"keys"`
Warnings []string `json:"warnings"`
NotRead []string `json:"not_read"`
}
// TriggerDir is triggerhappy's directory of trigger files; the module owns one file in it.
const TriggerDir = "/etc/triggerhappy/triggers.d"
// I3Config is the window manager's configuration, relative to the account's home. This module's lines
// are its contribution to node-display-session (novox/hq ADR 0212), placed there by the i3 module
// under a line that names this module.
const I3Config = ".config/i3/config"
// ModuleName is the line the controller writes before this module's contributed lines.
const ModuleName = "asus-zephyrus-g14"
// ownSection is this module's contributed lines in a placed file: from the line naming it to the next
// line naming another module, a section header, or the file's end.
func ownSection(text string) []string {
var out []string
in := false
for _, line := range strings.Split(text, "\n") {
t := strings.TrimSpace(line)
if t == "# "+ModuleName {
in = true
continue
}
if !in {
continue
}
if strings.HasPrefix(t, "####") || (strings.HasPrefix(t, "# ") && isModuleLine(strings.TrimPrefix(t, "# "))) {
break
}
out = append(out, line)
}
return out
}
// isModuleLine is a comment that is one catalogue module's name: lower case, digits and dashes.
func isModuleLine(s string) bool {
if s == "" {
return false
}
for _, r := range s {
if !(r >= 'a' && r <= 'z' || r >= '0' && r <= '9' || r == '-') {
return false
}
}
return true
}
// physicalKeys names the key behind an evdev code, as far as it is known on this model. The media
// codes are the M4 key and Fn+F4/F5 together; which code is which key was not recorded.
var physicalKeys = map[string]string{
"KEY_PROG1": "a media key (M4, Fn+F4 or Fn+F5)",
"KEY_PROG3": "a media key (M4, Fn+F4 or Fn+F5)",
"KEY_PROG4": "a media key (M4, Fn+F4 or Fn+F5)",
"KEY_BRIGHTNESSDOWN": "Fn+F7",
"KEY_BRIGHTNESSUP": "Fn+F8",
"KEY_F21": "Fn+F10 (touchpad)",
"$mod+Shift+s": "Fn+F6 (screenshot; the firmware sends Super+Shift+S)",
"$mod+p": "Fn+F9 (display; the firmware sends Super+P)",
}
// firmwareKeys are handled below any configuration file.
var firmwareKeys = []Key{
{Key: "Fn+F2 / Fn+F3", Physical: "keyboard backlight", Runs: "the firmware and asusd; zephyrus-kbd-notify shows the level", From: "firmware"},
}
var (
triggerLine = regexp.MustCompile(`^(\S+)\s+([0-9]+)\s+(.+)$`)
i3Bind = regexp.MustCompile(`^bindsym\s+((?:--\S+\s+)*)(\S+)\s+(.+)$`)
i3Exec = regexp.MustCompile(`^exec(?:_always)?\s+(?:--no-startup-id\s+)?(.+)$`)
)
var triggerWhen = map[string]string{"0": "released", "1": "pressed", "2": "held (repeat)"}
// Keys lists the laptop's custom keys: every triggerhappy trigger, the module's i3 lines and the keys
// the firmware handles itself.
func (m *Machine) Keys() KeysReport {
r := KeysReport{Keys: []Key{}, Warnings: []string{}, NotRead: []string{}}
seen := map[string][]string{}
files := m.glob(TriggerDir + "/*.conf")
sort.Strings(files)
if len(files) == 0 {
r.Warnings = append(r.Warnings, "no trigger file in "+TriggerDir+": the vendor keys do nothing")
}
for _, f := range files {
for _, line := range strings.Split(m.read(f), "\n") {
line = strings.TrimSpace(line)
if line == "" || strings.HasPrefix(line, "#") {
continue
}
g := triggerLine.FindStringSubmatch(line)
if g == nil {
continue
}
r.Keys = append(r.Keys, Key{Key: g[1], Physical: physicalKeys[g[1]], When: triggerWhen[g[2]], Runs: g[3], From: f})
id := g[1] + " " + g[2]
seen[id] = append(seen[id], f)
}
}
for id, fs := range seen {
if len(fs) > 1 {
r.Warnings = append(r.Warnings, fmt.Sprintf("%s is bound in %d places (%s): it fires every one", id, len(fs), strings.Join(fs, ", ")))
}
}
home := os.Getenv("MESH_OPERATOR_HOME")
if home == "" {
r.NotRead = append(r.NotRead, "the module's i3 lines (MESH_OPERATOR_HOME is not set)")
}
if home != "" {
lines := ownSection(m.read(filepath.Join(home, I3Config)))
if len(lines) == 0 {
r.Warnings = append(r.Warnings, "~/"+I3Config+" holds no lines of this module's: is the i3 module assigned and pushed?")
}
from := "~/" + I3Config + " (" + ModuleName + "'s contribution)"
for _, line := range lines {
line = strings.TrimSpace(line)
if g := i3Bind.FindStringSubmatch(line); g != nil {
when := "pressed"
if strings.Contains(g[1], "--release") {
when = "released"
}
r.Keys = append(r.Keys, Key{Key: g[2], Physical: physicalKeys[g[2]], When: when, Runs: strings.TrimPrefix(strings.TrimPrefix(g[3], "exec "), "--no-startup-id "), From: from})
} else if g := i3Exec.FindStringSubmatch(line); g != nil {
r.Keys = append(r.Keys, Key{Key: "(session start)", When: "at login", Runs: g[1], From: from})
}
}
}
r.Keys = append(r.Keys, firmwareKeys...)
sort.Strings(r.Warnings)
return r
}
// asUserReferences finds the predecessor's as-user wrapper still named in a trigger, a udev rule or
// the module's i3 lines.
func (m *Machine) asUserReferences() []string {
var at []string
for _, pattern := range []string{TriggerDir + "/*.conf", "/etc/udev/rules.d/*.rules", "/etc/systemd/system-sleep/*"} {
for _, f := range m.glob(pattern) {
if strings.Contains(m.read(f), "as-user") {
at = append(at, f)
}
}
}
sort.Strings(at)
return at
}
// triggerhappyUID is the real user id the running thd has, or -1 when none runs.
func (m *Machine) triggerhappyUID() int {
for _, dir := range m.glob("/proc/[0-9]*") {
if strings.TrimSpace(m.read(dir+"/comm")) != "thd" {
continue
}
for _, line := range strings.Split(m.read(dir+"/status"), "\n") {
if f := strings.Fields(line); len(f) >= 2 && f[0] == "Uid:" {
var uid int
if _, err := fmt.Sscanf(f[1], "%d", &uid); err == nil {
return uid
}
}
}
}
return -1
}
// operatorUID is the account the module's code runs as: the runtime runs it as the operator's.
var operatorUID = os.Getuid
@@ -1,113 +0,0 @@
package main
import (
"context"
"encoding/json"
"os"
"path/filepath"
"strings"
"testing"
)
func put(t *testing.T, root, p, content string) {
t.Helper()
full := filepath.Join(root, p)
if err := os.MkdirAll(filepath.Dir(full), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(full, []byte(content), 0o644); err != nil {
t.Fatal(err)
}
}
// The module's shipped triggers and i3 lines, read back as keys: what each runs and where.
func TestKeysListsTriggersI3LinesAndFirmwareKeys(t *testing.T) {
f := newFake(t)
m := readManifest(t)
content := map[string]string{}
for _, r := range m.Resources {
if c, ok := r["content"].(string); ok {
content[r["id"].(string)] = c
}
}
put(t, f.root, TriggerDir+"/mesh.conf", "# asus-zephyrus-g14\n"+m.triggers())
home := "/home/operator"
t.Setenv("MESH_OPERATOR_HOME", home)
// The i3 module's file as the controller composes it: other modules' lines around this one's.
var own string
for _, c := range m.Contributions {
if c.Seat == "node-display-session" {
own += c.Content
}
}
put(t, f.root, home+"/"+I3Config, "set $mod Mod4\n# rofi\nbindsym $mod+d exec rofi\n# "+ModuleName+"\n"+own+
"# triggerhappy-not-here\nbindsym $mod+Shift+z exec other\n#########################################\ninclude x\n")
r := f.machine().Keys()
got := map[string]Key{}
for _, k := range r.Keys {
got[k.Key+"|"+k.When] = k
}
if k := got["KEY_F21|pressed"]; !strings.Contains(k.Runs, "zephyrus-touchpad reset") || k.Physical == "" {
t.Errorf("touchpad key: %+v", k)
}
if k := got["KEY_PROG1|pressed"]; !strings.Contains(k.Runs, "zephyrus-media play-pause") {
t.Errorf("media key: %+v", k)
}
if k := got["$mod+Shift+s|released"]; !strings.Contains(k.Runs, "screenshot") || !strings.Contains(k.Physical, "Fn+F6") {
t.Errorf("screenshot key: %+v", k)
}
if k := got["$mod+p|pressed"]; !strings.Contains(k.Runs, "zephyrus-display order") {
t.Errorf("display key: %+v", k)
}
if k := got["(session start)|at login"]; k.Runs == "" {
t.Errorf("no session-start line read")
}
if k := got["Fn+F2 / Fn+F3|"]; k.From != "firmware" {
t.Errorf("firmware keys missing: %+v", r.Keys)
}
if len(r.Warnings) != 0 || len(r.NotRead) != 0 {
t.Errorf("warnings %v, not read %v", r.Warnings, r.NotRead)
}
if _, err := json.Marshal(r); err != nil {
t.Fatal(err)
}
}
// A key bound in two trigger files fires twice, which is said.
func TestKeysWarnsAboutAKeyInTwoTriggerFiles(t *testing.T) {
f := newFake(t)
put(t, f.root, TriggerDir+"/asus-g14.conf", "KEY_F21\t1\t/x reset\n")
put(t, f.root, TriggerDir+"/old.conf", "KEY_F21\t1\t/y reset\n")
t.Setenv("MESH_OPERATOR_HOME", "")
r := f.machine().Keys()
if len(r.Warnings) != 1 || !strings.Contains(r.Warnings[0], "KEY_F21") || len(r.NotRead) != 1 {
t.Errorf("warnings %v, not read %v", r.Warnings, r.NotRead)
}
}
// The check names what still calls as-user, and whether triggerhappy runs as the account.
func TestCheckFindsAsUserAndTriggerhappysAccount(t *testing.T) {
f := newFake(t)
put(t, f.root, "/etc/udev/rules.d/91-monitor-hotplug.rules", `RUN+="/x/scripts/as-user setsid wizard"`)
put(t, f.root, "/proc/4242/comm", "thd\n")
put(t, f.root, "/proc/4242/status", "Name:\tthd\nUid:\t0\t0\t0\t0\n")
was := operatorUID
operatorUID = func() int { return 1000 }
defer func() { operatorUID = was }()
t.Setenv("MESH_OPERATOR_HOME", "")
r := f.machine().Check(context.Background(), nil)
by := map[string]Check{}
for _, c := range r.Checks {
by[c.Name] = c
}
if c := by["no as-user"]; c.OK || !strings.Contains(c.Detail, "91-monitor-hotplug.rules") {
t.Errorf("as-user: %+v", c)
}
if c := by["triggerhappy as the account"]; c.OK || !strings.Contains(c.Detail, "uid 0") {
t.Errorf("triggerhappy: %+v", c)
}
if c := by["touchpad after resume"]; c.OK {
t.Errorf("resume: %+v", c)
}
}
@@ -1,124 +0,0 @@
package main
import (
"bytes"
"context"
"errors"
"fmt"
"os"
"os/exec"
"path/filepath"
"strconv"
"strings"
"time"
)
// CommandTimeout bounds every command a tool or the switcher runs: a vendor daemon that hangs on its
// bus must cost a tool call twenty seconds, never the runtime's thirty.
const CommandTimeout = 20 * time.Second
// Runner runs one command and answers its standard output. It is injected so that every tool is
// tested against recorded answers rather than this machine's daemons.
type Runner func(ctx context.Context, name string, args ...string) (string, error)
// ExecRunner runs a command on the machine, bounded by CommandTimeout. A failure carries what the
// command said on stderr, because "exit status 1" names nothing.
func ExecRunner(ctx context.Context, name string, args ...string) (string, error) {
ctx, cancel := context.WithTimeout(ctx, CommandTimeout)
defer cancel()
cmd := exec.CommandContext(ctx, name, args...)
var stdout, stderr bytes.Buffer
cmd.Stdout, cmd.Stderr = &stdout, &stderr
err := cmd.Run()
if ctx.Err() == context.DeadlineExceeded {
return stdout.String(), fmt.Errorf("%s did not answer within %s", name, CommandTimeout)
}
if err != nil {
said := strings.TrimSpace(stderr.String())
if said == "" {
said = strings.TrimSpace(stdout.String())
}
if said != "" {
return stdout.String(), fmt.Errorf("%s %s: %w: %s", name, strings.Join(args, " "), err, said)
}
return stdout.String(), fmt.Errorf("%s %s: %w", name, strings.Join(args, " "), err)
}
return stdout.String(), nil
}
// Machine is what the module reads and acts on: a filesystem root (the real one, or a test's tree of
// /sys and /proc and /etc) and a way to run commands.
type Machine struct {
Root string
Run Runner
}
// Here is the machine this process runs on.
func Here() *Machine { return &Machine{Root: "/", Run: ExecRunner} }
func (m *Machine) path(p string) string { return filepath.Join(m.Root, p) }
// read is a file's content, trimmed; "" when it cannot be read.
func (m *Machine) read(p string) string {
b, err := os.ReadFile(m.path(p))
if err != nil {
return ""
}
return strings.TrimSpace(string(b))
}
// readInt is a file holding one integer; ok false when it is absent or not a number.
func (m *Machine) readInt(p string) (int64, bool) {
s := m.read(p)
if s == "" {
return 0, false
}
n, err := strconv.ParseInt(s, 10, 64)
return n, err == nil
}
func (m *Machine) glob(pattern string) []string {
found, _ := filepath.Glob(m.path(pattern))
out := make([]string, 0, len(found))
for _, f := range found {
rel, err := filepath.Rel(m.Root, f)
if err != nil {
continue
}
out = append(out, "/"+filepath.ToSlash(rel))
}
return out
}
// write puts a value into a file of the kernel's (a backlight). Where the account may not write it
// — the udev rule that gives the video group the panel has not run yet — it escalates with `sudo -n`,
// which never prompts: the operator's account may escalate without one, and when it may not, the
// tool says so in sudo's words.
func (m *Machine) write(ctx context.Context, p, value string) error {
err := os.WriteFile(m.path(p), []byte(value), 0)
if err == nil {
return nil
}
if !errors.Is(err, os.ErrPermission) {
return err
}
if _, serr := m.Run(ctx, "sudo", "-n", "sh", "-c", `printf '%s' "$1" > "$2"`, "sh", value, m.path(p)); serr != nil {
return fmt.Errorf("%s is not writable by this account and sudo -n refused: %v", p, serr)
}
return nil
}
// notInstalled says a command failed because it is not on this machine at all.
func notInstalled(err error) bool { return errors.Is(err, exec.ErrNotFound) }
// round to one decimal, for watts and percentages a person reads.
func round1(f float64) float64 {
return float64(int64(f*10+sign(f)*0.5)) / 10
}
func sign(f float64) float64 {
if f < 0 {
return -1
}
return 1
}
@@ -1,25 +0,0 @@
// The asus-zephyrus-g14 module's Go bundle (novox/hq ADR 0188, ADR 0193, ADR 0198): one process the
// node's runtime launches, serving the module's tools over MCP on stdio and running its long-running
// code — the platform-profile switcher — beside them.
package main
import (
"context"
"fmt"
"os"
stdio "git.novox.be/novox/mesh-sdk/go"
)
func main() {
m := Here()
sw := NewSwitcher(m, func(eventType string, body any) error { return stdio.Emit(eventType, body) })
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
go sw.Run(ctx)
// An empty name serves as the module the runtime names (MESH_SERVED_MODULE).
if err := stdio.Serve("", Tools(m, sw)); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
}
@@ -1,127 +0,0 @@
package main
import (
"encoding/json"
"os"
"os/exec"
"path/filepath"
"sort"
"strings"
"testing"
)
type manifest struct {
Module string `json:"module"`
Tools []string `json:"tools"`
Emits []string `json:"emits"`
Resources []map[string]any `json:"resources"`
// Contributions to other modules' seats (novox/hq ADR 0212): the vendor keys go to node-hotkeys.
Contributions []struct {
Seat string `json:"seat"`
Kind string `json:"kind"`
Content string `json:"content"`
} `json:"contributions"`
}
// triggers is the module's contribution to node-hotkeys: its vendor keys.
func (m manifest) triggers() string {
for _, c := range m.Contributions {
if c.Seat == "node-hotkeys" && c.Kind == "trigger" {
return c.Content
}
}
return ""
}
func readManifest(t *testing.T) manifest {
t.Helper()
raw, err := os.ReadFile("../../module.json")
if err != nil {
t.Fatal(err)
}
var m manifest
if err := json.Unmarshal(raw, &m); err != nil {
t.Fatal(err)
}
return m
}
func TestTheManifestNamesExactlyTheToolsTheBundleServes(t *testing.T) {
m := readManifest(t)
var served []string
for _, tool := range Tools(&Machine{Root: t.TempDir(), Run: newFake(t).run}, nil) {
served = append(served, tool.Name)
}
sort.Strings(served)
listed := append([]string(nil), m.Tools...)
sort.Strings(listed)
if strings.Join(served, ",") != strings.Join(listed, ",") {
t.Fatalf("served %v, listed %v", served, listed)
}
if len(m.Emits) != 1 || m.Emits[0] != "profile.switched" {
t.Fatalf("emits %v", m.Emits)
}
}
// The module names the model, never a node, a person or a user id (novox/hq ADR 0112), and every
// trigger it names a service restart or reload on is one of its own resources.
func TestTheManifestNamesNoMachineAndItsTriggersExist(t *testing.T) {
m := readManifest(t)
ids := map[string]bool{}
for _, r := range m.Resources {
ids[r["id"].(string)] = true
}
raw, _ := os.ReadFile("../../module.json")
for _, banned := range []string{"jochen", "/home/", "/run/user/1000", "\"g14\"", "shanks"} {
if strings.Contains(string(raw), banned) {
t.Errorf("the manifest says %q", banned)
}
}
for _, r := range m.Resources {
for _, key := range []string{"restart-on", "reload-on"} {
list, _ := r[key].([]any)
for _, id := range list {
if !ids[id.(string)] {
t.Errorf("%s %s names %v, which is not a resource", r["id"], key, id)
}
}
}
}
}
// Every trigger runs a script the module ships, and every script parses.
func TestTheVendorKeysRunTheModulesOwnScriptsAndTheyParse(t *testing.T) {
m := readManifest(t)
triggers := m.triggers()
if triggers == "" {
t.Fatal("no trigger contribution to node-hotkeys")
}
for _, line := range strings.Split(triggers, "\n") {
f := strings.Split(line, "\t")
if strings.HasPrefix(line, "#") || len(f) < 3 {
continue
}
script := strings.Fields(f[2])[0]
local := filepath.Join("../../files/bin", filepath.Base(script))
if !strings.HasPrefix(script, "/usr/local/lib/asus-zephyrus-g14/bin/") {
t.Errorf("%s runs %s, which the module does not ship", f[0], script)
} else if st, err := os.Stat(local); err != nil || st.Mode()&0o111 == 0 {
t.Errorf("%s: %s is missing or not executable", f[0], local)
}
}
scripts, _ := filepath.Glob("../../files/bin/*")
if len(scripts) == 0 {
t.Fatal("no scripts")
}
// The scripts are bash's. The build seat's Go toolchain carries no bash, so there they are said as
// not parsed rather than failed: a missing interpreter is not a broken script.
if _, err := exec.LookPath("bash"); err != nil {
t.Logf("NOT PARSED: no bash on this machine, so the scripts' syntax was not checked")
return
}
for _, s := range scripts {
if out, err := exec.Command("bash", "-n", s).CombinedOutput(); err != nil {
t.Errorf("%s: %v %s", s, err, out)
}
}
}
@@ -1,137 +0,0 @@
package main
import (
"fmt"
"strconv"
"strings"
"time"
)
// The policy, as constants until the mesh has settings a module can read (novox/hq issue 168). The
// values are the predecessor's, made explicit, and two of its behaviours are changed on purpose:
//
// - **Sustained, not momentary.** The predecessor boosted on one five-second sample above 50 %: a
// compile's first second, a browser's tab restore. Here the load must stay above the line for
// SustainSamples samples in a row, and below the lower line as long, before the profile moves.
// - **Waiting on a disk is not load.** iowait is counted as idle: a machine stalled on its SSD does
// not get faster with a higher power limit, only hotter.
const (
ProfileOnBattery = "Quiet"
ProfileOnAC = "Balanced"
ProfileUnderLoad = "Performance"
CPUHighPercent = 50.0 // on mains, sustained at or above this boosts to ProfileUnderLoad
CPULowPercent = 20.0 // and sustained at or below this goes back to ProfileOnAC
SampleEvery = 10 * time.Second // CPU is sampled only on mains; on battery nothing is sampled
SustainSamples = 3 // 30 s above CPUHighPercent to boost
RelaxSamples = 3 // 30 s below CPULowPercent to relax
// SafetyRecheck is how often the power source is read when no event has said it changed: the
// kernel's event is the trigger, and this only covers one lost across a suspend.
SafetyRecheck = 5 * time.Minute
// DefaultHold is how long a profile chosen through the profile tool is kept before the switcher
// may move it again. A change of power source ends a hold at once.
DefaultHold = 60 * time.Minute
// ChargeLimitPercent is the battery charge limit the module asserts through asusd at start.
ChargeLimitPercent = 80
)
// Policy is the switcher's memory of recent load: how many samples in a row were above the upper line
// or below the lower one, and whether it is boosted.
type Policy struct {
Boosted bool `json:"boosted"`
Above int `json:"samples_above"`
Below int `json:"samples_below"`
Recent []float64 `json:"recent_cpu_percent"`
BoostedSince time.Time `json:"boosted_since,omitempty"`
}
// Observe takes one CPU sample (busy percent since the previous one) taken on mains.
func (p *Policy) Observe(cpu float64, at time.Time) {
p.Recent = append(p.Recent, round1(cpu))
if len(p.Recent) > 6 {
p.Recent = p.Recent[len(p.Recent)-6:]
}
switch {
case cpu >= CPUHighPercent:
p.Above++
p.Below = 0
if !p.Boosted && p.Above >= SustainSamples {
p.Boosted = true
p.BoostedSince = at
}
case cpu <= CPULowPercent:
p.Below++
p.Above = 0
if p.Boosted && p.Below >= RelaxSamples {
p.Boosted = false
p.BoostedSince = time.Time{}
}
default:
// Between the lines: no direction is sustained, and the profile stays where it is.
p.Above, p.Below = 0, 0
}
}
// Reset forgets the load, for a change of power source.
func (p *Policy) Reset() { *p = Policy{} }
// Decision is what the switcher would choose, and why.
type Decision struct {
Profile string `json:"profile"`
Reason string `json:"reason"`
}
// Decide is the policy: battery → ProfileOnBattery; mains → ProfileOnAC, or ProfileUnderLoad while
// boosted.
func (p *Policy) Decide(src Source) Decision {
if !src.OnAC {
return Decision{ProfileOnBattery, "on battery (" + src.Reason + ")"}
}
if p.Boosted {
return Decision{ProfileUnderLoad, fmt.Sprintf("on mains (%s) and CPU load sustained at or above %s%% for %d samples of %s",
src.Reason, strconv.FormatFloat(CPUHighPercent, 'f', -1, 64), SustainSamples, SampleEvery)}
}
return Decision{ProfileOnAC, fmt.Sprintf("on mains (%s), and CPU load not sustained at or above %s%%",
src.Reason, strconv.FormatFloat(CPUHighPercent, 'f', -1, 64))}
}
// CPUTimes is the first line of /proc/stat: total and idle jiffies (iowait counted as idle).
type CPUTimes struct{ Total, Idle uint64 }
// ParseProcStat reads the aggregate cpu line of /proc/stat.
func ParseProcStat(text string) (CPUTimes, error) {
line := strings.SplitN(text, "\n", 2)[0]
f := strings.Fields(line)
if len(f) < 6 || f[0] != "cpu" {
return CPUTimes{}, fmt.Errorf("/proc/stat does not start with the cpu line")
}
var t CPUTimes
for i, s := range f[1:] {
if i >= 8 { // user nice system idle iowait irq softirq steal; guest is already in user
break
}
n, err := strconv.ParseUint(s, 10, 64)
if err != nil {
return CPUTimes{}, fmt.Errorf("/proc/stat: %v", err)
}
t.Total += n
if i == 3 || i == 4 {
t.Idle += n
}
}
return t, nil
}
// Busy is the percentage of time not idle between two readings.
func Busy(before, after CPUTimes) (float64, bool) {
if after.Total <= before.Total {
return 0, false
}
total := float64(after.Total - before.Total)
idle := float64(after.Idle - before.Idle)
return (total - idle) / total * 100, true
}
@@ -1,180 +0,0 @@
package main
import (
"path"
"sort"
"strings"
)
// Supply is one entry of /sys/class/power_supply as the kernel reports it.
type Supply struct {
Name string `json:"name"`
Type string `json:"type"`
Scope string `json:"scope,omitempty"`
Status string `json:"status,omitempty"`
Online *bool `json:"online,omitempty"`
}
// Supplies is every power supply the kernel knows, sorted by name.
func (m *Machine) Supplies() []Supply {
var out []Supply
for _, dir := range m.glob("/sys/class/power_supply/*") {
s := Supply{
Name: path.Base(dir),
Type: m.read(dir + "/type"),
Scope: m.read(dir + "/scope"),
Status: m.read(dir + "/status"),
}
if v, ok := m.readInt(dir + "/online"); ok {
on := v == 1
s.Online = &on
}
out = append(out, s)
}
sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name })
return out
}
// system is a supply that powers this machine. A mouse's or a headset's battery reports scope
// Device, and it says nothing about whether the laptop is on mains.
func (s Supply) system() bool { return !strings.EqualFold(s.Scope, "Device") }
// Source is where the machine draws its power from, and why that was concluded.
type Source struct {
OnAC bool `json:"on_ac"`
Source string `json:"source"`
Reason string `json:"reason"`
}
// PowerSource decides mains or battery.
//
// **A battery that says it is discharging wins over any adapter that says it is online.** The
// predecessor's script took any `online` file reading 1 as mains, and a USB-C port reports `online`
// for things that do not power the machine. The battery's own status is the one fact that cannot be
// misread: it discharges exactly when nothing outside is carrying the load. Only when no battery says
// so are the adapters asked, and a machine with no system battery at all is on mains.
func PowerSource(supplies []Supply) Source {
batteries := 0
for _, s := range supplies {
if s.Type == "Battery" && s.system() {
batteries++
if strings.EqualFold(s.Status, "Discharging") {
return Source{OnAC: false, Source: "battery", Reason: s.Name + " is discharging"}
}
}
}
for _, s := range supplies {
if (s.Type == "Mains" || strings.HasPrefix(s.Type, "USB")) && s.system() && s.Online != nil && *s.Online {
return Source{OnAC: true, Source: "ac", Reason: s.Name + " (" + s.Type + ") is online"}
}
}
if batteries == 0 {
return Source{OnAC: true, Source: "ac", Reason: "this machine has no system battery"}
}
return Source{OnAC: false, Source: "battery", Reason: "no mains or USB supply is online"}
}
// Battery is what the battery tool answers.
type Battery struct {
Name string `json:"name"`
Status string `json:"status"`
ChargePercent *int64 `json:"charge_percent,omitempty"`
EnergyWh *float64 `json:"energy_wh,omitempty"`
FullWh *float64 `json:"full_wh,omitempty"`
DesignWh *float64 `json:"design_wh,omitempty"`
HealthPercent *float64 `json:"health_percent,omitempty"`
Cycles *int64 `json:"cycles"`
CyclesNote string `json:"cycles_note,omitempty"`
LimitPercent *int64 `json:"charge_limit_percent,omitempty"`
PowerW *float64 `json:"power_w,omitempty"`
HoursRemaining *float64 `json:"hours_remaining,omitempty"`
Technology string `json:"technology,omitempty"`
Model string `json:"model,omitempty"`
Manufacturer string `json:"manufacturer,omitempty"`
}
// Batteries reads every system battery.
func (m *Machine) Batteries() []Battery {
var out []Battery
for _, s := range m.Supplies() {
if s.Type != "Battery" || !s.system() {
continue
}
out = append(out, m.battery(s))
}
return out
}
func (m *Machine) battery(s Supply) Battery {
dir := "/sys/class/power_supply/" + s.Name
b := Battery{
Name: s.Name, Status: s.Status,
Technology: m.read(dir + "/technology"),
Model: m.read(dir + "/model_name"),
Manufacturer: strings.TrimSpace(m.read(dir + "/manufacturer")),
}
if v, ok := m.readInt(dir + "/capacity"); ok {
b.ChargePercent = &v
}
// Energy in Wh: energy_* (µWh) where the firmware reports it, else charge_* (µAh) times the
// design minimum voltage, which is how upower converts it too.
wh := func(energy, charge string) *float64 {
if v, ok := m.readInt(dir + "/" + energy); ok {
f := round1(float64(v) / 1e6)
return &f
}
c, okc := m.readInt(dir + "/" + charge)
volts, okv := m.readInt(dir + "/voltage_min_design")
if okc && okv {
f := round1(float64(c) * float64(volts) / 1e12)
return &f
}
return nil
}
b.EnergyWh = wh("energy_now", "charge_now")
b.FullWh = wh("energy_full", "charge_full")
b.DesignWh = wh("energy_full_design", "charge_full_design")
if b.FullWh != nil && b.DesignWh != nil && *b.DesignWh > 0 {
h := round1(*b.FullWh / *b.DesignWh * 100)
b.HealthPercent = &h
}
if v, ok := m.readInt(dir + "/cycle_count"); ok && v > 0 {
b.Cycles = &v
} else {
b.CyclesNote = "the firmware does not report a cycle count (it reads 0)"
}
if v, ok := m.readInt(dir + "/charge_control_end_threshold"); ok {
b.LimitPercent = &v
}
if w := m.batteryWatts(dir); w != nil {
b.PowerW = w
if strings.EqualFold(s.Status, "Discharging") && b.EnergyWh != nil && *w > 0.5 {
h := round1(*b.EnergyWh / *w)
b.HoursRemaining = &h
}
}
return b
}
// batteryWatts is how much the battery is giving or taking, in watts, unsigned: power_now where the
// firmware reports it, else current times voltage.
func (m *Machine) batteryWatts(dir string) *float64 {
if v, ok := m.readInt(dir + "/power_now"); ok {
f := round1(abs(float64(v)) / 1e6)
return &f
}
i, oki := m.readInt(dir + "/current_now")
u, oku := m.readInt(dir + "/voltage_now")
if oki && oku {
f := round1(abs(float64(i)) * float64(u) / 1e12)
return &f
}
return nil
}
func abs(f float64) float64 {
if f < 0 {
return -f
}
return f
}
@@ -1,73 +0,0 @@
package main
import "testing"
func on(b bool) *bool { return &b }
func TestADischargingBatteryWinsOverAnAdapterThatSaysOnline(t *testing.T) {
got := PowerSource([]Supply{
{Name: "BAT1", Type: "Battery", Status: "Discharging"},
{Name: "ucsi-source-psy-USBC000:001", Type: "USB", Scope: "System", Online: on(true)},
})
if got.OnAC {
t.Fatalf("a USB-C port reporting online while the battery discharges was read as mains: %+v", got)
}
}
func TestMainsOnlineIsAC(t *testing.T) {
got := PowerSource([]Supply{
{Name: "ACAD", Type: "Mains", Online: on(true)},
{Name: "BAT1", Type: "Battery", Status: "Not charging"},
})
if !got.OnAC || got.Reason != "ACAD (Mains) is online" {
t.Fatalf("%+v", got)
}
}
func TestAPeripheralsBatteryDecidesNothing(t *testing.T) {
got := PowerSource([]Supply{
{Name: "hidpp_battery_0", Type: "Battery", Scope: "Device", Status: "Discharging"},
{Name: "ACAD", Type: "Mains", Online: on(true)},
{Name: "BAT1", Type: "Battery", Status: "Charging"},
})
if !got.OnAC {
t.Fatalf("a mouse's discharging battery put the laptop on battery: %+v", got)
}
}
func TestNoSupplyOnlineWithABatteryIsBatteryAndNoBatteryIsMains(t *testing.T) {
if got := PowerSource([]Supply{{Name: "ACAD", Type: "Mains", Online: on(false)}, {Name: "BAT1", Type: "Battery", Status: "Unknown"}}); got.OnAC {
t.Fatalf("%+v", got)
}
if got := PowerSource(nil); !got.OnAC {
t.Fatalf("a machine with no battery is on mains: %+v", got)
}
}
func TestTheBatteryIsReadInWattHoursFromChargeAndHealthAgainstDesign(t *testing.T) {
f := newFake(t)
// The laptop's own battery, as measured: charge_* in µAh, no energy_* and no power_now.
f.supply("BAT1", map[string]string{
"type": "Battery", "status": "Discharging", "capacity": "80",
"charge_now": "3073000", "charge_full": "3865000", "charge_full_design": "4580000",
"voltage_min_design": "15939000", "current_now": "1000000", "voltage_now": "16000000",
"cycle_count": "0", "charge_control_end_threshold": "80", "manufacturer": "ASUS ",
})
bs := f.machine().Batteries()
if len(bs) != 1 {
t.Fatalf("%+v", bs)
}
b := bs[0]
if *b.EnergyWh != 49 || *b.FullWh != 61.6 || *b.DesignWh != 73 || *b.HealthPercent != 84.4 {
t.Fatalf("energy %v full %v design %v health %v", *b.EnergyWh, *b.FullWh, *b.DesignWh, *b.HealthPercent)
}
if b.Cycles != nil || b.CyclesNote == "" {
t.Fatal("a cycle count of 0 is the firmware not reporting one, and said so")
}
if *b.LimitPercent != 80 || *b.PowerW != 16 || b.HoursRemaining == nil || *b.HoursRemaining != 3.1 {
t.Fatalf("limit %v power %v hours %v", *b.LimitPercent, *b.PowerW, b.HoursRemaining)
}
if b.Manufacturer != "ASUS" {
t.Fatalf("manufacturer %q", b.Manufacturer)
}
}
@@ -1,303 +0,0 @@
package main
import (
"context"
"fmt"
"os"
"strconv"
"strings"
"sync"
"time"
)
// The module's long-running code (novox/hq ADR 0198): the profile switcher, launched with the tools
// by the node's runtime and running beside them in the same process.
//
// It replaces the predecessor's `auto-profile`, a user unit that woke every five seconds for ever —
// read the adapters, read /proc/stat, maybe call asusctl — on battery too, where its only possible
// answer was the one it had already given. Here the kernel's power-supply event is the trigger; the
// CPU is sampled only on mains, where the answer depends on it; and on battery the process sleeps
// until the adapter comes back.
//
// **It acts on a change of its decision, never to restore one.** A profile chosen by hand — the
// vendor's profile key, asusctl in a terminal, the profile tool — stays until the power source
// changes or the load crosses a line. The predecessor re-asserted its choice every five seconds and so
// made the profile key useless on battery.
// Emitter publishes an event as the module; nil when the process is not under the runtime.
type Emitter func(eventType string, body any) error
// Switcher is the switcher's state, shared with the tools that report it.
type Switcher struct {
m *Machine
now func() time.Time
emit Emitter
mu sync.Mutex
policy Policy
source *Source
decision *Decision
applied string
appliedAt time.Time
lastError string
holdUntil time.Time
holdOf string
watching string
cpuPrev *CPUTimes
disabled string
asserted []string
}
func NewSwitcher(m *Machine, emit Emitter) *Switcher {
return &Switcher{m: m, now: time.Now, emit: emit}
}
// Model is the machine's product family as its firmware reports it.
func (m *Machine) Model() string { return m.read("/sys/class/dmi/id/product_family") }
// ModelFamily is the family this module is written for.
const ModelFamily = "ROG Zephyrus G14"
// ThisModel says whether the machine is the model this module is written for.
func (m *Machine) ThisModel() bool { return strings.EqualFold(m.Model(), ModelFamily) }
// sampleCPU reads /proc/stat and answers the busy percentage since the previous reading.
func (s *Switcher) sampleCPU() (float64, bool) {
t, err := ParseProcStat(s.m.read("/proc/stat"))
if err != nil {
return 0, false
}
prev := s.cpuPrev
s.cpuPrev = &t
if prev == nil {
return 0, false
}
return Busy(*prev, t)
}
// Evaluate reads the power source, takes a CPU sample when asked and on mains, decides, and applies
// the decision when it changed. It is the whole of one wake-up and what the tests drive.
func (s *Switcher) Evaluate(ctx context.Context, sample bool) {
if body := s.evaluate(ctx, sample); body != nil && s.emit != nil {
// Outside the lock: publishing waits for the bus, and the tools that report the switcher
// must not wait with it.
if err := s.emit("profile.switched", body); err != nil {
fmt.Fprintf(os.Stderr, "profile.switched not published: %v\n", err)
}
}
}
// evaluate is Evaluate under the lock; it answers the event to publish when it switched.
func (s *Switcher) evaluate(ctx context.Context, sample bool) map[string]any {
s.mu.Lock()
defer s.mu.Unlock()
if s.disabled != "" {
return nil
}
src := PowerSource(s.m.Supplies())
now := s.now()
first := s.source == nil
if first || s.source.OnAC != src.OnAC {
// A new power source: what was learnt about load on the other one says nothing here, and a
// hold was for the source it was asked on.
s.policy.Reset()
s.cpuPrev = nil
s.holdUntil = time.Time{}
s.holdOf = ""
s.sampleCPU() // the first reading on this source, so the next sample is a difference
} else if sample && src.OnAC {
if busy, ok := s.sampleCPU(); ok {
s.policy.Observe(busy, now)
}
}
s.source = &src
d := s.policy.Decide(src)
s.decision = &d
// **Starting is not a reason to switch.** The runtime starts this process on every push that
// changes a bundle; at boot and at every change of power source asusd has already applied its own
// profile for the source, which AssertVendorSettings made the policy's. So the first decision is
// taken as applied, and a profile someone chose by hand survives a push.
if first {
s.applied = d.Profile
return nil
}
// Compared with what the switcher itself last applied, never with the profile in force: a profile
// someone chose by hand is not a reason to act, a new decision is.
if d.Profile == s.applied || now.Before(s.holdUntil) {
return nil
}
from := s.applied
if err := s.m.SetProfile(ctx, d.Profile); err != nil {
s.lastError = err.Error() // and tried again at the next wake-up, since applied did not move
return nil
}
s.lastError = ""
s.applied, s.appliedAt = d.Profile, now
body := map[string]any{"profile": d.Profile, "reason": d.Reason, "source": src.Source}
if from != "" {
body["from"] = from
}
return body
}
// Hold keeps a profile chosen through the tool for a while: the switcher does not move it until the
// hold ends or the power source changes.
func (s *Switcher) Hold(profile string, d time.Duration) time.Time {
s.mu.Lock()
defer s.mu.Unlock()
if d <= 0 {
s.holdUntil, s.holdOf = time.Time{}, ""
return time.Time{}
}
s.holdUntil, s.holdOf = s.now().Add(d), profile
return s.holdUntil
}
// Run is the switcher's life: assert asusd's settings once, then wake on each power-supply event, on
// each CPU sample while on mains, and at SafetyRecheck otherwise.
func (s *Switcher) Run(ctx context.Context) {
defer func() {
if r := recover(); r != nil {
s.mu.Lock()
s.disabled = fmt.Sprintf("the switcher stopped on a fault: %v", r)
s.mu.Unlock()
fmt.Fprintln(os.Stderr, s.disabled)
}
}()
if !s.m.ThisModel() {
s.mu.Lock()
s.disabled = fmt.Sprintf("this machine reports %q, not %q: the switcher does not act on another model",
s.m.Model(), ModelFamily)
s.mu.Unlock()
fmt.Fprintln(os.Stderr, s.disabled)
return
}
s.AssertVendorSettings(ctx)
events, err := listenPowerSupply(ctx)
s.mu.Lock()
if err != nil {
s.watching = "polling every " + SampleEvery.String() + ": " + err.Error()
} else {
s.watching = "the kernel's power-supply events"
}
s.mu.Unlock()
s.Evaluate(ctx, false)
timer := time.NewTimer(s.interval(err != nil))
defer timer.Stop()
for {
select {
case <-ctx.Done():
return
case _, open := <-events:
if !open {
events = nil
s.mu.Lock()
s.watching = "polling every " + SampleEvery.String() + ": the uevent socket closed"
s.mu.Unlock()
err = fmt.Errorf("closed")
continue
}
// Settle: an adapter change arrives as several events within a moment.
time.Sleep(time.Second)
s.Evaluate(ctx, false)
case <-timer.C:
s.Evaluate(ctx, true)
timer.Reset(s.interval(err != nil))
}
}
}
// interval is how long to sleep: a CPU sample's period on mains (or with no events to wake on), the
// safety recheck on battery.
func (s *Switcher) interval(polling bool) time.Duration {
s.mu.Lock()
defer s.mu.Unlock()
if polling || s.source == nil || s.source.OnAC {
return SampleEvery
}
return SafetyRecheck
}
// AssertVendorSettings puts asusd's own settings where the module wants them, once at start: the
// battery charge limit, and the profiles asusd itself switches to on mains and on battery, so that the
// vendor daemon's own switching and this module's never disagree. Each is read first and set only if
// it differs. A value changed later with a tool stands until the next start.
func (s *Switcher) AssertVendorSettings(ctx context.Context) []string {
var said []string
if limit, err := s.m.ChargeLimit(ctx); err != nil {
said = append(said, "charge limit not read: "+err.Error())
} else if limit != ChargeLimitPercent {
if _, err := s.m.Run(ctx, "asusctl", "battery", "limit", strconv.Itoa(ChargeLimitPercent)); err != nil {
said = append(said, "charge limit not set: "+vendor("asusctl", err).Error())
} else {
said = append(said, fmt.Sprintf("charge limit %d%% → %d%%", limit, ChargeLimitPercent))
}
} else {
said = append(said, fmt.Sprintf("charge limit already %d%%", ChargeLimitPercent))
}
p, err := s.m.Profile(ctx)
if err != nil {
said = append(said, "asusd's profiles not read: "+err.Error())
} else {
for _, want := range []struct{ flag, have, want, what string }{
{"-a", p.OnAC, ProfileOnAC, "on mains"},
{"-b", p.Battery, ProfileOnBattery, "on battery"},
} {
if want.have == "" || strings.EqualFold(want.have, want.want) {
continue
}
if _, err := s.m.Run(ctx, "asusctl", "profile", "set", want.flag, want.want); err != nil {
said = append(said, "asusd's profile "+want.what+" not set: "+err.Error())
} else {
said = append(said, fmt.Sprintf("asusd's profile %s %s → %s", want.what, want.have, want.want))
}
}
}
s.mu.Lock()
s.asserted = said
s.mu.Unlock()
for _, line := range said {
fmt.Fprintln(os.Stderr, line)
}
return said
}
// SwitcherReport is the switcher's state as the profile-policy tool shows it.
type SwitcherReport struct {
Running bool `json:"running"`
Disabled string `json:"disabled,omitempty"`
Watching string `json:"woken_by,omitempty"`
Source *Source `json:"source,omitempty"`
Decision *Decision `json:"decision,omitempty"`
Load Policy `json:"load"`
LastApplied string `json:"last_applied,omitempty"`
LastAppliedAt *time.Time `json:"last_applied_at,omitempty"`
LastError string `json:"last_error,omitempty"`
HeldUntil *time.Time `json:"held_until,omitempty"`
Held string `json:"held_profile,omitempty"`
AssertedAtStart []string `json:"asserted_at_start,omitempty"`
}
func (s *Switcher) Report() SwitcherReport {
s.mu.Lock()
defer s.mu.Unlock()
r := SwitcherReport{
Running: s.watching != "" && s.disabled == "", Disabled: s.disabled, Watching: s.watching,
Source: s.source, Decision: s.decision, Load: s.policy, LastApplied: s.applied,
LastAppliedAt: when(s.appliedAt), LastError: s.lastError, AssertedAtStart: s.asserted,
}
if s.now().Before(s.holdUntil) {
r.HeldUntil, r.Held = when(s.holdUntil), s.holdOf
}
return r
}
// when is a time for a report: absent rather than the zero time.
func when(t time.Time) *time.Time {
if t.IsZero() {
return nil
}
return &t
}
@@ -1,190 +0,0 @@
package main
import (
"context"
"errors"
"strconv"
"testing"
"time"
)
func TestTheLoadMustBeSustainedToBoostAndToRelaxAndBetweenTheLinesNothingMoves(t *testing.T) {
var p Policy
at := time.Now()
mains := Source{OnAC: true, Reason: "ACAD (Mains) is online"}
for i := 0; i < SustainSamples-1; i++ {
p.Observe(90, at)
}
if p.Decide(mains).Profile != ProfileOnAC {
t.Fatal("boosted before the load was sustained")
}
p.Observe(35, at) // between the lines breaks the run
p.Observe(90, at)
if p.Boosted {
t.Fatal("a broken run still counted")
}
for i := 0; i < SustainSamples; i++ {
p.Observe(CPUHighPercent, at)
}
if d := p.Decide(mains); d.Profile != ProfileUnderLoad {
t.Fatalf("%+v", d)
}
p.Observe(35, at)
if !p.Boosted {
t.Fatal("load between the lines relaxed the boost")
}
for i := 0; i < RelaxSamples; i++ {
p.Observe(5, at)
}
if p.Decide(mains).Profile != ProfileOnAC {
t.Fatal("did not relax after a sustained low")
}
p.Boosted = true
if d := p.Decide(Source{OnAC: false, Reason: "BAT1 is discharging"}); d.Profile != ProfileOnBattery {
t.Fatalf("battery: %+v", d)
}
}
func TestIOWaitIsIdle(t *testing.T) {
a, err := ParseProcStat("cpu 100 0 100 700 100 0 0 0 0 0\ncpu0 1 2 3\n")
if err != nil {
t.Fatal(err)
}
b, _ := ParseProcStat("cpu 150 0 150 700 200 0 0 0 0 0\n")
busy, ok := Busy(a, b)
if !ok || busy != 50 {
t.Fatalf("%v %v", busy, ok)
}
if _, ok := Busy(b, b); ok {
t.Fatal("no time passed and a load was answered")
}
if _, err := ParseProcStat("intr 1 2"); err == nil {
t.Fatal("a file without the cpu line was read")
}
}
func switcherOn(t *testing.T) (*fake, *Switcher, *[]map[string]any) {
f := newFake(t)
f.onMains()
f.file("/proc/stat", "cpu 0 0 0 0 0 0 0 0\n")
var emitted []map[string]any
sw := NewSwitcher(f.machine(), func(_ string, body any) error {
emitted = append(emitted, body.(map[string]any))
return nil
})
return f, sw, &emitted
}
func TestStartingIsNotAReasonToSwitch(t *testing.T) {
f, sw, emitted := switcherOn(t)
sw.Evaluate(context.Background(), false)
if calls := f.callsLike("asusctl profile set"); len(calls) != 0 || len(*emitted) != 0 {
t.Fatalf("the first decision acted: %v %v", calls, *emitted)
}
}
func TestAChangeOfPowerSourceSwitchesOnceAndPublishesIt(t *testing.T) {
f, sw, emitted := switcherOn(t)
ctx := context.Background()
sw.Evaluate(ctx, false)
f.onBattery()
sw.Evaluate(ctx, false)
sw.Evaluate(ctx, true) // nothing changed: nothing done, and on battery nothing sampled
if calls := f.callsLike("asusctl profile set"); len(calls) != 1 || calls[0] != "asusctl profile set Quiet" {
t.Fatalf("%v", calls)
}
if len(*emitted) != 1 || (*emitted)[0]["profile"] != "Quiet" || (*emitted)[0]["from"] != "Balanced" {
t.Fatalf("%v", *emitted)
}
f.onMains()
sw.Evaluate(ctx, false)
if calls := f.callsLike("asusctl profile set"); len(calls) != 2 || calls[1] != "asusctl profile set Balanced" {
t.Fatalf("%v", calls)
}
}
func TestSustainedLoadOnMainsBoostsFromSamples(t *testing.T) {
f, sw, _ := switcherOn(t)
ctx := context.Background()
sw.Evaluate(ctx, false)
var user int
for i := 1; i <= SustainSamples; i++ {
user += 90
f.file("/proc/stat", "cpu "+itoa(user)+" 0 0 "+itoa(i*10)+" 0 0 0 0\n")
sw.Evaluate(ctx, true)
}
if !f.called("asusctl profile set Performance") {
t.Fatalf("%v", f.calls)
}
}
func TestAHoldKeepsTheProfileUntilItEndsAndAFailureIsTriedAgain(t *testing.T) {
f, sw, _ := switcherOn(t)
ctx := context.Background()
now := time.Now()
sw.now = func() time.Time { return now }
sw.Evaluate(ctx, false)
f.onBattery()
sw.Evaluate(ctx, false) // source change ends any hold; switches to Quiet
sw.Hold("Performance", time.Hour)
f.fails["asusctl profile set Balanced"] = errors.New("asusd is restarting")
f.onMains()
sw.Evaluate(ctx, false) // a change of source: the hold ends, the switch is attempted and fails
if r := sw.Report(); r.LastError == "" || r.Held != "" {
t.Fatalf("%+v", r)
}
delete(f.fails, "asusctl profile set Balanced")
sw.Evaluate(ctx, false)
if r := sw.Report(); r.LastApplied != "Balanced" || r.LastError != "" {
t.Fatalf("not tried again: %+v", r)
}
// A hold on the same source keeps a new decision from acting until it ends.
sw.Hold("Quiet", time.Hour)
sw.policy.Boosted = true
sw.Evaluate(ctx, false)
if f.called("asusctl profile set Performance") {
t.Fatal("switched during a hold")
}
now = now.Add(2 * time.Hour)
sw.Evaluate(ctx, false)
if !f.called("asusctl profile set Performance") {
t.Fatal("did not act once the hold ended")
}
}
func TestAsusdsSettingsAreSetOnlyWhereTheyDiffer(t *testing.T) {
f := newFake(t)
f.answers["asusctl battery info"] = "Current battery charge limit: 100%\n"
f.answers["asusctl profile get"] = "Active profile: Balanced\nAC profile Performance\nBattery profile Quiet\n"
sw := NewSwitcher(f.machine(), nil)
sw.AssertVendorSettings(context.Background())
if !f.called("asusctl battery limit 80") || !f.called("asusctl profile set -a Balanced") || f.called("asusctl profile set -b Quiet") {
t.Fatalf("%v", f.calls)
}
}
func TestTheSwitcherDoesNotActOnAnotherModel(t *testing.T) {
f := newFake(t)
f.file("/sys/class/dmi/id/product_family", "ROG Strix\n")
sw := NewSwitcher(f.machine(), nil)
done := make(chan struct{})
go func() { sw.Run(context.Background()); close(done) }()
select {
case <-done:
case <-time.After(2 * time.Second):
t.Fatal("the switcher ran on another model")
}
if r := sw.Report(); r.Running || r.Disabled == "" || len(f.calls) != 0 {
t.Fatalf("%+v %v", r, f.calls)
}
}
func TestOnlyPowerSupplyUeventsWake(t *testing.T) {
yes := []byte("change@/devices/LNXSYSTM:00/ACPI0003:00/power_supply/ACAD\x00ACTION=change\x00SUBSYSTEM=power_supply\x00POWER_SUPPLY_ONLINE=0\x00")
no := []byte("change@/devices/virtual/net/wlan0\x00ACTION=change\x00SUBSYSTEM=net\x00")
if !powerSupplyEvent(yes) || powerSupplyEvent(no) {
t.Fatal("the uevent filter")
}
}
func itoa(n int) string { return strconv.Itoa(n) }
@@ -1,168 +0,0 @@
package main
import (
"context"
"path"
"sort"
"strconv"
"strings"
)
// Sensor is one temperature, fan or power reading from hwmon.
type Sensor struct {
Chip string `json:"chip"`
Label string `json:"label"`
Value float64 `json:"value"`
}
// DGPU is the discrete GPU as the PCI bus and its driver see it.
type DGPU struct {
Address string `json:"pci_address"`
Runtime string `json:"runtime_status"`
Name string `json:"name,omitempty"`
TempC *float64 `json:"temp_c,omitempty"`
PowerW *float64 `json:"power_w,omitempty"`
PState string `json:"pstate,omitempty"`
Note string `json:"note,omitempty"`
}
// hwmon reads every hwmon reading of one kind: "temp" (°C), "fan" (RPM) or "power" (W).
func (m *Machine) hwmon(kind string) []Sensor {
var out []Sensor
for _, dir := range m.glob("/sys/class/hwmon/hwmon*") {
chip := m.read(dir + "/name")
inputs := m.glob(dir + "/" + kind + "*_input")
if kind == "power" {
inputs = append(inputs, m.glob(dir+"/power*_average")...)
}
for _, in := range inputs {
v, ok := m.readInt(in)
if !ok {
continue
}
base := path.Base(in)
stem := base[:strings.LastIndex(base, "_")]
label := m.read(dir + "/" + stem + "_label")
if label == "" {
label = base
} else if strings.HasSuffix(base, "_average") {
label += " (average)"
}
value := float64(v)
switch kind {
case "temp":
value = round1(value / 1000)
case "power":
value = round1(value / 1e6)
}
out = append(out, Sensor{Chip: chip, Label: label, Value: value})
}
}
sort.Slice(out, func(i, j int) bool {
if out[i].Chip != out[j].Chip {
return out[i].Chip < out[j].Chip
}
return out[i].Label < out[j].Label
})
return out
}
// dgpu finds the NVIDIA display controller and, only when it is already awake, asks its driver for
// its temperature and draw. **Asking wakes it**: nvidia-smi brings a suspended GPU out of D3, which
// is the power a reading of power draw should not cost.
func (m *Machine) dgpu(ctx context.Context) *DGPU {
for _, dir := range m.glob("/sys/bus/pci/devices/*") {
if m.read(dir+"/vendor") != "0x10de" || !strings.HasPrefix(m.read(dir+"/class"), "0x03") {
continue
}
g := &DGPU{Address: path.Base(dir), Runtime: m.read(dir + "/power/runtime_status")}
if g.Runtime != "active" {
g.Note = "the discrete GPU is " + g.Runtime + "; not woken to be read"
return g
}
out, err := m.Run(ctx, "nvidia-smi", "--query-gpu=name,temperature.gpu,power.draw,pstate", "--format=csv,noheader,nounits")
if err != nil {
g.Note = "nvidia-smi: " + err.Error()
return g
}
f := strings.Split(strings.TrimSpace(strings.SplitN(out, "\n", 2)[0]), ",")
if len(f) >= 4 {
g.Name = strings.TrimSpace(f[0])
if t, err := strconv.ParseFloat(strings.TrimSpace(f[1]), 64); err == nil {
g.TempC = &t
}
if w, err := strconv.ParseFloat(strings.TrimSpace(f[2]), 64); err == nil {
w = round1(w)
g.PowerW = &w
}
g.PState = strings.TrimSpace(f[3])
}
return g
}
return nil
}
// Thermals is what the thermals tool answers.
type Thermals struct {
Temperatures []Sensor `json:"temperatures_c"`
Fans []Sensor `json:"fans_rpm"`
DGPU *DGPU `json:"dgpu,omitempty"`
Profile string `json:"platform_profile,omitempty"`
Hottest *Sensor `json:"hottest,omitempty"`
}
func (m *Machine) Thermals(ctx context.Context) Thermals {
t := Thermals{Temperatures: m.hwmon("temp"), Fans: m.hwmon("fan"), DGPU: m.dgpu(ctx),
Profile: m.read("/sys/firmware/acpi/platform_profile")}
if t.Temperatures == nil {
t.Temperatures = []Sensor{}
}
if t.Fans == nil {
t.Fans = []Sensor{}
}
for i := range t.Temperatures {
if t.Hottest == nil || t.Temperatures[i].Value > t.Hottest.Value {
h := t.Temperatures[i]
t.Hottest = &h
}
}
return t
}
// PowerDraw is what the power-draw tool answers.
type PowerDraw struct {
Source Source `json:"source"`
BatteryW *float64 `json:"battery_w,omitempty"`
BatteryFlow string `json:"battery_flow,omitempty"`
CPUPackageW *float64 `json:"apu_package_w,omitempty"`
DGPU *DGPU `json:"dgpu,omitempty"`
Note string `json:"note"`
}
func (m *Machine) PowerDraw(ctx context.Context) PowerDraw {
p := PowerDraw{Source: PowerSource(m.Supplies()), DGPU: m.dgpu(ctx),
Note: "on battery, battery_w is what the whole machine draws; on mains it is only what the battery takes or gives"}
for _, b := range m.Batteries() {
if b.PowerW != nil {
w := *b.PowerW
p.BatteryW = &w
switch strings.ToLower(b.Status) {
case "discharging":
p.BatteryFlow = "discharging"
case "charging":
p.BatteryFlow = "charging"
default:
p.BatteryFlow = strings.ToLower(b.Status)
}
break
}
}
// The integrated GPU's hwmon reports the whole APU's package power (PPT) on this model.
for _, s := range m.hwmon("power") {
if s.Chip == "amdgpu" && s.Label == "PPT" {
w := s.Value
p.CPUPackageW = &w
}
}
return p
}
@@ -1,315 +0,0 @@
package main
import (
"context"
"fmt"
"math"
"strconv"
"strings"
"time"
stdio "git.novox.be/novox/mesh-sdk/go"
)
// Tools is the module's tools, over one machine and its switcher.
func Tools(m *Machine, sw *Switcher) []stdio.Tool {
ctx := context.Background
return []stdio.Tool{
{
Name: "zephyrus_brightness",
Description: "Read or set the internal panel's and the keyboard's backlight. With no argument, reads both. " +
"panel is a percentage (40) or a step (+5, -10), never below 1 %; keyboard is off, low, med, high, 0-3, + or -.",
Input: map[string]any{
"panel": map[string]any{"type": "string", "description": "percentage or step, e.g. 40, +5, -10"},
"keyboard": map[string]any{"type": "string", "description": "off, low, med, high, 0-3, + or -"},
},
Run: func(args map[string]any) (any, error) {
out := map[string]any{}
if p := str(args, "panel"); p != "" {
got, err := m.SetPanel(ctx(), p)
if err != nil {
return nil, err
}
out["panel"] = got
} else if got, err := m.PanelBrightness(); err == nil {
out["panel"] = got
} else {
out["panel_error"] = err.Error()
}
if k := str(args, "keyboard"); k != "" {
got, err := m.SetKeyboard(ctx(), k)
if err != nil {
return nil, err
}
out["keyboard"] = got
} else if got, err := m.KeyboardBrightness(); err == nil {
out["keyboard"] = got
} else {
out["keyboard_error"] = err.Error()
}
return out, nil
},
},
{
Name: "zephyrus_battery",
Description: "The battery: charge, energy, health (full against design), cycles, the charge limit, the power it gives or takes, and time left when discharging.",
Run: func(map[string]any) (any, error) {
return map[string]any{"source": PowerSource(m.Supplies()), "batteries": orEmpty(m.Batteries())}, nil
},
},
{
Name: "zephyrus_charge_limit",
Description: fmt.Sprintf("Read or set the battery charge limit through asusd. limit is 20-100; oneshot charges to full once "+
"and goes back to the limit. The module asserts %d %% again when its process next starts.", ChargeLimitPercent),
Input: map[string]any{
"limit": map[string]any{"type": "integer", "description": "20-100"},
"oneshot": map[string]any{"type": "boolean", "description": "charge to full once, keeping the limit"},
},
Run: func(args map[string]any) (any, error) { return ChargeLimitTool(ctx(), m, args) },
},
{
Name: "zephyrus_gpu_mode",
Description: "Read or set the hybrid GPU's mode through supergfxd: Integrated, Hybrid or AsusMuxDgpu as the machine supports. " +
"Answers the mode, the discrete GPU's power state, any pending mode and the action it waits for (a logout, a reboot), " +
"and whether asusd will switch it again on the next change of power source.",
Input: map[string]any{
"mode": map[string]any{"type": "string", "description": "a supported mode, e.g. Integrated or Hybrid"},
},
Run: func(args map[string]any) (any, error) { return GPUModeTool(ctx(), m, args) },
},
{
Name: "zephyrus_profile",
Description: "Read or set the platform profile (Quiet, Balanced, Performance) through asusd. A profile set here is held " +
fmt.Sprintf("for hold_minutes (default %d, 0 for none) before the module's switcher may move it; a change of power source ends the hold.", int(DefaultHold.Minutes())),
Input: map[string]any{
"profile": map[string]any{"type": "string", "enum": Profiles},
"hold_minutes": map[string]any{"type": "integer", "description": "how long the switcher leaves it (default 60, at most 1440)"},
},
Run: func(args map[string]any) (any, error) { return ProfileTool(ctx(), m, sw, args) },
},
{
Name: "zephyrus_thermals",
Description: "Every temperature and fan the hardware reports (°C, RPM), the hottest, the platform profile, and the discrete GPU's temperature when it is awake (it is not woken to be read).",
Run: func(map[string]any) (any, error) { return m.Thermals(ctx()), nil },
},
{
Name: "zephyrus_power_draw",
Description: "What the machine draws: the battery's flow in watts, the APU's package power, the discrete GPU's draw when awake, and the power source with the reason it was decided.",
Run: func(map[string]any) (any, error) { return m.PowerDraw(ctx()), nil },
},
{
Name: "zephyrus_profile_policy",
Description: "What the module's profile switcher would choose now and why: the power source, recent CPU load against the thresholds, " +
"the decision, the profile in force, any hold, what woke it, and what it asserted in asusd at start.",
Run: func(map[string]any) (any, error) { return PolicyTool(ctx(), m, sw), nil },
},
{
Name: "zephyrus_fan_curves",
Description: "The fan curves asusd holds for each profile (or one profile): per fan, eight points of temperature and duty.",
Input: map[string]any{
"profile": map[string]any{"type": "string", "enum": Profiles},
},
Run: func(args map[string]any) (any, error) { return FanCurvesTool(ctx(), m, args) },
},
{
Name: "zephyrus_keys",
Description: "Every custom key on the laptop: each triggerhappy trigger (the vendor keys that reach no X client), the module's own i3 lines " +
"(keys the firmware sends as ordinary presses, and what starts with the session), and the keys the firmware handles itself — " +
"what each runs and the file it is defined in. Warns when one key is bound in two trigger files, which fires it twice.",
Run: func(map[string]any) (any, error) { return m.Keys(), nil },
},
{
Name: "zephyrus_check",
Description: "Check what this module expects of the machine: the model, the vendor packages and daemons, the NVIDIA options in force, " +
"suspend and resume, the charge limit, one authority each over the profile and the GPU mode, and the predecessor's leftovers. Says what it did not check.",
Run: func(map[string]any) (any, error) { return m.Check(ctx(), sw), nil },
},
}
}
// ChargeLimitTool reads or sets the limit.
func ChargeLimitTool(ctx context.Context, m *Machine, args map[string]any) (any, error) {
out := map[string]any{"module_limit_percent": ChargeLimitPercent}
if v, given := args["limit"]; given && v != nil {
n, err := whole(v, "limit")
if err != nil {
return nil, err
}
if n < 20 || n > 100 {
return nil, fmt.Errorf("limit %d is outside 20-100", n)
}
if _, err := m.Run(ctx, "asusctl", "battery", "limit", strconv.Itoa(n)); err != nil {
return nil, vendor("asusctl", err)
}
out["set"] = n
}
if b, _ := args["oneshot"].(bool); b {
if _, err := m.Run(ctx, "asusctl", "battery", "oneshot"); err != nil {
return nil, vendor("asusctl", err)
}
out["oneshot"] = "charging to full once; the limit returns after"
}
if n, err := m.ChargeLimit(ctx); err == nil {
out["asusd_limit_percent"] = n
} else {
out["asusd_error"] = err.Error()
}
for _, b := range m.Batteries() {
if b.LimitPercent != nil {
out["kernel_limit_percent"] = *b.LimitPercent
}
}
return out, nil
}
// GPUModeTool reads or sets the GPU mode.
func GPUModeTool(ctx context.Context, m *Machine, args map[string]any) (any, error) {
g, err := m.GPU(ctx)
if err != nil {
return nil, err
}
out := map[string]any{}
if want := str(args, "mode"); want != "" {
mode := ""
for _, s := range g.Supported {
if strings.EqualFold(s, want) {
mode = s
}
}
if mode == "" {
return nil, fmt.Errorf("mode %q is not one this machine supports (%s)", want, strings.Join(g.Supported, ", "))
}
said, err := m.Run(ctx, "supergfxctl", "-m", mode)
if err != nil {
return nil, vendor("supergfxctl", err)
}
out["requested"] = mode
if s := strings.TrimSpace(said); s != "" {
out["supergfxctl_said"] = s
}
if g, err = m.GPU(ctx); err != nil {
return nil, err
}
}
out["gpu"] = g
if c := m.Asusd(); c != nil && (c.ACCommand != "" || c.BatteryCommand != "") {
out["asusd_switches_it"] = map[string]string{"on_ac": c.ACCommand, "on_battery": c.BatteryCommand,
"note": "asusd runs these on every change of power source, so a mode set here lasts until the next one"}
}
return out, nil
}
// ProfileTool reads or sets the profile.
func ProfileTool(ctx context.Context, m *Machine, sw *Switcher, args map[string]any) (any, error) {
out := map[string]any{}
if want := str(args, "profile"); want != "" {
p, err := canonicalProfile(want)
if err != nil {
return nil, err
}
hold := DefaultHold
if v, given := args["hold_minutes"]; given && v != nil {
n, err := whole(v, "hold_minutes")
if err != nil {
return nil, err
}
if n < 0 {
return nil, fmt.Errorf("hold_minutes must not be negative")
}
hold = time.Duration(min(n, 1440)) * time.Minute
}
if err := m.SetProfile(ctx, p); err != nil {
return nil, err
}
out["set"] = p
if sw != nil {
if until := sw.Hold(p, hold); !until.IsZero() {
out["held_until"] = until
}
}
}
state, err := m.Profile(ctx)
if err != nil {
return nil, err
}
out["profile"] = state
return out, nil
}
// PolicyTool reports the switcher and, independently of it, what the policy says now.
func PolicyTool(ctx context.Context, m *Machine, sw *Switcher) any {
out := map[string]any{
"thresholds": map[string]any{
"on_battery": ProfileOnBattery, "on_ac": ProfileOnAC, "under_load": ProfileUnderLoad,
"cpu_high_percent": CPUHighPercent, "cpu_low_percent": CPULowPercent,
"sample_every": SampleEvery.String(), "sustain_samples": SustainSamples, "relax_samples": RelaxSamples,
"set_by": "constants until settings exist (novox/hq issue 168)",
},
}
if sw != nil {
out["switcher"] = sw.Report()
} else {
var p Policy
out["decision_now"] = p.Decide(PowerSource(m.Supplies()))
}
if state, err := m.Profile(ctx); err == nil {
out["in_force"] = state
} else {
out["in_force_error"] = err.Error()
}
if pid, ok := m.predecessorProcess("auto-profile"); ok {
out["second_switcher"] = fmt.Sprintf("the predecessor's auto-profile still runs (pid %d) and overrides this every five seconds", pid)
}
return out
}
// FanCurvesTool reads asusd's fan curves.
func FanCurvesTool(ctx context.Context, m *Machine, args map[string]any) (any, error) {
profiles := Profiles
if want := str(args, "profile"); want != "" {
p, err := canonicalProfile(want)
if err != nil {
return nil, err
}
profiles = []string{p}
}
out := map[string]any{}
for _, p := range profiles {
said, err := m.Run(ctx, "asusctl", "fan-curve", "--mod-profile", strings.ToLower(p))
if err != nil {
return nil, vendor("asusctl", err)
}
out[p] = orEmpty(ParseFanCurves(said))
}
return out, nil
}
func str(args map[string]any, key string) string {
s, _ := args[key].(string)
return strings.TrimSpace(s)
}
// whole is an integer argument given as a JSON number or a numeric string.
func whole(v any, key string) (int, error) {
switch n := v.(type) {
case float64:
if n != math.Trunc(n) {
return 0, fmt.Errorf("%s must be a whole number, not %v", key, n)
}
return int(n), nil
case string:
i, err := strconv.Atoi(strings.TrimSpace(n))
if err != nil {
return 0, fmt.Errorf("%s must be a whole number, not %q", key, n)
}
return i, nil
}
return 0, fmt.Errorf("%s must be a whole number", key)
}
func orEmpty[T any](s []T) []T {
if s == nil {
return []T{}
}
return s
}
@@ -1,72 +0,0 @@
package main
import (
"bytes"
"context"
"fmt"
"syscall"
)
// The kernel announces every change of a power supply — an adapter plugged or pulled, a battery
// starting or stopping to discharge — as a uevent on a netlink socket that any account may listen
// on. That is the event the switcher reacts to: no daemon, no bus client, no polling.
//
// upower re-announces the same changes on the system bus, and listening there would need a D-Bus
// client in the bundle; udev's re-broadcast (netlink group 2) carries a libudev header. The kernel's
// own group (1) is the source both of them read.
// powerSupplyEvent says whether a uevent is about a power supply.
func powerSupplyEvent(msg []byte) bool {
for _, field := range bytes.Split(msg, []byte{0}) {
if bytes.Equal(field, []byte("SUBSYSTEM=power_supply")) {
return true
}
}
return false
}
// listenPowerSupply opens the kernel's uevent socket and sends on the channel for each power-supply
// event, never blocking: a burst of events is one wake-up. It stops when ctx ends.
func listenPowerSupply(ctx context.Context) (<-chan struct{}, error) {
fd, err := syscall.Socket(syscall.AF_NETLINK, syscall.SOCK_RAW|syscall.SOCK_CLOEXEC, syscall.NETLINK_KOBJECT_UEVENT)
if err != nil {
return nil, fmt.Errorf("opening the kernel's uevent socket: %w", err)
}
if err := syscall.Bind(fd, &syscall.SockaddrNetlink{Family: syscall.AF_NETLINK, Groups: 1}); err != nil {
syscall.Close(fd)
return nil, fmt.Errorf("joining the kernel's uevent group: %w", err)
}
events := make(chan struct{}, 1)
go func() {
<-ctx.Done()
syscall.Close(fd)
}()
go func() {
defer close(events)
buf := make([]byte, 64*1024)
for {
n, _, err := syscall.Recvfrom(fd, buf, 0)
if err != nil {
if err == syscall.EINTR || err == syscall.ENOBUFS {
// ENOBUFS: events were dropped. Treat it as one, since a dropped one may have
// been the adapter.
if err == syscall.ENOBUFS {
select {
case events <- struct{}{}:
default:
}
}
continue
}
return
}
if powerSupplyEvent(buf[:n]) {
select {
case events <- struct{}{}:
default:
}
}
}
}()
return events, nil
}
@@ -1,33 +0,0 @@
#!/bin/bash
# zephyrus-backlight + | - | PERCENT — step or set the internal panel's backlight, never below 1 %.
# Shipped by the mesh's asus-zephyrus-g14 module; edit the catalogue.
#
# The panel is the backlight beneath the eDP connector, not a name: in hybrid mode this model also
# registers the discrete GPU's backlight (nvidia_0), which moves nothing. Writable by the video group
# through the module's udev rule, so the vendor-key trigger needs no root.
set -u
STEP=5
dev=""
for d in /sys/class/backlight/*; do
[ -e "$d" ] || continue
case "$(readlink -f "$d")" in *-eDP-*) dev=$d; break ;; esac
done
if [ -z "$dev" ]; then
for d in /sys/class/backlight/*; do [ -e "$d" ] && { dev=$d; break; }; done
fi
[ -n "$dev" ] || { echo "zephyrus-backlight: no backlight" >&2; exit 1; }
cur=$(cat "$dev/brightness")
max=$(cat "$dev/max_brightness")
pct=$(( cur * 100 / max ))
case "${1:-}" in
+|up|Up) pct=$(( pct + STEP )) ;;
-|down|Down) pct=$(( pct - STEP )) ;;
''|*[!0-9]*) echo "usage: zephyrus-backlight + | - | PERCENT" >&2; exit 2 ;;
*) pct=$1 ;;
esac
(( pct < 1 )) && pct=1
(( pct > 100 )) && pct=100
new=$(( max * pct / 100 ))
(( new < 1 )) && new=1
printf '%s' "$new" >"$dev/brightness" || exit 1
exec "$(dirname "$0")/zephyrus-notify" 5555 "Brightness: ${pct}%"
@@ -1,41 +0,0 @@
#!/bin/bash
# zephyrus-display primary | order — the laptop's internal panel among the outputs.
# Shipped by the mesh's asus-zephyrus-g14 module; edit the catalogue.
#
# primary makes the internal panel X's primary output, where the bars' tray goes.
# order the display key (Fn+F9, which the firmware sends as Super+P): odd workspaces to the
# internal panel, even ones to the first external output, or all to the panel when it
# is alone. The predecessor's orden-workspaces.
#
# The panel is found, not named: the connected output whose name starts with eDP. The predecessor
# wrote eDP-1, which is what this model calls it today and not a promise.
set -u
here=$(dirname "$0")
panel=$(xrandr --query 2>/dev/null | awk '$2 == "connected" && $1 ~ /^eDP/ { print $1; exit }')
[ -n "$panel" ] || { echo "zephyrus-display: no internal panel connected" >&2; exit 1; }
case "${1:-}" in
primary)
exec xrandr --output "$panel" --primary
;;
order)
external=$(xrandr --listmonitors 2>/dev/null | awk -v p="$panel" 'NR > 1 && $NF != p { print $NF; exit }')
[ -n "$external" ] || external=$panel
# i3's workspace list, one object per workspace; num and output read from each. No jq: the
# fields are flat strings and numbers, and rect, the only nested value, holds neither name.
i3-msg -t get_workspaces 2>/dev/null | sed 's/},{"id"/}\n{"id"/g' |
while read -r ws; do
num=$(printf '%s' "$ws" | grep -o '"num":-\?[0-9]*' | cut -d: -f2)
out=$(printf '%s' "$ws" | grep -o '"output":"[^"]*"' | cut -d'"' -f4)
[ -n "$num" ] && [ "$num" -ge 0 ] || continue
if [ $((num % 2)) -eq 0 ]; then dest=$external; else dest=$panel; fi
[ "$out" = "$dest" ] && continue
i3-msg "workspace number $num; move workspace to output $dest" >/dev/null
done
if [ "$external" = "$panel" ]; then
"$here/zephyrus-notify" 7780 "Workspaces: all on the panel"
else
"$here/zephyrus-notify" 7780 "Workspaces: odd on the panel, even on $external"
fi
;;
*) echo "usage: zephyrus-display primary | order" >&2; exit 2 ;;
esac
@@ -1,20 +0,0 @@
#!/bin/bash
# zephyrus-kbd-notify — shows the keyboard backlight's level when it changes. The level itself is set
# by the firmware and asusd (Fn+F2/F3), which tell UPower; this only listens and notifies. Started once
# per session from the module's i3 fragment. Shipped by the mesh's asus-zephyrus-g14 module.
#
# One instance per session: i3 runs its `exec` lines again on an in-place restart, and the
# predecessor's listener ran twice after one, showing every change twice.
set -u
here=$(dirname "$0")
lock="${XDG_RUNTIME_DIR:-/run/user/$(id -u)}/zephyrus-kbd-notify.lock"
exec 9>"$lock"
flock -n 9 || exit 0
levels=(Off Low Med High)
dbus-monitor --system "type='signal',interface='org.freedesktop.UPower.KbdBacklight',member='BrightnessChanged'" 2>/dev/null |
while read -r line; do
if [[ $line =~ int32\ ([0-9]+) ]]; then
v=${BASH_REMATCH[1]}
"$here/zephyrus-notify" 5556 "Keyboard: ${levels[$v]:-$v}"
fi
done
@@ -1,27 +0,0 @@
#!/bin/bash
# zephyrus-media play-pause | next | previous — the media keys, through MPRIS (playerctl), with a short
# notification of what happened. Shipped by the mesh's asus-zephyrus-g14 module; edit the catalogue.
#
# From the predecessor's media-control, kept: the lock against a double fire (the vendor keys can
# repeat), and the notification. Dropped: its fallback to a media server's local API, which needed a
# token from a file of secrets (novox/hq research 027 question 2). A player that speaks MPRIS is
# reached; one that does not says so.
set -u
here=$(dirname "$0")
action=${1:-play-pause}
case "$action" in play-pause | next | previous) ;; *) echo "usage: zephyrus-media play-pause | next | previous" >&2; exit 2 ;; esac
lock="${XDG_RUNTIME_DIR:-/run/user/$(id -u)}/zephyrus-media.lock"
exec 9>"$lock"
flock -n 9 || exit 0
if ! playerctl status >/dev/null 2>&1; then
"$here/zephyrus-notify" 7777 "Media: no player"
exit 0
fi
playerctl "$action" 2>/dev/null
if [ "$action" = play-pause ]; then
sleep 0.2
[ "$(playerctl status 2>/dev/null)" = Playing ] && label=Playing || label=Paused
else
label=${action^}
fi
"$here/zephyrus-notify" 7777 "Media: $label"
@@ -1,12 +0,0 @@
#!/bin/bash
# zephyrus-notify ID SUMMARY — a short desktop notification that replaces the previous one with the
# same ID, through the session's notification service on its bus. busctl is the service manager's
# own client, so nothing is installed for it. Shipped by the mesh's asus-zephyrus-g14 module.
set -u
id=${1:-0}
summary=${2:-}
uid=$(id -u)
DBUS_SESSION_BUS_ADDRESS="unix:path=/run/user/${uid}/bus" \
busctl --user call org.freedesktop.Notifications /org/freedesktop/Notifications \
org.freedesktop.Notifications Notify susssasa{sv}i \
asus-zephyrus-g14 "$id" "" "$summary" "" 0 1 urgency y 0 1500 >/dev/null 2>&1 || true
@@ -1,46 +0,0 @@
#!/bin/bash
# zephyrus-session COMMAND [ARG...] — run a command in the operator's graphical session from outside
# it: from a vendor-key trigger, which triggerhappy runs as the operator's account but with none of
# the session's environment. Shipped by the mesh's asus-zephyrus-g14 module; edit the catalogue.
#
# What it replaces: the predecessor's `as-user`, which triggerhappy ran as root and which `su`-ed to
# a named person with a hard-coded user id and display, and sourced a file of secrets on the way.
# Here the account is whoever runs it, the bus is that account's, and the display is the one the
# account's own session uses. Nothing is sourced.
set -u
uid=$(id -u)
export XDG_RUNTIME_DIR="/run/user/${uid}"
export DBUS_SESSION_BUS_ADDRESS="unix:path=${XDG_RUNTIME_DIR}/bus"
home=$(getent passwd "$uid" | cut -d: -f6)
[ -n "$home" ] && export HOME="$home"
# The session's own window manager says best which display and authority the session uses: read
# them from its environment, as the desktop modules' session finder does. Then logind, then any
# process of this account that has a display.
from_environ() {
env=$(tr '\0' '\n' <"/proc/$1/environ" 2>/dev/null) || return 1
d=$(printf '%s\n' "$env" | sed -n 's/^DISPLAY=//p' | head -n1)
[ -n "$d" ] || return 1
export DISPLAY="$d"
a=$(printf '%s\n' "$env" | sed -n 's/^XAUTHORITY=//p' | head -n1)
[ -n "$a" ] && export XAUTHORITY="$a"
return 0
}
if [ -z "${DISPLAY:-}" ]; then
for pid in $(pgrep -xu "$uid" i3 2>/dev/null); do
from_environ "$pid" && break
done
fi
if [ -z "${DISPLAY:-}" ]; then
for s in $(loginctl list-sessions --no-legend 2>/dev/null | awk -v u="$uid" '$2 == u { print $1 }'); do
d=$(loginctl show-session "$s" -p Display --value 2>/dev/null)
if [ -n "$d" ]; then export DISPLAY="$d"; break; fi
done
fi
if [ -z "${DISPLAY:-}" ]; then
for pid in $(pgrep -u "$uid" 2>/dev/null); do
from_environ "$pid" && break
done
fi
: "${XAUTHORITY:=${HOME}/.Xauthority}"
export XAUTHORITY
exec "$@"
@@ -1,29 +0,0 @@
#!/bin/bash
# zephyrus-touchpad reset | toggle — apply the touchpad's settings again, or switch it on or off.
# Shipped by the mesh's asus-zephyrus-g14 module; edit the catalogue.
#
# The settings themselves are an X input class (/etc/X11/xorg.conf.d/30-asus-zephyrus-g14-touchpad.conf),
# which X applies every time the device appears — after a resume too, which is what the predecessor's
# sleep hook existed for. This is the manual form, bound to the touchpad key.
set -u
here=$(dirname "$0")
name=$("$here/zephyrus-session" xinput list --name-only 2>/dev/null | grep -m1 -i 'touchpad')
[ -n "$name" ] || { echo "zephyrus-touchpad: no touchpad in this session" >&2; exit 1; }
x() { "$here/zephyrus-session" xinput "$@"; }
case "${1:-reset}" in
reset)
x set-prop "$name" "libinput Tapping Enabled" 1
x set-prop "$name" "libinput Natural Scrolling Enabled" 1
x set-prop "$name" "libinput Accel Speed" 0.15
x enable "$name"
"$here/zephyrus-session" "$here/zephyrus-notify" 7779 "Touchpad: reset"
;;
toggle)
if x list-props "$name" | grep -q 'Device Enabled ([0-9]*):[[:space:]]*1'; then
x disable "$name"; "$here/zephyrus-session" "$here/zephyrus-notify" 7779 "Touchpad: off"
else
x enable "$name"; "$here/zephyrus-session" "$here/zephyrus-notify" 7779 "Touchpad: on"
fi
;;
*) echo "usage: zephyrus-touchpad reset | toggle" >&2; exit 2 ;;
esac
-5
View File
@@ -1,5 +0,0 @@
module asuszephyrusg14
go 1.22
require git.novox.be/novox/mesh-sdk/go v0.1.6
-2
View File
@@ -1,2 +0,0 @@
git.novox.be/novox/mesh-sdk/go v0.1.6 h1:9qzdYONYbJdWcu6sxQcq9v1LI0JxcfkiKYkMUzJSkVQ=
git.novox.be/novox/mesh-sdk/go v0.1.6/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY=

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