Feature 57 (config and provisioning): modules declare config and provisions, their files rendered from templates; synced with main (issues of feature 57, 874)
This commit is contained in:
@@ -43,7 +43,7 @@ whenever the node's tool runtime collects the module's tools:
|
||||
| file | holds |
|
||||
|---|---|
|
||||
| `managed-mcp.json` | the tool servers every session loads: the mesh's console as `mesh`, and the servers set in this module's `mcp_servers` setting. **Exclusive**: a server not listed here does not load — not one added with `claude mcp add`, not a project's `.mcp.json`, not a plugin's |
|
||||
| `managed-settings.json` | the keys set in this module's `managed_settings` setting — at the controller's terminal alone (hq issue 340) — under the mesh's own keys: the repositories' attribution convention, the claude.ai connectors kept beside the managed servers, the key-helper while the node holds an API-key licence, and the two that name the `nox-mesh` marketplace and enable its plugin |
|
||||
| `managed-settings.json` | the keys set in this module's `managed_settings` setting — at the controller's terminal alone (hq issue 340) — under the mesh's own keys: the repositories' attribution convention, the claude.ai connectors kept beside the managed servers, the key-helper while the node holds an API-key licence, git's credential file while the node holds the forge's read credential (below), and the two that name the `nox-mesh` marketplace and enable its plugin |
|
||||
| `CLAUDE.md` | how a session on this mesh works, this node's name and role, the conventions, the **"instead of" table** — then the instruction sections registered for every node and for this one |
|
||||
| `marketplace/` | the `nox-mesh` plugin (hq ADR 0216): the skills, subagents, commands and output styles registered for every node and for this one, offered in a session as `nox-mesh:<name>` — and the **guard on the agent's shell** (`guard/`), the mesh's own hook and the plugin's only one (hq issue 340). Replaced whole, staged beside and swapped in |
|
||||
|
||||
@@ -55,6 +55,17 @@ same content, and never through a directory that is a symbolic link. A placed fi
|
||||
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.
|
||||
|
||||
**The forge's read credential** (hq ADR 0365 §4, issue 737). The module requires `gitea-read`, optionally: the forge
|
||||
mints this node's agent a token of its own account, `agent-<node>`, scoped to read repositories alone, and the vault
|
||||
keeps it for this module on this machine alone (issue 749); it arrives in the state as `gitea-read.secret`, git's
|
||||
credential-store lines. Every few seconds the module places them at `~/.config/mesh/forge.credentials` in the agent's
|
||||
home, mode 0600, written as the agent's account; puts the file back when git's store helper or a person changed it;
|
||||
and takes it back when none is held. A value that is not credential lines (one the mesh made before the forge was
|
||||
asked) is never placed. While one is held, `managed-settings.json` gives every session's environment
|
||||
`GIT_CONFIG_COUNT`, `GIT_CONFIG_KEY_0=credential.helper` and `GIT_CONFIG_VALUE_0=store --file='<that path>'`: the
|
||||
path, never the token. So `git clone` and `git fetch` of what the agent's account may read work with nothing typed,
|
||||
printed or put on a command line; pushing stays `gitea_commit`'s, and the forge refuses the token a push.
|
||||
|
||||
## Over NATS
|
||||
|
||||
Everything between this module and the rest of the mesh is NATS, in three kinds: an **event** says that
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
package main
|
||||
|
||||
// The forge's read credential in the agent's home (novox/hq ADR 0365 §4, issue 737).
|
||||
//
|
||||
// The forge's module mints each node's agent a token of its own account, `agent-<node>`, scoped to read
|
||||
// repositories alone, and the vault keeps it for this module on this machine alone (provision `gitea-read`, issue
|
||||
// 749). What arrives in this module's state is git's credential-store lines for it, one per origin the forge is
|
||||
// reached at. This module places them in the agent's home, a file of mode 0600 written as the agent's account (ADR
|
||||
// 0266: no other module writes there), and points every session's git at it through the managed settings'
|
||||
// environment: the file's path, never its content. So an agent clones and fetches what its account may read, and no
|
||||
// token is printed, typed on a command line or put in an environment a process listing shows.
|
||||
//
|
||||
// Git's own `store` helper reads the file; on a token the forge rejects it erases that line, and on a password a
|
||||
// person typed elsewhere it adds one. Both are undone at the next look (every few seconds): the file holds what the
|
||||
// forge issued, and nothing else. A value that is not credential lines — one the mesh made while the forge had not
|
||||
// been asked yet — is not placed, and what was placed before is taken back.
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// ForgeProvision is the provision whose credential this is, and the file this module's manifest has it delivered to.
|
||||
const (
|
||||
ForgeProvision = "gitea-read"
|
||||
forgeSecret = "gitea-read.secret"
|
||||
)
|
||||
|
||||
func (p Paths) forgeSecret() string { return filepath.Join(p.State, forgeSecret) }
|
||||
|
||||
// forgeCredentials is where the agent's git reads the credential: under the agent's home, in a directory of the
|
||||
// mesh's own.
|
||||
func (p Paths) forgeCredentials() string {
|
||||
return filepath.Join(p.Home, ".config", "mesh", "forge.credentials")
|
||||
}
|
||||
|
||||
// ForgeCredentialLines are the credential lines held, checked: each an http(s) origin with an account and a token,
|
||||
// and nothing else. Anything else is refused, said without the value.
|
||||
func ForgeCredentialLines(raw string) (string, error) {
|
||||
var lines []string
|
||||
for _, line := range strings.Split(raw, "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
u, err := url.Parse(line)
|
||||
if err != nil || (u.Scheme != "https" && u.Scheme != "http") || u.Host == "" || u.User == nil ||
|
||||
u.User.Username() == "" || u.Path != "" || u.RawQuery != "" || u.Fragment != "" {
|
||||
return "", errors.New("what the mesh holds for the forge's read credential is not git credential lines " +
|
||||
"(the forge has not issued it yet, or something else was given)")
|
||||
}
|
||||
if pw, ok := u.User.Password(); !ok || pw == "" {
|
||||
return "", errors.New("a forge credential line names no token")
|
||||
}
|
||||
lines = append(lines, line)
|
||||
}
|
||||
if len(lines) == 0 {
|
||||
return "", errors.New("the forge's read credential is empty")
|
||||
}
|
||||
return strings.Join(lines, "\n") + "\n", nil
|
||||
}
|
||||
|
||||
// ForgeCredentialHeld says whether this machine holds a forge read credential to place.
|
||||
func ForgeCredentialHeld(p Paths) bool {
|
||||
raw, err := os.ReadFile(p.forgeSecret())
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
_, err = ForgeCredentialLines(string(raw))
|
||||
return err == nil
|
||||
}
|
||||
|
||||
// PlaceForgeCredential puts the forge's read credential in the agent's home as it is held — written, rewritten when
|
||||
// it differs, taken back when none is held — and says what it did, or "" when nothing changed. Never the value.
|
||||
func PlaceForgeCredential(p Paths) (string, error) {
|
||||
path := p.forgeCredentials()
|
||||
raw, err := os.ReadFile(p.forgeSecret())
|
||||
var lines string
|
||||
var why error
|
||||
switch {
|
||||
case errors.Is(err, fs.ErrNotExist):
|
||||
why = errors.New("this machine holds no forge read credential")
|
||||
case err != nil:
|
||||
return "", fmt.Errorf("the forge's read credential cannot be read: %w", err)
|
||||
default:
|
||||
lines, why = ForgeCredentialLines(string(raw))
|
||||
}
|
||||
if why != nil {
|
||||
if _, err := fsys.Stat(path); errors.Is(err, fs.ErrNotExist) {
|
||||
return "", nil
|
||||
}
|
||||
if err := fsys.Remove(path); err != nil && !errors.Is(err, fs.ErrNotExist) {
|
||||
return "", fmt.Errorf("the forge credential at %s could not be taken back: %w", path, err)
|
||||
}
|
||||
return fmt.Sprintf("forge credential: taken back from %s: %v", path, why), nil
|
||||
}
|
||||
if held, err := fsys.ReadFile(path); err == nil && bytes.Equal(held, []byte(lines)) {
|
||||
if info, err := fsys.Stat(path); err == nil && info.Mode().Perm() == 0o600 {
|
||||
return "", nil
|
||||
}
|
||||
}
|
||||
if err := fsys.MkdirAll(filepath.Dir(path), 0o700); err != nil {
|
||||
return "", fmt.Errorf("the directory for the forge credential could not be made: %w", err)
|
||||
}
|
||||
tmp := path + ".mesh-tmp"
|
||||
if err := fsys.WriteFile(tmp, []byte(lines), 0o600); err != nil {
|
||||
return "", fmt.Errorf("the forge credential could not be written: %w", err)
|
||||
}
|
||||
if err := fsys.Chmod(tmp, 0o600); err != nil {
|
||||
_ = fsys.Remove(tmp)
|
||||
return "", fmt.Errorf("the forge credential could not be made 0600: %w", err)
|
||||
}
|
||||
if err := fsys.Rename(tmp, path); err != nil {
|
||||
_ = fsys.Remove(tmp)
|
||||
return "", fmt.Errorf("the forge credential could not be put in place: %w", err)
|
||||
}
|
||||
return fmt.Sprintf("forge credential: placed at %s for %d origin(s)", path, strings.Count(lines, "\n")), nil
|
||||
}
|
||||
|
||||
// forgeEnv is what every session's environment carries for git to read the credential: the file's path in git's
|
||||
// configuration from the environment, never the credential. Laid over the operator's `env`, the mesh's keys winning.
|
||||
func forgeEnv(managed map[string]any, credentials string) {
|
||||
if credentials == "" {
|
||||
return
|
||||
}
|
||||
env := map[string]any{}
|
||||
if held, ok := managed["env"].(map[string]any); ok {
|
||||
for k, v := range held {
|
||||
env[k] = v
|
||||
}
|
||||
}
|
||||
env["GIT_CONFIG_COUNT"] = "1"
|
||||
env["GIT_CONFIG_KEY_0"] = "credential.helper"
|
||||
env["GIT_CONFIG_VALUE_0"] = "store --file=" + shellQuote(credentials)
|
||||
managed["env"] = env
|
||||
}
|
||||
@@ -0,0 +1,262 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/cgi"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
const agentToken = "0123456789abcdef0123456789abcdef01234567"
|
||||
|
||||
// privateForge is a forge serving one private repository over git's smart http, to agent-anchor with its token
|
||||
// alone, as the forge does for an account a team lets read it.
|
||||
func privateForge(t *testing.T) (origin, bare string) {
|
||||
t.Helper()
|
||||
backend := filepath.Join(gitExecPath(t), "git-http-backend")
|
||||
if _, err := os.Stat(backend); err != nil {
|
||||
t.Skipf("git-http-backend is not installed: %v", err)
|
||||
}
|
||||
root := t.TempDir()
|
||||
bare = filepath.Join(root, "private.git")
|
||||
work := t.TempDir()
|
||||
runGit(t, work, nil, "git", "init", "-q", "-b", "main", ".")
|
||||
commit(t, work, "one")
|
||||
runGit(t, root, nil, "git", "clone", "-q", "--bare", work, bare)
|
||||
cgiHandler := &cgi.Handler{Path: backend, Env: []string{"GIT_PROJECT_ROOT=" + root, "GIT_HTTP_EXPORT_ALL=1"}}
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
user, pass, ok := r.BasicAuth()
|
||||
if !ok || user != "agent-anchor" || pass != agentToken {
|
||||
w.Header().Set("WWW-Authenticate", `Basic realm="Gitea"`)
|
||||
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
if r.Method == http.MethodPost && strings.HasSuffix(r.URL.Path, "git-receive-pack") {
|
||||
http.Error(w, "read only", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
cgiHandler.ServeHTTP(w, r)
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
return srv.URL, bare
|
||||
}
|
||||
|
||||
func gitExecPath(t *testing.T) string {
|
||||
t.Helper()
|
||||
out, err := exec.Command("git", "--exec-path").Output()
|
||||
if err != nil {
|
||||
t.Skipf("git is not installed: %v", err)
|
||||
}
|
||||
return strings.TrimSpace(string(out))
|
||||
}
|
||||
|
||||
func runGit(t *testing.T, dir string, env []string, name string, args ...string) string {
|
||||
t.Helper()
|
||||
out, err := runErr(dir, env, name, args...)
|
||||
if err != nil {
|
||||
t.Fatalf("%s %v: %v\n%s", name, args, err, out)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func runErr(dir string, env []string, name string, args ...string) (string, error) {
|
||||
cmd := exec.Command(name, args...)
|
||||
cmd.Dir = dir
|
||||
cmd.Env = append(os.Environ(), "GIT_AUTHOR_NAME=t", "GIT_AUTHOR_EMAIL=t@t", "GIT_COMMITTER_NAME=t",
|
||||
"GIT_COMMITTER_EMAIL=t@t", "GIT_TERMINAL_PROMPT=0", "GIT_ASKPASS=", "SSH_ASKPASS=")
|
||||
cmd.Env = append(cmd.Env, env...)
|
||||
out, err := cmd.CombinedOutput()
|
||||
return string(out), err
|
||||
}
|
||||
|
||||
func commit(t *testing.T, dir, text string) {
|
||||
t.Helper()
|
||||
if err := os.WriteFile(filepath.Join(dir, "file"), []byte(text+"\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
runGit(t, dir, nil, "git", "add", "file")
|
||||
runGit(t, dir, nil, "git", "commit", "-q", "-m", text)
|
||||
}
|
||||
|
||||
// sessionEnv is the environment a session starts with: the managed settings' `env`, in a home with no git
|
||||
// configuration of its own.
|
||||
func sessionEnv(t *testing.T, home, managedSettings string) []string {
|
||||
t.Helper()
|
||||
var settings struct {
|
||||
Env map[string]string `json:"env"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(managedSettings), &settings); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
env := []string{"HOME=" + home, "XDG_CONFIG_HOME=" + filepath.Join(home, ".config"), "GIT_CONFIG_NOSYSTEM=1"}
|
||||
for k, v := range settings.Env {
|
||||
env = append(env, k+"="+v)
|
||||
}
|
||||
return env
|
||||
}
|
||||
|
||||
func forgePaths(t *testing.T) Paths {
|
||||
t.Helper()
|
||||
return Paths{State: t.TempDir(), Home: t.TempDir(), Node: "anchor"}
|
||||
}
|
||||
|
||||
func managedFor(p Paths) string {
|
||||
facts := Facts{Node: p.Node, Console: "http://console"}
|
||||
if ForgeCredentialHeld(p) {
|
||||
facts.ForgeCredentials = p.forgeCredentials()
|
||||
}
|
||||
return Render(facts, Settings{}, nil, "", nil, Config{})["managed-settings.json"]
|
||||
}
|
||||
|
||||
// Issue 737's done-when, as far as a machine of its own proves it: an agent session clones a private repository and
|
||||
// fetches it again, and no credential appears in a log, a command line or the session.
|
||||
func TestAnAgentClonesAPrivateRepositoryAndFetchesItAgainWithTheCredentialPlaced(t *testing.T) {
|
||||
origin, bare := privateForge(t)
|
||||
p := forgePaths(t)
|
||||
repo := origin + "/private.git"
|
||||
|
||||
// Before the forge issued anything, the session's git has no credential: the clone is refused, as today.
|
||||
before := sessionEnv(t, p.Home, managedFor(p))
|
||||
if out, err := runErr(t.TempDir(), before, "git", "clone", "-q", repo, "x"); err == nil {
|
||||
t.Fatalf("a private repository was cloned with no credential:\n%s", out)
|
||||
}
|
||||
|
||||
// The vault hands this module what the forge issued for this machine's agent.
|
||||
lines := strings.Replace(origin, "http://", "http://agent-anchor:"+agentToken+"@", 1) + "\n" +
|
||||
"https://agent-anchor:" + agentToken + "@git.example.test\n"
|
||||
if err := os.WriteFile(p.forgeSecret(), []byte(lines), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
said, err := PlaceForgeCredential(p)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
info, err := os.Stat(p.forgeCredentials())
|
||||
if err != nil || info.Mode().Perm() != 0o600 {
|
||||
t.Fatalf("the credential file: %v %v, want mode 0600", info, err)
|
||||
}
|
||||
if again, err := PlaceForgeCredential(p); err != nil || again != "" {
|
||||
t.Fatalf("placed again with nothing changed: %q %v", again, err)
|
||||
}
|
||||
managed := managedFor(p)
|
||||
env := sessionEnv(t, p.Home, managed)
|
||||
|
||||
work := t.TempDir()
|
||||
cloneOut := runGit(t, work, env, "git", "clone", repo, "clone")
|
||||
clone := filepath.Join(work, "clone")
|
||||
if got := runGit(t, clone, env, "git", "log", "--format=%s"); strings.TrimSpace(got) != "one" {
|
||||
t.Fatalf("cloned %q", got)
|
||||
}
|
||||
|
||||
// The forge's repository moves on; the agent fetches it again.
|
||||
other := t.TempDir()
|
||||
runGit(t, other, nil, "git", "clone", "-q", bare, "w")
|
||||
commit(t, filepath.Join(other, "w"), "two")
|
||||
runGit(t, filepath.Join(other, "w"), nil, "git", "push", "-q", "origin", "main")
|
||||
fetchOut := runGit(t, clone, env, "git", "fetch", "origin")
|
||||
if got := runGit(t, clone, env, "git", "log", "--format=%s", "origin/main"); !strings.HasPrefix(got, "two") {
|
||||
t.Fatalf("fetched %q", got)
|
||||
}
|
||||
|
||||
// A push is the forge's to refuse (ADR 0365 §5): the token reads, and nothing writes with it.
|
||||
commit(t, clone, "three")
|
||||
if out, err := runErr(clone, env, "git", "push", "origin", "main"); err == nil {
|
||||
t.Fatalf("a push with the agent's credential went through:\n%s", out)
|
||||
}
|
||||
|
||||
// No credential in what the session saw, its command lines, its environment or the module's words.
|
||||
remote := runGit(t, clone, env, "git", "remote", "get-url", "origin")
|
||||
for what, text := range map[string]string{"the clone's output": cloneOut, "the fetch's output": fetchOut,
|
||||
"the remote's url": remote, "the managed settings": managed, "the session's environment": strings.Join(env, "\n"),
|
||||
"what the module said": said, "the clone's configuration": readOrEmpty(filepath.Join(clone, ".git", "config"))} {
|
||||
if strings.Contains(text, agentToken) {
|
||||
t.Errorf("the token appears in %s", what)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func readOrEmpty(path string) string {
|
||||
raw, _ := os.ReadFile(path)
|
||||
return string(raw)
|
||||
}
|
||||
|
||||
func TestWhatGitChangedInTheFileIsPutBackAndAValueNotIssuedIsNeverPlaced(t *testing.T) {
|
||||
p := forgePaths(t)
|
||||
// A value the mesh made while the forge had not been asked: not credential lines, never placed.
|
||||
if err := os.WriteFile(p.forgeSecret(), []byte("Zm9yZ2VkLWJ5LXRoZS1tZXNo\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if said, err := PlaceForgeCredential(p); err != nil || said != "" {
|
||||
t.Fatalf("%q %v", said, err)
|
||||
}
|
||||
if _, err := os.Stat(p.forgeCredentials()); err == nil {
|
||||
t.Fatal("a value the forge did not issue was placed")
|
||||
}
|
||||
if ForgeCredentialHeld(p) || strings.Contains(managedFor(p), "GIT_CONFIG") {
|
||||
t.Fatal("the sessions' git is pointed at a credential that is not held")
|
||||
}
|
||||
|
||||
issued := "https://agent-anchor:" + agentToken + "@git.example.test\n"
|
||||
if err := os.WriteFile(p.forgeSecret(), []byte(issued), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := PlaceForgeCredential(p); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// git's store helper erased the line after a rejection, and a person's password for another host was stored.
|
||||
if err := os.WriteFile(p.forgeCredentials(), []byte("https://me:mine@example.org\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if said, err := PlaceForgeCredential(p); err != nil || !strings.Contains(said, "placed") {
|
||||
t.Fatalf("%q %v", said, err)
|
||||
}
|
||||
if got := readOrEmpty(p.forgeCredentials()); got != issued {
|
||||
t.Fatalf("the file holds %q", strings.ReplaceAll(got, agentToken, "<token>"))
|
||||
}
|
||||
if info, _ := os.Stat(p.forgeCredentials()); info.Mode().Perm() != 0o600 {
|
||||
t.Fatalf("mode %v", info.Mode().Perm())
|
||||
}
|
||||
|
||||
// The credential withdrawn: taken back, and the sessions' git no longer pointed at it.
|
||||
if err := os.Remove(p.forgeSecret()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if said, err := PlaceForgeCredential(p); err != nil || !strings.Contains(said, "taken back") || strings.Contains(said, agentToken) {
|
||||
t.Fatalf("%q %v", said, err)
|
||||
}
|
||||
if _, err := os.Stat(p.forgeCredentials()); err == nil {
|
||||
t.Fatal("a withdrawn credential stayed in the agent's home")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheSessionsGitIsPointedAtTheFileUnderTheOperatorsOwnEnvironment(t *testing.T) {
|
||||
managed := map[string]any{"env": map[string]any{"FOO": "bar", "GIT_CONFIG_COUNT": "7"}}
|
||||
forgeEnv(managed, "/home/agent/.config/mesh/forge.credentials")
|
||||
env := managed["env"].(map[string]any)
|
||||
if env["FOO"] != "bar" || env["GIT_CONFIG_COUNT"] != "1" || env["GIT_CONFIG_KEY_0"] != "credential.helper" ||
|
||||
env["GIT_CONFIG_VALUE_0"] != "store --file='/home/agent/.config/mesh/forge.credentials'" {
|
||||
t.Fatalf("%v", env)
|
||||
}
|
||||
none := map[string]any{}
|
||||
forgeEnv(none, "")
|
||||
if _, set := none["env"]; set {
|
||||
t.Fatal("an environment was set with no credential held")
|
||||
}
|
||||
}
|
||||
|
||||
func TestForgeCredentialLinesAreReadStrictly(t *testing.T) {
|
||||
for _, bad := range []string{"", "\n", "token", "https://git.example.test", "https://agent-anchor@git.example.test",
|
||||
"https://agent-anchor:t@git.example.test/novox/hq.git", "ssh://agent-anchor:t@git.example.test",
|
||||
"https://agent-anchor:t@git.example.test\nnot a line"} {
|
||||
if _, err := ForgeCredentialLines(bad); err == nil {
|
||||
t.Errorf("%q was taken", bad)
|
||||
} else if strings.Contains(err.Error(), "t@git") {
|
||||
t.Errorf("the refusal said the value: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -585,7 +585,25 @@ func run(p Paths, view *ServerView, configView *ConfigView) {
|
||||
|
||||
report()
|
||||
var last string
|
||||
// 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)
|
||||
placeForge := func() {
|
||||
if line, err := PlaceForgeCredential(p); err != nil {
|
||||
say("%v", err)
|
||||
} else if line != "" {
|
||||
say("%s", line)
|
||||
}
|
||||
if held := ForgeCredentialHeld(p); held != forgeHeld {
|
||||
forgeHeld = held
|
||||
if _, err := RenderNow(p, writeManaged); err != nil {
|
||||
say("rendering after the forge's read credential changed: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
placeForge()
|
||||
for range time.Tick(5 * time.Second) {
|
||||
placeForge()
|
||||
info, err := fsys.Stat(p.credentials())
|
||||
now := "absent"
|
||||
if err == nil {
|
||||
|
||||
@@ -216,6 +216,9 @@ func RenderNow(p Paths, write WriteManaged) ([]string, error) {
|
||||
if !readJSON(p.Facts, &facts) || facts.Console == "" {
|
||||
return nil, fmt.Errorf("the mesh has not rendered %s yet; nothing to write", p.Facts)
|
||||
}
|
||||
if ForgeCredentialHeld(p) {
|
||||
facts.ForgeCredentials = p.forgeCredentials()
|
||||
}
|
||||
var settings Settings
|
||||
readJSON(p.Settings, &settings)
|
||||
var binding *Binding
|
||||
|
||||
@@ -34,6 +34,9 @@ const meshEntry = "mesh"
|
||||
type Facts struct {
|
||||
Node string `json:"node"`
|
||||
Console string `json:"console"`
|
||||
// ForgeCredentials is the forge's read credential placed in the agent's home, when this machine holds one
|
||||
// (forge.go): its path, which every session's git is pointed at. Never rendered by the mesh.
|
||||
ForgeCredentials string `json:"-"`
|
||||
}
|
||||
|
||||
// Settings are the operator's, for the mesh or this node.
|
||||
@@ -139,6 +142,7 @@ func RenderWithMesh(facts Facts, settings Settings, binding *Binding, helperPath
|
||||
}
|
||||
managed[key] = entries
|
||||
}
|
||||
forgeEnv(managed, facts.ForgeCredentials)
|
||||
if binding != nil && binding.Kind == "api-key" {
|
||||
managed["apiKeyHelper"] = helperPath
|
||||
}
|
||||
|
||||
@@ -19,11 +19,12 @@ const agentAccountFile = "/etc/claude-code/agent-account"
|
||||
|
||||
type manifestShell struct {
|
||||
Resources []map[string]any `json:"resources"`
|
||||
Shell []struct {
|
||||
For string `json:"for"`
|
||||
Slot string `json:"slot"`
|
||||
Code string `json:"code"`
|
||||
} `json:"shell"`
|
||||
// The zsh code is an item of the collection shell:zsh:<slot> (novox/hq ADR 0349 clause 13), its text the
|
||||
// template file beside the manifest.
|
||||
Items []struct {
|
||||
Collection string `json:"collection"`
|
||||
Template string `json:"template"`
|
||||
} `json:"items"`
|
||||
}
|
||||
|
||||
func readManifestShell(t *testing.T) manifestShell {
|
||||
@@ -54,9 +55,13 @@ func resourceByID(t *testing.T, m manifestShell, id string) map[string]any {
|
||||
func zshCode(t *testing.T, m manifestShell) string {
|
||||
t.Helper()
|
||||
var code []string
|
||||
for _, s := range m.Shell {
|
||||
if s.For == "zsh" {
|
||||
code = append(code, s.Code)
|
||||
for _, it := range m.Items {
|
||||
if strings.HasPrefix(it.Collection, "shell:zsh:") {
|
||||
text, err := os.ReadFile(filepath.Join("..", "..", it.Template))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
code = append(code, string(text))
|
||||
}
|
||||
}
|
||||
if len(code) != 1 {
|
||||
@@ -71,8 +76,8 @@ func TestTheAccountAgentsRunAsIsWrittenForTheShell(t *testing.T) {
|
||||
if r["type"] != "file" || r["path"] != agentAccountFile || r["mode"] != "0644" {
|
||||
t.Fatalf("the account's file is %v", r)
|
||||
}
|
||||
if r["content"] != "${machine:agent-account}\n" {
|
||||
t.Fatalf("the account's file holds %q", r["content"])
|
||||
if text := templateText(t, r); text != "${machine:agent-account}\n" {
|
||||
t.Fatalf("the account's file holds %q", text)
|
||||
}
|
||||
if _, has := r["owner"]; has {
|
||||
t.Fatal("the account's file must be root's, as claude-agent is")
|
||||
@@ -163,7 +168,7 @@ func TestClaudeAgentRunsTheRealClaudeUnderTheAlias(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
start, _ := resourceByID(t, readManifestShell(t), "start")["content"].(string)
|
||||
start := templateText(t, resourceByID(t, readManifestShell(t), "start"))
|
||||
if !strings.Contains(start, "exec claude \"$@\"") {
|
||||
t.Fatalf("claude-agent no longer runs claude itself:\n%s", start)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// The module's directory, where its manifest and its files/ are.
|
||||
const moduleDir = "../.."
|
||||
|
||||
// templateText is the text a file resource places on the machine: its template's, in the module's files/
|
||||
// (novox/hq ADR 0349 §2). A resource that names no template, or a template that is not there, fails the test.
|
||||
func templateText(t *testing.T, r map[string]any) string {
|
||||
t.Helper()
|
||||
name, _ := r["template"].(string)
|
||||
if name == "" {
|
||||
t.Fatalf("resource %v names no template", r["id"])
|
||||
}
|
||||
raw, err := os.ReadFile(filepath.Join(moduleDir, filepath.FromSlash(name)))
|
||||
if err != nil {
|
||||
t.Fatalf("resource %v names %s: %v", r["id"], name, err)
|
||||
}
|
||||
return string(raw)
|
||||
}
|
||||
|
||||
// No file resource carries its text inline: each names its template in files/, and the build packs files/
|
||||
// into the one archive the controller reads the templates back from (novox/hq ADR 0349 §2).
|
||||
func TestEveryFileTakesItsTextFromATemplate(t *testing.T) {
|
||||
raw, err := os.ReadFile(filepath.Join(moduleDir, "module.json"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var m struct {
|
||||
Resources []map[string]any `json:"resources"`
|
||||
Build struct {
|
||||
Artifacts []map[string]any `json:"artifacts"`
|
||||
} `json:"build"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &m); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, r := range m.Resources {
|
||||
if _, inline := r["content"]; inline {
|
||||
t.Errorf("resource %v keeps its text inline: keep it in files/ and name it as the template", r["id"])
|
||||
}
|
||||
if r["type"] == "file" {
|
||||
templateText(t, r)
|
||||
}
|
||||
}
|
||||
packs := 0
|
||||
for _, a := range m.Build.Artifacts {
|
||||
if a["kind"] == "archive" && a["from"] == "files" {
|
||||
packs++
|
||||
if a["name"] != "files" {
|
||||
t.Errorf("the templates archive is named %v, not files", a["name"])
|
||||
}
|
||||
}
|
||||
}
|
||||
if packs != 1 {
|
||||
t.Errorf("the build packs files/ into %d archives, not one", packs)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
${machine:agent-account}
|
||||
Executable
+13
@@ -0,0 +1,13 @@
|
||||
#!/bin/sh
|
||||
# The mesh's (module claude-code, novox/hq ADR 0266): start the agent as the account agents run as on this
|
||||
# machine. Written whole at every push: an edit here is overwritten.
|
||||
agent='${machine:agent-account}'
|
||||
if [ "$(id -un)" = "$agent" ]; then
|
||||
exec claude "$@"
|
||||
fi
|
||||
# Another account, the operator's, becomes the agent's through its own sudo: the person is the authority. The
|
||||
# operator's override of the guard travels only when it is set in this shell, as it would reach claude.
|
||||
if [ -n "$MESH_GUARD_OVERRIDE" ]; then
|
||||
exec sudo -iu "$agent" env MESH_GUARD_OVERRIDE="$MESH_GUARD_OVERRIDE" claude "$@"
|
||||
fi
|
||||
exec sudo -iu "$agent" claude "$@"
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"node": "${machine:name}",
|
||||
"console": "http://127.0.0.1:${bound:mcp-endpoint:port}/mcp"
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"role": "",
|
||||
"mcp_servers": {},
|
||||
"managed_settings": {},
|
||||
"instructions": {},
|
||||
"output_styles": {},
|
||||
"skills": {},
|
||||
"commands": {},
|
||||
"agents": {}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
# The agent's session (module claude-code, novox/hq ADR 0266): where this machine names an account of the
|
||||
# agents' own, claude in an interactive zsh is claude-agent, which starts the session as that account. Where
|
||||
# agents run as the operator's account the file names that account, and nothing is added. claude-agent runs
|
||||
# the real claude: exec, sudo and its sh see no alias.
|
||||
if [[ -r /etc/claude-code/agent-account ]]; then
|
||||
() {
|
||||
local agent=$(</etc/claude-code/agent-account)
|
||||
if [[ -n $agent && $agent != $USERNAME ]]; then
|
||||
alias claude=claude-agent
|
||||
fi
|
||||
}
|
||||
fi
|
||||
@@ -1,16 +1,24 @@
|
||||
{
|
||||
"module": "claude-code",
|
||||
"version": "1",
|
||||
"moved": true,
|
||||
"slug": "agent",
|
||||
"capabilities": [
|
||||
"package-manager"
|
||||
],
|
||||
"requires": [
|
||||
"mcp-endpoint"
|
||||
"mcp-endpoint",
|
||||
{
|
||||
"name": "gitea-read",
|
||||
"optional": true
|
||||
}
|
||||
],
|
||||
"binds": {
|
||||
"mcp-endpoint": "${dir:state}/mcp-endpoint.json"
|
||||
},
|
||||
"secrets": {
|
||||
"gitea-read": "${dir:state}/gitea-read.secret"
|
||||
},
|
||||
"uses": [
|
||||
"operator-channel"
|
||||
],
|
||||
@@ -85,6 +93,57 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
"config": {
|
||||
"role": {
|
||||
"type": "string",
|
||||
"description": "What this machine is for, in a sentence, written into the instructions every Claude Code session on it reads; empty says nothing.",
|
||||
"trusted": true,
|
||||
"default": ""
|
||||
},
|
||||
"mcp_servers": {
|
||||
"type": "object",
|
||||
"description": "The tool servers every session on this machine is given beside the mesh's own, by name, each as Claude Code's managed MCP configuration takes it; empty adds none.",
|
||||
"trusted": true,
|
||||
"default": {}
|
||||
},
|
||||
"managed_settings": {
|
||||
"type": "object",
|
||||
"description": "Keys of Claude Code's managed settings every session on this machine obeys, such as spinnerTipsEnabled, laid over the mesh's own; empty adds none. Falls back to agent-managed-settings, the mesh's or this node's.",
|
||||
"trusted": true,
|
||||
"predefined": "agent-managed-settings",
|
||||
"default": {}
|
||||
},
|
||||
"instructions": {
|
||||
"type": "object",
|
||||
"description": "The instructions every session on this machine reads, by name, each a text as one line or a list of its lines; empty adds none.",
|
||||
"trusted": true,
|
||||
"default": {}
|
||||
},
|
||||
"output_styles": {
|
||||
"type": "object",
|
||||
"description": "The output styles offered to every session on this machine, by name, each a text as one line or a list of its lines; empty adds none.",
|
||||
"trusted": true,
|
||||
"default": {}
|
||||
},
|
||||
"skills": {
|
||||
"type": "object",
|
||||
"description": "The skills offered to every session on this machine, by name, each its files by path, a text as one line or a list of its lines; a skill without its SKILL.md is left out; empty adds none.",
|
||||
"trusted": true,
|
||||
"default": {}
|
||||
},
|
||||
"commands": {
|
||||
"type": "object",
|
||||
"description": "The slash commands offered to every session on this machine, by name, each a text as one line or a list of its lines; empty adds none.",
|
||||
"trusted": true,
|
||||
"default": {}
|
||||
},
|
||||
"agents": {
|
||||
"type": "object",
|
||||
"description": "The agents offered to every session on this machine, by name, each a text as one line or a list of its lines; empty adds none.",
|
||||
"trusted": true,
|
||||
"default": {}
|
||||
}
|
||||
},
|
||||
"resources": [
|
||||
{
|
||||
"id": "package",
|
||||
@@ -102,14 +161,14 @@
|
||||
"type": "file",
|
||||
"path": "/usr/local/bin/claude-agent",
|
||||
"mode": "0755",
|
||||
"content": "#!/bin/sh\n# The mesh's (module claude-code, novox/hq ADR 0266): start the agent as the account agents run as on this\n# machine. Written whole at every push: an edit here is overwritten.\nagent='${machine:agent-account}'\nif [ \"$(id -un)\" = \"$agent\" ]; then\n\texec claude \"$@\"\nfi\n# Another account, the operator's, becomes the agent's through its own sudo: the person is the authority. The\n# operator's override of the guard travels only when it is set in this shell, as it would reach claude.\nif [ -n \"$MESH_GUARD_OVERRIDE\" ]; then\n\texec sudo -iu \"$agent\" env MESH_GUARD_OVERRIDE=\"$MESH_GUARD_OVERRIDE\" claude \"$@\"\nfi\nexec sudo -iu \"$agent\" claude \"$@\"\n"
|
||||
"template": "files/claude-agent"
|
||||
},
|
||||
{
|
||||
"id": "agent-account-name",
|
||||
"type": "file",
|
||||
"path": "/etc/claude-code/agent-account",
|
||||
"mode": "0644",
|
||||
"content": "${machine:agent-account}\n"
|
||||
"template": "files/agent-account"
|
||||
},
|
||||
{
|
||||
"id": "agent-account",
|
||||
@@ -138,7 +197,7 @@
|
||||
"path": "${dir:state}/facts.json",
|
||||
"mode": "0600",
|
||||
"owner": "${machine:account}",
|
||||
"content": "{\n \"node\": \"${machine:name}\",\n \"console\": \"http://127.0.0.1:${bound:mcp-endpoint:port}/mcp\"\n}\n"
|
||||
"template": "files/facts.json"
|
||||
},
|
||||
{
|
||||
"id": "settings",
|
||||
@@ -146,15 +205,15 @@
|
||||
"path": "${dir:state}/settings.json",
|
||||
"mode": "0600",
|
||||
"owner": "${machine:account}",
|
||||
"takes-config": ["role", "mcp_servers", "managed_settings", "instructions", "output_styles", "skills", "commands", "agents"],
|
||||
"merge": "json",
|
||||
"content": "{\n \"role\": \"\",\n \"mcp_servers\": {},\n \"managed_settings\": {},\n \"instructions\": {},\n \"output_styles\": {},\n \"skills\": {},\n \"commands\": {},\n \"agents\": {}\n}\n"
|
||||
"template": "files/settings.json"
|
||||
}
|
||||
],
|
||||
"shell": [
|
||||
"items": [
|
||||
{
|
||||
"for": "zsh",
|
||||
"slot": "normal",
|
||||
"code": "# The agent's session (module claude-code, novox/hq ADR 0266): where this machine names an account of the\n# agents' own, claude in an interactive zsh is claude-agent, which starts the session as that account. Where\n# agents run as the operator's account the file names that account, and nothing is added. claude-agent runs\n# the real claude: exec, sudo and its sh see no alias.\nif [[ -r /etc/claude-code/agent-account ]]; then\n\t() {\n\t\tlocal agent=$(</etc/claude-code/agent-account)\n\t\tif [[ -n $agent && $agent != $USERNAME ]]; then\n\t\t\talias claude=claude-agent\n\t\tfi\n\t}\nfi\n"
|
||||
"collection": "shell:zsh:normal",
|
||||
"template": "files/zsh-normal"
|
||||
}
|
||||
],
|
||||
"build": {
|
||||
@@ -174,6 +233,11 @@
|
||||
"MESH_CLAUDE_CODE_FACTS": "${dir:state}/facts.json",
|
||||
"MESH_CLAUDE_CODE_SETTINGS": "${dir:state}/settings.json"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "files",
|
||||
"kind": "archive",
|
||||
"from": "files"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user