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
|
ADR 0206, design 39 and its milestone bus-and-switch). Every grant and access token lives in the vault; this module
|
||||||
keeps none.
|
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
|
## How a licence comes to exist
|
||||||
|
|
||||||
Nothing is configured. Every node running `claude-code` says what it holds as the event
|
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
|
// claude-licence-manager (novox/hq ADR 0183, ADR 0206, design 39): one binary, launched by a runtime of its own under
|
||||||
// node's runtime and speaking MCP to it over stdio through the Go SDK (ADR 0193, ADR 0198). It serves the
|
// its own account (`runs-as claude-licence-manager`, design 39 bus-and-switch C1), never by the machine's tool runner,
|
||||||
// `anthropic-licence-manager` seat's verbs and, beside them, runs long: it watches what every node reports
|
// and speaking MCP to it over stdio through the Go SDK (ADR 0193, ADR 0198). Its service part runs long (the
|
||||||
// holding and adopts a login it does not hold, keeps every grant alive under a lease, and reads usage.
|
// 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.
|
// stdout is the MCP channel; everything this module says, it says on stderr.
|
||||||
package main
|
package main
|
||||||
@@ -58,7 +60,7 @@ func manager() (*Manager, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
keys, err := keypair(dir)
|
keys, err := keypair(dir, say)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
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.
|
// 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.
|
// Written whole, then linked into place, so a second process reads the first's key and never half of it. A pair it
|
||||||
func keypair(dir string) (KeyPair, error) {
|
// 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")
|
file := filepath.Join(dir, "manager-key.json")
|
||||||
if _, err := os.Stat(file); errors.Is(err, os.ErrNotExist) {
|
if _, err := os.Stat(file); errors.Is(err, os.ErrNotExist) {
|
||||||
k, err := GenerateKeyPair()
|
if err := writeKeyPair(dir, file, false); err != nil {
|
||||||
if err != nil {
|
|
||||||
return KeyPair{}, err
|
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)
|
raw, err := os.ReadFile(file)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return KeyPair{}, err
|
return KeyPair{}, err
|
||||||
}
|
}
|
||||||
var k KeyPair
|
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 ---------------------------------------------------------------------------
|
// ---- the long-running half ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
"testing"
|
"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 (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -41,3 +44,29 @@ func TestABoxOpensOnlyForItsRecipient(t *testing.T) {
|
|||||||
t.Fatal("a box opened for another key")
|
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
|
dir string
|
||||||
dbURL string
|
dbURL string
|
||||||
vendor *vendor
|
vendor *vendor
|
||||||
|
own issued // what the manager's own runtime is issued from its manifest (C1)
|
||||||
|
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
said []said
|
said []said
|
||||||
@@ -299,11 +300,14 @@ type mesh struct {
|
|||||||
conditions []oc.Condition
|
conditions []oc.Condition
|
||||||
}
|
}
|
||||||
|
|
||||||
const busStates = "licences bindings leases offered carried conditions"
|
|
||||||
const busLogs = "usage audit"
|
|
||||||
|
|
||||||
func newWholeMesh(t *testing.T, dbURL string) *mesh {
|
func newWholeMesh(t *testing.T, dbURL string) *mesh {
|
||||||
t.Helper()
|
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())
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
t.Cleanup(cancel)
|
t.Cleanup(cancel)
|
||||||
dir := t.TempDir()
|
dir := t.TempDir()
|
||||||
@@ -323,12 +327,12 @@ func newWholeMesh(t *testing.T, dbURL string) *mesh {
|
|||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
// What the bus module creates from the manifest's templates (§1 to §9) before the manager's want moves (§50).
|
// 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 {
|
if _, err := js.CreateKeyValue(ctx, jetstream.KeyValueConfig{Bucket: Publisher + "_" + s, History: 8}); err != nil {
|
||||||
t.Fatal(err)
|
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,
|
if _, err := js.CreateStream(ctx, jetstream.StreamConfig{Name: "LOG_" + Publisher + "_" + l,
|
||||||
Subjects: []string{"mesh.log." + Publisher + "." + l + ".>"}}); err != nil {
|
Subjects: []string{"mesh.log." + Publisher + "." + l + ".>"}}); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
@@ -340,7 +344,7 @@ func newWholeMesh(t *testing.T, dbURL string) *mesh {
|
|||||||
t.Fatal(err)
|
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{},
|
news: make(chan struct{}, 1), secrets: map[string]string{}, calls: map[string][32]byte{},
|
||||||
callArgs: map[string]map[string]any{}, logins: map[string]FullGrant{}}
|
callArgs: map[string]map[string]any{}, logins: map[string]FullGrant{}}
|
||||||
sub, err := nc.Subscribe("mesh.seat.operator-channel.accept.condition.>", func(msg *nats.Msg) {
|
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"`
|
Pattern string `json:"pattern"`
|
||||||
}
|
}
|
||||||
_ = json.Unmarshal(w.Params, &a)
|
_ = 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.") {
|
if strings.HasPrefix(w.Method, "mesh/log.") {
|
||||||
p.log(w)
|
p.log(w)
|
||||||
return
|
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) {
|
func (p *started) say(key string, body json.RawMessage) {
|
||||||
p.mu.Lock()
|
p.mu.Lock()
|
||||||
p.said = append(p.said, said{key: key, body: append(json.RawMessage(nil), body...)})
|
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)
|
i, _ := info.Info(m.ctx)
|
||||||
out[s] = i.State.LastSeq
|
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)
|
info, err := m.js.Stream(m.ctx, "LOG_"+Publisher+"_"+l)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
m.t.Fatal(err)
|
m.t.Fatal(err)
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
"module": "claude-licence-manager",
|
"module": "claude-licence-manager",
|
||||||
"version": "1",
|
"version": "1",
|
||||||
"slug": "licmgr",
|
"slug": "licmgr",
|
||||||
|
"runs-as": "claude-licence-manager",
|
||||||
"requires": [
|
"requires": [
|
||||||
"postgres-database",
|
"postgres-database",
|
||||||
"secret",
|
"secret",
|
||||||
@@ -91,6 +92,10 @@
|
|||||||
"grant-key": "${dir:state}/grant.key"
|
"grant-key": "${dir:state}/grant.key"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"own-secrets": {
|
||||||
|
"broker": "${dir:state}/broker"
|
||||||
|
},
|
||||||
|
"secrets-owner": "claude-licence-manager",
|
||||||
"uses": [
|
"uses": [
|
||||||
"operator-channel"
|
"operator-channel"
|
||||||
],
|
],
|
||||||
@@ -98,7 +103,8 @@
|
|||||||
"seat:operator-channel.tell",
|
"seat:operator-channel.tell",
|
||||||
"seat:mesh-vault.licence-put",
|
"seat:mesh-vault.licence-put",
|
||||||
"seat:mesh-vault.get",
|
"seat:mesh-vault.get",
|
||||||
"seat:mesh-config.config"
|
"seat:mesh-config.config",
|
||||||
|
"claude-code.claude_code_grant"
|
||||||
],
|
],
|
||||||
"seats": [
|
"seats": [
|
||||||
{
|
{
|
||||||
@@ -232,10 +238,18 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"resources": [
|
"resources": [
|
||||||
|
{
|
||||||
|
"id": "account",
|
||||||
|
"type": "user",
|
||||||
|
"name": "claude-licence-manager",
|
||||||
|
"shell": "/usr/bin/nologin",
|
||||||
|
"home": "/var/lib/claude-licence-manager"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"id": "state",
|
"id": "state",
|
||||||
"type": "directory",
|
"type": "directory",
|
||||||
"mode": "0700",
|
"mode": "0700",
|
||||||
|
"owner": "claude-licence-manager",
|
||||||
"place": ".",
|
"place": ".",
|
||||||
"unknown": "program"
|
"unknown": "program"
|
||||||
},
|
},
|
||||||
@@ -244,6 +258,7 @@
|
|||||||
"type": "file",
|
"type": "file",
|
||||||
"path": "${dir:state}/database.url",
|
"path": "${dir:state}/database.url",
|
||||||
"mode": "0600",
|
"mode": "0600",
|
||||||
|
"owner": "claude-licence-manager",
|
||||||
"template": "files/database.url"
|
"template": "files/database.url"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -251,6 +266,7 @@
|
|||||||
"type": "file",
|
"type": "file",
|
||||||
"path": "${dir:state}/settings.json",
|
"path": "${dir:state}/settings.json",
|
||||||
"mode": "0600",
|
"mode": "0600",
|
||||||
|
"owner": "claude-licence-manager",
|
||||||
"takes-config": [
|
"takes-config": [
|
||||||
"cadence_minutes",
|
"cadence_minutes",
|
||||||
"floor_minutes",
|
"floor_minutes",
|
||||||
|
|||||||
Reference in New Issue
Block a user