diff --git a/modules/claude-code/README.md b/modules/claude-code/README.md index ea92d70..a9ec2cb 100644 --- a/modules/claude-code/README.md +++ b/modules/claude-code/README.md @@ -29,8 +29,9 @@ whenever the node's tool runtime collects the module's tools: Under the operator's home: `~/.claude/.credentials.json`, only when the licence manager hands this node a subscription token; and what is registered at the **home** scope for this node — a skill, subagent, command, output style, or instructions as a rule file — each path recorded in the module's state -(`home-placed.json`). It writes, changes and removes only those, never a path the person made, and leaves a -placed file alone once it was changed by hand (hq ADR 0182). The status tool reads the rest of the home's +(`home-placed.json`). It writes, changes and removes only those — never a path the person made, even one with the +same content, and never through a directory that is a symbolic link. A placed file changed by hand is left +alone, and one the person deleted stays deleted until the item is unregistered (hq ADR 0182). The status tool reads the rest of the home's items to report them; nothing else is read or written. ## Over NATS @@ -56,7 +57,8 @@ manager), `claude_code_mcp_list`, node alone, its answer names the other nodes running claude-code), `claude_code_mcp_unregister`. The agent's configuration (hq ADR 0216), each registered at a **scope** — `mesh` (the default), `node` -(`nodes`, or this node) or `home` (the operator account's own `~/.claude` on `nodes`, or this node): +(`nodes`: a list, or `"all"` for every node running claude-code; absent is this node) or `home` (the +operator account's own `~/.claude` on those nodes): - for each kind — `skill`, `agent`, `command`, `hook`, `output_style`, `instructions` — `claude_code__list`, `_register`, `_unregister`. A skill is its files (`files`, or `content` for a diff --git a/modules/claude-code/cmd/claude-code/config.go b/modules/claude-code/cmd/claude-code/config.go index 632e128..13e1333 100644 --- a/modules/claude-code/cmd/claude-code/config.go +++ b/modules/claude-code/cmd/claude-code/config.go @@ -69,6 +69,14 @@ const SettingsName = "settings" var itemName = regexp.MustCompile(`^[a-z0-9][a-z0-9-]{0,63}$`) +// nodeName is what a node may be called in a key. +var nodeName = regexp.MustCompile(`^[a-z0-9][a-z0-9-]{0,62}$`) + +// meshOwnedKeys are the settings a registration may not set: the mesh's own, and those that would deny +// the mesh's console or its marketplace by another way. +var meshOwnedKeys = []string{"attribution", "allowAllClaudeAiMcps", "apiKeyHelper", "extraKnownMarketplaces", "enabledPlugins", + "allowedMcpServers", "deniedMcpServers", "allowManagedMcpServersOnly", "strictKnownMarketplaces", "blockedMarketplaces"} + // hookEvents are the events a hook may be registered for. var hookEvents = map[string]bool{"PreToolUse": true, "PostToolUse": true, "UserPromptSubmit": true, "Notification": true, "Stop": true, "SubagentStop": true, "SessionStart": true, "SessionEnd": true, "PreCompact": true} @@ -139,9 +147,9 @@ func (it Item) Problem() string { if len(it.Settings) == 0 { return "no settings given" } - for _, key := range []string{"attribution", "allowAllClaudeAiMcps", "apiKeyHelper", "extraKnownMarketplaces", "enabledPlugins"} { + for _, key := range meshOwnedKeys { if _, ok := it.Settings[key]; ok { - return fmt.Sprintf("%q is one of the mesh's own keys; a registration cannot set it", key) + return fmt.Sprintf("%q is one of the mesh's own keys, or would turn off the mesh's console or plugin; a registration cannot set it", key) } } } else if !itemName.MatchString(it.Name) { @@ -159,8 +167,13 @@ func (it Item) Problem() string { } } for rel := range it.Files { - if rel == "" || path.IsAbs(rel) || strings.Contains(rel, "\\") || path.Clean(rel) != rel || strings.HasPrefix(rel, "..") { - return fmt.Sprintf("%q is not a path inside the item", rel) + if problem := pathProblem(rel); problem != "" { + return problem + } + for other := range it.Files { + if strings.HasPrefix(other, rel+"/") { + return fmt.Sprintf("%q is a file and also the directory of %q", rel, other) + } } } switch it.Kind { @@ -179,6 +192,20 @@ func (it Item) Problem() string { return "" } +// pathProblem says why a file's path is not one inside the item, or "": relative, slash-separated, every +// segment a real name — never empty, `.` or `..`. +func pathProblem(rel string) string { + if rel == "" || path.IsAbs(rel) || strings.ContainsAny(rel, "\\\x00") { + return fmt.Sprintf("%q is not a path inside the item", rel) + } + for _, seg := range strings.Split(rel, "/") { + if seg == "" || seg == "." || seg == ".." { + return fmt.Sprintf("%q is not a path inside the item", rel) + } + } + return "" +} + func (it Item) size() int { raw, _ := json.Marshal(it) return len(raw) @@ -213,14 +240,17 @@ func (v *ConfigView) Take(key, op string, item *Item) bool { if !v.Applies(key) { return false } + _, node, _, _, _ := ParseKey(key) v.mu.Lock() - if op == "put" && item != nil && item.Problem() == "" { + defer v.mu.Unlock() + // Only an item that is what its key says: a key decides which nodes take an item, the item where it + // lands, and the two must agree — a mesh key holding a home item would land in every home. + if op == "put" && item != nil && item.Problem() == "" && item.Key(node) == key { v.items[key] = *item } else { delete(v.items, key) } - v.mu.Unlock() - return v.writeThrough() + return v.writeThroughLocked() } // Prune drops what the view holds and the state no longer does: a registration removed while this node @@ -231,13 +261,13 @@ func (v *ConfigView) Prune(present []string) bool { keep[k] = true } v.mu.Lock() + defer v.mu.Unlock() for k := range v.items { if !keep[k] { delete(v.items, k) } } - v.mu.Unlock() - return v.writeThrough() + return v.writeThroughLocked() } // Items is what applies here, by key. @@ -251,8 +281,10 @@ func (v *ConfigView) Items() map[string]Item { return out } -func (v *ConfigView) writeThrough() bool { - now, _ := indented(v.Items()) +// writeThroughLocked writes the view to its file, with v.mu held: the snapshot and the write are one step, so +// an older snapshot never lands after a newer one. +func (v *ConfigView) writeThroughLocked() bool { + now, _ := indented(v.items) before, _ := os.ReadFile(v.p.config()) if string(before) == string(now) { return false @@ -481,9 +513,15 @@ func digest(s string) string { return hex.EncodeToString(sum[:]) } -// PlaceHome brings the home in line with what the home scope wants: writes what is wanted and absent or -// its own, never a path the person made, and removes what it placed and is no longer wanted — unless the -// person changed it since. Answers what it did and what it left alone, and why. +// deletedByHand marks, in the record, a path the mesh placed and the person then deleted: their choice, +// kept until the item is unregistered. +const deletedByHand = "deleted-by-hand" + +// PlaceHome brings the home in line with what the home scope wants (ADR 0182): it writes what is wanted and +// absent, or its own; it never writes a path the person made, nor through a directory that is a symbolic +// link; it removes what it placed and is no longer wanted, unless the person changed it since; and a file it +// placed that the person deleted stays deleted until the item is unregistered. Answers what it did and what +// it left alone, and why. func PlaceHome(p Paths, want map[string]string) (done []string, left []string) { dir := filepath.Join(p.Home, ".claude") var placed Placed @@ -498,22 +536,30 @@ func PlaceHome(p Paths, want map[string]string) (done []string, left []string) { for _, rel := range paths { full := filepath.Join(dir, filepath.FromSlash(rel)) content := want[rel] + if why := linkedParent(dir, rel); why != "" { + left = append(left, rel+": "+why+", left alone") + continue + } current, err := os.ReadFile(full) exists := err == nil ours, wasPlaced := placed[rel] switch { + case !exists && wasPlaced && ours == deletedByHand: + continue + case !exists && wasPlaced: + placed[rel] = deletedByHand + left = append(left, rel+": deleted by hand, left deleted until the item is unregistered") + continue case exists && !wasPlaced: - if string(current) == content { - placed[rel] = digest(content) // the same bytes: taken over, nothing changes - continue - } left = append(left, rel+": the person's own, left alone") continue - case exists && wasPlaced && digest(string(current)) != ours && digest(string(current)) != digest(content): - left = append(left, rel+": changed by hand since it was placed, left alone") + case exists && ours == deletedByHand: + left = append(left, rel+": made again by hand after the mesh's was deleted, left alone") continue case exists && string(current) == content: - placed[rel] = digest(content) + continue + case exists && digest(string(current)) != ours: + left = append(left, rel+": changed by hand since it was placed, left alone") continue } if err := os.MkdirAll(filepath.Dir(full), 0o755); err != nil { @@ -536,22 +582,50 @@ func PlaceHome(p Paths, want map[string]string) (done []string, left []string) { continue } full := filepath.Join(dir, filepath.FromSlash(rel)) + if ours == deletedByHand { + delete(placed, rel) + continue + } + if why := linkedParent(dir, rel); why != "" { + left = append(left, rel+": no longer registered, but "+why+", left alone") + continue + } current, err := os.ReadFile(full) if err == nil && digest(string(current)) != ours { left = append(left, rel+": no longer registered, but changed by hand, left alone") delete(placed, rel) continue } - _ = os.Remove(full) - removeEmptyParents(dir, filepath.Dir(full)) + if err := os.Remove(full); err != nil && !os.IsNotExist(err) { + left = append(left, rel+": no longer registered, and could not be removed: "+err.Error()) + continue + } + // Up to the kind's own directory, never it: `skills/` goes when empty, `skills` stays. + removeEmptyParents(filepath.Join(dir, strings.SplitN(rel, "/", 2)[0]), filepath.Dir(full)) delete(placed, rel) done = append(done, rel+": removed") } raw, _ := indented(placed) - _ = os.WriteFile(p.placed(), raw, 0o600) + if err := os.WriteFile(p.placed(), raw, 0o600); err != nil { + left = append(left, "the record of what was placed could not be saved: "+err.Error()) + } return done, left } +// linkedParent says, when a directory between the agent directory and a file is a symbolic link, which one: +// writing through it would write wherever it points. +func linkedParent(dir, rel string) string { + parts := strings.Split(rel, "/") + at := dir + for _, seg := range parts[:len(parts)-1] { + at = filepath.Join(at, seg) + if info, err := os.Lstat(at); err == nil && info.Mode()&os.ModeSymlink != 0 { + return at + " is a symbolic link" + } + } + return "" +} + // removeEmptyParents removes empty directories from dir up to, not including, root. func removeEmptyParents(root, dir string) { for dir != root && strings.HasPrefix(dir, root+string(filepath.Separator)) { @@ -714,6 +788,8 @@ func Register(p Paths, it Item, nodes []string, unregister bool, state ConfigSta nodes = []string{""} } else if len(nodes) == 0 { nodes = []string{p.Node} + } else if problem := nodesProblem(nodes); problem != "" { + return map[string]any{verbOf(unregister): false, "reason": problem}, nil } if !unregister && it.Scope == ScopeHome { for _, n := range nodes { @@ -761,10 +837,10 @@ func Register(p Paths, it Item, nodes []string, unregister bool, state ConfigSta } if changed { rendered, err := RenderNow(p, write) - if err != nil { - return nil, err - } answer["rendered here"] = rendered + if err != nil { + answer["not written here"] = err.Error() // kept on the bus all the same; the next render tries again + } answer["sessions"] = "a new session takes it; a running one at /reload-plugins" } else if v.Applies(keys[0]) || len(keys) > 1 { answer["here"] = "already so" @@ -774,6 +850,35 @@ func Register(p Paths, it Item, nodes []string, unregister bool, state ConfigSta return answer, nil } +func verbOf(unregister bool) string { + return map[bool]string{false: "registered", true: "unregistered"}[unregister] +} + +// nodesProblem says why a list of nodes cannot name keys, or "". "all" has been expanded before this. +func nodesProblem(nodes []string) string { + for _, n := range nodes { + if !nodeName.MatchString(n) { + return fmt.Sprintf("%q is not a node's name", n) + } + } + return "" +} + +// ExpandNodes turns `all` into every node running the module; anything else is kept. +func ExpandNodes(nodes []string, running func() ([]string, error)) ([]string, error) { + if len(nodes) != 1 || nodes[0] != "all" { + return nodes, nil + } + all, err := running() + if err != nil { + return nil, fmt.Errorf("which nodes run claude-code: %w", err) + } + if len(all) == 0 { + return nil, fmt.Errorf("no node is known to run claude-code") + } + return all, nil +} + // List is every registration of a kind ("" for every kind) on the mesh, by key, read from the state. func List(state ConfigState, kind string) (map[string]any, error) { keys, err := state.Keys() diff --git a/modules/claude-code/cmd/claude-code/config_test.go b/modules/claude-code/cmd/claude-code/config_test.go index d457839..69036ed 100644 --- a/modules/claude-code/cmd/claude-code/config_test.go +++ b/modules/claude-code/cmd/claude-code/config_test.go @@ -339,3 +339,119 @@ func TestWhatWasRemovedWhileANodeWasAwayIsDropped(t *testing.T) { t.Fatalf("after pruning: %v", back.Items()) } } + +// The review's findings, each held by a test. + +func TestAPathOrAKeyThatIsNotWhatItSaysIsRefused(t *testing.T) { + for label, files := range map[string]map[string]string{ + "a dot": {"SKILL.md": "x", ".": "x"}, + "an empty segment": {"SKILL.md": "x", "a//b": "x"}, + "a parent segment": {"SKILL.md": "x", "a/../b": "x"}, + "a file and directory": {"SKILL.md": "x", "a": "x", "a/b": "x"}, + } { + if (Item{Kind: KindSkill, Name: "s", Scope: ScopeMesh, Files: files}).Problem() == "" { + t.Errorf("%s was accepted", label) + } + } + p, _ := node(t, "laptop") + v := NewConfigView(p) + home := Item{Kind: KindCommand, Name: "x", Scope: ScopeHome, Files: one("x", "y")} + if v.Take("mesh.command.x", "put", &home); len(v.Items()) != 0 { + t.Fatal("a mesh key holding a home item was taken") + } + for _, key := range []string{"mesh.command.x", "mesh.agent.x"} { + mesh := Item{Kind: KindCommand, Name: "x", Scope: ScopeMesh, Files: one("x", "y")} + v.Take(key, "put", &mesh) + } + if len(v.Items()) != 1 { + t.Fatalf("an item under a key of another kind was taken: %v", v.Items()) + } +} + +func TestAllIsEveryNodeAndANameThatCannotBeAKeyIsRefused(t *testing.T) { + nodes, err := ExpandNodes([]string{"all"}, func() ([]string, error) { return []string{"ace", "g14"}, nil }) + if err != nil || strings.Join(nodes, ",") != "ace,g14" { + t.Fatalf("%v %v", nodes, err) + } + p, w := node(t, "laptop") + answer, _ := Register(p, Item{Kind: KindCommand, Name: "c", Scope: ScopeNode, Files: one("c", "x")}, []string{"a.b"}, false, memConfig{}, NewConfigView(p), writer(w)) + if answer["registered"] != false { + t.Fatalf("a node name with a dot was used in a key: %v", answer) + } + if (Item{Kind: KindSettings, Name: SettingsName, Scope: ScopeMesh, Settings: map[string]any{"deniedMcpServers": []any{}}}).Problem() == "" { + t.Fatal("a registration could deny the mesh's console") + } +} + +func TestTheOperatorsOwnPluginsAndMarketplacesAreKept(t *testing.T) { + out := Render(Facts{Console: "x"}, Settings{ManagedSettings: map[string]any{ + "enabledPlugins": map[string]any{"theirs@market": true}, + "extraKnownMarketplaces": map[string]any{"market": map[string]any{"source": map[string]any{"source": "github", "repo": "o/r"}}}, + }}, nil, "/h", nil, Config{}) + var managed struct { + Enabled map[string]any `json:"enabledPlugins"` + Known map[string]any `json:"extraKnownMarketplaces"` + } + _ = json.Unmarshal([]byte(out["managed-settings.json"]), &managed) + if managed.Enabled["theirs@market"] != true || managed.Enabled[Plugin+"@"+Plugin] != true || managed.Known["market"] == nil || managed.Known[Plugin] == nil { + t.Fatalf("%+v", managed) + } +} + +func TestTheHomeLeavesThePersonsChoicesAlone(t *testing.T) { + p, _ := node(t, "laptop") + dir := filepath.Join(p.Home, ".claude") + // An identical file the person made is not taken over, so unregistering never removes it. + _ = os.MkdirAll(filepath.Join(dir, "agents"), 0o755) + writeFile(t, filepath.Join(dir, "agents", "same.md"), "x") + if _, left := PlaceHome(p, map[string]string{"agents/same.md": "x"}); len(left) != 1 { + t.Fatalf("an identical file of the person's was taken over: %v", left) + } + PlaceHome(p, map[string]string{}) + if _, err := os.Stat(filepath.Join(dir, "agents", "same.md")); err != nil { + t.Fatal("the person's file was removed") + } + // A placed file the person deleted stays deleted while it is registered. + PlaceHome(p, map[string]string{"commands/c.md": "x"}) + _ = os.Remove(filepath.Join(dir, "commands", "c.md")) + PlaceHome(p, map[string]string{"commands/c.md": "x"}) + if _, err := os.Stat(filepath.Join(dir, "commands", "c.md")); err == nil { + t.Fatal("a file the person deleted was placed again") + } + // The kind's own directory stays when the mesh's last item in it goes. + PlaceHome(p, map[string]string{"skills/s/SKILL.md": "x"}) + PlaceHome(p, map[string]string{}) + if _, err := os.Stat(filepath.Join(dir, "skills", "s")); err == nil { + t.Fatal("the item's own directory was left") + } + if _, err := os.Stat(filepath.Join(dir, "skills")); err != nil { + t.Fatal("the kind's directory was removed") + } + // Never through a symbolic link. + elsewhere := t.TempDir() + if err := os.Symlink(elsewhere, filepath.Join(dir, "output-styles")); err != nil { + t.Skip("no symbolic links here") + } + PlaceHome(p, map[string]string{"output-styles/o.md": "x"}) + if _, err := os.Stat(filepath.Join(elsewhere, "o.md")); err == nil { + t.Fatal("a file was written through a symbolic link") + } +} + +func TestOneFileThatCannotBeWrittenDoesNotStopTheOthers(t *testing.T) { + p, _ := node(t, "laptop") + w := map[string]string{} + failing := func(name, content string) (string, error) { + if name == MarketplaceDir+"/" { + return "", os.ErrPermission + } + w[name] = content + return name + ": written", nil + } + if _, err := RenderNow(p, failing); err == nil { + t.Fatal("the failure was not reported") + } + if w["managed-settings.json"] == "" || w["managed-mcp.json"] == "" || w["CLAUDE.md"] == "" { + t.Fatalf("the other files were not written: %v", w) + } +} diff --git a/modules/claude-code/cmd/claude-code/config_tools.go b/modules/claude-code/cmd/claude-code/config_tools.go index 153122d..db10fba 100644 --- a/modules/claude-code/cmd/claude-code/config_tools.go +++ b/modules/claude-code/cmd/claude-code/config_tools.go @@ -41,6 +41,11 @@ func strArg(a map[string]any, k string) string { return strings.TrimSpace(s) } +// targetNodes reads the `nodes` argument: absent is this node, "all" every node running claude-code, else a list. +func targetNodes(a map[string]any) ([]string, error) { + return ExpandNodes(nodesOf(a["nodes"]), nodesRunningMe) +} + // scopeOf reads the `scope` argument; absent is the mesh, which is what a registration is most often for. func scopeOf(a map[string]any) string { if s := strArg(a, "scope"); s != "" { @@ -76,7 +81,7 @@ func filesOf(kind, name string, a map[string]any) (map[string]string, error) { func configTools(p Paths, state ConfigState, view *ConfigView) []stdio.Tool { scopeArg := str(`mesh (every node; the default), node (the nodes given, or this one) or home (the operator account's own ~/.claude on the nodes given, or this one)`) - nodesArg := str(`for the node and home scopes: a comma-separated list of nodes; absent is this node`) + nodesArg := str(`for the node and home scopes: "all" for every node running claude-code, or a comma-separated list; absent is this node`) var out []stdio.Tool for _, k := range kindTools { k := k @@ -112,14 +117,22 @@ func configTools(p Paths, state ConfigState, view *ConfigView) []stdio.Tool { } it := Item{Kind: k.kind, Name: name, Scope: scopeOf(a), Files: files, Event: strArg(a, "event"), Matcher: strArg(a, "matcher"), Command: strArg(a, "command")} - return Register(p, it, nodesOf(a["nodes"]), false, state, view, writeManaged) + nodes, err := targetNodes(a) + if err != nil { + return nil, err + } + return Register(p, it, nodes, false, state, view, writeManaged) }}, stdio.Tool{Name: "claude_code_" + k.tool + "_unregister", Description: "Remove a " + k.kind + " registered through this module, at its scope. At home, only what the mesh placed is removed, and not if it was changed by hand since.", Input: map[string]any{"name": str("the name"), "scope": scopeArg, "nodes": nodesArg}, Run: func(a map[string]any) (any, error) { it := Item{Kind: k.kind, Name: strArg(a, "name"), Scope: scopeOf(a)} - return Register(p, it, nodesOf(a["nodes"]), true, state, view, writeManaged) + nodes, err := targetNodes(a) + if err != nil { + return nil, err + } + return Register(p, it, nodes, true, state, view, writeManaged) }}, ) } @@ -160,7 +173,11 @@ func configTools(p Paths, state ConfigState, view *ConfigView) []stdio.Tool { if len(given) == 0 { return nil, errors.New("no settings given; to remove a scope's settings, claude_code_settings_clear") } - return SetSettings(p, scopeOf(a), nodesOf(a["nodes"]), func(held map[string]any) map[string]any { + nodes, err := targetNodes(a) + if err != nil { + return nil, err + } + return SetSettings(p, scopeOf(a), nodes, func(held map[string]any) map[string]any { if boolArg(a, "replace") { return given } @@ -172,7 +189,11 @@ func configTools(p Paths, state ConfigState, view *ConfigView) []stdio.Tool { Input: map[string]any{"scope": settingsScope, "nodes": nodesArg}, Run: func(a map[string]any) (any, error) { it := Item{Kind: KindSettings, Name: SettingsName, Scope: scopeOf(a)} - return Register(p, it, nodesOf(a["nodes"]), true, state, view, writeManaged) + nodes, err := targetNodes(a) + if err != nil { + return nil, err + } + return Register(p, it, nodes, true, state, view, writeManaged) }}, stdio.Tool{Name: "claude_code_permission_add", Description: "Add a permission rule to Claude Code's managed settings, for every node or some: allow (runs without asking), ask (always asks) or deny (never runs). A rule is a tool and an optional specifier, e.g. Bash(git status:*), Read(./secrets/**), mcp__mesh__mesh_call.", @@ -182,7 +203,11 @@ func configTools(p Paths, state ConfigState, view *ConfigView) []stdio.Tool { if (list != "allow" && list != "ask" && list != "deny") || rule == "" { return nil, errors.New("list is allow, ask or deny, and a rule is needed") } - return SetSettings(p, scopeOf(a), nodesOf(a["nodes"]), func(held map[string]any) map[string]any { + nodes, err := targetNodes(a) + if err != nil { + return nil, err + } + return SetSettings(p, scopeOf(a), nodes, func(held map[string]any) map[string]any { return mergeSettings(held, map[string]any{"permissions": map[string]any{list: []any{rule}}}) }, state, view) }}, @@ -191,7 +216,11 @@ func configTools(p Paths, state ConfigState, view *ConfigView) []stdio.Tool { Input: map[string]any{"list": str("allow, ask or deny"), "rule": str("the rule"), "scope": settingsScope, "nodes": nodesArg}, Run: func(a map[string]any) (any, error) { list, rule := strArg(a, "list"), strArg(a, "rule") - return SetSettings(p, scopeOf(a), nodesOf(a["nodes"]), func(held map[string]any) map[string]any { + nodes, err := targetNodes(a) + if err != nil { + return nil, err + } + return SetSettings(p, scopeOf(a), nodes, func(held map[string]any) map[string]any { perms, _ := held["permissions"].(map[string]any) rules, _ := perms[list].([]any) if len(rules) == 0 { @@ -266,7 +295,11 @@ func configTools(p Paths, state ConfigState, view *ConfigView) []stdio.Tool { if it.Scope == ScopeHome { return nil, errors.New("it is already at home; import it to the mesh or node scope") } - return Register(p, it, nodesOf(a["nodes"]), false, state, view, writeManaged) + nodes, err := targetNodes(a) + if err != nil { + return nil, err + } + return Register(p, it, nodes, false, state, view, writeManaged) }}, ) return out @@ -283,6 +316,8 @@ func SetSettings(p Paths, scope string, nodes []string, change func(held map[str nodes = []string{""} } else if len(nodes) == 0 { nodes = []string{p.Node} + } else if problem := nodesProblem(nodes); problem != "" { + return map[string]any{"set": false, "reason": problem}, nil } answers := map[string]any{} for _, n := range nodes { diff --git a/modules/claude-code/cmd/claude-code/node.go b/modules/claude-code/cmd/claude-code/node.go index c1e1e71..3dd859f 100644 --- a/modules/claude-code/cmd/claude-code/node.go +++ b/modules/claude-code/cmd/claude-code/node.go @@ -141,11 +141,15 @@ func RenderNow(p Paths, write WriteManaged) ([]string, error) { } return names[i] < names[j] }) + // Every file is attempted: one that cannot be written — a registration the vendor's layout refuses, a + // failed escalation — must not keep the licence, the tool servers or the instructions from landing. var out []string + var failed []error for _, n := range names { line, err := write(n, files[n]) if err != nil { - return out, err + failed = append(failed, err) + continue } out = append(out, line) } @@ -157,7 +161,7 @@ func RenderNow(p Paths, write WriteManaged) ([]string, error) { for _, line := range left { out = append(out, "home "+line) } - return out, nil + return out, errors.Join(failed...) } // ---- the licence ---------------------------------------------------------------------------------- diff --git a/modules/claude-code/cmd/claude-code/render.go b/modules/claude-code/cmd/claude-code/render.go index 58e5aa9..7640b01 100644 --- a/modules/claude-code/cmd/claude-code/render.go +++ b/modules/claude-code/cmd/claude-code/render.go @@ -118,8 +118,19 @@ func Render(facts Facts, settings Settings, binding *Binding, helperPath string, managed["attribution"] = map[string]any{"commit": "", "pr": ""} managed["allowAllClaudeAiMcps"] = true delete(managed, "apiKeyHelper") + // The plugin's marketplace and the plugin itself, as entries in the operator's own maps, the mesh's + // entry winning: the operator may know more marketplaces and enable more plugins. for key, value := range MarketplaceKeys() { - managed[key] = value + entries := map[string]any{} + if held, ok := managed[key].(map[string]any); ok { + for k, v := range held { + entries[k] = v + } + } + for k, v := range value.(map[string]any) { + entries[k] = v + } + managed[key] = entries } if binding != nil && binding.Kind == "api-key" { managed["apiKeyHelper"] = helperPath