199 lines
6.5 KiB
Go
199 lines
6.5 KiB
Go
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"`
|
|
// 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 {
|
|
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 _, 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 {
|
|
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 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")
|
|
}
|
|
}
|
|
|
|
// 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 := 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)
|
|
}
|
|
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 }
|