mesh/merge-gate pass: builds claude-code, mesh-knowledge → ace, g14, novox, shanks; no bus step; every machine composes with the change as it did without (…
mesh/repo-check pass: its merge-check.sh passed, each part in its toolchain: merge-check.sh (go), merge-check-typescript.sh (typescript)
mesh/delivery delivered
A judgement over one bus call's 30 seconds is collected by its ticket from claude-code, so an eleven-clause record can be reviewed; a proposal out of review tries is told to the operator, not raised as the module's fault, so it no longer holds the module's own rollouts.
568 lines
22 KiB
Go
568 lines
22 KiB
Go
// claude-code's bundle (novox/hq design 36, ADR 0183, ADR 0206): a binary the node's runtime launches over
|
|
// stdio as the operator account (ADR 0193) and is the bus for (ADR 0198). It is given its state directory
|
|
// and two files the mesh renders into it (ADR 0192), beside the runtime's own words.
|
|
//
|
|
// At start it renders the agent's managed directory, reports what this node holds as the module's
|
|
// `holdings` state and again whenever the credentials file changes, watches the licence manager's
|
|
// `bindings` state for this node and fetches the token when it says so, and watches the module's
|
|
// `servers` state — every node's MCP server registrations (ADR 0201). node.go holds the logic.
|
|
//
|
|
// stdout is the MCP channel; everything this module says, it says on stderr.
|
|
package main
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"strings"
|
|
"time"
|
|
|
|
stdio "git.novox.be/novox/mesh-sdk/go"
|
|
)
|
|
|
|
func say(format string, args ...any) {
|
|
fmt.Fprintf(os.Stderr, "[claude-code] "+format+"\n", args...)
|
|
}
|
|
|
|
// writeManaged writes one managed file as root, only when its content changed. From a staged file, never
|
|
// /dev/stdin: a child's input may be a socket, which /dev/stdin cannot open (found on the first assignment).
|
|
func writeManaged(name, content string) (string, error) {
|
|
if strings.HasSuffix(name, "/") {
|
|
return writeManagedTree(strings.TrimSuffix(name, "/"), content)
|
|
}
|
|
path := filepath.Join(ManagedDir, name)
|
|
if was, err := os.ReadFile(path); err == nil && string(was) == content {
|
|
return name + ": unchanged", nil
|
|
}
|
|
staged, err := os.MkdirTemp("", "claude-code-")
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
defer os.RemoveAll(staged)
|
|
source := filepath.Join(staged, name)
|
|
if err := os.WriteFile(source, []byte(content), 0o644); err != nil {
|
|
return "", err
|
|
}
|
|
args := []string{"install", "-D", "-m", "0644", source, path}
|
|
if os.Geteuid() != 0 {
|
|
args = append([]string{"sudo", "-n"}, args...)
|
|
}
|
|
if out, err := exec.Command(args[0], args[1:]...).CombinedOutput(); err != nil {
|
|
return "", fmt.Errorf("%s: could not be written to %s (%s); the module writes there through the operator account's passwordless sudo",
|
|
name, ManagedDir, strings.TrimSpace(string(out)))
|
|
}
|
|
return name + ": written", nil
|
|
}
|
|
|
|
// writeManagedTree replaces one directory of the managed directory whole — the plugin's marketplace (ADR
|
|
// 0216) — when what it holds differs from the files given (a JSON map of path to PluginFile). Staged
|
|
// beside, then swapped in as root, so a session never reads half of it.
|
|
func writeManagedTree(name, content string) (string, error) {
|
|
var files map[string]PluginFile
|
|
if err := json.Unmarshal([]byte(content), &files); err != nil {
|
|
return "", err
|
|
}
|
|
target := filepath.Join(ManagedDir, name)
|
|
if sameTree(target, files) {
|
|
return name + "/: unchanged", nil
|
|
}
|
|
staged, err := os.MkdirTemp("", "claude-code-tree-")
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
defer os.RemoveAll(staged)
|
|
for rel, f := range files {
|
|
full := filepath.Join(staged, filepath.FromSlash(rel))
|
|
if err := os.MkdirAll(filepath.Dir(full), 0o755); err != nil {
|
|
return "", err
|
|
}
|
|
mode := os.FileMode(0o644)
|
|
if f.Executable {
|
|
mode = 0o755
|
|
}
|
|
content := []byte(f.Content)
|
|
if f.From != "" {
|
|
var err error
|
|
if content, err = os.ReadFile(f.From); err != nil {
|
|
return "", err
|
|
}
|
|
}
|
|
if err := os.WriteFile(full, content, mode); err != nil {
|
|
return "", err
|
|
}
|
|
_ = os.Chmod(full, mode)
|
|
}
|
|
_ = os.Chmod(staged, 0o755)
|
|
script := `set -e; rm -rf "$2.next" "$2.old"; cp -r "$1" "$2.next"; chown -R root:root "$2.next"; chmod -R go-w,a+rX "$2.next";
|
|
if [ -d "$2" ]; then mv "$2" "$2.old"; fi; mv "$2.next" "$2"; rm -rf "$2.old"`
|
|
args := []string{"sh", "-c", script, "sh", staged, target}
|
|
if os.Geteuid() != 0 {
|
|
args = append([]string{"sudo", "-n"}, args...)
|
|
}
|
|
if out, err := exec.Command(args[0], args[1:]...).CombinedOutput(); err != nil {
|
|
return "", fmt.Errorf("%s/: could not be written to %s (%s); the module writes there through the operator account's passwordless sudo",
|
|
name, ManagedDir, strings.TrimSpace(string(out)))
|
|
}
|
|
return fmt.Sprintf("%s/: written, %d file(s)", name, len(files)), nil
|
|
}
|
|
|
|
// sameTree says whether a directory holds exactly these files, with these contents and executable bits.
|
|
func sameTree(dir string, files map[string]PluginFile) bool {
|
|
found := 0
|
|
err := filepath.WalkDir(dir, func(full string, d os.DirEntry, err error) error {
|
|
if err != nil || d.IsDir() {
|
|
return err
|
|
}
|
|
rel, _ := filepath.Rel(dir, full)
|
|
f, ok := files[filepath.ToSlash(rel)]
|
|
if !ok {
|
|
return errors.New("not wanted")
|
|
}
|
|
raw, err := os.ReadFile(full)
|
|
info, ierr := d.Info()
|
|
want := []byte(f.Content)
|
|
if f.From != "" {
|
|
if want, err = os.ReadFile(f.From); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
if err != nil || ierr != nil || !bytes.Equal(raw, want) || (info.Mode()&0o111 != 0) != f.Executable {
|
|
return errors.New("differs")
|
|
}
|
|
found++
|
|
return nil
|
|
})
|
|
return err == nil && found == len(files)
|
|
}
|
|
|
|
// ask is a tool on the bus, through the runtime: its answer is the tool's value.
|
|
func ask(address string, args any) (json.RawMessage, error) { return stdio.Ask(address, args) }
|
|
|
|
// stateOf adapts the SDK's state to what node.go asks of one.
|
|
type stateOf struct{ s stdio.KeptState }
|
|
|
|
func (s stateOf) Put(key string, value any) error { _, err := s.s.Put(key, value); return err }
|
|
func (s stateOf) Delete(key string) error { return s.s.Delete(key) }
|
|
func (s stateOf) Create(key string, value any) error {
|
|
_, err := s.s.Create(key, value)
|
|
return err
|
|
}
|
|
func (s stateOf) Keys() ([]string, error) { return s.s.Keys() }
|
|
func (s stateOf) Get(key string) (json.RawMessage, bool, error) {
|
|
e, err := s.s.Get(key)
|
|
if err != nil || e == nil {
|
|
return nil, false, err
|
|
}
|
|
return e.Value, true, nil
|
|
}
|
|
|
|
// nodesRunningMe is the nodes claude-code runs on, from the controller's list of modules — for the register
|
|
// tool's question.
|
|
func nodesRunningMe() ([]string, error) {
|
|
raw, err := ask("seat:mesh-controller.modules", map[string]any{})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return NodesRunning(raw, "claude-code"), nil
|
|
}
|
|
|
|
// NodesRunning reads where a module runs from the controller's `modules` answer. The verb runs `module list
|
|
// --json`, so the answer is a JSON list of {module, on}: the lines of the printed list it was once read as
|
|
// never came, and every "also on" hint and every `nodes: "all"` named no machine. The printed form, `<module>
|
|
// … on a, b`, is still read when that is what came.
|
|
func NodesRunning(raw json.RawMessage, module string) []string {
|
|
var listed []struct {
|
|
Module string `json:"module"`
|
|
On []string `json:"on"`
|
|
}
|
|
if json.Unmarshal(verbAnswer(raw), &listed) == nil {
|
|
for _, m := range listed {
|
|
if m.Module == module {
|
|
return m.On
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
text := outputOf(raw)
|
|
if text == "" {
|
|
text = string(raw)
|
|
}
|
|
for _, line := range strings.Split(text, "\n") {
|
|
if !strings.HasPrefix(line, module+" ") {
|
|
continue
|
|
}
|
|
_, on, ok := strings.Cut(line, " on ")
|
|
if !ok || strings.TrimSpace(on) == "nothing" {
|
|
return nil
|
|
}
|
|
var out []string
|
|
for _, n := range strings.Split(on, ",") {
|
|
if n = strings.TrimSpace(n); n != "" {
|
|
out = append(out, n)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func fingerprintOfFile(path string) any {
|
|
raw, err := os.ReadFile(path)
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
return Fingerprint(string(raw))
|
|
}
|
|
|
|
func status(p Paths) map[string]any {
|
|
creds := ReadCredentials(p.credentials())
|
|
var token any
|
|
if g := GrantOf(creds); g != nil {
|
|
token = map[string]any{"fingerprint": Fingerprint(g.AccessToken), "expiresAt": stamp(g.ExpiresAt), "loginWaiting": HoldsLogin(creds)}
|
|
}
|
|
var managed []map[string]any
|
|
for _, f := range []string{"managed-mcp.json", "managed-settings.json", "CLAUDE.md"} {
|
|
path := filepath.Join(ManagedDir, f)
|
|
managed = append(managed, map[string]any{"file": path, "fingerprint": fingerprintOfFile(path)})
|
|
}
|
|
var licence any
|
|
var b Binding
|
|
if readJSON(p.binding(), &b) {
|
|
licence = b
|
|
}
|
|
names := []string{}
|
|
for n := range Registered(p) {
|
|
names = append(names, n)
|
|
}
|
|
agent := map[string]any{"account": p.Agent, "home": p.Home, "own_account": p.Confined()}
|
|
if !p.Confined() {
|
|
agent["note"] = "agents here run as the operator's account"
|
|
}
|
|
return map[string]any{"node": p.Node, "agent": agent, "licence": licence, "token": token, "holdings": HoldingsOf(p),
|
|
"managed": managed, "registered": names}
|
|
}
|
|
|
|
func str(description string) map[string]any {
|
|
return map[string]any{"type": "string", "description": description}
|
|
}
|
|
|
|
// nodesOf reads the tools' `nodes` argument: absent is this node, "all" every node, else a list.
|
|
func nodesOf(v any) []string {
|
|
s, _ := v.(string)
|
|
s = strings.TrimSpace(s)
|
|
switch s {
|
|
case "":
|
|
return nil
|
|
case "all":
|
|
return []string{"all"}
|
|
}
|
|
var out []string
|
|
for _, n := range strings.Split(s, ",") {
|
|
if n = strings.TrimSpace(n); n != "" {
|
|
out = append(out, n)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
func tools(p Paths, servers ServerState, view *ServerView, config ConfigState, configView *ConfigView) []stdio.Tool {
|
|
nodesArg := str(`more nodes: "all" for every node running claude-code, or a comma-separated list; absent is this node only`)
|
|
out := []stdio.Tool{
|
|
{Name: "claude_code_status",
|
|
Description: "Claude Code on this machine as the mesh configured it: the licence it holds and when its token expires, what it reports holding, the managed files, the MCP servers registered here. Fingerprints only, never a token.",
|
|
Run: func(map[string]any) (any, error) { return status(p), nil }},
|
|
{Name: "claude_code_render",
|
|
Description: "Write Claude Code's managed directory now, from the mesh's facts, this module's settings and the servers registered here — after asking the controller again what each tool replaces and which machines the mesh has (the instructions' table and the shell's guard).",
|
|
Run: func(map[string]any) (any, error) {
|
|
answer := map[string]any{}
|
|
if m, err := AskMeshTools(ask, lookup); err != nil {
|
|
answer["asked"] = "the controller did not answer in full (" + err.Error() + "); what was kept is rendered"
|
|
} else {
|
|
KeepMeshTools(p, m)
|
|
}
|
|
out, err := RenderNow(p, writeManaged)
|
|
answer["rendered"] = out
|
|
return answer, err
|
|
}},
|
|
{Name: "claude_code_guard",
|
|
Description: "The guard on the agent's shell on this machine (novox/hq ADR 0245): what it refuses — ssh to a mesh machine, writing /etc/hosts or /etc/resolv.conf, HOSTALIASES — the machines and the replaced commands it judges with, the \"instead of\" table rendered into the agent's instructions, and its record of what it refused and what the operator's override let through, newest last.",
|
|
Input: map[string]any{"last": map[string]any{"type": "integer", "description": "how many lines of the record (default 50)"}},
|
|
Run: func(a map[string]any) (any, error) {
|
|
last := 50
|
|
if n, ok := a["last"].(float64); ok && n > 0 {
|
|
last = int(n)
|
|
}
|
|
return GuardStatus(p, last), nil
|
|
}},
|
|
{Name: "claude_code_pull",
|
|
Description: "Ask the licence manager for this node's current token now and apply it, rather than waiting for its binding to change.",
|
|
Run: func(map[string]any) (any, error) { return Pull(p, ask, writeManaged) }},
|
|
{Name: "claude_code_grant",
|
|
Description: "For the licence manager (ADR 0206): the full grant in this node's credentials file — a login made here — sealed to the public key given, with the account it belongs to. Nothing when no login is waiting. Never answers a token in the clear.",
|
|
Input: map[string]any{"public_key": str("the manager's public key, PEM; the grant opens only with its private half")},
|
|
Run: func(a map[string]any) (any, error) {
|
|
key, _ := a["public_key"].(string)
|
|
if !strings.Contains(key, "PUBLIC KEY") {
|
|
return nil, errors.New("claude_code_grant seals to a public key, and none was given")
|
|
}
|
|
return GrantFor(p, key)
|
|
}},
|
|
{Name: "claude_code_add_api_key",
|
|
Description: "Add an Anthropic API key as a licence (ADR 0209): read from a file on this machine — never typed as an argument — sealed to the licence manager's key, handed over, and the file removed once taken. With use_here, this machine switches to it at once; other machines move with the manager's `switch`.",
|
|
Input: map[string]any{
|
|
"name": str("the licence's name, e.g. api"),
|
|
"file": str("a file on this machine holding the key, e.g. ~/api-key (removed once the manager has it)"),
|
|
"use_here": map[string]any{"type": "boolean", "description": "switch this machine to the new licence"},
|
|
},
|
|
Run: func(a map[string]any) (any, error) {
|
|
name, _ := a["name"].(string)
|
|
file, _ := a["file"].(string)
|
|
if strings.TrimSpace(name) == "" || strings.TrimSpace(file) == "" {
|
|
return nil, errors.New("name and file are required")
|
|
}
|
|
if strings.HasPrefix(file, "~/") {
|
|
file = filepath.Join(p.Home, file[2:])
|
|
}
|
|
useHere, _ := a["use_here"].(bool)
|
|
return AddAPIKey(p, strings.TrimSpace(name), file, useHere, ask)
|
|
}},
|
|
{Name: "claude_code_mcp_list",
|
|
Description: "The MCP servers registered through this module: those that apply on this node (beside the console, `mesh`, and those set in the module's settings), and every registration on the mesh, by key — `all.<server>` for every node, `<node>.<server>` for one.",
|
|
Run: func(map[string]any) (any, error) {
|
|
keys, err := servers.Keys()
|
|
return map[string]any{"here": Registered(p), "everywhere": keys}, err
|
|
}},
|
|
{Name: "claude_code_mcp_register",
|
|
Description: "Refused, and says why (novox/hq issue 340): a tool server is started by every session on the nodes it reaches, as whoever starts it, and this tool cannot tell an agent from the operator. Tool servers are the operator's, set at the controller's terminal in this module's mcp_servers setting; what was registered before stays, and claude_code_registrations lists it.",
|
|
Input: map[string]any{
|
|
"name": str("the server's name"),
|
|
"nodes": nodesArg,
|
|
},
|
|
Run: func(map[string]any) (any, error) { return nil, errors.New(ServerAtTheTerminal) }},
|
|
{Name: "claude_code_mcp_unregister",
|
|
Description: "Remove an MCP server registered through this module before registering was refused (novox/hq issue 340), on this node or more: it stops loading in the sessions there. Removing takes away what a session runs; adding is the operator's, at the controller's terminal.",
|
|
Input: map[string]any{"name": str("the server's name"), "nodes": nodesArg},
|
|
Run: func(a map[string]any) (any, error) {
|
|
name, _ := a["name"].(string)
|
|
return RegisterServer(p, Registration{Name: name, Nodes: nodesOf(a["nodes"])}, servers, view, writeManaged, nodesRunningMe)
|
|
}},
|
|
}
|
|
out = append(out, stdio.Tool{Name: "claude_code_registrations",
|
|
Description: "Read-only: everything registered through this module that a Claude Code session runs or obeys — the tool servers, the hooks, the settings and the skills, subagents, commands, output styles and instructions — by key, with who registered it, when, and whether it is still rendered (novox/hq issue 340), beside what the operator set for this node at the controller's terminal (managed_settings, mcp_servers). `by` is the node whose tool runner served the call, never the caller.",
|
|
Run: func(map[string]any) (any, error) { return Registrations(p, servers, config) }})
|
|
out = append(out, proposalTools(p)...)
|
|
judge := NewJudge(p)
|
|
out = append(out, judgeTool(judge), judgedTool(judge))
|
|
return append(out, configTools(p, config, configView)...)
|
|
}
|
|
|
|
// persist asks the state again until it answers: its bucket or the bus's grant may arrive after the module.
|
|
func persist(what string, attempt func() error, done func(refusals int)) {
|
|
waits := []time.Duration{2 * time.Second, 5 * time.Second, 10 * time.Second, 30 * time.Second}
|
|
for n := 0; ; n++ {
|
|
err := attempt()
|
|
if err == nil {
|
|
done(n)
|
|
return
|
|
}
|
|
pause := time.Minute
|
|
if n < len(waits) {
|
|
pause = waits[n]
|
|
}
|
|
say("%s not yet (%v); asking again in %s", what, err, pause)
|
|
time.Sleep(pause)
|
|
}
|
|
}
|
|
|
|
func main() {
|
|
// The guard on the agent's shell (novox/hq ADR 0245): this binary, run by the agent's hook.
|
|
if len(os.Args) == 3 && os.Args[1] == "guard" {
|
|
os.Exit(runGuard(os.Args[2], os.Stdin, os.Stderr))
|
|
}
|
|
// The helper that touches the agent's home as the agent's account (novox/hq ADR 0266), started by this
|
|
// module through sudo: one answer per request on stdin, until it ends.
|
|
if len(os.Args) == 2 && os.Args[1] == HelperArg {
|
|
if err := ServeHelper(os.Stdin, os.Stdout); err != nil {
|
|
fmt.Fprintln(os.Stderr, err)
|
|
os.Exit(1)
|
|
}
|
|
return
|
|
}
|
|
p, launched := PathsFrom(os.Getenv)
|
|
if launched {
|
|
fsys = FilesFor(p)
|
|
}
|
|
if !launched {
|
|
// Outside a launch — a build, a check — it serves nothing and says why.
|
|
if (os.Getenv("MESH_AGENT_ACCOUNT") == "") != (os.Getenv("MESH_AGENT_HOME") == "") {
|
|
say("the runtime named the agent's account without its home, or its home without the account " +
|
|
"(MESH_AGENT_ACCOUNT, MESH_AGENT_HOME); serving no tools rather than writing the operator's home " +
|
|
"for an agent that runs as another account")
|
|
} else {
|
|
say("not launched by the runtime with this module's words; serving no tools")
|
|
}
|
|
if err := stdio.Serve("", nil); err != nil {
|
|
os.Exit(1)
|
|
}
|
|
return
|
|
}
|
|
if _, err := Keypair(p); err != nil {
|
|
say("this module's key: %v", err)
|
|
}
|
|
if out, err := RenderNow(p, writeManaged); err != nil {
|
|
say("%v", err)
|
|
} else {
|
|
for _, line := range out {
|
|
if !strings.HasSuffix(line, "unchanged") {
|
|
say("%s", line)
|
|
}
|
|
}
|
|
}
|
|
go refreshMeshTools(p)
|
|
servers := stateOf{stdio.State("servers")}
|
|
view := NewServerView(p)
|
|
config := stateOf{stdio.State("config")}
|
|
configView := NewConfigView(p)
|
|
proposing = &Proposing{Store: stateOf{stdio.State(ProposalsState)}, Bus: sdkAskBus{}, Record: recordWarrant,
|
|
Now: time.Now, Write: writeManaged}
|
|
go proposing.watch(p)
|
|
go run(p, view, configView)
|
|
if err := stdio.Serve("", tools(p, servers, view, config, configView)); err != nil {
|
|
say("%v", err)
|
|
os.Exit(1)
|
|
}
|
|
}
|
|
|
|
// run is the module's long-running half, beside the tools (ADR 0198).
|
|
func run(p Paths, view *ServerView, configView *ConfigView) {
|
|
// The agent's configuration, at every scope (ADR 0216): the whole current set first, then each change.
|
|
go persist("watching the agent's configuration", func() error {
|
|
keys, err := stdio.State("config").Keys()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if configView.Prune(keys) {
|
|
if _, err := RenderNow(p, writeManaged); err != nil {
|
|
say("rendering after what was removed while away: %v", err)
|
|
}
|
|
}
|
|
return stdio.State("config").Watch("", func(c stdio.StateChange) error {
|
|
var item *Item
|
|
if c.Op == "put" {
|
|
item = &Item{}
|
|
if json.Unmarshal(c.Value, item) != nil {
|
|
item = nil
|
|
}
|
|
}
|
|
if !configView.Take(c.Key, c.Op, item) {
|
|
return nil
|
|
}
|
|
if out, err := RenderNow(p, writeManaged); err != nil {
|
|
say("taking %s %s: %v", c.Op, c.Key, err)
|
|
} else {
|
|
say("took %s %s", c.Op, c.Key)
|
|
for _, line := range out {
|
|
if !strings.HasSuffix(line, "unchanged") {
|
|
say("%s", line)
|
|
}
|
|
}
|
|
}
|
|
return nil
|
|
})
|
|
}, func(n int) { say("watching the agent's configuration%s", refusals(n)) })
|
|
|
|
// Every node's MCP servers: the whole current set first, then each change (ADR 0201).
|
|
go persist("watching the MCP servers", func() error {
|
|
return stdio.State("servers").Watch("", func(c stdio.StateChange) error {
|
|
var value map[string]any
|
|
_ = json.Unmarshal(c.Value, &value)
|
|
if done, err := OnServerChange(view, ServerChange{Key: c.Key, Op: c.Op, Value: value}, p, writeManaged); err != nil {
|
|
say("taking %s %s: %v", c.Op, c.Key, err) // the view took it; the next render writes it
|
|
} else if done != "" {
|
|
say("%s", done)
|
|
}
|
|
return nil
|
|
})
|
|
}, func(n int) { say("watching the MCP servers%s", refusals(n)) })
|
|
|
|
// What this node holds (ADR 0206): at start — a node already logged in is reported at once — and on
|
|
// every change of the credentials file, polled, because the file is replaced by rename and a watch on
|
|
// the old inode would go quiet. Fingerprints and expiries only.
|
|
holdings := stdio.State("holdings")
|
|
reported := ""
|
|
report := func() {
|
|
now := HoldingsOf(p)
|
|
raw, _ := json.Marshal(now)
|
|
if string(raw) == reported {
|
|
return
|
|
}
|
|
persist("reporting what this node holds", func() error { _, err := holdings.Put(p.Node, now); return err }, func(int) {
|
|
reported = string(raw)
|
|
account := "no account"
|
|
if now.Identity != nil && now.Identity.EmailAddress != "" {
|
|
account = now.Identity.EmailAddress
|
|
}
|
|
line := "reported: " + account
|
|
if now.Kind != nil {
|
|
line += ", " + *now.Kind
|
|
}
|
|
if now.Refresh.Present {
|
|
line += ", a login waiting"
|
|
}
|
|
if now.Licence != nil {
|
|
line += fmt.Sprintf(", licence %s g%d", *now.Licence, now.Generation)
|
|
}
|
|
say("%s", line)
|
|
})
|
|
}
|
|
|
|
// What this node should hold (ADR 0206): the manager's `bindings` key for this node; a newer
|
|
// generation is fetched with the seat's `current`, sealed to this module's key.
|
|
go persist("watching this node's licence binding", func() error {
|
|
return stdio.State(Manager+".bindings").Watch(p.Node, func(c stdio.StateChange) error {
|
|
if c.Key != p.Node {
|
|
return nil
|
|
}
|
|
var b *BindingState
|
|
if c.Op == "put" {
|
|
b = &BindingState{}
|
|
if err := json.Unmarshal(c.Value, b); err != nil {
|
|
return nil
|
|
}
|
|
}
|
|
if done, err := OnBinding(p, b, ask, writeManaged); err != nil {
|
|
say("fetching this node's token failed: %v", err)
|
|
} else if done != "" {
|
|
say("%s", done)
|
|
}
|
|
go report()
|
|
return nil
|
|
})
|
|
}, func(n int) { say("watching this node's licence binding%s", refusals(n)) })
|
|
|
|
report()
|
|
var last string
|
|
for range time.Tick(5 * time.Second) {
|
|
info, err := fsys.Stat(p.credentials())
|
|
now := "absent"
|
|
if err == nil {
|
|
now = fmt.Sprintf("%d/%d", info.ModTime().UnixNano(), info.Size())
|
|
}
|
|
if now != last {
|
|
last = now
|
|
report()
|
|
}
|
|
}
|
|
}
|
|
|
|
func refusals(n int) string {
|
|
if n == 0 {
|
|
return ""
|
|
}
|
|
return fmt.Sprintf(" (after %d refusal(s))", n)
|
|
}
|