The licence manager runs as its own account, so its own logs are issued to it and its carry-over can finish (issue 1409) #659

Merged
mesh-tools merged 2 commits from fix/1409-own-runtime into main 2026-11-12 08:41:49 +00:00
6 changed files with 244 additions and 25 deletions
+6
View File
@@ -5,6 +5,12 @@ rotation source, its consumers moved off a licence near its limit by the manager
ADR 0206, design 39 and its milestone bus-and-switch). Every grant and access token lives in the vault; this module
keeps none.
It runs as an account of its own (`runs-as claude-licence-manager`, design 39 bus-and-switch C1), in a runtime of its
own, never in the machine's tool runner: its service part (the carry-over at start, the usage timer, the switch rule,
the conditions) and its tools beside it, so every state, log and secret below is issued to its own account. Its
account is issued what its manifest names: the asks under `invokes` (the vault, the operator channel, mesh-config and
a node's `claude_code_grant`), the events under `emits` and `consumes`.
## How a licence comes to exist
Nothing is configured. Every node running `claude-code` says what it holds as the event
@@ -1,7 +1,9 @@
// claude-licence-manager (novox/hq ADR 0183, ADR 0206, design 39): one binary, launched by the control
// node's runtime and speaking MCP to it over stdio through the Go SDK (ADR 0193, ADR 0198). It serves the
// `anthropic-licence-manager` seat's verbs and, beside them, runs long: it watches what every node reports
// holding and adopts a login it does not hold, keeps every grant alive under a lease, and reads usage.
// claude-licence-manager (novox/hq ADR 0183, ADR 0206, design 39): one binary, launched by a runtime of its own under
// its own account (`runs-as claude-licence-manager`, design 39 bus-and-switch C1), never by the machine's tool runner,
// and speaking MCP to it over stdio through the Go SDK (ADR 0193, ADR 0198). Its service part runs long (the
// carry-over at start, the usage timer, the switch rule, the conditions): it watches what every node reports holding
// and adopts a login it does not hold, keeps every grant alive under a lease, and reads usage. Its tools part, the
// seat's verbs and the module's tools, is served beside it and acts only through that one running manager.
//
// stdout is the MCP channel; everything this module says, it says on stderr.
package main
@@ -58,7 +60,7 @@ func manager() (*Manager, error) {
if err != nil {
return nil, err
}
keys, err := keypair(dir)
keys, err := keypair(dir, say)
if err != nil {
return nil, err
}
@@ -129,28 +131,60 @@ func configLevels(node string) (map[string]string, error) {
}
// keypair is this module's own, made once in its state directory; the private half never leaves it.
// Written whole, then linked into place, so a second process reads the first's key and never half of it.
func keypair(dir string) (KeyPair, error) {
// Written whole, then linked into place, so a second process reads the first's key and never half of it. A pair it
// finds and cannot read (another account's, left before the module ran as its own) is made anew in its place and said
// in its journal (design 39 bus-and-switch C2): every use passes the public key per ask, so nothing depends on the old.
func keypair(dir string, log func(string, ...any)) (KeyPair, error) {
file := filepath.Join(dir, "manager-key.json")
if _, err := os.Stat(file); errors.Is(err, os.ErrNotExist) {
k, err := GenerateKeyPair()
if err != nil {
if err := writeKeyPair(dir, file, false); err != nil {
return KeyPair{}, err
}
raw, _ := json.Marshal(k)
tmp := filepath.Join(dir, fmt.Sprintf(".manager-key.%d.json", os.Getpid()))
if err := os.WriteFile(tmp, raw, 0o600); err != nil {
return KeyPair{}, err
}
_ = os.Link(tmp, file) // fails when another made it first, which is right
_ = os.Remove(tmp)
}
k, err := readKeyPair(file)
if err == nil {
return k, nil
}
log("the key pair %s cannot be read (%v); a new one is made in its place", file, err)
if err := writeKeyPair(dir, file, true); err != nil {
return KeyPair{}, err
}
return readKeyPair(file)
}
func readKeyPair(file string) (KeyPair, error) {
raw, err := os.ReadFile(file)
if err != nil {
return KeyPair{}, err
}
var k KeyPair
return k, json.Unmarshal(raw, &k)
if err := json.Unmarshal(raw, &k); err != nil {
return KeyPair{}, err
}
if k.PublicKey == "" || k.PrivateKey == "" {
return KeyPair{}, errors.New("it holds no whole key pair")
}
return k, nil
}
// writeKeyPair writes a new pair whole beside file, then links it into place, or, replacing, renames it over the one
// that cannot be read.
func writeKeyPair(dir, file string, replace bool) error {
k, err := GenerateKeyPair()
if err != nil {
return err
}
raw, _ := json.Marshal(k)
tmp := filepath.Join(dir, fmt.Sprintf(".manager-key.%d.json", os.Getpid()))
if err := os.WriteFile(tmp, raw, 0o600); err != nil {
return err
}
if replace {
return os.Rename(tmp, file)
}
_ = os.Link(tmp, file) // fails when another made it first, which is right
_ = os.Remove(tmp)
return nil
}
// ---- the long-running half ---------------------------------------------------------------------------
@@ -4,6 +4,8 @@ import (
"encoding/json"
"os"
"path/filepath"
"regexp"
"strings"
"testing"
)
@@ -48,3 +50,105 @@ func TestTheManifestKeepsNoOldForm(t *testing.T) {
}
}
}
// issued is what the manager's own runtime is issued from its manifest (design 39 bus-and-switch C1): its account, the
// states and logs it keeps (`requires` by template), what it may ask (`invokes`) and say (`emits`).
type issued struct {
RunsAs, SecretsOwner string
OwnSecrets map[string]string
Resources []map[string]any
States, Logs map[string]bool
Invokes, Emits map[string]bool
}
func manifestIssued(t testing.TB) issued {
t.Helper()
raw, err := os.ReadFile(filepath.Join(moduleDir, "module.json"))
if err != nil {
t.Fatal(err)
}
var m struct {
RunsAs string `json:"runs-as"`
SecretsOwner string `json:"secrets-owner"`
OwnSecrets map[string]string `json:"own-secrets"`
Requires []json.RawMessage `json:"requires"`
Resources []map[string]any `json:"resources"`
Invokes []string `json:"invokes"`
Emits []string `json:"emits"`
}
if err := json.Unmarshal(raw, &m); err != nil {
t.Fatal(err)
}
out := issued{RunsAs: m.RunsAs, SecretsOwner: m.SecretsOwner, OwnSecrets: m.OwnSecrets, Resources: m.Resources,
States: map[string]bool{}, Logs: map[string]bool{}, Invokes: map[string]bool{}, Emits: map[string]bool{}}
for _, r := range m.Requires {
var e struct{ Name, Storage string }
if json.Unmarshal(r, &e) != nil {
continue
}
switch e.Storage {
case "key-value":
out.States[e.Name] = true
case "stream":
out.Logs[e.Name] = true
}
}
for _, k := range m.Invokes {
out.Invokes[k] = true
}
for _, e := range m.Emits {
out.Emits[e] = true
}
return out
}
// mayAsk is whether the manager's own account may ask key: a module tool asked on one node (`<module>.<tool>@<node>`)
// is invoked as `<module>.<tool>`.
func (i issued) mayAsk(key string) bool {
k, _, _ := strings.Cut(key, "@")
return i.Invokes[k]
}
// design 39 bus-and-switch C1: the manager runs as an account of its own, which it makes and which owns what it places
// on its machine and its secrets, reaching the bus as itself; and that account is issued every ask its code makes,
// since its runtime is not the machine's tool runner.
func TestTheManagerRunsAsItsOwnAccount(t *testing.T) {
m := manifestIssued(t)
if m.RunsAs != Publisher {
t.Fatalf("the manager runs as %q, not as its own account %s", m.RunsAs, Publisher)
}
if m.SecretsOwner != Publisher || m.OwnSecrets["broker"] == "" {
t.Errorf("its secrets belong to %q and its own broker secret is %q: both are its account's", m.SecretsOwner,
m.OwnSecrets["broker"])
}
made := false
for _, r := range m.Resources {
switch r["type"] {
case "user":
made = made || r["name"] == Publisher
case "directory", "file":
if r["owner"] != Publisher {
t.Errorf("resource %v is owned by %v, not by the manager's account", r["id"], r["owner"])
}
}
}
if !made {
t.Errorf("the manager does not make its account: no user resource named %s", Publisher)
}
files, _ := filepath.Glob("*.go")
asked := regexp.MustCompile(`stdio\.Ask\("([^"]+)"`)
for _, f := range files {
if strings.HasSuffix(f, "_test.go") {
continue
}
src, err := os.ReadFile(f)
if err != nil {
t.Fatal(err)
}
for _, k := range asked.FindAllStringSubmatch(string(src), -1) {
if !m.mayAsk(k[1]) {
t.Errorf("%s asks %s, which the manifest's invokes do not name", f, k[1])
}
}
}
}
@@ -2,7 +2,10 @@ package main
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"strings"
"testing"
)
@@ -41,3 +44,29 @@ func TestABoxOpensOnlyForItsRecipient(t *testing.T) {
t.Fatal("a box opened for another key")
}
}
// design 39 bus-and-switch C2: a key pair the manager finds and cannot read (left by another account, here mode 000) is
// replaced in its place by a new one, said in its journal; the new one is read back by the next start.
func TestAKeyPairItCannotReadIsMadeAnew(t *testing.T) {
if os.Geteuid() == 0 {
t.Fatal("run as root, a file of mode 000 is still read: this test needs an ordinary account")
}
dir := t.TempDir()
file := filepath.Join(dir, "manager-key.json")
if err := os.WriteFile(file, []byte(`{"publicKey":"old","privateKey":"old"}`), 0o000); err != nil {
t.Fatal(err)
}
var said []string
log := func(format string, a ...any) { said = append(said, fmt.Sprintf(format, a...)) }
k, err := keypair(dir, log)
if err != nil {
t.Fatalf("a key pair it cannot read stops the manager: %v", err)
}
if k.PublicKey == "" || k.PublicKey == "old" || len(said) != 1 || !strings.Contains(said[0], "manager-key.json") {
t.Fatalf("not made anew and said: key %q, journal %q", k.PublicKey, said)
}
again, err := keypair(dir, log)
if err != nil || again != k || len(said) != 1 {
t.Fatalf("the new pair is not the one read at the next start: %v, %v, %q", again == k, err, said)
}
}
@@ -287,6 +287,7 @@ type mesh struct {
dir string
dbURL string
vendor *vendor
own issued // what the manager's own runtime is issued from its manifest (C1)
mu sync.Mutex
said []said
@@ -299,11 +300,14 @@ type mesh struct {
conditions []oc.Condition
}
const busStates = "licences bindings leases offered carried conditions"
const busLogs = "usage audit"
func newWholeMesh(t *testing.T, dbURL string) *mesh {
t.Helper()
// The manager runs as its own account, never in the machine's tool runner, whose account is issued none of its
// logs or states (C1): this player plays that runtime of its own, issuing what the manifest names and nothing else.
own := manifestIssued(t)
if own.RunsAs != Publisher {
t.Fatalf("the manager declares runs-as %q: it would run in the machine's tool runner, not as its own account", own.RunsAs)
}
ctx, cancel := context.WithCancel(context.Background())
t.Cleanup(cancel)
dir := t.TempDir()
@@ -323,12 +327,12 @@ func newWholeMesh(t *testing.T, dbURL string) *mesh {
t.Fatal(err)
}
// What the bus module creates from the manifest's templates (§1 to §9) before the manager's want moves (§50).
for _, s := range strings.Fields(busStates) {
for s := range own.States {
if _, err := js.CreateKeyValue(ctx, jetstream.KeyValueConfig{Bucket: Publisher + "_" + s, History: 8}); err != nil {
t.Fatal(err)
}
}
for _, l := range strings.Fields(busLogs) {
for l := range own.Logs {
if _, err := js.CreateStream(ctx, jetstream.StreamConfig{Name: "LOG_" + Publisher + "_" + l,
Subjects: []string{"mesh.log." + Publisher + "." + l + ".>"}}); err != nil {
t.Fatal(err)
@@ -340,7 +344,7 @@ func newWholeMesh(t *testing.T, dbURL string) *mesh {
t.Fatal(err)
}
}
m := &mesh{t: t, ctx: ctx, nc: nc, js: js, bin: bin, dir: dir, dbURL: dbURL, vendor: newVendor(t),
m := &mesh{t: t, ctx: ctx, nc: nc, js: js, bin: bin, dir: dir, dbURL: dbURL, vendor: newVendor(t), own: own,
news: make(chan struct{}, 1), secrets: map[string]string{}, calls: map[string][32]byte{},
callArgs: map[string]map[string]any{}, logins: map[string]FullGrant{}}
sub, err := nc.Subscribe("mesh.seat.operator-channel.accept.condition.>", func(msg *nats.Msg) {
@@ -521,6 +525,10 @@ func (p *started) handle(w wire) {
Pattern string `json:"pattern"`
}
_ = json.Unmarshal(w.Params, &a)
if why := p.refused(w.Method, w.Params); why != nil {
p.answer(w.ID, nil, why)
return
}
if strings.HasPrefix(w.Method, "mesh/log.") {
p.log(w)
return
@@ -620,6 +628,28 @@ func (p *started) handle(w wire) {
}
}
// refused is the runtime's refusal of what the manager's own account was not issued (C1): a state or a log its
// manifest does not ask for, an ask its invokes do not name, an event its emits do not name; nil when it was issued.
func (p *started) refused(method string, params json.RawMessage) error {
var a struct {
State string `json:"state"`
Log string `json:"log"`
Key string `json:"key"`
}
_ = json.Unmarshal(params, &a)
switch {
case strings.HasPrefix(method, "mesh/state.") && !p.own.States[a.State]:
return fmt.Errorf("%s keeps no state called %q", Publisher, a.State)
case strings.HasPrefix(method, "mesh/log.") && !p.own.Logs[a.Log]:
return fmt.Errorf("%s keeps no log called %q", Publisher, a.Log)
case method == "mesh/ask" && !p.own.mayAsk(a.Key):
return fmt.Errorf("this account may not call %s", a.Key)
case method == "mesh/publish" && !p.own.Emits[a.Key]:
return fmt.Errorf("%s does not emit %s", Publisher, a.Key)
}
return nil
}
func (p *started) say(key string, body json.RawMessage) {
p.mu.Lock()
p.said = append(p.said, said{key: key, body: append(json.RawMessage(nil), body...)})
@@ -899,7 +929,7 @@ func (m *mesh) written() map[string]uint64 {
i, _ := info.Info(m.ctx)
out[s] = i.State.LastSeq
}
for _, l := range strings.Fields(busLogs) {
for l := range m.own.Logs {
info, err := m.js.Stream(m.ctx, "LOG_"+Publisher+"_"+l)
if err != nil {
m.t.Fatal(err)
+17 -1
View File
@@ -2,6 +2,7 @@
"module": "claude-licence-manager",
"version": "1",
"slug": "licmgr",
"runs-as": "claude-licence-manager",
"requires": [
"postgres-database",
"secret",
@@ -91,6 +92,10 @@
"grant-key": "${dir:state}/grant.key"
}
},
"own-secrets": {
"broker": "${dir:state}/broker"
},
"secrets-owner": "claude-licence-manager",
"uses": [
"operator-channel"
],
@@ -98,7 +103,8 @@
"seat:operator-channel.tell",
"seat:mesh-vault.licence-put",
"seat:mesh-vault.get",
"seat:mesh-config.config"
"seat:mesh-config.config",
"claude-code.claude_code_grant"
],
"seats": [
{
@@ -232,10 +238,18 @@
}
},
"resources": [
{
"id": "account",
"type": "user",
"name": "claude-licence-manager",
"shell": "/usr/bin/nologin",
"home": "/var/lib/claude-licence-manager"
},
{
"id": "state",
"type": "directory",
"mode": "0700",
"owner": "claude-licence-manager",
"place": ".",
"unknown": "program"
},
@@ -244,6 +258,7 @@
"type": "file",
"path": "${dir:state}/database.url",
"mode": "0600",
"owner": "claude-licence-manager",
"template": "files/database.url"
},
{
@@ -251,6 +266,7 @@
"type": "file",
"path": "${dir:state}/settings.json",
"mode": "0600",
"owner": "claude-licence-manager",
"takes-config": [
"cadence_minutes",
"floor_minutes",