diff --git a/modules/xclip/README.md b/modules/xclip/README.md new file mode 100644 index 0000000..f31253e --- /dev/null +++ b/modules/xclip/README.md @@ -0,0 +1,68 @@ +# xclip + +The command-line X clipboard, as a module (novox/hq research 026/04: "`xclip` is a module of its own, +a package and nothing else. It is the tool scripts depend on, and a module that needs it requires +it"; to-be 42 phase 2 step 7). + +## Owns + +| what | where | +|---|---| +| `xclip` | package `xclip` (official repositories) | + +Nothing else. The clipboard's history is the clipboard manager's (`node-clipboard`, a later module). +This one is the plain clipboard, without a manager. + +## Improves + +- **A declared dependency.** The window manager's screenshot script copies through `xclip` on both + workstations, and nothing said it needed it. A module that ships such a script requires this one. +- **The clipboard from the mesh.** Text can be put on the operator's clipboard from any machine, for + example a command or a link prepared elsewhere, and read back. That is safe with a clipboard manager + too: the manager takes what `xclip` offers into its history. + +## Reaching the operator's session + +The node's tool runtime is a system service running as the operator account. It is given no session +words: no `DISPLAY`, no `XAUTHORITY`. Measured on both workstations on 2026-10-04: + +- the runtime runs as the account, in the machine's own mount namespace, with no private `/tmp`; +- the X server's socket is `/tmp/.X11-unix/X1`; +- the cookie is in `~/.Xauthority`, readable by the account; +- the window manager's environment names both. + +So a child of the runtime **can** reach the session. `session.go` finds it in this order: + +1. the process's own `DISPLAY`; +2. else the `DISPLAY` and `XAUTHORITY` of the account's running processes, read from + `/proc//environ` (the window manager's by preference), whose socket exists; +3. else the only X socket, with `~/.Xauthority`. + +Checked from a shell with both variables unset: it found `:1` through the window manager's process, +and the server answered. With no session (nobody logged in to the desktop), every tool answers that no +graphical session of the account is running, and runs nothing. A server that refuses the cookie is +named with the display and how it was found. + +**What it does not cover.** A Wayland session: there `wl-clipboard` is the tool, and a Wayland module +carries it (research 026/04). Also, a copy is held by `xclip`'s own background process, a child of the +tool bundle. If the runtime restarts the bundle before another program takes the selection, the copied +text is gone. + +## Tools + +All answer JSON. `(r)` reads; `(d)` acts in the operator's session. + +| tool | what | +|---|---| +| `xclip_copy` (d) | put text (at most 1 MiB) on the clipboard, primary or secondary selection, as a given type (default UTF-8 text) | +| `xclip_paste` (r) | what a selection holds: text, or base64 for a type that is not text; `empty` when there is nothing; cut at 256 KiB | +| `xclip_targets` (r) | the types a selection is offered as, without reading it | +| `xclip_session` (r) | the session the tools reach and how it was found, or why there is none, and whether the server answers | + +## What changes when it is assigned + +Nothing on disk on either workstation: `xclip` is installed explicitly on both. + +## Leaves as found + +The scripts that call `xclip` (the window manager's screenshot script), which are their own modules'. diff --git a/modules/xclip/cmd/xclip-tools/kit.go b/modules/xclip/cmd/xclip-tools/kit.go new file mode 100644 index 0000000..adc5aac --- /dev/null +++ b/modules/xclip/cmd/xclip-tools/kit.go @@ -0,0 +1,352 @@ +package main + +// kit.go is the same file in each of the workstations' tool bundles (fonts, docker-compose, snapd, +// flatpak, cups, bluetooth, xclip, dmenu): how a tool runs a command, escalates, bounds what it +// keeps, and names a failure. A module is built from its own directory, so the file is copied rather +// than shared; a change to one copy is made to all eight. +// +// The rules it holds (novox/hq research 026/05, to-be 38 WP4): +// - the node's tool runtime runs as the operator account, not root (ADR 0175 §4); a command that +// needs root goes through `sudo -n`, never a prompt, and a refusal is named as such; +// - one command gets 20 s, below the runtime's 30 s call limit, and is ended with everything it +// started when it takes longer; +// - each stream is kept to 256 KiB, and the answer says when it was cut; +// - a failure is an error with what went wrong in it, never an empty answer. + +import ( + "bytes" + "context" + "errors" + "fmt" + "io" + "os" + "os/exec" + "strings" + "syscall" + "time" +) + +// Bounds every command is held to. +const ( + CallTimeout = 20 * time.Second + MostOutput = 256 << 10 +) + +// Cmd is one command a tool runs. +type Cmd struct { + Name string + Args []string + // Stdin is written to the command's standard input when not empty. + Stdin string + // Env is added to this process's own environment. + Env []string + // Root says the command needs root: it is run through `sudo -n` when this process is not root. + Root bool + // Timeout replaces CallTimeout; only a background job (jobs.go) asks for longer. + Timeout time.Duration + // Detached is for a program that forks a child which outlives it, as xclip does to keep the + // selection: its streams go to files, because a pipe the child inherits would hold the call open + // until the child exits. + Detached bool +} + +// Result is what a command did. +type Result struct { + Stdout string `json:"stdout"` + Stderr string `json:"stderr"` + Status int `json:"status"` + // Error is why it did not run to an answer: "not-found" when the program is not there, + // "timeout" when it was ended for taking too long, else the spawn error. + Error string `json:"error,omitempty"` + Truncated bool `json:"truncated,omitempty"` +} + +// Runner runs a command. Tests replace it; nothing else does. +type Runner func(Cmd) Result + +var ( + run Runner = execRun + euid = os.Geteuid +) + +// argv is the command as it is run: through sudo without a prompt when it needs root and this +// process is not root. +func argv(c Cmd) (string, []string) { + if c.Root && euid() != 0 { + return "sudo", append([]string{"-n", c.Name}, c.Args...) + } + return c.Name, c.Args +} + +// bounded keeps the first MostOutput bytes written to it and notes that more came. +type bounded struct { + b bytes.Buffer + cut bool +} + +func (w *bounded) Write(p []byte) (int, error) { + room := MostOutput - w.b.Len() + if room <= 0 { + w.cut = w.cut || len(p) > 0 + return len(p), nil + } + if len(p) > room { + w.b.Write(p[:room]) + w.cut = true + return len(p), nil + } + return w.b.Write(p) +} + +func execRun(c Cmd) Result { + timeout := c.Timeout + if timeout <= 0 { + timeout = CallTimeout + } + ctx, cancel := context.WithTimeout(context.Background(), timeout) + defer cancel() + name, args := argv(c) + cmd := exec.CommandContext(ctx, name, args...) + cmd.Env = append(append(os.Environ(), "LC_ALL=C"), c.Env...) + if !c.Detached { + // Its own process group, so that ending it on a timeout ends what it started too. + cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} + cmd.Cancel = func() error { + if cmd.Process != nil { + _ = syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL) + } + return nil + } + } + cmd.WaitDelay = 2 * time.Second + if c.Stdin != "" { + cmd.Stdin = strings.NewReader(c.Stdin) + } + var out, errs bounded + var outFile, errFile *os.File + if c.Detached { + var err error + if outFile, err = os.CreateTemp("", "mesh-tool-out-*"); err != nil { + return Result{Status: 127, Error: err.Error()} + } + defer os.Remove(outFile.Name()) + defer outFile.Close() + if errFile, err = os.CreateTemp("", "mesh-tool-err-*"); err != nil { + return Result{Status: 127, Error: err.Error()} + } + defer os.Remove(errFile.Name()) + defer errFile.Close() + cmd.Stdout, cmd.Stderr = outFile, errFile + } else { + cmd.Stdout, cmd.Stderr = &out, &errs + } + err := cmd.Run() + if c.Detached { + for _, f := range []struct { + file *os.File + into *bounded + }{{outFile, &out}, {errFile, &errs}} { + if _, e := f.file.Seek(0, io.SeekStart); e == nil { + _, _ = io.Copy(f.into, f.file) + } + } + } + r := Result{Stdout: out.b.String(), Stderr: errs.b.String(), Truncated: out.cut || errs.cut} + var exit *exec.ExitError + switch { + case err == nil: + case ctx.Err() == context.DeadlineExceeded: + r.Status, r.Error = 124, "timeout" + case errors.Is(err, exec.ErrNotFound) || errors.Is(err, os.ErrNotExist): + r.Status, r.Error = 127, "not-found" + case errors.As(err, &exit): + r.Status = exit.ExitCode() + default: + r.Status, r.Error = 127, err.Error() + } + return r +} + +// call runs a command and answers its result, or an error naming what went wrong. +func call(c Cmd) (Result, error) { + r := run(c) + if r.Status == 0 && r.Error == "" { + return r, nil + } + return r, failure(c, r) +} + +// failure names how a command failed: not installed, refused escalation, too slow, or its exit +// status with the end of what it said. +func failure(c Cmd, r Result) error { + program, _ := argv(c) + switch { + case r.Error == "not-found" && program == "sudo": + return fmt.Errorf("%s needs root, and sudo is not installed here for the runtime's account to escalate with", c.Name) + case r.Error == "not-found": + if hint, ok := providedBy[c.Name]; ok { + return fmt.Errorf("%s is not installed on this machine (%s)", c.Name, hint) + } + return fmt.Errorf("%s is not installed on this machine", c.Name) + case r.Error == "timeout": + limit := c.Timeout + if limit <= 0 { + limit = CallTimeout + } + return fmt.Errorf("%s gave no answer within %s and was ended", c.Name, limit) + case r.Error != "": + return fmt.Errorf("%s did not run: %s", c.Name, r.Error) + case program == "sudo" && strings.Contains(r.Stderr, "command not found"): + if hint, ok := providedBy[c.Name]; ok { + return fmt.Errorf("%s is not installed on this machine (%s)", c.Name, hint) + } + return fmt.Errorf("%s is not installed on this machine", c.Name) + case program == "sudo" && strings.HasPrefix(strings.TrimSpace(r.Stderr), "sudo:"): + return fmt.Errorf("%s needs root, and sudo -n refused the runtime's account: %s (the escalation is the sudo module's to declare)", + c.Name, firstLine(r.Stderr)) + } + said := tail(strings.TrimSpace(r.Stderr), 2000) + if said == "" { + said = tail(strings.TrimSpace(r.Stdout), 2000) + } + if said == "" { + said = "and said nothing" + } + return fmt.Errorf("%s %s exited %d: %s", c.Name, strings.Join(c.Args, " "), r.Status, said) +} + +func firstLine(s string) string { + s = strings.TrimSpace(s) + if i := strings.IndexByte(s, '\n'); i >= 0 { + return s[:i] + } + return s +} + +func tail(s string, n int) string { + if len(s) <= n { + return s + } + return "…" + s[len(s)-n:] +} + +// lines are a command's output lines, blank ones dropped. +func lines(s string) []string { + out := []string{} + for _, l := range strings.Split(s, "\n") { + if strings.TrimSpace(l) != "" { + out = append(out, strings.TrimRight(l, "\r")) + } + } + return out +} + +// Arguments, read the way a tool's JSON arguments arrive. + +func text(args map[string]any, key string) (string, error) { + v, ok := args[key] + if !ok || v == nil { + return "", fmt.Errorf("%s is required", key) + } + s, ok := v.(string) + if !ok { + return "", fmt.Errorf("%s must be a string", key) + } + if strings.TrimSpace(s) == "" { + return "", fmt.Errorf("%s must not be empty", key) + } + return s, nil +} + +func optText(args map[string]any, key, def string) (string, error) { + v, ok := args[key] + if !ok || v == nil { + return def, nil + } + s, ok := v.(string) + if !ok { + return "", fmt.Errorf("%s must be a string", key) + } + if strings.TrimSpace(s) == "" { + return def, nil + } + return s, nil +} + +// optWhole reads a whole number, defaulted, refused below least and held to most. +func optWhole(args map[string]any, key string, def, least, most int) (int, error) { + v, ok := args[key] + if !ok || v == nil { + return def, nil + } + f, ok := v.(float64) + if !ok { + if i, isInt := v.(int); isInt { + f = float64(i) + } else { + return 0, fmt.Errorf("%s must be a number", key) + } + } + if f != float64(int(f)) { + return 0, fmt.Errorf("%s must be a whole number", key) + } + n := int(f) + if n < least { + return 0, fmt.Errorf("%s must be at least %d", key, least) + } + if n > most { + n = most + } + return n, nil +} + +func optFlag(args map[string]any, key string, def bool) (bool, error) { + v, ok := args[key] + if !ok || v == nil { + return def, nil + } + b, ok := v.(bool) + if !ok { + return false, fmt.Errorf("%s must be true or false", key) + } + return b, nil +} + +func optList(args map[string]any, key string) ([]string, error) { + v, ok := args[key] + if !ok || v == nil { + return nil, nil + } + items, ok := v.([]any) + if !ok { + return nil, fmt.Errorf("%s must be a list of strings", key) + } + out := make([]string, 0, len(items)) + for _, it := range items { + s, ok := it.(string) + if !ok || strings.TrimSpace(s) == "" { + return nil, fmt.Errorf("%s must be a list of non-empty strings", key) + } + out = append(out, s) + } + return out, nil +} + +// oneOf refuses a value outside a closed set. +func oneOf(key, value string, allowed ...string) error { + for _, a := range allowed { + if value == a { + return nil + } + } + return fmt.Errorf("%s must be one of %s, not %q", key, strings.Join(allowed, ", "), value) +} + +// plainName refuses a name that could be read as an option or carries a path or a space: package, +// snap, application and printer names never do. +func plainName(key, value string) error { + if strings.HasPrefix(value, "-") || strings.ContainsAny(value, " \t\n/\\") { + return fmt.Errorf("%s %q is not a plain name", key, value) + } + return nil +} diff --git a/modules/xclip/cmd/xclip-tools/kit_test.go b/modules/xclip/cmd/xclip-tools/kit_test.go new file mode 100644 index 0000000..c5d3557 --- /dev/null +++ b/modules/xclip/cmd/xclip-tools/kit_test.go @@ -0,0 +1,147 @@ +package main + +// Tests of kit.go, the same in each workstation module. + +import ( + "strings" + "testing" + "time" +) + +// fake records the commands asked and answers each from a function of the command line. +type fake struct { + asked []Cmd + answer func(line string, c Cmd) Result +} + +func (f *fake) runner() Runner { + return func(c Cmd) Result { + f.asked = append(f.asked, c) + name, args := argv(c) + line := strings.TrimSpace(name + " " + strings.Join(args, " ")) + if f.answer == nil { + return Result{} + } + return f.answer(line, c) + } +} + +func (f *fake) lines() []string { + out := []string{} + for _, c := range f.asked { + name, args := argv(c) + out = append(out, strings.TrimSpace(name+" "+strings.Join(args, " "))) + } + return out +} + +// using installs a fake runner and a non-root uid for one test. +func using(t *testing.T, answer func(line string, c Cmd) Result) *fake { + t.Helper() + f := &fake{answer: answer} + wasRun, wasUID := run, euid + run, euid = f.runner(), func() int { return 1000 } + t.Cleanup(func() { run, euid = wasRun, wasUID }) + return f +} + +func ok(stdout string) Result { return Result{Stdout: stdout} } + +func TestKitAnActThatNeedsRootGoesThroughSudoWithoutAPromptUnlessAlreadyRoot(t *testing.T) { + was := euid + defer func() { euid = was }() + euid = func() int { return 1000 } + if name, args := argv(Cmd{Name: "x", Args: []string{"a"}, Root: true}); name != "sudo" || strings.Join(args, " ") != "-n x a" { + t.Fatalf("not root: %s %v", name, args) + } + if name, _ := argv(Cmd{Name: "x"}); name != "x" { + t.Fatalf("a read is run as the account: %s", name) + } + euid = func() int { return 0 } + if name, _ := argv(Cmd{Name: "x", Root: true}); name != "x" { + t.Fatalf("as root no sudo: %s", name) + } +} + +func TestKitAFailureIsNamedByHowItFailed(t *testing.T) { + was := euid + defer func() { euid = was }() + euid = func() int { return 1000 } + cases := []struct { + c Cmd + r Result + want string + }{ + {Cmd{Name: "nothere"}, Result{Status: 127, Error: "not-found"}, "not installed"}, + {Cmd{Name: "x", Root: true}, Result{Status: 127, Error: "not-found"}, "sudo is not installed"}, + {Cmd{Name: "x", Root: true}, Result{Status: 1, Stderr: "sudo: a password is required\n"}, "sudo -n refused"}, + {Cmd{Name: "x", Root: true}, Result{Status: 1, Stderr: "sudo: x: command not found\n"}, "x is not installed"}, + {Cmd{Name: "x"}, Result{Status: 124, Error: "timeout"}, "within 20s"}, + {Cmd{Name: "x", Args: []string{"y"}}, Result{Status: 3, Stderr: "boom\n"}, "x y exited 3: boom"}, + {Cmd{Name: "x"}, Result{Status: 3}, "said nothing"}, + } + for _, k := range cases { + err := failure(k.c, k.r) + if err == nil || !strings.Contains(err.Error(), k.want) { + t.Errorf("%+v: %v, want %q", k.r, err, k.want) + } + } +} + +func TestKitOutputIsBoundedAndSaysSo(t *testing.T) { + var w bounded + big := strings.Repeat("a", MostOutput+10) + n, _ := w.Write([]byte(big)) + if n != len(big) || w.b.Len() != MostOutput || !w.cut { + t.Fatalf("kept %d of %d, cut %v", w.b.Len(), len(big), w.cut) + } +} + +func TestKitTheRealRunnerRunsEndsAndReportsAMissingProgram(t *testing.T) { + r := execRun(Cmd{Name: "sh", Args: []string{"-c", "echo out; echo err >&2; exit 3"}}) + if r.Status != 3 || strings.TrimSpace(r.Stdout) != "out" || strings.TrimSpace(r.Stderr) != "err" { + t.Fatalf("%+v", r) + } + r = execRun(Cmd{Name: "sh", Args: []string{"-c", "sleep 5 & sleep 5"}, Timeout: 200 * time.Millisecond}) + if r.Error != "timeout" { + t.Fatalf("a slow command: %+v", r) + } + r = execRun(Cmd{Name: "no-such-program-anywhere"}) + if r.Error != "not-found" { + t.Fatalf("a missing program: %+v", r) + } + r = execRun(Cmd{Name: "cat", Stdin: "given"}) + if r.Stdout != "given" { + t.Fatalf("stdin: %+v", r) + } + start := time.Now() + r = execRun(Cmd{Name: "sh", Args: []string{"-c", "echo kept; (sleep 3 &) ; exit 0"}, Detached: true}) + if r.Status != 0 || strings.TrimSpace(r.Stdout) != "kept" || time.Since(start) > 2*time.Second { + t.Fatalf("a detached command returns when it exits, not when its child does: %+v after %s", r, time.Since(start)) + } +} + +func TestKitArgumentsAreReadStrictly(t *testing.T) { + args := map[string]any{"s": "x", "n": float64(5), "f": 1.5, "b": true, "l": []any{"a", "b"}} + if _, err := text(args, "missing"); err == nil { + t.Error("a missing required string") + } + if n, _ := optWhole(args, "n", 1, 1, 3); n != 3 { + t.Errorf("held to most: %d", n) + } + if _, err := optWhole(args, "n", 1, 6, 9); err == nil { + t.Error("below least") + } + if _, err := optWhole(args, "f", 1, 0, 9); err == nil { + t.Error("a fraction") + } + if l, _ := optList(args, "l"); len(l) != 2 { + t.Errorf("list: %v", l) + } + if b, _ := optFlag(args, "b", false); !b { + t.Error("flag") + } + if err := plainName("name", "--all"); err == nil { + t.Error("an option as a name") + } +} diff --git a/modules/xclip/cmd/xclip-tools/main.go b/modules/xclip/cmd/xclip-tools/main.go new file mode 100644 index 0000000..07c8fc0 --- /dev/null +++ b/modules/xclip/cmd/xclip-tools/main.go @@ -0,0 +1,102 @@ +// The xclip module's tools (novox/hq research 026/04, 026/05): the plain X clipboard without a +// manager. Copy puts text on a selection, paste reads one, targets says what a selection offers. A Go +// bundle the node's runtime launches over stdio (ADR 0188, ADR 0193). It runs as the operator account +// and reaches the account's X session as session.go finds it; with no session, every tool says so. +package main + +import ( + "fmt" + "os" + + stdio "git.novox.be/novox/mesh-sdk/go" +) + +var providedBy = map[string]string{ + "xclip": "the xclip package, which this module installs", +} + +func main() { + if err := stdio.Serve("", tools()); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } +} + +var selectionArg = map[string]any{"type": "string", "enum": Selections, "description": "which selection: clipboard (default), primary or secondary"} + +func selectionOf(args map[string]any) (string, error) { + s, err := optText(args, "selection", "clipboard") + if err != nil { + return "", err + } + return s, oneOf("selection", s, Selections...) +} + +func tools() []stdio.Tool { + return []stdio.Tool{ + { + Name: "xclip_copy", + Description: "Put text on the operator's clipboard (or the primary or secondary selection), as a given type " + + "(default plain UTF-8 text). Needs the operator's graphical session. (d)", + Input: map[string]any{ + "text": map[string]any{"type": "string", "description": "what to copy, at most 1 MiB"}, + "selection": selectionArg, + "type": map[string]any{"type": "string", "description": "the content's type, such as text/html (default UTF8_STRING)"}, + }, + Run: func(args map[string]any) (any, error) { + t, ok := args["text"].(string) + if !ok { + return nil, fmt.Errorf("text is required, as a string (it may be empty)") + } + sel, err := selectionOf(args) + if err != nil { + return nil, err + } + typ, err := optText(args, "type", "") + if err != nil { + return nil, err + } + return Copy(t, sel, typ) + }, + }, + { + Name: "xclip_paste", + Description: "What the operator's clipboard (or another selection) holds now, as text, or as base64 for a " + + "type that is not text. Cut at 256 KiB. Needs the operator's graphical session. (r)", + Input: map[string]any{ + "selection": selectionArg, + "type": map[string]any{"type": "string", "description": "the type to ask for, such as text/html or image/png (default UTF8_STRING)"}, + }, + Run: func(args map[string]any) (any, error) { + sel, err := selectionOf(args) + if err != nil { + return nil, err + } + typ, err := optText(args, "type", "") + if err != nil { + return nil, err + } + return Paste(sel, typ) + }, + }, + { + Name: "xclip_targets", + Description: "The types a selection is offered as right now (text, HTML, an image…), without reading the content. (r)", + Input: map[string]any{"selection": selectionArg}, + Run: func(args map[string]any) (any, error) { + sel, err := selectionOf(args) + if err != nil { + return nil, err + } + return Targets(sel) + }, + }, + { + Name: "xclip_session", + Description: "Which X session the clipboard tools reach and how it was found, or why there is none; and " + + "whether the X server answers. (r)", + Input: map[string]any{}, + Run: func(map[string]any) (any, error) { return SessionCheck() }, + }, + } +} diff --git a/modules/xclip/cmd/xclip-tools/manifest_kit_test.go b/modules/xclip/cmd/xclip-tools/manifest_kit_test.go new file mode 100644 index 0000000..3e675b4 --- /dev/null +++ b/modules/xclip/cmd/xclip-tools/manifest_kit_test.go @@ -0,0 +1,107 @@ +package main + +// manifest_kit_test.go is the same file in each workstation module: it reads the module's +// definition so the module's own tests can hold it to what it says. + +import ( + "encoding/json" + "os" + "path/filepath" + "sort" + "strings" + "testing" +) + +type manifest struct { + Module string `json:"module"` + Capabilities []string `json:"capabilities"` + Claims []any `json:"claims"` + Seats []any `json:"seats"` + Tools []string `json:"tools"` + Resources []map[string]any `json:"resources"` + Build struct { + Artifacts []map[string]any `json:"artifacts"` + } `json:"build"` +} + +func readManifest(t *testing.T) manifest { + t.Helper() + 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.Fatalf("module.json: %v", err) + } + return m +} + +func (m manifest) resource(id string) map[string]any { + for _, r := range m.Resources { + if r["id"] == id { + return r + } + } + return nil +} + +// packages are the packages the module installs, sorted. +func (m manifest) packages() []string { + out := []string{} + for _, r := range m.Resources { + if r["type"] == "package" && r["absent"] != true { + out = append(out, r["package"].(string)) + } + } + sort.Strings(out) + return out +} + +// services are the units the module declares, by unit name. +func (m manifest) services() map[string]map[string]any { + out := map[string]map[string]any{} + for _, r := range m.Resources { + if r["type"] == "service" { + out[r["unit"].(string)] = r + } + } + return out +} + +// holdsTheBundle holds the manifest to the Go bundle this directory builds: every tool registered +// is listed and nothing else, each named _…, and the artifact builds this command. +func holdsTheBundle(t *testing.T, m manifest, prefix string) { + t.Helper() + registered := []string{} + for _, tool := range tools() { + registered = append(registered, tool.Name) + if !strings.HasPrefix(tool.Name, prefix+"_") { + t.Errorf("tool %s is not named %s_…", tool.Name, prefix) + } + if tool.Description == "" || tool.Run == nil || tool.Input == nil { + t.Errorf("tool %s is not described, runnable and given an input schema", tool.Name) + } + } + if strings.Join(registered, ",") != strings.Join(m.Tools, ",") { + t.Errorf("registered %v, listed %v", registered, m.Tools) + } + if len(m.Build.Artifacts) != 1 { + t.Fatalf("one artifact, got %d", len(m.Build.Artifacts)) + } + cwd, _ := os.Getwd() + binary := filepath.Base(cwd) + a := m.Build.Artifacts[0] + want := map[string]any{"kind": "bundle", "language": "go", "system": "arch", "from": "cmd/" + binary, "binary": binary} + for k, v := range want { + if a[k] != v { + t.Errorf("artifact %s = %v, want %v", k, a[k], v) + } + } + if loads, _ := a["loads"].([]any); len(loads) != 1 || loads[0] != binary { + t.Errorf("artifact loads %v, want [%s]", a["loads"], binary) + } + if m.Claims != nil || m.Seats != nil { + t.Errorf("claims %v, seats %v: this module holds no seat", m.Claims, m.Seats) + } +} diff --git a/modules/xclip/cmd/xclip-tools/session.go b/modules/xclip/cmd/xclip-tools/session.go new file mode 100644 index 0000000..43ba119 --- /dev/null +++ b/modules/xclip/cmd/xclip-tools/session.go @@ -0,0 +1,177 @@ +package main + +// session.go is the same file in the bundles whose tools act in the operator's graphical session +// (xclip, dmenu): how a process the node's tool runtime launched reaches that session. +// +// The runtime is a system service running as the operator account (novox/hq ADR 0175 §4), in the +// machine's own mount namespace, and is given no session words: no DISPLAY, no XAUTHORITY. An X +// server accepts a client that names its display and presents the cookie in the authority file, and +// both are the account's: the display's socket is in /tmp/.X11-unix, and the cookie file is +// readable by the account. So the session is found, not configured: +// +// 1. the process's own DISPLAY, when the runtime happens to have one; +// 2. else the DISPLAY and XAUTHORITY of the account's own running processes, read from +// /proc//environ (the window manager's, by preference), whose socket exists; +// 3. else the only X socket there is, with the authority file in the account's home. +// +// When none is found the tool says that no graphical session of the account is running, and does +// nothing. + +import ( + "fmt" + "os" + "path/filepath" + "sort" + "strconv" + "strings" + "syscall" +) + +// Session is the operator's X session as a tool reaches it. +type Session struct { + Display string `json:"display"` + XAuthority string `json:"xauthority,omitempty"` + // FoundBy says how: "environment", "process ()" or "socket". + FoundBy string `json:"found_by"` +} + +// Env is what a command needs to reach the session. +func (s Session) Env() []string { + env := []string{"DISPLAY=" + s.Display} + if s.XAuthority != "" { + env = append(env, "XAUTHORITY="+s.XAuthority) + } + return env +} + +// Where the session is looked for. Tests point these at a tree of their own. +var ( + procRoot = "/proc" + x11Sockets = "/tmp/.X11-unix" + getenv = os.Getenv + myUID = os.Getuid +) + +// sessionWMs are the programs whose environment is the session's own, preferred over any other +// process's (a terminal's child may carry a stale or forwarded DISPLAY). +var sessionWMs = map[string]bool{"i3": true, "sway": true, "xinit": true, "i3bar": true, "picom": true, "dunst": true} + +func accountHome() string { + if h := strings.TrimSpace(getenv("MESH_OPERATOR_HOME")); h != "" { + return h + } + if h := strings.TrimSpace(getenv("HOME")); h != "" { + return h + } + h, _ := os.UserHomeDir() + return h +} + +// socketOf is the local socket of a display such as ":1" or ":1.0", or "" for a remote one. +func socketOf(display string) string { + if !strings.HasPrefix(display, ":") { + return "" + } + n := strings.TrimPrefix(display, ":") + if i := strings.IndexByte(n, '.'); i >= 0 { + n = n[:i] + } + if _, err := strconv.Atoi(n); err != nil { + return "" + } + return filepath.Join(x11Sockets, "X"+n) +} + +func exists(p string) bool { + _, err := os.Stat(p) + return err == nil +} + +// findSession answers the account's X session, or an error saying there is none. +func findSession() (Session, error) { + if d := strings.TrimSpace(getenv("DISPLAY")); d != "" { + if s := socketOf(d); s == "" || exists(s) { + return Session{Display: d, XAuthority: getenv("XAUTHORITY"), FoundBy: "environment"}, nil + } + } + type seen struct { + Session + wm bool + count int + } + found := map[string]*seen{} + entries, _ := os.ReadDir(procRoot) + for _, e := range entries { + pid, err := strconv.Atoi(e.Name()) + if err != nil || !e.IsDir() { + continue + } + dir := filepath.Join(procRoot, e.Name()) + info, err := os.Stat(dir) + if err != nil { + continue + } + if st, ok := info.Sys().(*syscall.Stat_t); !ok || int(st.Uid) != myUID() { + continue + } + raw, err := os.ReadFile(filepath.Join(dir, "environ")) + if err != nil { + continue + } + var display, auth string + for _, kv := range strings.Split(string(raw), "\x00") { + switch { + case strings.HasPrefix(kv, "DISPLAY="): + display = strings.TrimPrefix(kv, "DISPLAY=") + case strings.HasPrefix(kv, "XAUTHORITY="): + auth = strings.TrimPrefix(kv, "XAUTHORITY=") + } + } + if display == "" { + continue + } + if s := socketOf(display); s == "" || !exists(s) { + continue + } + comm, _ := os.ReadFile(filepath.Join(dir, "comm")) + name := strings.TrimSpace(string(comm)) + key := display + "\x00" + auth + if found[key] == nil { + found[key] = &seen{Session: Session{Display: display, XAuthority: auth, FoundBy: fmt.Sprintf("process %d (%s)", pid, name)}} + } + f := found[key] + f.count++ + if sessionWMs[name] && !f.wm { + f.wm = true + f.FoundBy = fmt.Sprintf("process %d (%s)", pid, name) + } + } + if len(found) > 0 { + all := make([]*seen, 0, len(found)) + for _, f := range found { + all = append(all, f) + } + sort.Slice(all, func(i, k int) bool { + if all[i].wm != all[k].wm { + return all[i].wm + } + if all[i].count != all[k].count { + return all[i].count > all[k].count + } + return all[i].Display < all[k].Display + }) + return all[0].Session, nil + } + sockets, _ := filepath.Glob(filepath.Join(x11Sockets, "X*")) + if len(sockets) == 1 { + s := Session{Display: ":" + strings.TrimPrefix(filepath.Base(sockets[0]), "X"), FoundBy: "socket"} + if a := filepath.Join(accountHome(), ".Xauthority"); exists(a) { + s.XAuthority = a + } + return s, nil + } + if len(sockets) > 1 { + return Session{}, fmt.Errorf("no process of this account names its X display, and there are %d X sockets in %s: which one is the operator's session cannot be told", len(sockets), x11Sockets) + } + return Session{}, fmt.Errorf("no graphical session of this account is running on this machine: no process of the account has DISPLAY set, and there is no X socket in %s. A desktop tool acts only while the operator is logged in to the graphical session", x11Sockets) +} diff --git a/modules/xclip/cmd/xclip-tools/session_test.go b/modules/xclip/cmd/xclip-tools/session_test.go new file mode 100644 index 0000000..707e754 --- /dev/null +++ b/modules/xclip/cmd/xclip-tools/session_test.go @@ -0,0 +1,94 @@ +package main + +import ( + "os" + "path/filepath" + "strconv" + "strings" + "testing" +) + +// aMachine gives findSession a /proc and an X socket directory of the test's own. +func aMachine(t *testing.T, env map[string]string) (proc, sockets string) { + t.Helper() + root := t.TempDir() + proc, sockets = filepath.Join(root, "proc"), filepath.Join(root, "x11") + for _, d := range []string{proc, sockets} { + if err := os.MkdirAll(d, 0o755); err != nil { + t.Fatal(err) + } + } + wasProc, wasX, wasEnv := procRoot, x11Sockets, getenv + procRoot, x11Sockets = proc, sockets + getenv = func(k string) string { return env[k] } + t.Cleanup(func() { procRoot, x11Sockets, getenv = wasProc, wasX, wasEnv }) + return proc, sockets +} + +func aProcess(t *testing.T, proc string, pid int, comm string, env ...string) { + t.Helper() + dir := filepath.Join(proc, strconv.Itoa(pid)) + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatal(err) + } + _ = os.WriteFile(filepath.Join(dir, "comm"), []byte(comm+"\n"), 0o644) + _ = os.WriteFile(filepath.Join(dir, "environ"), []byte(strings.Join(env, "\x00")+"\x00"), 0o644) +} + +func aSocket(t *testing.T, dir, name string) { + t.Helper() + if err := os.WriteFile(filepath.Join(dir, name), nil, 0o644); err != nil { + t.Fatal(err) + } +} + +func TestSessionTheWindowManagersDisplayAndCookieAreTheSessions(t *testing.T) { + proc, sockets := aMachine(t, map[string]string{"MESH_OPERATOR_HOME": "/home/op"}) + aSocket(t, sockets, "X1") + aProcess(t, proc, 3, "bash", "DISPLAY=:9", "XAUTHORITY=/stale") + aProcess(t, proc, 4, "kitty", "DISPLAY=:1", "XAUTHORITY=/home/op/.Xauthority") + aProcess(t, proc, 5, "i3", "DISPLAY=:1.0", "XAUTHORITY=/home/op/.Xauthority") + aProcess(t, proc, 6, "sshd", "PATH=/bin") + s, err := findSession() + if err != nil { + t.Fatal(err) + } + if s.Display != ":1.0" || s.XAuthority != "/home/op/.Xauthority" || !strings.Contains(s.FoundBy, "i3") { + t.Fatalf("%+v: a display without a socket (:9) is skipped, and the window manager's is preferred", s) + } + if got := strings.Join(s.Env(), " "); got != "DISPLAY=:1.0 XAUTHORITY=/home/op/.Xauthority" { + t.Fatalf("env %s", got) + } +} + +func TestSessionTheOnlySocketWithTheHomesCookieIsTheFallback(t *testing.T) { + home := t.TempDir() + _ = os.WriteFile(filepath.Join(home, ".Xauthority"), []byte("c"), 0o600) + _, sockets := aMachine(t, map[string]string{"MESH_OPERATOR_HOME": home}) + aSocket(t, sockets, "X0") + s, err := findSession() + if err != nil || s.Display != ":0" || s.XAuthority != filepath.Join(home, ".Xauthority") || s.FoundBy != "socket" { + t.Fatalf("%+v %v", s, err) + } +} + +func TestSessionNoSessionIsSaidNotGuessed(t *testing.T) { + _, sockets := aMachine(t, map[string]string{}) + if _, err := findSession(); err == nil || !strings.Contains(err.Error(), "no graphical session") { + t.Fatalf("none: %v", err) + } + aSocket(t, sockets, "X0") + aSocket(t, sockets, "X1") + if _, err := findSession(); err == nil || !strings.Contains(err.Error(), "2 X sockets") { + t.Fatalf("two: %v", err) + } +} + +func TestSessionTheProcessesOwnDisplayComesFirst(t *testing.T) { + _, sockets := aMachine(t, map[string]string{"DISPLAY": ":2", "XAUTHORITY": "/a"}) + aSocket(t, sockets, "X2") + s, err := findSession() + if err != nil || s.Display != ":2" || s.FoundBy != "environment" { + t.Fatalf("%+v %v", s, err) + } +} diff --git a/modules/xclip/cmd/xclip-tools/xclip.go b/modules/xclip/cmd/xclip-tools/xclip.go new file mode 100644 index 0000000..27aa661 --- /dev/null +++ b/modules/xclip/cmd/xclip-tools/xclip.go @@ -0,0 +1,160 @@ +package main + +import ( + "encoding/base64" + "fmt" + "regexp" + "strings" + "unicode/utf8" +) + +// Selections are the X selections xclip reaches. +var Selections = []string{"clipboard", "primary", "secondary"} + +// MostCopy bounds what a caller may put on the clipboard. +const MostCopy = 1 << 20 + +var targetName = regexp.MustCompile(`^[A-Za-z0-9_][A-Za-z0-9_.+/;=-]{0,127}$`) + +func checkTarget(t string) error { + if t != "" && !targetName.MatchString(t) { + return fmt.Errorf("%q is not a type a selection can be offered as", t) + } + return nil +} + +// xclip runs xclip in the session, naming an unreachable display as such. +func xclip(s Session, c Cmd) (Result, error) { + c.Name, c.Env = "xclip", s.Env() + r := run(c) + said := r.Stderr + r.Stdout + switch { + case r.Error != "": + return r, failure(c, r) + case strings.Contains(said, "Can't open display"): + return r, fmt.Errorf("the X session at %s (found by %s) refused the connection: the display is gone, or the cookie in %s is not the server's", s.Display, s.FoundBy, orNone(s.XAuthority)) + case r.Status != 0: + return r, failure(c, r) + } + return r, nil +} + +func orNone(s string) string { + if s == "" { + return "(no authority file)" + } + return s +} + +// CopyAnswer is what xclip_copy answers. +type CopyAnswer struct { + Selection string `json:"selection"` + Type string `json:"type"` + Bytes int `json:"bytes"` + Session Session `json:"session"` + Note string `json:"note"` +} + +// Copy puts text on a selection. xclip forks a process that holds the selection until another +// program takes it, so the command runs detached. +func Copy(text, selection, typ string) (CopyAnswer, error) { + if len(text) > MostCopy { + return CopyAnswer{}, fmt.Errorf("text is %d bytes; at most %d are copied", len(text), MostCopy) + } + if err := checkTarget(typ); err != nil { + return CopyAnswer{}, err + } + s, err := findSession() + if err != nil { + return CopyAnswer{}, err + } + args := []string{"-selection", selection, "-in"} + if typ != "" { + args = append(args, "-t", typ) + } + if _, err := xclip(s, Cmd{Args: args, Stdin: text, Detached: true}); err != nil { + return CopyAnswer{}, err + } + if typ == "" { + typ = "UTF8_STRING" + } + return CopyAnswer{Selection: selection, Type: typ, Bytes: len(text), Session: s, + Note: "xclip holds the selection until another program takes it; a clipboard manager may copy it into its history"}, nil +} + +// PasteAnswer is what xclip_paste answers. +type PasteAnswer struct { + Selection string `json:"selection"` + Type string `json:"type"` + Empty bool `json:"empty"` + Text string `json:"text,omitempty"` + Base64 string `json:"base64,omitempty"` + Bytes int `json:"bytes"` + Truncated bool `json:"truncated,omitempty"` +} + +// Paste reads a selection. +func Paste(selection, typ string) (PasteAnswer, error) { + if err := checkTarget(typ); err != nil { + return PasteAnswer{}, err + } + s, err := findSession() + if err != nil { + return PasteAnswer{}, err + } + args := []string{"-selection", selection, "-out"} + if typ != "" { + args = append(args, "-t", typ) + } else { + typ = "UTF8_STRING" + } + out := PasteAnswer{Selection: selection, Type: typ} + r, err := xclip(s, Cmd{Args: args}) + if err != nil { + // "Error: target … not available": the selection is empty, or not offered as that type. + if strings.Contains(r.Stderr, "not available") { + out.Empty = true + return out, nil + } + return PasteAnswer{}, err + } + out.Bytes, out.Truncated = len(r.Stdout), r.Truncated + if utf8.ValidString(r.Stdout) { + out.Text = r.Stdout + } else { + out.Base64 = base64.StdEncoding.EncodeToString([]byte(r.Stdout)) + } + out.Empty = out.Bytes == 0 + return out, nil +} + +// Targets answers the types a selection is offered as. +func Targets(selection string) (map[string]any, error) { + s, err := findSession() + if err != nil { + return nil, err + } + r, err := xclip(s, Cmd{Args: []string{"-selection", selection, "-out", "-t", "TARGETS"}}) + if err != nil { + if strings.Contains(r.Stderr, "not available") { + return map[string]any{"selection": selection, "targets": []string{}, "empty": true}, nil + } + return nil, err + } + return map[string]any{"selection": selection, "targets": lines(r.Stdout), "empty": strings.TrimSpace(r.Stdout) == ""}, nil +} + +// SessionCheck answers the session and whether its X server answers. +func SessionCheck() (map[string]any, error) { + s, err := findSession() + if err != nil { + return map[string]any{"found": false, "why": err.Error()}, nil + } + out := map[string]any{"found": true, "session": s} + if _, err := Targets("clipboard"); err != nil { + out["answers"], out["why"] = false, err.Error() + } else { + out["answers"] = true + } + return out, nil +} diff --git a/modules/xclip/cmd/xclip-tools/xclip_test.go b/modules/xclip/cmd/xclip-tools/xclip_test.go new file mode 100644 index 0000000..2a455c6 --- /dev/null +++ b/modules/xclip/cmd/xclip-tools/xclip_test.go @@ -0,0 +1,106 @@ +package main + +import ( + "strings" + "testing" +) + +func TestTheManifestIsThePackageAndNothingElse(t *testing.T) { + m := readManifest(t) + holdsTheBundle(t, m, "xclip") + if got := strings.Join(m.packages(), ","); got != "xclip" || len(m.Resources) != 1 { + t.Errorf("packages %s, resources %v", got, m.Resources) + } +} + +// aSession gives the tools an X session on :1 with the account's cookie. +func aSession(t *testing.T) { + t.Helper() + _, sockets := aMachine(t, map[string]string{"DISPLAY": ":1", "XAUTHORITY": "/home/op/.Xauthority"}) + aSocket(t, sockets, "X1") +} + +func TestCopyRunsDetachedInTheSessionWithTheTextOnItsInput(t *testing.T) { + aSession(t) + f := using(t, func(string, Cmd) Result { return ok("") }) + got, err := Copy("hello", "clipboard", "") + if err != nil || got.Bytes != 5 || got.Type != "UTF8_STRING" || got.Session.Display != ":1" { + t.Fatalf("%+v %v", got, err) + } + c := f.asked[0] + if f.lines()[0] != "xclip -selection clipboard -in" || c.Stdin != "hello" || !c.Detached { + t.Errorf("%v %+v", f.lines(), c) + } + if strings.Join(c.Env, " ") != "DISPLAY=:1 XAUTHORITY=/home/op/.Xauthority" { + t.Errorf("env %v", c.Env) + } + if _, err := Copy("x", "primary", "text/html"); err != nil || f.lines()[1] != "xclip -selection primary -in -t text/html" { + t.Errorf("%v %v", f.lines(), err) + } + if _, err := Copy(strings.Repeat("a", MostCopy+1), "clipboard", ""); err == nil { + t.Error("more than 1 MiB") + } + if _, err := Copy("x", "clipboard", "-o"); err == nil { + t.Error("an option as a type") + } +} + +func TestPasteAnswersTextBase64OrEmpty(t *testing.T) { + aSession(t) + answer := Result{Stdout: "some text"} + f := using(t, func(string, Cmd) Result { return answer }) + got, err := Paste("clipboard", "") + if err != nil || got.Text != "some text" || got.Bytes != 9 || got.Empty { + t.Fatalf("%+v %v", got, err) + } + if f.lines()[0] != "xclip -selection clipboard -out" || f.asked[0].Detached { + t.Errorf("%v", f.lines()) + } + answer = Result{Stdout: "\x89PNG\r\n\x1a\n\xff"} + got, _ = Paste("clipboard", "image/png") + if got.Text != "" || got.Base64 == "" { + t.Errorf("binary: %+v", got) + } + answer = Result{Status: 1, Stderr: "Error: target UTF8_STRING not available\n"} + got, err = Paste("clipboard", "") + if err != nil || !got.Empty { + t.Errorf("an empty clipboard is an answer: %+v %v", got, err) + } +} + +func TestADisplayThatRefusesIsSaidWithHowItWasFound(t *testing.T) { + aSession(t) + using(t, func(string, Cmd) Result { return Result{Status: 1, Stderr: "Error: Can't open display: :1\n"} }) + _, err := Paste("clipboard", "") + if err == nil || !strings.Contains(err.Error(), "refused the connection") || !strings.Contains(err.Error(), "environment") { + t.Fatalf("%v", err) + } +} + +func TestWithoutASessionNothingRunsAndTheToolSaysWhy(t *testing.T) { + aMachine(t, map[string]string{}) + f := using(t, func(string, Cmd) Result { return ok("") }) + if _, err := Copy("x", "clipboard", ""); err == nil || !strings.Contains(err.Error(), "no graphical session") { + t.Fatalf("%v", err) + } + got, err := SessionCheck() + if err != nil || got["found"] != false || !strings.Contains(got["why"].(string), "logged in") { + t.Fatalf("%v %v", got, err) + } + if len(f.asked) != 0 { + t.Errorf("nothing runs without a session: %v", f.lines()) + } +} + +func TestTargetsListWhatASelectionOffers(t *testing.T) { + aSession(t) + using(t, func(string, Cmd) Result { return ok("TIMESTAMP\nTARGETS\nUTF8_STRING\ntext/html\n") }) + got, err := Targets("clipboard") + if err != nil || len(got["targets"].([]string)) != 4 { + t.Fatalf("%v %v", got, err) + } + s, _ := SessionCheck() + if s["answers"] != true { + t.Errorf("%v", s) + } +} diff --git a/modules/xclip/go.mod b/modules/xclip/go.mod new file mode 100644 index 0000000..9284445 --- /dev/null +++ b/modules/xclip/go.mod @@ -0,0 +1,5 @@ +module xclip + +go 1.22 + +require git.novox.be/novox/mesh-sdk/go v0.1.6 diff --git a/modules/xclip/go.sum b/modules/xclip/go.sum new file mode 100644 index 0000000..0dd6061 --- /dev/null +++ b/modules/xclip/go.sum @@ -0,0 +1,2 @@ +git.novox.be/novox/mesh-sdk/go v0.1.6 h1:9qzdYONYbJdWcu6sxQcq9v1LI0JxcfkiKYkMUzJSkVQ= +git.novox.be/novox/mesh-sdk/go v0.1.6/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY= diff --git a/modules/xclip/module.json b/modules/xclip/module.json new file mode 100644 index 0000000..bf06f6f --- /dev/null +++ b/modules/xclip/module.json @@ -0,0 +1,35 @@ +{ + "module": "xclip", + "version": "1", + "capabilities": [ + "package-manager" + ], + "tools": [ + "xclip_copy", + "xclip_paste", + "xclip_targets", + "xclip_session" + ], + "resources": [ + { + "id": "package", + "type": "package", + "package": "xclip" + } + ], + "build": { + "artifacts": [ + { + "name": "tools", + "kind": "bundle", + "language": "go", + "system": "arch", + "from": "cmd/xclip-tools", + "binary": "xclip-tools", + "loads": [ + "xclip-tools" + ] + } + ] + } +}