From 7531c3257d3d09929be78557a20004b8f90c0220 Mon Sep 17 00:00:00 2001 From: jochen Date: Fri, 30 Oct 2026 18:13:44 +0100 Subject: [PATCH] Give claude-code's runtime its forge credential file, and say a credential it cannot place as a condition (issue 774) The file gitea-read.secret was declared with no owner, so it was root's at 0600; claude-code's tools run in the node's runtime as the operator's account and could not read it ("permission denied", ~870 times a machine), said only in the journal. A file a bundle reads is the runtime account's only when a word of the bundle names it (catalogue givenTo); this one was found through MESH_CLAUDE_CODE_STATE alone. Quick fix: name the file with the bundle word MESH_CLAUDE_CODE_FORGE, which the reader now uses. Thorough fix: the same, plus a failure to place the credential raises a warning condition of the module on the operator-channel seat, once per reason, cleared when it places again. Chose thorough: the fault ran unseen for days. A change of the credential now restarts the node's runtime, as for every file a bundle word names. claude-code's mesh-sdk pin moves to 2da0de75 (main's), the first with asks.Condition. --- modules/claude-code/cmd/claude-code/forge.go | 56 ++++++++++++++- .../claude-code/cmd/claude-code/forge_test.go | 72 +++++++++++++++++++ modules/claude-code/cmd/claude-code/main.go | 25 ++++++- modules/claude-code/cmd/claude-code/node.go | 7 +- modules/claude-code/go.mod | 4 +- modules/claude-code/go.sum | 10 +-- modules/claude-code/module.json | 3 +- 7 files changed, 162 insertions(+), 15 deletions(-) diff --git a/modules/claude-code/cmd/claude-code/forge.go b/modules/claude-code/cmd/claude-code/forge.go index 112c033c..fda20225 100644 --- a/modules/claude-code/cmd/claude-code/forge.go +++ b/modules/claude-code/cmd/claude-code/forge.go @@ -24,6 +24,8 @@ import ( "os" "path/filepath" "strings" + + "git.novox.be/novox/mesh-sdk/go/asks" ) // ForgeProvision is the provision whose credential this is, and the file this module's manifest has it delivered to. @@ -32,7 +34,59 @@ const ( forgeSecret = "gitea-read.secret" ) -func (p Paths) forgeSecret() string { return filepath.Join(p.State, forgeSecret) } +func (p Paths) forgeSecret() string { + if p.Forge != "" { + return p.Forge + } + return filepath.Join(p.State, forgeSecret) +} + +// ForgeHealth is what this module says to the operator of its forge credential (the sweep of 2026-10-30, issue 774: +// the file was root's, 0600, and the runtime, the operator's account, could not read it; said only in the journal, +// some 870 times a machine). raised is the reason last raised, "" when none. +type ForgeHealth struct { + Node string + raised string + looked bool +} + +// forgeConditionKey is the condition's key: its last part is its kind. +func (f *ForgeHealth) key() string { return "claude-code.machine." + f.Node + ".forge-credential" } + +// Look is the condition to say after one placing of the credential, nil when nothing changed: raised on a failure, again +// when its reason changes, cleared when it places again; on the first look a success clears what a run before raised. +func (f *ForgeHealth) Look(err error) *asks.Condition { + first := !f.looked + f.looked = true + subject := asks.Scope{Scope: "machine", ID: f.Node, Machine: f.Node} + if err == nil { + if f.raised == "" && !first { + return nil + } + f.raised = "" + return &asks.Condition{Key: f.key(), Subject: subject, State: asks.StateCleared} + } + why := err.Error() + if why == f.raised { + return nil + } + f.raised = why + needs := "read claude-code's journal on " + f.Node + " and the owner of its forge credential file" + return &asks.Condition{Key: f.key(), Severity: asks.SeverityWarning, State: asks.StateRaised, Subject: subject, + Headline: "Agents on " + f.Node + " cannot use the forge credential", + Summary: why, Needs: needs, + Explanation: asks.NeedsYou + needs + ". " + sentence(why) + " Until it is placed, an agent there cannot " + + "clone or fetch the mesh's private repositories.", + Resolved: "The forge's read credential is placed again on " + f.Node + "."} +} + +func sentence(s string) string { + s = strings.TrimSpace(s) + if s == "" || strings.HasSuffix(s, ".") { + return s + } + return strings.ToUpper(s[:1]) + s[1:] + "." +} // forgeCredentials is where the agent's git reads the credential: under the agent's home, in a directory of the // mesh's own. diff --git a/modules/claude-code/cmd/claude-code/forge_test.go b/modules/claude-code/cmd/claude-code/forge_test.go index 8c504620..551ad774 100644 --- a/modules/claude-code/cmd/claude-code/forge_test.go +++ b/modules/claude-code/cmd/claude-code/forge_test.go @@ -2,6 +2,7 @@ package main import ( "encoding/json" + "errors" "net/http" "net/http/cgi" "net/http/httptest" @@ -10,6 +11,8 @@ import ( "path/filepath" "strings" "testing" + + "git.novox.be/novox/mesh-sdk/go/asks" ) const agentToken = "0123456789abcdef0123456789abcdef01234567" @@ -260,3 +263,72 @@ func TestForgeCredentialLinesAreReadStrictly(t *testing.T) { } } } + +// The forge credential's file is named by a word of the bundle, so the runtime's account owns it and reads it (the +// sweep of 2026-10-30: root's 0600 file, permission denied ~870 times a machine), and a failure to place it is said to +// the operator as a condition, raised once per reason, cleared when it places again, and cleared at a first good look. +func TestTheForgeCredentialIsTheRuntimesAndItsFailureIsAConditionOfTheModule(t *testing.T) { + raw, err := os.ReadFile("../../module.json") + if err != nil { + t.Fatal(err) + } + var m struct { + Secrets map[string]string `json:"secrets"` + Build struct { + Artifacts []struct { + Kind string `json:"kind"` + Env map[string]string `json:"env"` + } `json:"artifacts"` + } `json:"build"` + } + if err := json.Unmarshal(raw, &m); err != nil { + t.Fatal(err) + } + named := false + for _, a := range m.Build.Artifacts { + if a.Kind == "bundle" && a.Env["MESH_CLAUDE_CODE_FORGE"] == m.Secrets[ForgeProvision] && m.Secrets[ForgeProvision] != "" { + named = true + } + } + if !named { + t.Errorf("no word of the bundle names the forge credential's file %q: the runtime cannot read it", m.Secrets[ForgeProvision]) + } + p, _ := PathsFrom(envMap{"MESH_CLAUDE_CODE_STATE": "/s", "MESH_CLAUDE_CODE_FORGE": "/s/forge"}.get) + if p.forgeSecret() != "/s/forge" { + t.Errorf("the word is not read: %s", p.forgeSecret()) + } + + f := &ForgeHealth{Node: "anchor"} + denied := errors.New("the forge's read credential cannot be read: open /s/gitea-read.secret: permission denied") + for i, row := range []struct { + err error + want string // "" nothing said, else the state said + }{ + {nil, "cleared"}, // a first good look clears what a run before raised + {nil, ""}, + {denied, "raised"}, + {denied, ""}, + {errors.New("the forge credential could not be written: disk full"), "raised"}, + {nil, "cleared"}, + {nil, ""}, + } { + c := f.Look(row.err) + got := "" + if c != nil { + got = c.State + if _, err := asks.EncodeCondition(*c); err != nil { + t.Errorf("look %d: the condition is refused: %v", i, err) + } + if c.Key != "claude-code.machine.anchor.forge-credential" { + t.Errorf("look %d: key %s", i, c.Key) + } + } + if got != row.want { + t.Errorf("look %d: said %q, want %q", i, got, row.want) + } + } +} + +type envMap map[string]string + +func (e envMap) get(k string) string { return e[k] } diff --git a/modules/claude-code/cmd/claude-code/main.go b/modules/claude-code/cmd/claude-code/main.go index d879bbae..9e0d6c43 100644 --- a/modules/claude-code/cmd/claude-code/main.go +++ b/modules/claude-code/cmd/claude-code/main.go @@ -22,6 +22,7 @@ import ( "time" stdio "git.novox.be/novox/mesh-sdk/go" + "git.novox.be/novox/mesh-sdk/go/asks" ) func say(format string, args ...any) { @@ -177,6 +178,19 @@ func (s memoryStateOf) Delete(key string) error { return s.s.Delete(key) } // memoryCarrier carries this node's memory folders (ADR 0343); nil until the long-running half starts it. var memoryCarrier *MemoryCarrier +// sayCondition says a condition on the operator-channel seat under this module's name (novox/hq ADR 0437 §9). +func sayCondition(c asks.Condition) error { + subject, err := asks.ConditionSubject("claude-code") + if err != nil { + return err + } + raw, err := asks.EncodeCondition(c) + if err != nil { + return err + } + return stdio.SeatPublish(subject, json.RawMessage(raw), "") +} + // notifyOperator tells the operator through the operator channel's notify verb (ADR 0253). func notifyOperator(args map[string]any) error { _, err := ask("seat:operator-channel.notify", args) @@ -599,12 +613,21 @@ func run(p Paths, view *ServerView, configView *ConfigView) { // The forge's read credential (forge.go, ADR 0365 §4): placed as held, and put back when git or a person changed // it; the sessions' environment names it only while one is held. forgeHeld := ForgeCredentialHeld(p) + forgeHealth := &ForgeHealth{Node: p.Node} placeForge := func() { - if line, err := PlaceForgeCredential(p); err != nil { + line, err := PlaceForgeCredential(p) + if err != nil { say("%v", err) } else if line != "" { say("%s", line) } + // Said to the operator as a condition, raised once per reason and cleared when it places again. + if c := forgeHealth.Look(err); c != nil { + if serr := sayCondition(*c); serr != nil { + say("the forge credential's condition could not be said (%s): %v", c.State, serr) + forgeHealth = &ForgeHealth{Node: p.Node} // said again at the next look + } + } if held := ForgeCredentialHeld(p); held != forgeHeld { forgeHeld = held if _, err := RenderNow(p, writeManaged); err != nil { diff --git a/modules/claude-code/cmd/claude-code/node.go b/modules/claude-code/cmd/claude-code/node.go index 12868291..8b04d109 100644 --- a/modules/claude-code/cmd/claude-code/node.go +++ b/modules/claude-code/cmd/claude-code/node.go @@ -54,14 +54,17 @@ func Fingerprint(s string) string { // 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 + State, Facts, Settings, Home, Node string + // Forge is the file the forge's read credential arrives in (MESH_CLAUDE_CODE_FORGE): named by a word of the + // bundle, so the runtime's account owns it and can read it (catalogue givenTo); empty is gitea-read.secret in State. + Forge 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"), + Settings: env("MESH_CLAUDE_CODE_SETTINGS"), Forge: env("MESH_CLAUDE_CODE_FORGE"), 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 == "") { diff --git a/modules/claude-code/go.mod b/modules/claude-code/go.mod index cc52e29f..4a904ffb 100644 --- a/modules/claude-code/go.mod +++ b/modules/claude-code/go.mod @@ -1,7 +1,7 @@ module claude-code -go 1.25.0 +go 1.26.0 -require git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261013182924-5933eb757d44 +require git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261030001116-2da0de75c3f0 require gopkg.in/yaml.v3 v3.0.1 diff --git a/modules/claude-code/go.sum b/modules/claude-code/go.sum index c26c88fc..cb8e6968 100644 --- a/modules/claude-code/go.sum +++ b/modules/claude-code/go.sum @@ -1,11 +1,5 @@ -git.novox.be/novox/mesh-sdk/go v0.1.13 h1:Su4JYpZ+zhNovkGA2DgxiZdcuHpjw2tPJzc/7PljhXg= -git.novox.be/novox/mesh-sdk/go v0.1.13/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY= -git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261013141520-9588871d6629 h1:7TYFV1Q6Mcz+lsLP/sds87tysET4rKCn3rRIQV8gNmA= -git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261013141520-9588871d6629/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY= -git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261013145622-f4745447ba6d h1:HEJyqorKktrkJPeeJdwEo0kdrJjH3fm/YVS9MGftCqY= -git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261013145622-f4745447ba6d/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY= -git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261013182924-5933eb757d44 h1:r5xIo5qWOXQdCSMxvqJy8kV4U+P/QWXFK6971Ha6wU8= -git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261013182924-5933eb757d44/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY= +git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261030001116-2da0de75c3f0 h1:lKU3HjNXGiVbLW2hUgFjU/TMTUYMlNnnmZxG5T7biQA= +git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261030001116-2da0de75c3f0/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= diff --git a/modules/claude-code/module.json b/modules/claude-code/module.json index 3b62d219..9f85df0c 100644 --- a/modules/claude-code/module.json +++ b/modules/claude-code/module.json @@ -236,7 +236,8 @@ "env": { "MESH_CLAUDE_CODE_STATE": "${dir:state}", "MESH_CLAUDE_CODE_FACTS": "${dir:state}/facts.json", - "MESH_CLAUDE_CODE_SETTINGS": "${dir:state}/settings.json" + "MESH_CLAUDE_CODE_SETTINGS": "${dir:state}/settings.json", + "MESH_CLAUDE_CODE_FORGE": "${dir:state}/gitea-read.secret" } }, {