Merge pull request '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) from fix/1409-own-runtime into main
This commit was merged in pull request #659.
This commit is contained in:
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
+37
-7
@@ -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)
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user