Files
mesh-catalog/modules/claude-code/cmd/claude-code/agent_account.go
T
jochen b240085e0a
mesh/merge-gate pass: builds claude-code, claude-licence-manager, restic, sudo → ace, g14, novox, shanks; no bus step; every machine composes with the chan…
mesh/repo-check pass: THE CHANGE ALTERS ITS OWN CHECK (merge-check.sh): main's version judged it; the change's judges the pull requests after it merges; it…
mesh/delivery delivered
Close the review's gaps round the agent account (hq ADR 0266)
The managed settings apply to every account's claude, so a key-helper script under the
agent's home would run as the operator, and through sudo as root: the command is now
carried whole in the root-owned managed settings and reads the key only as the agent.
The helper reads only a regular file, bounded, without blocking, and a request it does
not answer fails in time instead of holding every later one; a directory entry it
answers that is not a name is refused. The guard's record is matched on its binary in
the query, a half-named agent account is not taken as the operator's, the kept API key
stays until the agent's home holds one, restic refuses to restore beside a directory
others may write, and the agent-home check cleans a path before judging it.
2026-10-09 23:48:15 +02:00

95 lines
4.2 KiB
Go

package main
// Where the agent runs as an account of its own (novox/hq ADR 0266).
//
// The control node names an agent account beside the operator's: agents run there as an account that cannot
// become root without a person. The controller hands this module both — MESH_AGENT_ACCOUNT and its home, and
// the operator's — and everything this module writes for the agent goes to the agent's: the licence token, the
// account it is logged in as, an API key and its helper, the items registered at the home scope (homefs.go).
//
// Two things follow when a machine moves its agents to such an account:
//
// - **the licence follows at once.** The binding applied is the same, so nothing would fetch the token again
// until the licence rotates; the agent's new home would hold none. A binding counts as applied only while
// the account the agent runs as holds it.
// - **what the module placed in the operator's home goes** (ADR 0182: the mesh owns what it places). The
// credentials file the module wrote there holds an access token and never a refresh token — so a file that
// holds no refresh token is the module's, and is removed — only once the agent's own home holds the licence,
// so a move that has not fetched it yet leaves the operator's in place — with the API key and helper it
// kept in its state.
// A file holding a refresh token is a login the person made there: theirs, left, and said. The items placed
// at the home scope there go as unregistering takes them — each unless the person changed it since.
import (
"os"
"path/filepath"
)
// holdsApplied says whether the account the agent runs as holds a licence of this kind.
func holdsApplied(p Paths, kind string) bool {
if kind == "api-key" {
_, err := fsys.Stat(p.apiKey())
return err == nil
}
return GrantOf(ReadCredentials(p.credentials())) != nil
}
// holdsAnyLicence says whether the agent's account holds a token or an API key now.
func holdsAnyLicence(p Paths) bool {
return holdsApplied(p, "subscription") || holdsApplied(p, "api-key")
}
// RetireOperatorHome takes back what this module placed for the agent under the operator's home, once the agent
// runs as an account of its own; answers what it did, or nothing.
func RetireOperatorHome(p Paths) []string {
if !p.Confined() || p.OperatorHome == "" || filepath.Clean(p.OperatorHome) == filepath.Clean(p.Home) {
return nil
}
var out []string
file := filepath.Join(p.OperatorHome, ".claude", ".credentials.json")
if creds := ReadCredentials(file); creds != nil {
switch {
case HoldsLogin(creds):
out = append(out, "left "+file+": it holds a login the person made, which is theirs; the agent here runs as "+
p.Agent+" and no longer reads it")
case GrantOf(creds) != nil && !holdsAnyLicence(p):
out = append(out, "kept the token this module placed in "+file+" until "+p.Agent+"'s home holds the licence")
case GrantOf(creds) != nil:
if err := os.Remove(file); err != nil {
out = append(out, "the token this module placed in "+file+" could not be removed: "+err.Error())
} else {
out = append(out, "removed the token this module placed in "+file+": the agent here runs as "+p.Agent)
}
}
}
// The items placed at the home scope in the operator's home: each removed unless the person changed it,
// as unregistering would; they are placed in the agent's home now, from a record of its own.
record := filepath.Join(p.State, "home-placed.json")
if _, err := os.Stat(record); err == nil {
done, left := placeHomeIn(filepath.Join(p.OperatorHome, ".claude"), record, nil)
for _, line := range done {
out = append(out, "operator's home "+line)
}
for _, line := range left {
out = append(out, "operator's home "+line)
}
var still Placed
if readJSON(record, &still) && len(still) == 0 {
_ = os.Remove(record)
}
}
for _, name := range []string{"api-key", "api-key-helper"} {
old := filepath.Join(p.State, name)
if name == "api-key" && !holdsApplied(p, "api-key") {
// Kept, like the token above, until the agent's own home holds the key.
continue
}
if _, err := os.Stat(old); err == nil {
if err := os.Remove(old); err == nil {
out = append(out, "removed "+old+": the key and its helper are kept where "+p.Agent+" reads them")
}
}
}
return out
}