claude-code: claude in the operator's zsh starts the agent's session where the machine names an agent account (hq ADR 0266)
mesh/merge-gate pass: builds claude-code → ace, g14, novox, shanks; no bus step; every machine composes with the change as it did without (4 of 4 compose)
mesh/repo-check pass: its merge-check.sh passed, each part in its toolchain: merge-check.sh (go), merge-check-typescript.sh (typescript)
mesh/delivery covered: a later merge that contains it was delivered: novox/mesh-catalog@e4edab3fd0eb (merged as c41c32cb into main, walk plan-17916298010…
mesh/merge-gate pass: builds claude-code → ace, g14, novox, shanks; no bus step; every machine composes with the change as it did without (4 of 4 compose)
mesh/repo-check pass: its merge-check.sh passed, each part in its toolchain: merge-check.sh (go), merge-check-typescript.sh (typescript)
mesh/delivery covered: a later merge that contains it was delivered: novox/mesh-catalog@e4edab3fd0eb (merged as c41c32cb into main, walk plan-17916298010…
Typing claude on the control node should start the session as the agent's own account, not as the operator. The controller fills no machine fact into shell code, so the account agents run as is written to /etc/claude-code/agent-account and the zsh code adds alias claude=claude-agent only when that file names an account other than the shell's own. Where agents run as the operator's account, nothing is added. claude-agent keeps running the real claude: exec sees no alias.
This commit is contained in:
@@ -26,6 +26,13 @@ And, for where the agent runs as an account of its own (hq ADR 0266):
|
||||
- `/usr/local/bin/claude-agent`, root's, `0755`: starts the agent as that account — `claude` itself when run
|
||||
as it, otherwise `sudo -iu <account> claude` through the operator's own sudo, carrying the guard's
|
||||
override only when it is set in the operator's shell.
|
||||
- `/etc/claude-code/agent-account`, root's, `0644`: the name of that account, the machine fact
|
||||
`agent-account`, for the shell to read — the controller fills no machine fact into shell code.
|
||||
- **`claude` in the operator's interactive zsh is `claude-agent`** where the machine names an agent account:
|
||||
zsh code in the login shell's `normal` slot (hq ADR 0204) reads that file and adds
|
||||
`alias claude=claude-agent` only when it names an account other than the shell's own. Where agents run as
|
||||
the operator's account the file names that account, and nothing is added. `claude-agent` still runs the
|
||||
real `claude`: an alias is a word of an interactive zsh, and neither `exec`, `sudo` nor its `sh` sees it.
|
||||
|
||||
## What it writes
|
||||
|
||||
|
||||
@@ -0,0 +1,193 @@
|
||||
package main
|
||||
|
||||
// The agent's session from the operator's shell (novox/hq ADR 0266): where a machine names an account of the
|
||||
// agents' own, `claude` typed in the operator's interactive zsh is `claude-agent`; where agents run as the
|
||||
// operator's account, nothing is added. The manifest writes the account agents run as to a file, and the zsh
|
||||
// code it contributes reads it: the controller never fills a machine fact into shell code.
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"os/exec"
|
||||
"os/user"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
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"`
|
||||
}
|
||||
|
||||
func readManifestShell(t *testing.T) manifestShell {
|
||||
t.Helper()
|
||||
raw, err := os.ReadFile("../../module.json")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var m manifestShell
|
||||
if err := json.Unmarshal(raw, &m); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
func resourceByID(t *testing.T, m manifestShell, id string) map[string]any {
|
||||
t.Helper()
|
||||
for _, r := range m.Resources {
|
||||
if r["id"] == id {
|
||||
return r
|
||||
}
|
||||
}
|
||||
t.Fatalf("the manifest has no resource %q", id)
|
||||
return nil
|
||||
}
|
||||
|
||||
// zshCode is the one piece of zsh code the module contributes.
|
||||
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)
|
||||
}
|
||||
}
|
||||
if len(code) != 1 {
|
||||
t.Fatalf("the module contributes %d pieces of zsh code; one is expected", len(code))
|
||||
}
|
||||
return code[0]
|
||||
}
|
||||
|
||||
// The file names the account agents run as, from the machine fact, which is the operator's where none is named.
|
||||
func TestTheAccountAgentsRunAsIsWrittenForTheShell(t *testing.T) {
|
||||
r := resourceByID(t, readManifestShell(t), "agent-account-name")
|
||||
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 _, has := r["owner"]; has {
|
||||
t.Fatal("the account's file must be root's, as claude-agent is")
|
||||
}
|
||||
}
|
||||
|
||||
// The alias stands once, and only inside the guard that compares the account with the shell's own.
|
||||
func TestTheAliasStandsOnlyBehindTheGuard(t *testing.T) {
|
||||
code := zshCode(t, readManifestShell(t))
|
||||
if n := strings.Count(code, "alias claude="); n != 1 {
|
||||
t.Fatalf("the code names the alias %d times", n)
|
||||
}
|
||||
lines := strings.Split(code, "\n")
|
||||
guard, alias, end := -1, -1, -1
|
||||
for i, l := range lines {
|
||||
l = strings.TrimSpace(l)
|
||||
switch {
|
||||
case l == "if [[ -n $agent && $agent != $USERNAME ]]; then":
|
||||
guard = i
|
||||
case l == "alias claude=claude-agent":
|
||||
alias = i
|
||||
case l == "fi" && guard >= 0 && alias > guard && end < 0:
|
||||
end = i
|
||||
}
|
||||
}
|
||||
if guard < 0 || alias < 0 || end < 0 || !(guard < alias && alias < end) {
|
||||
t.Fatalf("the alias is not inside the guard:\n%s", code)
|
||||
}
|
||||
if strings.Contains(code, "${") {
|
||||
t.Fatal("the code holds ${…}, which the controller does not fill in shell code and zsh reads as its own")
|
||||
}
|
||||
}
|
||||
|
||||
// Run in zsh, the code adds the alias only where the file names an account other than the shell's.
|
||||
func TestTheCodeAddsTheAliasOnlyForAnotherAccount(t *testing.T) {
|
||||
zsh, err := exec.LookPath("zsh")
|
||||
if err != nil {
|
||||
t.Skip("no zsh here")
|
||||
}
|
||||
me, err := user.Current()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
code := zshCode(t, readManifestShell(t))
|
||||
cases := []struct {
|
||||
name string
|
||||
content *string
|
||||
alias bool
|
||||
}{
|
||||
{"an agent account of its own", ptr("agent-of-its-own\n"), true},
|
||||
{"agents run as the operator's account", ptr(me.Username + "\n"), false},
|
||||
{"an empty file", ptr(""), false},
|
||||
{"no file", nil, false},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
file := filepath.Join(t.TempDir(), "agent-account")
|
||||
if c.content != nil {
|
||||
if err := os.WriteFile(file, []byte(*c.content), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
script := strings.ReplaceAll(code, agentAccountFile, file) + "alias claude || true\n"
|
||||
out, err := exec.Command(zsh, "-f", "-c", script).CombinedOutput()
|
||||
if err != nil {
|
||||
t.Fatalf("%v: %s", err, out)
|
||||
}
|
||||
got := strings.TrimSpace(string(out))
|
||||
if c.alias && got != "claude=claude-agent" {
|
||||
t.Fatalf("no alias: %q", got)
|
||||
}
|
||||
if !c.alias && got != "" {
|
||||
t.Fatalf("an alias, or output, where none belongs: %q", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// claude-agent runs the real claude, never itself: with the alias in force in the shell that types claude,
|
||||
// claude-agent's own `exec claude` finds the program on PATH, since an alias is a word of an interactive zsh
|
||||
// and neither exec, sudo nor claude-agent's sh sees it.
|
||||
func TestClaudeAgentRunsTheRealClaudeUnderTheAlias(t *testing.T) {
|
||||
zsh, err := exec.LookPath("zsh")
|
||||
if err != nil {
|
||||
t.Skip("no zsh here")
|
||||
}
|
||||
me, err := user.Current()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
start, _ := resourceByID(t, readManifestShell(t), "start")["content"].(string)
|
||||
if !strings.Contains(start, "exec claude \"$@\"") {
|
||||
t.Fatalf("claude-agent no longer runs claude itself:\n%s", start)
|
||||
}
|
||||
bin := t.TempDir()
|
||||
// Rendered for an account that is the caller's own, the branch that runs claude without sudo.
|
||||
// A line after the shebang says the call went through it.
|
||||
agent := strings.ReplaceAll(start, "${machine:agent-account}", me.Username)
|
||||
agent = strings.Replace(agent, "\n", "\necho through claude-agent\n", 1)
|
||||
if err := os.WriteFile(filepath.Join(bin, "claude-agent"), []byte(agent), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(bin, "claude"), []byte("#!/bin/sh\necho \"the real claude $*\"\n"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// zsh reads a -c string whole before it runs any of it, so the typed line is read after the alias, by eval.
|
||||
cmd := exec.Command(zsh, "-f", "-c", "alias claude=claude-agent\neval 'claude --resume'\n")
|
||||
cmd.Env = append(os.Environ(), "PATH="+bin+string(os.PathListSeparator)+os.Getenv("PATH"))
|
||||
out, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
t.Fatalf("%v: %s", err, out)
|
||||
}
|
||||
if got := strings.TrimSpace(string(out)); got != "through claude-agent\nthe real claude --resume" {
|
||||
t.Fatalf("claude under the alias ran %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func ptr(s string) *string { return &s }
|
||||
@@ -91,6 +91,13 @@
|
||||
"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"
|
||||
},
|
||||
{
|
||||
"id": "agent-account-name",
|
||||
"type": "file",
|
||||
"path": "/etc/claude-code/agent-account",
|
||||
"mode": "0644",
|
||||
"content": "${machine:agent-account}\n"
|
||||
},
|
||||
{
|
||||
"id": "agent-account",
|
||||
"type": "user",
|
||||
@@ -130,6 +137,13 @@
|
||||
"content": "{\n \"role\": \"\",\n \"mcp_servers\": {},\n \"managed_settings\": {},\n \"instructions\": {},\n \"output_styles\": {},\n \"skills\": {},\n \"commands\": {},\n \"agents\": {}\n}\n"
|
||||
}
|
||||
],
|
||||
"shell": [
|
||||
{
|
||||
"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"
|
||||
}
|
||||
],
|
||||
"build": {
|
||||
"artifacts": [
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user