Merge remote-tracking branch 'origin/main' into feat/47-G-join11

# Conflicts:
#	modules/distribution/internal/holder/holder_test.go
This commit is contained in:
2026-11-12 10:25:58 +01:00
13 changed files with 470 additions and 87 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": "0.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",
@@ -223,7 +223,6 @@ const builder = "ace.mesh-build-go"
// TestTheHolderOfTheTwoImageSeats holds design 51's holder cases at the unit level (CAT-D1, D2, D3, D3b, D4, D4b, D5):
// one table or case per rule, the caller list and the store front's.
func TestTheHolderOfTheTwoImageSeats(t *testing.T) {
t.Run("a publish is checked against the builder's reservation", reservedChecksAPublishAgainstTheReservation)
blob := []byte(strings.Repeat("a", 3000))
part := Part{Digest: digestOf(blob), Size: int64(len(blob))}
req := func(id string) PublishRequest {
@@ -243,14 +242,14 @@ func TestTheHolderOfTheTwoImageSeats(t *testing.T) {
if a, _ := b.h.Publish(ArtifactSeat, builder, req("t1")); !reflect.DeepEqual(a.Missing, []string{part.Digest}) {
t.Errorf("resumed publish = %+v", a)
}
// A chunk 0 again starts the part afresh (it travels whole again, design 51 Publishing); a chunk that is neither the
// cursor nor 0 is refused, naming the cursor.
if _, err := b.h.PublishPart(ArtifactSeat, builder, PartRequest{Transfer: "t1", Digest: part.Digest, Sequence: 0, Data: blob[:1000]}); err != nil {
t.Errorf("a chunk 0 sent again was refused: %v", err)
}
// A chunk that is neither the cursor nor 0 is refused; a chunk 0 again is a part lacked travelling whole again
// (mesh-delivery's parts.Receiver.Take, 1966/AC203-1, AC203-2): its earlier chunks are dropped and it is taken.
if _, err := b.h.PublishPart(ArtifactSeat, builder, PartRequest{Transfer: "t1", Digest: part.Digest, Sequence: 2, Data: blob[:1000]}); err == nil {
t.Error("a chunk out of sequence was taken: the transfer goes on from the chunks held")
}
if _, err := b.h.PublishPart(ArtifactSeat, builder, PartRequest{Transfer: "t1", Digest: part.Digest, Sequence: 0, Data: blob[:1000]}); err != nil {
t.Errorf("a chunk 0 again was refused: %v", err)
}
a, err = b.h.PublishPart(ArtifactSeat, builder, PartRequest{Transfer: "t1", Digest: part.Digest, Sequence: 1, Last: true, Data: blob[1000:]})
if err != nil || !a.Ended || len(b.said) != 1 || b.said[0].event != EventPublished || b.said[0].body.Reference == "" {
t.Fatalf("last chunk = %+v, %v; said %+v", a, err, b.said)
@@ -292,12 +291,12 @@ func TestTheHolderOfTheTwoImageSeats(t *testing.T) {
_, err := b.h.Publish(ArtifactSeat, builder, req("t"))
return err
}, "at their cap"},
{"a version the builder did not reserve", func(b *bed) error {
{"a version the builder did not number", func(b *bed) error {
b.h.Builder = fakeBuilder{ok: false}
b.store.has[part.Digest] = true // held already: the publish ends at once
_, err := b.h.Publish(ArtifactSeat, builder, req("t"))
return err
}, "the builder did not reserve it"},
}, "the builder did not number it"},
{"an image on the artifact seat", func(b *bed) error {
r := req("t")
r.Kind = KindImage
@@ -849,9 +848,9 @@ func TestTheHolderOfTheTwoImageSeats(t *testing.T) {
if err := json.Unmarshal(raw, &m); err != nil {
t.Fatal(err)
}
// The reservation is asked through the seat mesh-builds, so the manifest takes no package of the builder (design 47 §381).
if len(m.Packages) != 0 {
t.Errorf("packages %v: want none", m.Packages)
// The builder's question is its published package, taken by its series (ADR 0634 §1).
if m.Packages["git.novox.be/novox/mesh-delivery/modules/mesh-delivery-builder/builds"] != "0.1" {
t.Errorf("packages %v: want builds of series 0.1", m.Packages)
}
// E15: the provision artifact-store retires (ADR 0632 §3); the bootstrap keeps it as an alias on port 5100 while
// the running node-engines still find the store by it (issue 1966), and nothing else is provided or served.
@@ -870,7 +869,7 @@ func TestTheHolderOfTheTwoImageSeats(t *testing.T) {
if _, ok := claims[OldSeat]; !ok {
t.Error("the old seat mesh-artifact-store is no longer claimed while its window is open")
}
if !reflect.DeepEqual(m.Consumes, []string{"mesh-delivery-view.delivered", "node-engine.manifest-reads-changed", "mesh-conditions.findings-asked"}) {
if !reflect.DeepEqual(m.Consumes, []string{"mesh-delivery-view.delivered", "node-engine.manifest-reads-changed"}) {
t.Errorf("consumes = %v, want delivered and manifest-reads-changed (the windows' wakes)", m.Consumes)
}
// E14: the registry container, on the loopback, is listed before the program, so the port is free when it starts.
@@ -916,34 +915,19 @@ func TestTheHolderOfTheTwoImageSeats(t *testing.T) {
// Running review F2: incoming asks the bus 8 GiB, the most the objects template allows, equal to IncomingCap
// (said only where no binding is read); transfers keeps for good, the holder dropping an idle one by its key
// in idle, a state of keys with their own time to live.
// The image registry is a foundation part (design 47 G §584): its bus state is in its old sections, incoming in
// `objects`, the others in `state`.
var req struct {
Objects []struct {
Name string `json:"name"`
MaxMiB uint64 `json:"max-mib"`
} `json:"objects"`
State []json.RawMessage `json:"state"`
Requires []struct {
Name string `json:"name"`
Options map[string]any `json:"options"`
} `json:"requires"`
}
_ = json.Unmarshal(raw, &req)
opts := map[string]map[string]any{}
for _, e := range req.State {
var o map[string]any
if json.Unmarshal(e, &o) != nil {
var name string
_ = json.Unmarshal(e, &name)
o = map[string]any{"name": name}
}
opts[fmt.Sprint(o["name"])] = o
for _, r := range req.Requires {
opts[r.Name] = r.Options
}
var incoming uint64
for _, o := range req.Objects {
if o.Name == IncomingObjects {
incoming = o.MaxMiB
}
}
if incoming<<20 != IncomingCap {
t.Errorf("incoming asks %v MiB, IncomingCap is %d bytes: want them equal", incoming, uint64(IncomingCap))
if mib, _ := opts[IncomingObjects]["max-mib"].(float64); uint64(mib)<<20 != IncomingCap {
t.Errorf("incoming asks %v MiB, IncomingCap is %d bytes: want them equal", opts[IncomingObjects]["max-mib"], uint64(IncomingCap))
}
if _, ok := opts[TransfersState]["ttl-seconds"]; ok {
t.Errorf("transfers has a lifetime %v: the bus would drop a transfer's key and leave its parts", opts[TransfersState])
+72 -22
View File
@@ -319,6 +319,9 @@ func (m *Manager) Journal(scope Scope, unit string, q JournalQuery) (map[string]
if err := unitArg(unit); err != nil {
return nil, err
}
if q.Boot != "" {
return nil, fmt.Errorf("boot %q: the journal of a unit is not read by boot; kernel-log takes a boot", q.Boot)
}
q, err := q.valid()
if err != nil {
return nil, err
@@ -332,28 +335,7 @@ func (m *Manager) Journal(scope Scope, unit string, q JournalQuery) (map[string]
return nil, err
}
known, envErr := m.unitSecrets(scope, unit)
all := []string{}
for _, l := range strings.Split(out, "\n") {
if l != "" {
all = append(all, l)
}
}
scanned := len(all)
kept, redacted := []string{}, 0
for _, l := range all {
l, n := redact(l, known)
redacted += n
if q.Match != "" && !strings.Contains(l, q.Match) {
continue
}
if len(l) > LongestLine {
l = l[:LongestLine] + "…"
}
kept = append(kept, l)
}
if len(kept) > q.Lines {
kept = kept[len(kept)-q.Lines:]
}
kept, scanned, redacted := shapeLines(out, known, q)
answer := map[string]any{"unit": unit, "scope": string(scope), "lines": kept, "count": len(kept)}
for k, v := range map[string]string{"since": q.Since, "until": q.Until, "match": q.Match, "priority": q.Priority} {
if v != "" {
@@ -376,6 +358,74 @@ func (m *Manager) Journal(scope Scope, unit string, q JournalQuery) (map[string]
return answer, nil
}
// shapeLines is the lines of journalctl's output that a query keeps, each redacted and cut, the last q.Lines of
// those holding the match; and how many lines were read and how many secrets replaced.
func shapeLines(out string, known []knownSecret, q JournalQuery) (kept []string, scanned, redacted int) {
all := []string{}
for _, l := range strings.Split(out, "\n") {
if l != "" {
all = append(all, l)
}
}
scanned = len(all)
kept = []string{}
for _, l := range all {
l, n := redact(l, known)
redacted += n
if q.Match != "" && !strings.Contains(l, q.Match) {
continue
}
if len(l) > LongestLine {
l = l[:LongestLine] + "…"
}
kept = append(kept, l)
}
if len(kept) > q.Lines {
kept = kept[len(kept)-q.Lines:]
}
return kept, scanned, redacted
}
// KernelLog is the kernel's messages of one boot (journalctl -k, dmesg's, of this boot or an earlier one) that a
// query keeps: the same window, priority, match, cap and redaction as Journal. The kernel's messages belong to
// no unit and no manager, so it has no scope; the read is the system journal's, escalated as Journal's is.
func (m *Manager) KernelLog(q JournalQuery) (map[string]any, error) {
q, err := q.valid()
if err != nil {
return nil, err
}
read := q.Lines
if q.Match != "" {
read = MatchScan
}
out, err := m.call(System, "journalctl", q.kernelArgv(read)...)
if err != nil {
return nil, err
}
kept, scanned, redacted := shapeLines(out, nil, q)
boot := q.Boot
if boot == "" {
boot = "0"
}
answer := map[string]any{"boot": boot, "lines": kept, "count": len(kept)}
for k, v := range map[string]string{"since": q.Since, "until": q.Until, "match": q.Match, "priority": q.Priority} {
if v != "" {
answer[k] = v
}
}
if q.Match != "" {
answer["scanned"] = scanned
if scanned >= MatchScan {
answer["note"] = fmt.Sprintf("the match was looked for in the window's last %d lines only: narrow the window to reach earlier ones", MatchScan)
}
}
if redacted > 0 {
answer["redacted"] = redacted
answer["leak"] = "the kernel's messages hold secrets, shown as [redacted: <what it was>]"
}
return answer, nil
}
// unitSecrets are the values of the unit's own Environment= that must not be answered. Read with
// systemctl show, which needs no escalation; a unit that does not exist has none.
func (m *Manager) unitSecrets(scope Scope, unit string) ([]knownSecret, error) {
+28 -3
View File
@@ -28,6 +28,8 @@ const (
LongestLine = 4096
// LongestMatch is the longest text a match may be.
LongestMatch = 256
// MostBoots is how far back a kernel-log read may name a boot.
MostBoots = 9999
)
// JournalQuery is what a journal read is narrowed by.
@@ -37,6 +39,8 @@ type JournalQuery struct {
Until string
Match string
Priority string
// Boot is which boot a kernel-log read is of: "0" this boot, "-1" the one before, and so on (empty is "0").
Boot string
}
// relative is a time journalctl reads relative to now: -30min, +1h, 2h30min ago, and the day words.
@@ -101,6 +105,13 @@ func (q JournalQuery) valid() (JournalQuery, error) {
return q, fmt.Errorf("priority %q: 0-7, or emerg, alert, crit, err, warning, notice, info, debug", q.Priority)
}
}
if q.Boot != "" {
n, err := strconv.Atoi(q.Boot)
if err != nil || n > 0 || n < -MostBoots {
return q, fmt.Errorf("boot %q: 0 for this boot, -1 for the one before, and so on (down to -%d)", q.Boot, MostBoots)
}
q.Boot = strconv.Itoa(n)
}
if len(q.Match) > LongestMatch {
return q, fmt.Errorf("a match is at most %d bytes", LongestMatch)
}
@@ -112,7 +123,21 @@ func (q JournalQuery) valid() (JournalQuery, error) {
// argv is journalctl's words for a valid query: each value inside the word of its own option.
func (q JournalQuery) argv(unit string, lines int) []string {
args := []string{"--no-pager", "--output=short-iso", "--unit=" + unit}
return q.window([]string{"--no-pager", "--output=short-iso", "--unit=" + unit}, lines)
}
// kernelArgv is journalctl's words for a valid query of the kernel's messages of one boot (-k, as dmesg
// reads them, but also of an earlier boot and in a window).
func (q JournalQuery) kernelArgv(lines int) []string {
boot := q.Boot
if boot == "" {
boot = "0"
}
return q.window([]string{"--no-pager", "--output=short-iso", "--dmesg", "--boot=" + boot}, lines)
}
// window adds the window, the priority and the line count to the words that choose what is read.
func (q JournalQuery) window(args []string, lines int) []string {
if q.Since != "" {
v, _, _ := when("since", q.Since, false)
args = append(args, "--since="+v)
@@ -149,7 +174,7 @@ func journalQuery(a map[string]any) (JournalQuery, error) {
default:
return q, fmt.Errorf("lines: a whole number")
}
for name, into := range map[string]*string{"since": &q.Since, "until": &q.Until, "match": &q.Match, "priority": &q.Priority} {
for name, into := range map[string]*string{"since": &q.Since, "until": &q.Until, "match": &q.Match, "priority": &q.Priority, "boot": &q.Boot} {
switch v := a[name].(type) {
case nil:
case string:
@@ -159,7 +184,7 @@ func journalQuery(a map[string]any) (JournalQuery, error) {
*into = strings.TrimSpace(v)
}
case float64:
if name != "priority" {
if name != "priority" && name != "boot" {
return q, fmt.Errorf("%s: text", name)
}
*into = strconv.FormatFloat(v, 'f', -1, 64)
@@ -241,3 +241,89 @@ func TestFailedIsOnTheSeatAndNarrowsToAScope(t *testing.T) {
t.Fatal("a scope that is none was accepted")
}
}
// 1975/AC1: kernel-log reads journalctl --dmesg --boot=N under journal's escalation (issue 1975): the kernel's messages of one boot, in the journal verb's window, priority,
// match, cap and redaction, and a boot named 0, -1, and so on.
func TestTheKernelLogIsOneBootsMessagesInTheJournalsForms(t *testing.T) {
var calls []call
m := journalOf([]string{"2026-11-12T10:00:00+0100 host kernel: usb 1-1: new device"}, "", &calls)
got, err := m.KernelLog(JournalQuery{Lines: 50, Since: "-2h", Priority: "err", Boot: "-1"})
if err != nil {
t.Fatal(err)
}
want := "--no-pager --output=short-iso --dmesg --boot=-1 --since=-2h --priority=3 --lines=50"
if a := strings.Join(journalArgs(t, calls), " "); a != want {
t.Fatalf("journalctl was given\n %s\nnot\n %s", a, want)
}
if calls[0].cmd != "sudo" || got["boot"] != "-1" || got["count"] != 1 {
t.Fatalf("not escalated, or the answer is wrong: %+v %+v", calls[0], got)
}
calls = nil
if _, err := m.KernelLog(JournalQuery{}); err != nil {
t.Fatal(err)
}
if a := strings.Join(journalArgs(t, calls), " "); a != "--no-pager --output=short-iso --dmesg --boot=0 --lines=100" {
t.Fatalf("the default is this boot and a hundred lines: %s", a)
}
// Arguments arrive as the seat's strings or as numbers.
for _, a := range []map[string]any{{"boot": float64(-2)}, {"boot": "-2"}} {
q, err := journalQuery(a)
if err != nil || q.Boot != "-2" {
t.Errorf("%v: %+v %v", a, q, err)
}
}
}
// 1975/AC2: a boot that is not 0 to -9999 is refused and no command runs.
func TestAKernelLogBootIsRefusedBeforeAnythingRunsUnlessItIsZeroOrBefore(t *testing.T) {
for _, bad := range []string{"1", "abc", "--user", "-1 --merge", "-10000", "0x1", "-1;id"} {
var calls []call
m := journalOf(nil, "", &calls)
if _, err := m.KernelLog(JournalQuery{Boot: bad}); err == nil {
t.Errorf("boot %q accepted", bad)
}
if len(calls) != 0 {
t.Errorf("boot %q reached journalctl: %v", bad, calls)
}
}
}
// 1975/AC3: the window, priority, match, 2000-line cap and redaction are journal's.
func TestTheKernelLogKeepsTheCapTheMatchAndTheRedaction(t *testing.T) {
var calls []call
m := journalOf([]string{"k one", "k password=hunter2secretvalue here", "k two"}, "", &calls)
got, err := m.KernelLog(JournalQuery{Match: "k t", Lines: 5000})
if err != nil {
t.Fatal(err)
}
if a := strings.Join(journalArgs(t, calls), " "); !strings.HasSuffix(a, fmt.Sprintf("--lines=%d", MatchScan)) {
t.Errorf("a match scans the window's last %d lines: %s", MatchScan, a)
}
if lines := got["lines"].([]string); len(lines) != 1 || lines[0] != "k two" {
t.Errorf("the match kept %v", lines)
}
calls = nil
m = journalOf([]string{"k password=hunter2secretvalue here"}, "", &calls)
got, err = m.KernelLog(JournalQuery{Lines: 5000})
if err != nil {
t.Fatal(err)
}
if a := strings.Join(journalArgs(t, calls), " "); !strings.HasSuffix(a, "--lines=2000") {
t.Errorf("the cap is 2000: %s", a)
}
if strings.Contains(fmt.Sprint(got["lines"]), "hunter2") || got["redacted"] == nil {
t.Errorf("a secret in a kernel line was answered: %v", got)
}
}
// 1975/AC4: journal refuses a boot argument it does not use, never reading this boot quietly.
func TestJournalRefusesABootArgument(t *testing.T) {
var calls []call
m := journalOf([]string{"a"}, "", &calls)
if _, err := m.Journal(System, "mail.service", JournalQuery{Boot: "-1"}); err == nil {
t.Fatal("journal accepted a boot and read this boot")
}
if len(calls) != 0 {
t.Fatalf("a refused boot reached the manager: %v", calls)
}
}
+18
View File
@@ -124,6 +124,24 @@ func tools(m *Manager) []stdio.Tool {
}
return m.Journal(scope, unit, q)
}},
{Name: seat + ".kernel-log",
Description: "The kernel's messages (what journalctl -k and dmesg give) of this boot or an earlier one, at most 2000 lines, " +
"in a time window and narrowed to a priority and to lines holding a text when asked. A secret a line held is " +
"shown as [redacted: <what it was>].",
Input: map[string]any{
"boot": str("which boot: 0 this boot (the default), -1 the one before, -2 the one before that, and so on (optional)"),
"lines": str("how many lines from the end of what matches (default 100, at most 2000)"),
"since": str("the window's start: an RFC 3339 time (2026-10-07T09:30:00Z) or relative to now (-30min, -2h, yesterday) (optional)"),
"until": str("the window's end, in the same forms (optional; now when absent)"),
"match": str("only the lines holding this text, as written — a fixed string, not a pattern (optional)"),
"priority": str("only entries this severe or more: 0-7 or emerg, alert, crit, err, warning, notice, info, debug (optional)")},
Run: func(a map[string]any) (any, error) {
q, err := journalQuery(a)
if err != nil {
return nil, err
}
return m.KernelLog(q)
}},
// **Clearing a failed record** (novox/hq issue 332): a unit whose file is gone keeps its failed state,
// and its start-limit count, in its manager until it is reset. A unit's name is required, so the
// verb never resets every failed unit at once.
@@ -274,8 +274,8 @@ func TestAGlobIsRefusedAsAUnitByEveryVerb(t *testing.T) {
m.State = t.TempDir()
for _, tool := range tools(m) {
verb := strings.TrimPrefix(tool.Name, seat+".")
if verb == "units" || verb == "failed" {
continue // a pattern, and no unit
if verb == "units" || verb == "failed" || verb == "kernel-log" {
continue // a pattern, or no unit at all
}
_, err := tool.Run(map[string]any{"unit": "*", "scope": "user", "why": "test"})
if err == nil {
+1
View File
@@ -18,6 +18,7 @@
"enable",
"disable",
"journal",
"kernel-log",
"failed",
"reset-failed",
"wanted-by",