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:
@@ -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 }
|
||||
Reference in New Issue
Block a user