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.
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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] }
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 == "") {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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=
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
},
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user