mesh/merge-gate pass: builds claude-code, claude-licence-manager, restic, sudo → ace, g14, novox, shanks; no bus step; every machine composes with the chan…
mesh/repo-check pass: THE CHANGE ALTERS ITS OWN CHECK (merge-check.sh): main's version judged it; the change's judges the pull requests after it merges; it…
mesh/delivery delivered
The managed settings apply to every account's claude, so a key-helper script under the agent's home would run as the operator, and through sudo as root: the command is now carried whole in the root-owned managed settings and reads the key only as the agent. The helper reads only a regular file, bounded, without blocking, and a request it does not answer fails in time instead of holding every later one; a directory entry it answers that is not a name is refused. The guard's record is matched on its binary in the query, a half-named agent account is not taken as the operator's, the kept API key stays until the agent's home holds one, restic refuses to restore beside a directory others may write, and the agent-home check cleans a path before judging it.
786 lines
31 KiB
Go
786 lines
31 KiB
Go
package main
|
|
|
|
// What claude-code does on a node, written against what it is handed — a way to ask a tool on the bus, its
|
|
// own state, a way to write a managed file — so every path is tested without a bus (novox/hq design 36,
|
|
// ADR 0183, ADR 0201, ADR 0206).
|
|
//
|
|
// Over NATS, and nothing an event: what is current is state, and a secret only ever travels on a request,
|
|
// sealed to its one recipient.
|
|
// - What this node holds is the module's `holdings` state, one key per node: the account, the kind,
|
|
// fingerprints and expiries — never a token. Written at start and on every change of the credentials
|
|
// file, so the licence manager learns a login, or a node already logged in, from the state alone.
|
|
// - The grant itself leaves only when the manager asks `claude_code_grant`, sealed to the key it gives.
|
|
// - What this node should hold is the manager's `bindings` state; a newer generation for this node is
|
|
// fetched with the seat's `current` verb, sealed to this module's key, and written access-token-only.
|
|
// - An MCP server registered through this module is a key in its `servers` state — `all.<server>` for
|
|
// every node, `<node>.<server>` for one — which every node watches.
|
|
|
|
import (
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"regexp"
|
|
"sort"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
)
|
|
|
|
// Seat is the licence manager's role, and Manager the module whose `bindings` state this one reads.
|
|
const (
|
|
Seat = "anthropic-licence-manager"
|
|
Manager = "claude-licence-manager"
|
|
)
|
|
|
|
// SeatVerb is a seat's verb as the runtime addresses it: a role, not a module.
|
|
func SeatVerb(verb string) string { return "seat:" + Seat + "." + verb }
|
|
|
|
// Fingerprint names a token without being one: the first 16 hex of its SHA-256, as the manager computes it.
|
|
func Fingerprint(s string) string {
|
|
sum := sha256.Sum256([]byte(s))
|
|
return "sha256:" + hex.EncodeToString(sum[:])[:16]
|
|
}
|
|
|
|
// Paths are where this node's files are, from the module's words (ADR 0192).
|
|
//
|
|
// Home is the home of the account the agent runs as, and Agent that account (novox/hq ADR 0266): the runtime's
|
|
// MESH_AGENT_ACCOUNT and MESH_AGENT_HOME, which the controller sets to the operator's account where the node
|
|
// names no agent account of its own — and which a controller older than that record does not set, so the
|
|
// operator's are read then. Operator is the operator's account: Confined says the agent's is another.
|
|
// AgentHome and OperatorHome are the runtime's words as given, read to judge whose home the home scope writes
|
|
// (novox/hq issue 340).
|
|
type Paths struct {
|
|
State, Facts, Settings, Home, Node string
|
|
Agent, Operator, AgentHome, OperatorHome string
|
|
}
|
|
|
|
// PathsFrom reads them, or answers false outside a launch.
|
|
func PathsFrom(env func(string) string) (Paths, bool) {
|
|
p := Paths{State: env("MESH_CLAUDE_CODE_STATE"), Facts: env("MESH_CLAUDE_CODE_FACTS"),
|
|
Settings: env("MESH_CLAUDE_CODE_SETTINGS"), Home: env("MESH_AGENT_HOME"), Node: env("MESH_NODE"),
|
|
Agent: env("MESH_AGENT_ACCOUNT"), Operator: env("MESH_OPERATOR_ACCOUNT"),
|
|
AgentHome: env("MESH_AGENT_HOME"), OperatorHome: env("MESH_OPERATOR_HOME")}
|
|
if (p.Home == "") != (p.Agent == "") {
|
|
// Half of the agent's account is not "the operator's": taken so, the agent's account rule of the guard
|
|
// would be off and its home never written, and nothing would say so. Not launched, said (main).
|
|
return p, false
|
|
}
|
|
if p.Home == "" {
|
|
p.Home, p.Agent, p.AgentHome = env("MESH_OPERATOR_HOME"), p.Operator, env("MESH_OPERATOR_HOME")
|
|
}
|
|
return p, p.State != "" && p.Facts != "" && p.Settings != "" && p.Home != "" && p.Node != ""
|
|
}
|
|
|
|
// Confined says the agent runs here as an account of its own, not the operator's (novox/hq ADR 0266).
|
|
func (p Paths) Confined() bool { return p.Agent != "" && p.Operator != "" && p.Agent != p.Operator }
|
|
|
|
// HomeIsTheAgentsOwn says whether the home this module writes the home scope into belongs to an agent account of
|
|
// its own (novox/hq issue 340): the agent is confined here, and the home is the agent's, not the operator's.
|
|
// Anywhere else the home scope would write the operator's own ~/.claude, whose rules and skills load in every
|
|
// session the operator starts.
|
|
func (p Paths) HomeIsTheAgentsOwn() bool {
|
|
if !p.Confined() || p.AgentHome == "" || p.Home == "" {
|
|
return false
|
|
}
|
|
home := filepath.Clean(p.Home)
|
|
return home == filepath.Clean(p.AgentHome) && (p.OperatorHome == "" || home != filepath.Clean(p.OperatorHome))
|
|
}
|
|
|
|
// HomeChangeRefused says why this node's home may not be changed through the tools, or "": removing or putting
|
|
// back an item of it changes what every session there obeys as surely as placing one — an agent could remove a
|
|
// rule that constrains it — so it is done only where the home belongs to an agent account of its own (novox/hq
|
|
// issue 340). The operator's own home is the operator's, changed by the person, not through a call.
|
|
func HomeChangeRefused(p Paths, what string) string {
|
|
if p.HomeIsTheAgentsOwn() {
|
|
return ""
|
|
}
|
|
return what + " on " + p.Node + " would change the operator's own ~/.claude, whose rules and skills every " +
|
|
"session the operator starts obeys, and the tool runner cannot tell the operator from an agent. It is done " +
|
|
"only where the agent runs as an account of its own and the home is that account's (novox/hq ADR 0266, " +
|
|
"issue 340); in the operator's own home it is the person's act, by hand. Nothing was changed"
|
|
}
|
|
|
|
// HomeScopeRefused says why the home scope is not written for a node, or "": only this node's own home, and only
|
|
// where it belongs to an agent account of its own (novox/hq issue 340). Another node's home cannot be judged
|
|
// from here; that node's own module judges it.
|
|
func HomeScopeRefused(p Paths, target string) string {
|
|
if target != p.Node {
|
|
return "the home scope reaches " + target + "'s home, and whose home that is cannot be judged from " + p.Node +
|
|
"; call that node's own claude-code tool, which judges it (novox/hq issue 340). Nothing was registered"
|
|
}
|
|
if !p.HomeIsTheAgentsOwn() {
|
|
return "the home scope on " + p.Node + " would write the operator's own ~/.claude, whose rules and skills " +
|
|
"load in every session the operator starts there, and the tool runner cannot tell the operator from an " +
|
|
"agent. It is taken only where the agent runs as an account of its own and the home is that account's " +
|
|
"(novox/hq ADR 0266, issue 340). Nothing was registered"
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func (p Paths) credentials() string { return filepath.Join(p.Home, ".claude", ".credentials.json") }
|
|
func (p Paths) account() string { return filepath.Join(p.Home, ".claude.json") }
|
|
func (p Paths) binding() string { return filepath.Join(p.State, "licence.json") }
|
|
func (p Paths) apiKey() string { return filepath.Join(p.keyDir(), "api-key") }
|
|
func (p Paths) helper() string { return filepath.Join(p.keyDir(), "api-key-helper") }
|
|
|
|
// helperCommand is the managed settings' apiKeyHelper: the key-helper's path in the module's state where the agent
|
|
// runs as the operator. Where it runs as an account of its own, never a file under that account's home: the
|
|
// managed settings apply to every account's `claude`, the operator's and root's included, so a script the agent
|
|
// owns there would run as them at their next start (novox/hq ADR 0266, review of 2026-10-09). It is a command
|
|
// written whole in the root-owned managed settings instead, which reads the key only when run as the agent's
|
|
// account, and as it.
|
|
func (p Paths) helperCommand() string {
|
|
if !p.Confined() {
|
|
return p.helper()
|
|
}
|
|
return "[ \"$(id -un)\" = " + shellQuote(p.Agent) + " ] && exec cat -- " + shellQuote(p.apiKey())
|
|
}
|
|
|
|
// shellQuote is s as one word of a POSIX shell.
|
|
func shellQuote(s string) string { return "'" + strings.ReplaceAll(s, "'", `'\''`) + "'" }
|
|
|
|
// keyDir is where an API-key licence lives: the module's state, with the key-helper the agent runs from it as
|
|
// the operator — or, where the agent runs as an account of its own, which cannot enter the module's state, a
|
|
// directory of the module's in that account's agent directory (novox/hq ADR 0266), and no helper there: the
|
|
// managed settings carry its command (helperCommand). Either way readable by the account the agent runs as,
|
|
// and by it alone.
|
|
func (p Paths) keyDir() string {
|
|
if p.Confined() {
|
|
return filepath.Join(p.Home, ".claude", "mesh")
|
|
}
|
|
return p.State
|
|
}
|
|
func (p Paths) registry() string { return filepath.Join(p.State, "mcp-servers.json") }
|
|
func (p Paths) config() string { return filepath.Join(p.State, "config.json") }
|
|
func (p Paths) placed() string {
|
|
if p.Confined() {
|
|
// One record per home: what was placed in the operator's is not in the agent's (ADR 0266).
|
|
return filepath.Join(p.State, "home-placed-"+p.Agent+".json")
|
|
}
|
|
return filepath.Join(p.State, "home-placed.json")
|
|
}
|
|
|
|
// Ask is a tool on the bus: its address and arguments in, its JSON answer out.
|
|
type Ask func(address string, args any) (json.RawMessage, error)
|
|
|
|
// WriteManaged writes one managed file and answers what happened.
|
|
type WriteManaged func(name, content string) (string, error)
|
|
|
|
func readJSON(path string, into any) bool {
|
|
raw, err := fsys.ReadFile(path)
|
|
return err == nil && json.Unmarshal(raw, into) == nil
|
|
}
|
|
|
|
// Keypair is this module's own, made once in its state; the TypeScript module's files are kept, so a node
|
|
// moving to this binary keeps the key it had.
|
|
func Keypair(p Paths) (KeyPair, error) {
|
|
priv, pub := filepath.Join(p.State, "key.pem"), filepath.Join(p.State, "key.pub.pem")
|
|
if _, err := fsys.Stat(priv); errors.Is(err, os.ErrNotExist) {
|
|
k, err := GenerateKeyPair()
|
|
if err != nil {
|
|
return KeyPair{}, err
|
|
}
|
|
if err := fsys.WriteFile(priv, []byte(k.PrivateKey), 0o600); err != nil {
|
|
return KeyPair{}, err
|
|
}
|
|
if err := fsys.WriteFile(pub, []byte(k.PublicKey), 0o644); err != nil {
|
|
return KeyPair{}, err
|
|
}
|
|
}
|
|
a, err1 := fsys.ReadFile(priv)
|
|
b, err2 := fsys.ReadFile(pub)
|
|
return KeyPair{PrivateKey: string(a), PublicKey: string(b)}, errors.Join(err1, err2)
|
|
}
|
|
|
|
// Registered is what applies here of the servers registered through this module.
|
|
func Registered(p Paths) Servers {
|
|
s := Servers{}
|
|
readJSON(p.registry(), &s)
|
|
return s
|
|
}
|
|
|
|
var renderMu sync.Mutex
|
|
|
|
// RenderNow writes the managed directory from the facts, the settings, the licence held and the servers
|
|
// registered here.
|
|
func RenderNow(p Paths, write WriteManaged) ([]string, error) {
|
|
// One at a time: the watches and the tools all render, and the home's record of what was placed is
|
|
// read and written whole.
|
|
renderMu.Lock()
|
|
defer renderMu.Unlock()
|
|
var facts Facts
|
|
if !readJSON(p.Facts, &facts) || facts.Console == "" {
|
|
return nil, fmt.Errorf("the mesh has not rendered %s yet; nothing to write", p.Facts)
|
|
}
|
|
var settings Settings
|
|
readJSON(p.Settings, &settings)
|
|
var binding *Binding
|
|
var b Binding
|
|
if readJSON(p.binding(), &b) {
|
|
binding = &b
|
|
}
|
|
var items map[string]Item
|
|
_ = readJSON(p.config(), &items)
|
|
config := ConfigOf(Taken(items))
|
|
var leftOut []string
|
|
config.Terminal, leftOut = terminalText(p)
|
|
mesh := ReadMeshTools(p)
|
|
if mesh != nil {
|
|
mesh.Refused = RefusedCounts(GuardDataOf(p, mesh).Log)
|
|
}
|
|
files := RenderWithMesh(facts, settings, binding, p.helperCommand(), Registered(p), config, mesh)
|
|
tree, _ := json.Marshal(MarketplaceWith(config, guardFiles(p, mesh)))
|
|
files[MarketplaceDir+"/"] = string(tree)
|
|
names := make([]string, 0, len(files))
|
|
for n := range files {
|
|
names = append(names, n)
|
|
}
|
|
// The marketplace first: the settings that enable its plugin must never name one that is not there yet.
|
|
sort.Slice(names, func(i, j int) bool {
|
|
if (names[i] == MarketplaceDir+"/") != (names[j] == MarketplaceDir+"/") {
|
|
return names[i] == MarketplaceDir+"/"
|
|
}
|
|
return names[i] < names[j]
|
|
})
|
|
// Every file is attempted: one that cannot be written — a registration the vendor's layout refuses, a
|
|
// failed escalation — must not keep the licence, the tool servers or the instructions from landing.
|
|
var out []string
|
|
var failed []error
|
|
for _, n := range names {
|
|
line, err := write(n, files[n])
|
|
if err != nil {
|
|
failed = append(failed, err)
|
|
continue
|
|
}
|
|
out = append(out, line)
|
|
}
|
|
// The home scope: what this module places in the account's own agent directory (ADR 0182).
|
|
// Only into a home that belongs to an agent account of its own (novox/hq issue 340): the operator's own home is
|
|
// never written from the bus, whoever put an item there; what the module placed there before is taken back,
|
|
// unless the person changed it since.
|
|
want := HomeFiles(config)
|
|
if !p.HomeIsTheAgentsOwn() {
|
|
if len(want) > 0 {
|
|
out = append(out, fmt.Sprintf("home: %d file(s) registered for this home not placed: it is the operator's "+
|
|
"own, and the home scope writes only an agent account's own home (novox/hq issue 340)", len(want)))
|
|
}
|
|
want = nil
|
|
}
|
|
done, left := PlaceHome(p, want)
|
|
for _, line := range done {
|
|
out = append(out, "home "+line)
|
|
}
|
|
for _, line := range left {
|
|
out = append(out, "home "+line)
|
|
}
|
|
for _, line := range leftOut {
|
|
out = append(out, "left out of the terminal's shared text: "+line)
|
|
}
|
|
// Where the agent moved to an account of its own, what this module placed for it under the operator's home
|
|
// goes (ADR 0266).
|
|
out = append(out, RetireOperatorHome(p)...)
|
|
return out, errors.Join(failed...)
|
|
}
|
|
|
|
// ---- the licence ----------------------------------------------------------------------------------
|
|
|
|
// BindingState is what the manager's `bindings` state says one consumer should hold (ADR 0206).
|
|
type BindingState struct {
|
|
Licence string `json:"licence"`
|
|
Kind string `json:"kind"`
|
|
Generation int64 `json:"generation"`
|
|
}
|
|
|
|
// Current is what the seat answers to `current`: the licence this node is bound to and its token, sealed.
|
|
type Current struct {
|
|
Licence string `json:"licence"`
|
|
Kind string `json:"kind"`
|
|
Generation int64 `json:"generation"`
|
|
Sealed *SealedBox `json:"sealed"`
|
|
Identity *Identity `json:"identity"`
|
|
}
|
|
|
|
// Holdings is what this node holds, as the `holdings` state carries it (ADR 0206): enough for the manager
|
|
// to tell a login it has not adopted from one it has, and never a token — fingerprints and expiries only.
|
|
type Holdings struct {
|
|
Node string `json:"node"`
|
|
Identity *Identity `json:"identity"`
|
|
Kind *string `json:"kind"`
|
|
Refresh struct {
|
|
Present bool `json:"present"`
|
|
Fingerprint *string `json:"fingerprint"`
|
|
ExpiresAt *int64 `json:"expiresAt"`
|
|
} `json:"refresh"`
|
|
Access *struct {
|
|
Fingerprint string `json:"fingerprint"`
|
|
ExpiresAt int64 `json:"expiresAt"`
|
|
} `json:"access"`
|
|
Licence *string `json:"licence"`
|
|
Generation int64 `json:"generation"`
|
|
ChangedAt *string `json:"changedAt"`
|
|
}
|
|
|
|
// HoldingsOf is what this node holds now.
|
|
func HoldingsOf(p Paths) Holdings {
|
|
h := Holdings{Node: p.Node, Identity: ReadIdentity(p.account())}
|
|
creds := ReadCredentials(p.credentials())
|
|
if info, err := fsys.Stat(p.credentials()); err == nil {
|
|
at := info.ModTime().UTC().Format("2006-01-02T15:04:05.000Z")
|
|
h.ChangedAt = &at
|
|
}
|
|
if rt := RefreshTokenOf(creds); rt != "" {
|
|
fp := Fingerprint(rt)
|
|
h.Refresh.Present, h.Refresh.Fingerprint = true, &fp
|
|
}
|
|
if v, ok := number(creds.oauth()["refreshTokenExpiresAt"]); ok {
|
|
h.Refresh.ExpiresAt = &v
|
|
}
|
|
if g := GrantOf(creds); g != nil {
|
|
h.Access = &struct {
|
|
Fingerprint string `json:"fingerprint"`
|
|
ExpiresAt int64 `json:"expiresAt"`
|
|
}{Fingerprint(g.AccessToken), g.ExpiresAt}
|
|
}
|
|
kind := ""
|
|
if _, err := fsys.Stat(p.apiKey()); err == nil {
|
|
kind = "api-key"
|
|
} else if h.Access != nil {
|
|
kind = "subscription"
|
|
}
|
|
if kind != "" {
|
|
h.Kind = &kind
|
|
}
|
|
var applied Binding
|
|
if readJSON(p.binding(), &applied) && applied.Licence != "" {
|
|
h.Licence, h.Generation = &applied.Licence, applied.Generation
|
|
}
|
|
return h
|
|
}
|
|
|
|
// GrantAnswer is what `claude_code_grant` answers: a login sealed to the key given, or nothing waiting.
|
|
type GrantAnswer struct {
|
|
Sealed *SealedBox `json:"sealed,omitempty"`
|
|
Identity *Identity `json:"identity,omitempty"`
|
|
Fingerprint string `json:"fingerprint,omitempty"`
|
|
Waiting *bool `json:"waiting,omitempty"`
|
|
}
|
|
|
|
// GrantFor is the full grant in the credentials file sealed to the manager's key — the one time a refresh
|
|
// token leaves this node, for the manager to adopt by refreshing it (ADR 0206). Nothing waiting when the
|
|
// file holds no refresh token.
|
|
func GrantFor(p Paths, managerPublicKey string) (GrantAnswer, error) {
|
|
creds := ReadCredentials(p.credentials())
|
|
rt := RefreshTokenOf(creds)
|
|
if rt == "" {
|
|
no := false
|
|
return GrantAnswer{Waiting: &no}, nil
|
|
}
|
|
raw, err := json.Marshal(creds.oauth())
|
|
if err != nil {
|
|
return GrantAnswer{}, err
|
|
}
|
|
box, err := Seal(string(raw), managerPublicKey)
|
|
if err != nil {
|
|
return GrantAnswer{}, err
|
|
}
|
|
return GrantAnswer{Sealed: &box, Identity: ReadIdentity(p.account()), Fingerprint: Fingerprint(rt)}, nil
|
|
}
|
|
|
|
// Pull asks the seat for this node's current token and applies it.
|
|
func Pull(p Paths, ask Ask, write WriteManaged) (map[string]any, error) {
|
|
keys, err := Keypair(p)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
raw, err := ask(SeatVerb("current"), map[string]any{"consumer": p.Node, "public_key": keys.PublicKey})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
var c Current
|
|
if err := json.Unmarshal(raw, &c); err != nil || c.Sealed == nil {
|
|
return map[string]any{"applied": false, "reason": "the seat holds no licence for this node"}, nil
|
|
}
|
|
return Apply(p, c, write)
|
|
}
|
|
|
|
// OnBinding takes a change to this node's key in the manager's `bindings` state (ADR 0206): the token is
|
|
// fetched when the binding differs from the one applied. A released binding keeps the last token, which
|
|
// lives hours, and says so.
|
|
//
|
|
// **Differs, not "is newer"** (novox/hq issue 243). The state keeps only the latest value per node, so
|
|
// nothing older can arrive. A manager whose store was rebuilt counts generations from one again, and
|
|
// a node that waited for a number above its own ignored every binding it was sent, its login and the
|
|
// licence's rotations included, until the count caught up. Only the binding already applied is skipped.
|
|
func OnBinding(p Paths, b *BindingState, ask Ask, write WriteManaged) (string, error) {
|
|
if b == nil {
|
|
return "this node's binding was released; it keeps its last token until it expires", nil
|
|
}
|
|
var applied Binding
|
|
if readJSON(p.binding(), &applied) && applied.Generation == b.Generation && applied.Licence == b.Licence &&
|
|
holdsApplied(p, applied.Kind) {
|
|
return "", nil
|
|
}
|
|
out, err := Pull(p, ask, write)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
raw, _ := json.Marshal(out)
|
|
return string(raw), nil
|
|
}
|
|
|
|
// Apply applies what the seat handed over. A switch replaces the grant whole and cleans up after the old
|
|
// licence; whatever it is, the file is written without a refresh token, so the agent here never refreshes.
|
|
func Apply(p Paths, c Current, write WriteManaged) (map[string]any, error) {
|
|
keys, err := Keypair(p)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
plain, err := Open(*c.Sealed, keys.PrivateKey)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
var previous Binding
|
|
had := readJSON(p.binding(), &previous)
|
|
switched := !had || previous.Licence != c.Licence
|
|
out := map[string]any{"applied": true, "licence": c.Licence, "kind": c.Kind, "switched": switched}
|
|
if c.Kind == "api-key" {
|
|
if err := fsys.MkdirAll(p.keyDir(), 0o700); err != nil {
|
|
return nil, err
|
|
}
|
|
if err := fsys.WriteFile(p.apiKey(), []byte(strings.TrimSpace(plain)+"\n"), 0o600); err != nil {
|
|
return nil, err
|
|
}
|
|
if p.Confined() {
|
|
// No script under the agent's home: the managed settings carry the command whole (helperCommand).
|
|
_ = fsys.Remove(p.helper())
|
|
} else {
|
|
if err := fsys.WriteFile(p.helper(), []byte("#!/bin/sh\nexec cat "+shellQuote(p.apiKey())+"\n"), 0o700); err != nil {
|
|
return nil, err
|
|
}
|
|
_ = fsys.Chmod(p.helper(), 0o700)
|
|
}
|
|
} else {
|
|
var g Grant
|
|
if err := json.Unmarshal([]byte(plain), &g); err != nil {
|
|
return nil, err
|
|
}
|
|
local := ReadCredentials(p.credentials())
|
|
// A login waiting here was handed to the manager first (ADR 0206): what comes back is its successor,
|
|
// and the refresh token in the file is the one the manager just spent.
|
|
d := DecideApply(GrantOf(local), g, switched || HoldsLogin(local))
|
|
if d.Apply {
|
|
next := WithGrant(local, g)
|
|
if switched {
|
|
next = ReplacedBy(local, g)
|
|
}
|
|
if err := WriteCredentials(p.credentials(), next); err != nil {
|
|
return nil, err
|
|
}
|
|
} else {
|
|
out = map[string]any{"applied": false, "licence": c.Licence, "reason": d.Reason}
|
|
}
|
|
// Away from the API key: it goes, with its helper.
|
|
_ = fsys.Remove(p.apiKey())
|
|
_ = fsys.Remove(p.helper())
|
|
}
|
|
// The account file follows the token: on a switch, and wherever the agent's account file names no account
|
|
// yet — a home the agent has just moved to (ADR 0266) — so the agent shows the account whose token it holds.
|
|
if (switched || ReadIdentity(p.account()) == nil) && c.Identity != nil && c.Identity.AccountUUID != "" {
|
|
changed, err := WriteIdentity(p.account(), *c.Identity)
|
|
if err == nil {
|
|
out["account"] = map[bool]string{true: "updated", false: "unchanged"}[changed]
|
|
}
|
|
}
|
|
gen := c.Generation
|
|
if gen == 0 {
|
|
gen = previous.Generation
|
|
}
|
|
raw, _ := json.Marshal(Binding{Licence: c.Licence, Kind: c.Kind, Generation: gen})
|
|
if err := fsys.WriteFile(p.binding(), append(raw, '\n'), 0o600); err != nil {
|
|
return nil, err
|
|
}
|
|
// The key-helper comes or goes with the licence's kind.
|
|
if rendered, err := RenderNow(p, write); err != nil {
|
|
out["rendered"] = map[string]any{"failed": err.Error()}
|
|
} else {
|
|
out["rendered"] = rendered
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// apiKeyShape is an Anthropic API key; apiKeyFileMax the most a file holding one is read for.
|
|
var apiKeyShape = regexp.MustCompile(`^sk-ant-[A-Za-z0-9_-]{20,300}$`)
|
|
|
|
const apiKeyFileMax = 4096
|
|
|
|
// agentsOwnFile is a path under the home of the account the agent runs as, with no symbolic link between the
|
|
// home and it, the file itself included; refused otherwise.
|
|
func agentsOwnFile(p Paths, file string) (string, error) {
|
|
if p.Home == "" || !filepath.IsAbs(file) {
|
|
return "", fmt.Errorf("%s: the key file is named by its full path, or ~/…, in the agent's home", file)
|
|
}
|
|
home, clean := filepath.Clean(p.Home), filepath.Clean(file)
|
|
rel, err := filepath.Rel(home, clean)
|
|
if err != nil || rel == "." || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) {
|
|
return "", fmt.Errorf("%s is not in the agent's home (%s): the key file is taken only from there", file, home)
|
|
}
|
|
at := home
|
|
for _, part := range strings.Split(rel, string(filepath.Separator)) {
|
|
at = filepath.Join(at, part)
|
|
info, err := fsys.Lstat(at)
|
|
if err != nil {
|
|
return "", fmt.Errorf("the key file: %w", err)
|
|
}
|
|
if info.Mode()&os.ModeSymlink != 0 {
|
|
return "", fmt.Errorf("%s is a symbolic link: the key file is taken only from the agent's home itself", at)
|
|
}
|
|
}
|
|
return clean, nil
|
|
}
|
|
|
|
// AddAPIKey adds an API key from a file on this node to the licence manager (ADR 0209): sealed to the
|
|
// manager's public key, handed to the seat's `adopt` on request/reply, and the file removed once taken. With
|
|
// useHere, this node is switched to the new licence. The key never crosses the bus in the clear and is
|
|
// never an argument.
|
|
//
|
|
// **The file is the agent's, never the runtime's** (novox/hq ADR 0266). The tool reads a file and removes it,
|
|
// and the runtime runs as the operator's account: a path anywhere would let whoever calls it take the runtime's
|
|
// bus credential or the operator's keys off the machine, sealed, and delete the original. So the file must lie
|
|
// under the home of the account the agent runs as, reached through no symbolic link, and is read as that
|
|
// account; what it holds must be an Anthropic API key, or nothing is handed over and nothing removed.
|
|
func AddAPIKey(p Paths, name, file string, useHere bool, ask Ask) (map[string]any, error) {
|
|
file, err := agentsOwnFile(p, file)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
raw, err := fsys.ReadFile(file)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("the key is read from a file in the agent's home: %w", err)
|
|
}
|
|
if len(raw) > apiKeyFileMax {
|
|
return nil, fmt.Errorf("%s is not a key file: larger than %d bytes; nothing handed over", file, apiKeyFileMax)
|
|
}
|
|
key := strings.TrimSpace(string(raw))
|
|
if key == "" {
|
|
return nil, fmt.Errorf("%s is empty", file)
|
|
}
|
|
if !apiKeyShape.MatchString(key) {
|
|
// Never the content: the file may hold a secret of another kind.
|
|
return nil, fmt.Errorf("%s does not hold an Anthropic API key (sk-ant-…); nothing handed over, nothing removed", file)
|
|
}
|
|
answer, err := ask(SeatVerb("public-key"), map[string]any{})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
var pk struct {
|
|
PublicKey string `json:"public_key"`
|
|
}
|
|
if err := json.Unmarshal(answer, &pk); err != nil || !strings.Contains(pk.PublicKey, "PUBLIC KEY") {
|
|
return nil, errors.New("the licence manager did not say what key to seal to")
|
|
}
|
|
box, err := Seal(key, pk.PublicKey)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
adopted, err := ask(SeatVerb("adopt"), map[string]any{"name": name, "sealed": box, "from": p.Node})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
out := map[string]any{"adopted": json.RawMessage(adopted)}
|
|
// Taken: the key now lives encrypted in the manager's store alone.
|
|
if err := fsys.Remove(file); err != nil {
|
|
out["file"] = "could not be removed: " + err.Error()
|
|
} else {
|
|
out["file"] = "removed"
|
|
}
|
|
if useHere {
|
|
switched, err := ask(SeatVerb("switch"), map[string]any{"consumer": p.Node, "licence": name})
|
|
if err != nil {
|
|
return out, fmt.Errorf("adopted, and switching this node to it failed: %w", err)
|
|
}
|
|
out["switched"] = json.RawMessage(switched)
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// ---- MCP servers ----------------------------------------------------------------------------------
|
|
|
|
// Registration is a server registered (or, with no entry, unregistered) through this module.
|
|
type Registration struct {
|
|
Name string
|
|
Entry map[string]any
|
|
// Nodes: nil for this node, ["all"] for every node running the module, or a list.
|
|
Nodes []string
|
|
}
|
|
|
|
// ServerState is the `servers` state as this module reaches it through the runtime.
|
|
type ServerState interface {
|
|
Put(key string, value any) error
|
|
Delete(key string) error
|
|
Keys() ([]string, error)
|
|
Get(key string) (json.RawMessage, bool, error)
|
|
}
|
|
|
|
// ServerChange is one change to the `servers` state, as a watch hands it over.
|
|
type ServerChange struct {
|
|
Key string
|
|
Op string // put | delete
|
|
Value map[string]any
|
|
}
|
|
|
|
// KeyOf is the key a registration lives at: `all.<server>` for every node, `<node>.<server>` for one.
|
|
func KeyOf(scope, name string) string { return scope + "." + name }
|
|
|
|
// ServerView is what this node takes from the `servers` state: the entries for every node and for this
|
|
// one, kept in memory from the watch and written through to the module's own file whenever what applies
|
|
// here changes, so the managed directory renders without the bus.
|
|
type ServerView struct {
|
|
p Paths
|
|
mu sync.Mutex
|
|
entries map[string]map[string]any
|
|
}
|
|
|
|
// NewServerView is an empty view for this node.
|
|
func NewServerView(p Paths) *ServerView {
|
|
return &ServerView{p: p, entries: map[string]map[string]any{}}
|
|
}
|
|
|
|
// Take takes one change, and answers whether what applies to this node changed.
|
|
func (v *ServerView) Take(c ServerChange) bool {
|
|
scope, name, ok := strings.Cut(c.Key, ".")
|
|
if !ok || scope == "" || (scope != "all" && scope != v.p.Node) {
|
|
return false
|
|
}
|
|
v.mu.Lock()
|
|
if c.Op == "put" && c.Value != nil && EntryProblem(name, c.Value) == "" {
|
|
v.entries[c.Key] = c.Value
|
|
} else {
|
|
delete(v.entries, c.Key)
|
|
}
|
|
v.mu.Unlock()
|
|
return v.writeThrough()
|
|
}
|
|
|
|
// Effective is what applies here: every node's entries, with this node's own laid over them by name.
|
|
func (v *ServerView) Effective() Servers {
|
|
v.mu.Lock()
|
|
defer v.mu.Unlock()
|
|
out := Servers{}
|
|
for _, scope := range []string{"all", v.p.Node} {
|
|
for key, entry := range v.entries {
|
|
if name, ok := strings.CutPrefix(key, scope+"."); ok {
|
|
out[name] = entry
|
|
}
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
func (v *ServerView) writeThrough() bool {
|
|
now, _ := indented(v.Effective())
|
|
before, _ := fsys.ReadFile(v.p.registry())
|
|
if string(before) == string(now) {
|
|
return false
|
|
}
|
|
_ = fsys.WriteFile(v.p.registry(), now, 0o600)
|
|
return true
|
|
}
|
|
|
|
// OnServerChange takes a change from the watch, and renders when what applies here changed.
|
|
func OnServerChange(v *ServerView, c ServerChange, p Paths, write WriteManaged) (string, error) {
|
|
if !v.Take(c) {
|
|
return "", nil
|
|
}
|
|
if _, err := RenderNow(p, write); err != nil {
|
|
return "", err
|
|
}
|
|
what := "registered"
|
|
if c.Op != "put" {
|
|
what = "unregistered"
|
|
}
|
|
return what + " " + c.Key, nil
|
|
}
|
|
|
|
// ServerAtTheTerminal is why a tool server is never registered through this module (novox/hq issue 340): every
|
|
// session on the nodes it reaches starts it, a stdio server as a program run as whoever started the session, and
|
|
// an http one is handed what the session asks — and the tool runner is not told who called it.
|
|
const ServerAtTheTerminal = "a tool server is started, or handed what the agent asks it, by every session on the nodes it " +
|
|
"reaches, as whoever starts that session — the operator, whose account holds root — and the tool runner is not " +
|
|
"told who called it, so an agent on another node could register one. It is set at the controller's terminal " +
|
|
"only, in this module's mcp_servers setting, on the control-node: mesh-cli settings set claude-code '{\"mcp_servers\": {...}}' " +
|
|
"[--node <node>] (novox/hq issue 340). Nothing was registered"
|
|
|
|
// RegisterServer unregisters a server: a delete per scope in the `servers` state, taken into this node's view at
|
|
// once so the answer says what it did here; every other node takes it from its watch. A registration — an entry
|
|
// given — is refused (ServerAtTheTerminal): what is in the state from before stays, and is only ever removed here.
|
|
func RegisterServer(p Paths, r Registration, servers ServerState, v *ServerView, write WriteManaged,
|
|
others func() ([]string, error)) (map[string]any, error) {
|
|
if r.Entry != nil {
|
|
return nil, errors.New(ServerAtTheTerminal)
|
|
}
|
|
scopes := r.Nodes
|
|
if len(scopes) == 0 {
|
|
scopes = []string{p.Node}
|
|
}
|
|
// Compared before and after rather than read from Take: this node's own watch may hand the view the
|
|
// same change first, and then Take here finds nothing new although this call made it.
|
|
before, _ := json.Marshal(v.Effective())
|
|
for _, scope := range scopes {
|
|
key := KeyOf(scope, r.Name)
|
|
if err := servers.Delete(key); err != nil {
|
|
return nil, err
|
|
}
|
|
v.Take(ServerChange{Key: key, Op: "delete"})
|
|
}
|
|
after, _ := json.Marshal(v.Effective())
|
|
changed := string(before) != string(after)
|
|
here := false
|
|
for _, s := range scopes {
|
|
here = here || s == "all" || s == p.Node
|
|
}
|
|
answer := map[string]any{"unregistered": r.Name, "on": scopes}
|
|
switch {
|
|
case !here:
|
|
answer["here"] = "not this node"
|
|
case changed:
|
|
answer["here"] = "changed"
|
|
default:
|
|
answer["here"] = "already so"
|
|
}
|
|
if changed {
|
|
rendered, err := RenderNow(p, write)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
answer["rendered"] = rendered
|
|
}
|
|
if _, still := v.Effective()[r.Name]; still {
|
|
answer["still"] = r.Name + " still applies here from another registration (for every node, or for this one); unregister that too"
|
|
}
|
|
if len(r.Nodes) == 0 {
|
|
// The question the operator wanted asked: here only, or more?
|
|
var elsewhere []string
|
|
if nodes, err := others(); err == nil {
|
|
for _, n := range nodes {
|
|
if n != p.Node {
|
|
elsewhere = append(elsewhere, n)
|
|
}
|
|
}
|
|
}
|
|
if len(elsewhere) > 0 {
|
|
answer["also"] = fmt.Sprintf("claude-code also runs on %s. To unregister it there too, call again with nodes: \"all\" or a list of those nodes.",
|
|
strings.Join(elsewhere, ", "))
|
|
} else {
|
|
answer["also"] = "To do the same on every node running claude-code, call again with nodes: \"all\"."
|
|
}
|
|
}
|
|
return answer, nil
|
|
}
|
|
|
|
// stamp is a time as the status answers it.
|
|
func stamp(ms int64) string { return time.UnixMilli(ms).UTC().Format(time.RFC3339) }
|