Compare commits

..
Author SHA1 Message Date
mesh-admin 86bcbc48ad claude-code: tips is a preference, set in one call like any module's
mesh/merge-gate head 86bcbc48ad78 merged into trunk 0d723484a84a — fail: builds claude-code → ace, g14, novox, shanks; no bus step; a manifest the change t…
mesh/repo-check head 86bcbc48ad78 merged into trunk 0d723484a84a — fail: its merge-check.sh failed: files asking for a setting without saying whether it is…
mesh/delivery stopped: the pull request closed unmerged
Turning Claude Code's tips off meant a change to the trusted managed_settings layer, proposed for the operator's approval. Tips decide how a session looks, not what it may do, so the module declares `tips` as a preference in an untrusted file of its own and renders it as spinnerTipsEnabled; the controller's settings verb sets it for the mesh or a node, with its history.
2026-10-13 12:15:49 +00:00
14 changed files with 73 additions and 2557 deletions
+2 -32
View File
@@ -11,9 +11,8 @@ Two directories, declared, so the mesh refuses a second module owning either:
- `~/.claude` under the home of the account agents run as (the operator's, unless the machine names an agent
account of its own), that account's, `0700`. The module owns the directory —
that it exists, who owns it, its mode — and of what is inside only what it writes. Everything else
in it (history, projects, local settings, a person's own rules and skills) is the person's
and is never read or written (hq ADR 0182) — but for each project's memory folder, which the module
carries between nodes (hq ADR 0343, below). Unassigned, the module leaves the directory: the host
in it (memory, history, projects, local settings, a person's own rules and skills) is the person's
and is never read or written (hq ADR 0182). Unassigned, the module leaves the directory: the host
removes a directory only when it is empty.
And, for where the agent runs as an account of its own (hq ADR 0266):
@@ -70,37 +69,8 @@ must see, a node that joins later included — kept, so it carries no secret eit
| an MCP server registered through this module before registering was refused (hq issue 340) | a key in the module's `servers` state — `all.<server>` for every node, `<node>.<server>` for one; every node watches it and renders what applies to it, a node's own entry over the one for every node. A node that joins later, or was off, reads the whole current set at start; unregistering is a delete. Nothing adds a key any more: a new tool server is the operator's, in the `mcp_servers` setting at the controller's terminal |
| a proposed instruction section (hq ADR 0274) | a key in the module's `proposals` state per proposal, the ask published on `operator-channel` under this module's name, and the router's record of it read under that name alone; what a node approved is its own file, never the bus |
| a session's question to the operator (hq ADR 0332) | a key `q-…` in the module's `questions` state per question, the ask published on `operator-channel` under this module's name, and the router's record of it read under that name alone; the router's word that it ended without a choice is kept on the question; two slot keys `slot-1`, `slot-2` name the questions open |
| a project's memory (hq ADR 0343) | a key in the module's `memory` state per file — `<kind>.<project and path, base64url>`, `operator` for the operator's own homes and `agent` for an agent account's — holding the text, its digest, the digest of the version it replaced, the node and when it changed; a removal is a value marked removed, never a deleted key. Written at the revision read, so of two nodes writing one file only one wins. Every node of the same kind watches it. No event and no tool answer carries memory text |
| the agent's configuration (hq ADR 0216) | a key in the module's `config` state per registration — `mesh.<kind>.<name>` for every node, `node.<node>.<kind>.<name>` for one, `home.<node>.<kind>.<name>` for one account's own directory — the item and its files in one value, at most 256 KiB. Every node watches it and renders what applies to it, a node item over a mesh item of the same kind and name. A hook, settings, or an item that would run something by itself is never taken, whenever it was put there (hq issue 340) |
## A session's memory follows the operator (hq ADR 0343)
A session started on another node starts with what the sessions on every other node learned (hq issue 367). The
module's long-running process carries each project's memory folder, `~/.claude/projects/<project>/memory/`, and
nothing else of the home. A project is one entry of the projects directory and a path is relative, no part empty,
`.` or `..`: a value in the state naming anything else is never taken, and nothing is touched for it.
- **Picked up** every ten seconds: a file whose text differs from the version last carried goes into the `memory`
state. Not carried, and listed with why: a symbolic link, a file over 128 KiB or too large for one value once
encoded, and a file holding a key block, a
signed token, a known token prefix or a password in an address.
- **Written** on every other node of the same home kind, only where its file is still the version the change
replaced, or the version it last carried (a node that was away), or there is none; staged in the folder and renamed in, never through a symbolic link. The version
replaced is kept first in `memory-copies/` in the module's state directory, 30 days.
- **A file changed on two nodes** becomes one file holding both, the newer first, then a line naming each version's
node and time, then the older (two versions too large for one file: the newer in it, the older beside it as
`<name>.older-from-<node>`); what was written is carried, so every node ends on it, and the operator is warned through
the operator channel's `notify` (a warning, cleared when the file next changes without a conflict).
- **A removal** of a carried file, from a folder that still exists, is a marker in the state for 30 days: a node still
holding the removed version moves it into the copies; a node that changed it keeps it and carries it again. A
missing folder, a key the state lost and a deleted key remove nothing, and neither does a file or folder that
cannot be read on a look: it is kept everywhere and listed as not carried.
- **The two home kinds never mix**: the operator's own homes carry among themselves, an agent account's among
themselves (hq ADR 0266), so a confined agent's notes never reach a session run as the operator.
- **No tool writes memory** (hq issue 340): `claude_code_memory` only lists what is carried — fingerprints, never the
text — the files not carried, the open conflicts and the copies kept. A value put straight into the state by a
holder of a tool runner's credential is not yet told apart (hq issue 638).
## The mesh's tools first (hq ADR 0245)
The agent kept reaching for `ssh <machine> journalctl` while the service manager's `journal` verb existed. So:
@@ -495,3 +495,22 @@ func TestALowerGenerationAfterTheManagerWasRebuiltIsStillApplied(t *testing.T) {
t.Fatalf("asked %v, credentials %v: a lower generation was ignored", asked, creds(t, p))
}
}
// The tips preference reaches the managed settings as spinnerTipsEnabled, either way, and is left out when the
// mesh filled none: set in one call through the controller, never at the terminal.
func TestTheTipsPreferenceIsRenderedAsSpinnerTipsEnabled(t *testing.T) {
off, on := false, true
for _, c := range []struct {
tips *bool
want any
}{{&off, false}, {&on, true}, {nil, nil}} {
out := Render(Facts{Console: "x"}, Settings{Preferences: Preferences{Tips: c.tips}}, nil, "/h", nil, Config{})
var managed map[string]any
if err := json.Unmarshal([]byte(out["managed-settings.json"]), &managed); err != nil {
t.Fatal(err)
}
if got, has := managed["spinnerTipsEnabled"]; got != c.want || has != (c.want != nil) {
t.Errorf("tips %v rendered spinnerTipsEnabled %v (present %v)", c.tips, got, has)
}
}
}
@@ -152,36 +152,6 @@ func (s stateOf) Create(key string, value any) error {
return err
}
func (s stateOf) Keys() ([]string, error) { return s.s.Keys() }
// memoryStateOf adapts the SDK's state to the memory carrier: every write at the revision read (ADR 0343).
type memoryStateOf struct{ s stdio.KeptState }
func (s memoryStateOf) Get(key string) (json.RawMessage, uint64, bool, error) {
e, err := s.s.Get(key)
if err != nil || e == nil {
return nil, 0, false, err
}
return e.Value, e.Revision, true, nil
}
func (s memoryStateOf) Create(key string, value any) error {
_, err := s.s.Create(key, value)
return err
}
func (s memoryStateOf) Update(key string, value any, revision uint64) error {
_, err := s.s.Update(key, value, revision)
return err
}
func (s memoryStateOf) Keys() ([]string, error) { return s.s.Keys() }
func (s memoryStateOf) Delete(key string) error { return s.s.Delete(key) }
// memoryCarrier carries this node's memory folders (ADR 0343); nil until the long-running half starts it.
var memoryCarrier *MemoryCarrier
// notifyOperator tells the operator through the operator channel's notify verb (ADR 0253).
func notifyOperator(args map[string]any) error {
_, err := ask("seat:operator-channel.notify", args)
return err
}
func (s stateOf) Get(key string) (json.RawMessage, bool, error) {
e, err := s.s.Get(key)
if err != nil || e == nil {
@@ -384,9 +354,6 @@ func tools(p Paths, servers ServerState, view *ServerView, config ConfigState, c
out = append(out, stdio.Tool{Name: "claude_code_registrations",
Description: "Read-only: everything registered through this module that a Claude Code session runs or obeys — the tool servers, the hooks, the settings and the skills, subagents, commands, output styles and instructions — by key, with who registered it, when, and whether it is still rendered (novox/hq issue 340), beside what the operator set for this node at the controller's terminal (managed_settings, mcp_servers). `by` is the node whose tool runner served the call, never the caller.",
Run: func(map[string]any) (any, error) { return Registrations(p, servers, config) }})
out = append(out, stdio.Tool{Name: "claude_code_memory",
Description: "Read-only: what this node carries of the agent's memory between nodes (novox/hq ADR 0343) — the home kind it carries for (the operator's own homes, or an agent account's), each project's memory files with their fingerprint and the node and time of their last version, the files not carried and why, the conflicts still open (a file changed on two nodes, kept twice in one file, the newer first), and how many replaced or removed versions are kept. Fingerprints only, never the text; it changes nothing.",
Run: func(map[string]any) (any, error) { return memoryCarrier.Status(), nil }})
out = append(out, proposalTools(p)...)
judge := NewJudge(p)
out = append(out, judgeTool(judge), judgedTool(judge))
@@ -463,12 +430,6 @@ func main() {
proposing = &Proposing{Store: stateOf{stdio.State(ProposalsState)}, Bus: sdkAskBus{}, Record: recordWarrant,
Now: time.Now, Write: writeManaged}
go proposing.watch(p)
memoryCarrier = NewMemoryCarrier(p, memoryStateOf{stdio.State(MemoryState)}, notifyOperator)
go memoryCarrier.Run(func(handle func(key, op string, value json.RawMessage, revision uint64) error) error {
return stdio.State(MemoryState).Watch("", func(c stdio.StateChange) error {
return handle(c.Key, c.Op, c.Value, c.Revision)
})
})
go run(p, view, configView)
if err := stdio.Serve("", tools(p, servers, view, config, configView)); err != nil {
say("%v", err)
File diff suppressed because it is too large Load Diff
@@ -1,968 +0,0 @@
package main
// A session's memory follows the operator (novox/hq ADR 0343, issue 367). Every test runs against temporary homes
// and a state in memory with the bus's watch semantics: never the live memory folders.
import (
"encoding/json"
"errors"
"os"
"path/filepath"
"sort"
"strings"
"sync"
"testing"
"time"
)
// memState is the `memory` state in memory: revisions, create and compare-and-set, and every change handed to every
// watcher, the writer included, as the bus does.
type memState struct {
mu sync.Mutex
values map[string]json.RawMessage
revs map[string]uint64
rev uint64
pending []change
watchers []*MemoryCarrier
}
type change struct {
key, op string
value json.RawMessage
rev uint64
}
func newMemState() *memState {
return &memState{values: map[string]json.RawMessage{}, revs: map[string]uint64{}}
}
var errChanged = errors.New("wrong last revision")
func (s *memState) Get(key string) (json.RawMessage, uint64, bool, error) {
s.mu.Lock()
defer s.mu.Unlock()
v, ok := s.values[key]
return v, s.revs[key], ok, nil
}
func (s *memState) write(key string, value any) {
raw, _ := json.Marshal(value)
s.rev++
s.values[key], s.revs[key] = raw, s.rev
s.pending = append(s.pending, change{key, "put", raw, s.rev})
}
func (s *memState) Create(key string, value any) error {
s.mu.Lock()
defer s.mu.Unlock()
if _, ok := s.values[key]; ok {
return errChanged
}
s.write(key, value)
return nil
}
func (s *memState) Update(key string, value any, revision uint64) error {
s.mu.Lock()
defer s.mu.Unlock()
if s.revs[key] != revision {
return errChanged
}
s.write(key, value)
return nil
}
func (s *memState) Keys() ([]string, error) {
s.mu.Lock()
defer s.mu.Unlock()
var keys []string
for k := range s.values {
keys = append(keys, k)
}
sort.Strings(keys)
return keys, nil
}
func (s *memState) Delete(key string) error {
s.mu.Lock()
defer s.mu.Unlock()
delete(s.values, key)
delete(s.revs, key)
s.rev++
s.pending = append(s.pending, change{key, "delete", nil, s.rev})
return nil
}
// pump hands every change so far to every watcher, until none is left.
func (s *memState) pump(t *testing.T) {
t.Helper()
for i := 0; i < 50; i++ {
s.mu.Lock()
todo := s.pending
s.pending = nil
s.mu.Unlock()
if len(todo) == 0 {
return
}
for _, c := range todo {
for _, w := range s.watchers {
if err := w.Take(c.key, c.op, c.value, c.rev); err != nil {
t.Logf("%s taking %s: %v", w.Node, c.key, err)
}
}
}
for _, w := range s.watchers {
if err := w.Drain(); err != nil && !errors.Is(err, errChanged) {
t.Logf("%s applying: %v", w.Node, err)
}
}
}
t.Fatal("the state never settled")
}
// clock is a clock a test moves.
type clock struct{ now time.Time }
func (c *clock) Now() time.Time { return c.now }
// memNode is one node's carrier over its own temporary home and state directory.
type memNode struct {
*MemoryCarrier
notices []map[string]any
}
func newMemNode(t *testing.T, root, name, kind string, state *memState, c *clock) *memNode {
t.Helper()
n := &memNode{}
home := filepath.Join(root, name, "home")
stateDir := filepath.Join(root, name, "state")
for _, d := range []string{filepath.Join(home, ".claude", "projects"), stateDir} {
if err := os.MkdirAll(d, 0o700); err != nil {
t.Fatal(err)
}
}
n.MemoryCarrier = &MemoryCarrier{Home: home, Node: name, State: stateDir, Kind: kind, Store: state, Now: c.Now,
wake: make(chan struct{}, 1),
Notify: func(a map[string]any) error { n.notices = append(n.notices, a); return nil }}
state.watchers = append(state.watchers, n.MemoryCarrier)
return n
}
func (n *memNode) path(project, file string) string {
return filepath.Join(n.Home, ".claude", "projects", project, "memory", filepath.FromSlash(file))
}
func (n *memNode) write(t *testing.T, project, file, text string, at time.Time) {
t.Helper()
p := n.path(project, file)
if err := os.MkdirAll(filepath.Dir(p), 0o700); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(p, []byte(text), 0o644); err != nil {
t.Fatal(err)
}
_ = os.Chtimes(p, at, at)
}
func (n *memNode) read(t *testing.T, project, file string) (string, bool) {
t.Helper()
raw, err := os.ReadFile(n.path(project, file))
if os.IsNotExist(err) {
return "", false
}
if err != nil {
t.Fatal(err)
}
return string(raw), true
}
func (n *memNode) look(t *testing.T) {
t.Helper()
if err := n.Look(); err != nil && !errors.Is(err, errChanged) {
t.Fatalf("%s's look: %v", n.Node, err)
}
}
// copies is every text kept in the node's copies.
func (n *memNode) copies(t *testing.T) []string {
t.Helper()
var out []string
_ = filepath.WalkDir(n.MemoryCarrier.copies(), func(p string, d os.DirEntry, err error) error {
if err == nil && !d.IsDir() {
raw, _ := os.ReadFile(p)
out = append(out, string(raw))
}
return nil
})
return out
}
func holdsText(list []string, s string) bool {
for _, x := range list {
if x == s {
return true
}
}
return false
}
const project = "-home-op-projects-novox-hq"
func twoNodes(t *testing.T) (*memState, *clock, *memNode, *memNode) {
state, c := newMemState(), &clock{now: time.Date(2026, 10, 13, 12, 0, 0, 0, time.UTC)}
root := t.TempDir()
return state, c, newMemNode(t, root, "g14", HomeOperator, state, c), newMemNode(t, root, "shanks", HomeOperator, state, c)
}
// carried writes a file on g14 and carries it to shanks.
func carriedOnBoth(t *testing.T, state *memState, c *clock, a, b *memNode, file, text string) {
t.Helper()
a.write(t, project, file, text, c.now)
a.look(t)
state.pump(t)
b.look(t)
state.pump(t)
if got, _ := b.read(t, project, file); got != text {
t.Fatalf("not carried to %s: %q", b.Node, got)
}
}
// §5, §6: a memory file written on one node appears unchanged on another, at any depth, and is carried once.
func TestAMemoryFileWrittenOnOneNodeAppearsUnchangedOnAnother(t *testing.T) {
state, c, g14, shanks := twoNodes(t)
g14.write(t, project, "MEMORY.md", "- [state](state.md) — where things stand\n", c.now)
g14.write(t, project, "notes/state.md", "ADR 0343 proposed.\n", c.now)
g14.look(t)
state.pump(t)
for file, want := range map[string]string{"MEMORY.md": "- [state](state.md) — where things stand\n", "notes/state.md": "ADR 0343 proposed.\n"} {
if got, ok := shanks.read(t, project, file); !ok || got != want {
t.Fatalf("%s on shanks: %q, %v", file, got, ok)
}
}
before := state.rev
shanks.look(t)
g14.look(t)
state.pump(t)
if state.rev != before {
t.Fatalf("a carried file was carried again: %d changes", state.rev-before)
}
// A change on shanks comes back to g14, over the version it replaced, which g14 keeps.
c.now = c.now.Add(time.Minute)
shanks.write(t, project, "notes/state.md", "ADR 0343 accepted.\n", c.now)
shanks.look(t)
state.pump(t)
if got, _ := g14.read(t, project, "notes/state.md"); got != "ADR 0343 accepted.\n" {
t.Fatalf("the change did not reach g14: %q", got)
}
if !holdsText(g14.copies(t), "ADR 0343 proposed.\n") {
t.Fatal("the version replaced on g14 was not kept")
}
if len(g14.notices)+len(shanks.notices) != 0 {
t.Fatalf("a plain change warned: %v %v", g14.notices, shanks.notices)
}
// No value in the state is anything but a version of a memory file, and no tool answer carries the text.
raw, _ := json.Marshal(g14.Status())
if strings.Contains(string(raw), "accepted") {
t.Fatalf("the memory tool answered the text: %s", raw)
}
}
// §1, §2: only the memory folders are carried; a symbolic link, an oversized file and a secret's shape are not, and are
// listed with why.
func TestOnlyTheMemoryFoldersAreCarried(t *testing.T) {
state, c, g14, shanks := twoNodes(t)
projectDir := filepath.Join(g14.Home, ".claude", "projects", project)
g14.write(t, project, "MEMORY.md", "index\n", c.now)
for path, text := range map[string]string{
filepath.Join(projectDir, "session.jsonl"): "{}\n",
filepath.Join(g14.Home, ".claude", "settings.json"): "{}\n",
filepath.Join(g14.Home, ".claude", "history.jsonl"): "{}\n",
} {
if err := os.WriteFile(path, []byte(text), 0o600); err != nil {
t.Fatal(err)
}
}
g14.write(t, project, "key.md", "-----BEGIN OPENSSH PRIVATE KEY-----\nabc\n", c.now)
g14.write(t, project, "url.md", "https://op:hunter2@git.example.org/x\n", c.now)
g14.write(t, project, "big.md", strings.Repeat("x", MemoryMostBytes+1), c.now)
g14.write(t, project, "hash.md", "head 1fe6ea4ee47379007598e163784dbd7ac50b7f79\n", c.now)
outside := filepath.Join(t.TempDir(), "elsewhere.md")
_ = os.WriteFile(outside, []byte("not memory\n"), 0o600)
if err := os.Symlink(outside, g14.path(project, "link.md")); err != nil {
t.Fatal(err)
}
g14.look(t)
state.pump(t)
for _, file := range []string{"key.md", "url.md", "big.md", "link.md"} {
if _, ok := shanks.read(t, project, file); ok {
t.Errorf("%s was carried", file)
}
}
for _, file := range []string{"MEMORY.md", "hash.md"} {
if _, ok := shanks.read(t, project, file); !ok {
t.Errorf("%s was not carried", file)
}
}
if _, err := os.Stat(filepath.Join(shanks.Home, ".claude", "projects", project, "session.jsonl")); err == nil {
t.Error("a session was carried")
}
for _, f := range []string{"settings.json", "history.jsonl"} {
if _, err := os.Stat(filepath.Join(shanks.Home, ".claude", f)); err == nil {
t.Errorf("%s was carried", f)
}
}
keys, _ := state.Keys()
if len(keys) != 2 {
t.Fatalf("the state holds %d keys, not the two memory files: %v", len(keys), keys)
}
not, _ := g14.Status()["not carried"].(map[string]string)
for _, file := range []string{"key.md", "url.md", "big.md", "link.md"} {
if not[project+"/"+file] == "" {
t.Errorf("%s is not listed as not carried: %v", file, not)
}
}
}
// §4: the operator's homes and an agent account's homes never take each other's memory.
func TestHomesOfTheTwoKindsNeverTakeEachOthersMemory(t *testing.T) {
state, c := newMemState(), &clock{now: time.Date(2026, 10, 13, 12, 0, 0, 0, time.UTC)}
root := t.TempDir()
laptop := newMemNode(t, root, "g14", HomeOperator, state, c)
control := newMemNode(t, root, "novox", HomeAgent, state, c)
control.write(t, project, "MEMORY.md", "from a confined agent\n", c.now)
control.look(t)
state.pump(t)
if _, ok := laptop.read(t, project, "MEMORY.md"); ok {
t.Fatal("a confined agent's memory reached the operator's home")
}
laptop.write(t, project, "MEMORY.md", "the operator's\n", c.now)
laptop.look(t)
state.pump(t)
if got, _ := control.read(t, project, "MEMORY.md"); got != "from a confined agent\n" {
t.Fatalf("the operator's memory reached the agent account's home: %q", got)
}
// A value of the other kind put under this kind's key is refused too.
key := MemoryKey(HomeOperator, project, "x.md")
raw, _ := json.Marshal(MemoryVersion{Kind: HomeAgent, Project: project, Path: "x.md", Text: "x", Digest: digestOf([]byte("x")), Node: "novox"})
if err := laptop.Take(key, "put", raw, 99); err == nil {
t.Fatal("a value of another kind was taken")
}
// Which kind a node carries for.
if k, _ := HomeKind(Paths{Agent: "jochen", Operator: "jochen", Home: "/home/jochen"}); k != HomeOperator {
t.Fatalf("the operator's own home is %q", k)
}
if k, _ := HomeKind(Paths{Agent: "agent", Operator: "jochen", Home: "/home/agent", AgentHome: "/home/agent", OperatorHome: "/home/jochen"}); k != HomeAgent {
t.Fatalf("an agent account's home is %q", k)
}
if k, why := HomeKind(Paths{Agent: "agent", Operator: "jochen", Home: "/home/jochen", AgentHome: "/home/agent", OperatorHome: "/home/jochen"}); k != "" || why == "" {
t.Fatalf("a confined agent in the operator's home carries %q", k)
}
}
// §7, §8: a file changed on two nodes since it was carried is kept twice in one file on both, the newer first, with
// one warning; it clears once the file is tidied.
func TestAFileChangedOnTwoNodesIsKeptTwiceTheNewerFirstWithAWarning(t *testing.T) {
state, c, g14, shanks := twoNodes(t)
carriedOnBoth(t, state, c, g14, shanks, "MEMORY.md", "- base\n")
g14.write(t, project, "MEMORY.md", "- base\n- learned on g14\n", c.now.Add(time.Minute))
shanks.write(t, project, "MEMORY.md", "- base\n- learned on shanks\n", c.now.Add(2*time.Minute))
c.now = c.now.Add(3 * time.Minute)
g14.look(t)
shanks.look(t)
state.pump(t)
g14.look(t)
shanks.look(t)
state.pump(t)
a, _ := g14.read(t, project, "MEMORY.md")
b, _ := shanks.read(t, project, "MEMORY.md")
if a != b {
t.Fatalf("the nodes did not end on one text:\n%s\n----\n%s", a, b)
}
newer, older := strings.Index(a, "learned on shanks"), strings.Index(a, "learned on g14")
marker := strings.Index(a, "<!-- novox mesh: this file was changed on two nodes")
if newer < 0 || older < 0 || marker < 0 || !(newer < marker && marker < older) {
t.Fatalf("not both versions, the newer first, with the line between:\n%s", a)
}
if !strings.Contains(a[marker:], "changed on shanks") || !strings.Contains(a[marker:], "changed on g14") {
t.Fatalf("the line does not name both nodes:\n%s", a)
}
notices := append(append([]map[string]any{}, g14.notices...), shanks.notices...)
if len(notices) != 1 || notices[0]["severity"] != "warning" || notices[0]["clear"] != nil {
t.Fatalf("not one warning: %v", notices)
}
for _, field := range []string{"headline", "explanation", "summary", "resolved"} {
s, _ := notices[0][field].(string)
if s == "" || strings.ContainsAny(s, "/~:") && field != "explanation" || strings.Contains(s, ".md") {
t.Errorf("the warning's %s is empty or names a path: %q", field, s)
}
// The plain rule (ADR 0253 §3): it never sends the operator elsewhere.
if field != "summary" && (strings.Contains(strings.ToLower(s), "agent") || strings.Contains(strings.ToLower(s), "session")) {
t.Errorf("the warning's %s sends the operator elsewhere: %q", field, s)
}
}
if e, _ := notices[0]["explanation"].(string); !strings.HasPrefix(e, "Needs you: ") {
t.Errorf("the explanation does not open with its verdict: %q", e)
}
if h, _ := notices[0]["headline"].(string); len([]rune(h)) > 60 {
t.Errorf("the headline is longer than sixty characters: %q", h)
}
all := append(g14.copies(t), shanks.copies(t)...)
for _, v := range []string{"- base\n- learned on g14\n", "- base\n- learned on shanks\n"} {
if !holdsText(all, v) {
t.Errorf("the version %q was not kept", v)
}
}
if st := g14.Status(); len(st["conflicts"].([]conflict))+len(shanks.Status()["conflicts"].([]conflict)) != 1 {
t.Fatalf("the conflict is not listed: %v", st)
}
// Tidied on either node, the warning clears.
c.now = c.now.Add(time.Minute)
g14.write(t, project, "MEMORY.md", "- base\n- learned on g14\n- learned on shanks\n", c.now)
g14.look(t)
state.pump(t)
cleared := false
for _, n := range append(g14.notices, shanks.notices...) {
if n["clear"] == true {
cleared = true
}
}
if !cleared {
t.Fatal("the warning did not clear once the file was tidied")
}
if got, _ := shanks.read(t, project, "MEMORY.md"); got != "- base\n- learned on g14\n- learned on shanks\n" {
t.Fatalf("the tidied file did not reach shanks: %q", got)
}
}
// The order of the two versions: the later change first, equal times by node name.
func TestMergeVersionsPutsTheNewerFirst(t *testing.T) {
at := time.Date(2026, 10, 13, 12, 0, 0, 0, time.UTC)
m := string(MergeVersions([]byte("old\n"), "g14", at, []byte("new\n"), "shanks", at.Add(time.Second)))
if strings.Index(m, "new") > strings.Index(m, "old") {
t.Fatalf("the newer is not first: %s", m)
}
m = string(MergeVersions([]byte("from shanks\n"), "shanks", at, []byte("from ace\n"), "ace", at))
if strings.Index(m, "from ace") > strings.Index(m, "from shanks") {
t.Fatalf("equal times are not ordered by node name: %s", m)
}
}
// §9: a removal moves the other node's unchanged file into the copies.
func TestARemovalMovesTheUnchangedFileIntoTheCopies(t *testing.T) {
state, c, g14, shanks := twoNodes(t)
carriedOnBoth(t, state, c, g14, shanks, "old.md", "an old note\n")
carriedOnBoth(t, state, c, g14, shanks, "MEMORY.md", "index\n")
if err := os.Remove(g14.path(project, "old.md")); err != nil {
t.Fatal(err)
}
g14.look(t)
state.pump(t)
if _, ok := shanks.read(t, project, "old.md"); ok {
t.Fatal("the removal did not reach shanks")
}
if !holdsText(shanks.copies(t), "an old note\n") {
t.Fatal("the removed version was not kept on shanks")
}
raw, _, ok, _ := state.Get(MemoryKey(HomeOperator, project, "old.md"))
var v MemoryVersion
_ = json.Unmarshal(raw, &v)
if !ok || !v.Removed || v.Text != "" {
t.Fatalf("the removal is not a marker in the state: %s", raw)
}
if got, _ := shanks.read(t, project, "MEMORY.md"); got != "index\n" {
t.Fatal("another file was touched")
}
}
// §9: a change outlives a removal, whichever reaches the state first.
func TestAChangeOutlivesARemoval(t *testing.T) {
for _, removalFirst := range []bool{true, false} {
state, c, g14, shanks := twoNodes(t)
carriedOnBoth(t, state, c, g14, shanks, "note.md", "v1\n")
_ = os.Remove(g14.path(project, "note.md"))
shanks.write(t, project, "note.md", "v2\n", c.now.Add(time.Minute))
if removalFirst {
g14.look(t)
state.pump(t)
shanks.look(t)
} else {
shanks.look(t)
g14.look(t)
}
state.pump(t)
g14.look(t)
shanks.look(t)
state.pump(t)
for _, n := range []*memNode{g14, shanks} {
if got, _ := n.read(t, project, "note.md"); got != "v2\n" {
t.Fatalf("removal first %v: the change did not outlive the removal on %s: %q", removalFirst, n.Node, got)
}
}
}
}
// §9: a missing folder, a state that lost its keys and a deleted key remove nothing.
func TestAMissingFolderOrAnAbsentKeyRemovesNothing(t *testing.T) {
state, c, g14, shanks := twoNodes(t)
carriedOnBoth(t, state, c, g14, shanks, "MEMORY.md", "index\n")
key := MemoryKey(HomeOperator, project, "MEMORY.md")
// The folder gone on g14.
if err := os.RemoveAll(filepath.Join(g14.Home, ".claude", "projects", project, "memory")); err != nil {
t.Fatal(err)
}
g14.look(t)
state.pump(t)
if got, _ := shanks.read(t, project, "MEMORY.md"); got != "index\n" {
t.Fatal("a missing folder removed the file elsewhere")
}
// A deleted key.
_ = state.Delete(key)
state.pump(t)
if got, _ := shanks.read(t, project, "MEMORY.md"); got != "index\n" {
t.Fatal("a deleted key removed the file")
}
// The state lost: the file is carried again, not removed.
shanks.look(t)
state.pump(t)
if _, _, ok, _ := state.Get(key); !ok {
t.Fatal("a file whose key the state lost was not carried again")
}
if got, _ := shanks.read(t, project, "MEMORY.md"); got != "index\n" {
t.Fatalf("the file was touched on shanks: %q", got)
}
}
// §10: on a first carrying, two nodes' different versions are both kept, and each node's own files reach the other.
func TestTheFirstCarryingKeepsEveryVersion(t *testing.T) {
state, c, g14, shanks := twoNodes(t)
g14.write(t, project, "MEMORY.md", "- laptop's index\n", c.now)
g14.write(t, project, "laptop.md", "only on the laptop\n", c.now)
shanks.write(t, project, "MEMORY.md", "- workstation's index\n", c.now.Add(time.Hour))
shanks.write(t, project, "workstation.md", "only on the workstation\n", c.now)
shanks.write(t, project, "same.md", "same\n", c.now)
g14.write(t, project, "same.md", "same\n", c.now)
g14.look(t)
state.pump(t)
shanks.look(t)
state.pump(t)
g14.look(t)
state.pump(t)
for _, n := range []*memNode{g14, shanks} {
index, _ := n.read(t, project, "MEMORY.md")
if !strings.Contains(index, "laptop's index") || !strings.Contains(index, "workstation's index") ||
strings.Index(index, "workstation's index") > strings.Index(index, "laptop's index") {
t.Fatalf("%s's index does not hold both, the newer first:\n%s", n.Node, index)
}
for file, want := range map[string]string{"laptop.md": "only on the laptop\n", "workstation.md": "only on the workstation\n", "same.md": "same\n"} {
if got, _ := n.read(t, project, file); got != want {
t.Fatalf("%s's %s: %q", n.Node, file, got)
}
}
}
if n := len(g14.notices) + len(shanks.notices); n != 1 {
t.Fatalf("%d warnings for one conflict", n)
}
}
// Nothing is written through a symbolic link: a memory folder that is one is neither read nor written.
func TestNothingIsWrittenThroughASymbolicLink(t *testing.T) {
state, c, g14, shanks := twoNodes(t)
elsewhere := t.TempDir()
if err := os.MkdirAll(filepath.Join(shanks.Home, ".claude", "projects", project), 0o700); err != nil {
t.Fatal(err)
}
if err := os.Symlink(elsewhere, filepath.Join(shanks.Home, ".claude", "projects", project, "memory")); err != nil {
t.Fatal(err)
}
g14.write(t, project, "MEMORY.md", "index\n", c.now)
g14.look(t)
state.pump(t)
if entries, _ := os.ReadDir(elsewhere); len(entries) != 0 {
t.Fatalf("written through a symbolic link: %v", entries)
}
}
// The manifest declares the state and the tool; the tool reads and never writes.
func TestTheManifestDeclaresTheMemoryState(t *testing.T) {
raw, err := os.ReadFile("../../module.json")
if err != nil {
t.Fatal(err)
}
var m struct {
State []string `json:"state"`
}
_ = json.Unmarshal(raw, &m)
if !holdsText(m.State, MemoryState) {
t.Fatalf("the manifest does not declare the %q state: %v", MemoryState, m.State)
}
var carrier *MemoryCarrier
if st := carrier.Status(); st["carrying"] != false {
t.Fatalf("a carrier not started says it carries: %v", st)
}
}
// replay hands one node the whole current set, as a watch begins when the node comes back.
func (s *memState) replay(t *testing.T, n *memNode) {
t.Helper()
keys, _ := s.Keys()
for _, k := range keys {
raw, rev, _, _ := s.Get(k)
if err := n.Take(k, "put", raw, rev); err != nil {
t.Logf("%s taking %s: %v", n.Node, k, err)
}
}
if err := n.Drain(); err != nil && !errors.Is(err, errChanged) {
t.Logf("%s applying: %v", n.Node, err)
}
}
// §6: a node that was away while a file changed twice takes the newest version, whose base it never saw, because
// its own file holds no change it did not carry; the version replaced is kept.
func TestANodeThatWasAwayTakesTheNewestVersion(t *testing.T) {
state, c, g14, shanks := twoNodes(t)
carriedOnBoth(t, state, c, g14, shanks, "MEMORY.md", "v1\n")
state.watchers = []*MemoryCarrier{g14.MemoryCarrier}
for _, v := range []string{"v2\n", "v3\n"} {
c.now = c.now.Add(time.Minute)
g14.write(t, project, "MEMORY.md", v, c.now)
g14.look(t)
state.pump(t)
}
state.watchers = append(state.watchers, shanks.MemoryCarrier)
state.replay(t, shanks)
if got, _ := shanks.read(t, project, "MEMORY.md"); got != "v3\n" {
t.Fatalf("the node that was away holds %q", got)
}
if !holdsText(shanks.copies(t), "v1\n") {
t.Fatal("the version replaced was not kept")
}
if len(g14.notices)+len(shanks.notices) != 0 {
t.Fatal("taking a newer version warned")
}
}
// §2, §7: two versions too large to share one file are kept in two: the newer in the file, the older beside it,
// both carried; and a text that would not fit in one value of the state is not carried.
func TestTwoVersionsTooLargeForOneFileAreKeptInTwo(t *testing.T) {
state, c, g14, shanks := twoNodes(t)
carriedOnBoth(t, state, c, g14, shanks, "big.md", "base\n")
older := strings.Repeat("from g14 ", MemoryMostBytes/9-100) + "\n"
newer := strings.Repeat("from shanks ", MemoryMostBytes/12-100) + "\n"
g14.write(t, project, "big.md", older, c.now.Add(time.Minute))
shanks.write(t, project, "big.md", newer, c.now.Add(2*time.Minute))
c.now = c.now.Add(3 * time.Minute)
g14.look(t)
shanks.look(t)
state.pump(t)
g14.look(t)
shanks.look(t)
state.pump(t)
beside := OlderBeside("big.md", "g14")
if beside != "big.older-from-g14.md" {
t.Fatalf("the older is named %q", beside)
}
for _, n := range []*memNode{g14, shanks} {
if got, _ := n.read(t, project, "big.md"); got != newer {
t.Fatalf("%s's file does not hold the newer version", n.Node)
}
if got, _ := n.read(t, project, beside); got != older {
t.Fatalf("%s does not hold the older version beside it", n.Node)
}
}
notices := append(append([]map[string]any{}, g14.notices...), shanks.notices...)
if len(notices) != 1 || !strings.Contains(notices[0]["explanation"].(string), "second file beside it") {
t.Fatalf("not one warning saying the older is beside it: %v", notices)
}
// Under the size limit, but too large once encoded: not carried, and said.
g14.write(t, project, "angles.md", strings.Repeat("<", MemoryMostBytes-10), c.now)
g14.look(t)
state.pump(t)
if _, ok := shanks.read(t, project, "angles.md"); ok {
t.Fatal("a text too large for one value was carried")
}
not, _ := g14.Status()["not carried"].(map[string]string)
if not[project+"/angles.md"] == "" {
t.Fatalf("it is not listed as not carried: %v", not)
}
}
// stateHolds says whether any value of the state holds s.
func (s *memState) holds(sub string) bool {
s.mu.Lock()
defer s.mu.Unlock()
for _, v := range s.values {
var mv MemoryVersion
_ = json.Unmarshal(v, &mv)
if strings.Contains(mv.Text, sub) {
return true
}
}
return false
}
// putRaw puts a value straight into the state, as a holder of a tool runner's credential could.
func (s *memState) putRaw(t *testing.T, v MemoryVersion) string {
t.Helper()
key := MemoryKey(v.Kind, v.Project, v.Path)
s.mu.Lock()
s.write(key, v)
s.mu.Unlock()
return key
}
// Review of #276, 1: a value whose project or path leads out of a memory folder is never taken, and nothing is
// written for it.
func TestAValueLeadingOutOfAMemoryFolderIsNeverTaken(t *testing.T) {
state, c, g14, shanks := twoNodes(t)
root := filepath.Dir(filepath.Dir(shanks.Home))
for _, bad := range []struct{ project, path string }{
{"../../../escaped", "x.md"}, {"..", "x.md"}, {"a/b", "x.md"}, {"", "x.md"},
{project, "../x.md"}, {project, "/abs.md"}, {project, "a//x.md"}, {project, "./x.md"}, {project, "a/../../x.md"},
} {
text := "planted\n"
state.putRaw(t, MemoryVersion{Kind: HomeOperator, Project: bad.project, Path: bad.path, Text: text,
Digest: digestOf([]byte(text)), Node: "elsewhere", Changed: c.now})
if NameProblem(bad.project, bad.path) == "" {
t.Errorf("%q %q is taken as a name", bad.project, bad.path)
}
}
state.pump(t)
g14.look(t)
shanks.look(t)
_ = filepath.WalkDir(root, func(p string, d os.DirEntry, err error) error {
if err == nil && !d.IsDir() {
if raw, _ := os.ReadFile(p); string(raw) == "planted\n" {
t.Errorf("a planted value was written at %s", p)
}
}
return nil
})
if NameProblem(project, "notes/a.md") != "" || NameProblem(project, "MEMORY.md") != "" {
t.Fatal("a memory file's own name is refused")
}
}
// Review of #276, 2: a file or a folder that cannot be read is kept everywhere, never carried as a removal, and is
// said as not carried.
func TestWhatCannotBeReadIsNeverTakenForGone(t *testing.T) {
if os.Geteuid() == 0 {
t.Skip("root reads a file whatever its mode")
}
state, c, g14, shanks := twoNodes(t)
carriedOnBoth(t, state, c, g14, shanks, "MEMORY.md", "index\n")
carriedOnBoth(t, state, c, g14, shanks, "notes/deep.md", "deep\n")
file, folder := g14.path(project, "MEMORY.md"), filepath.Dir(g14.path(project, "notes/deep.md"))
_ = os.Chmod(file, 0)
_ = os.Chmod(folder, 0)
t.Cleanup(func() { _ = os.Chmod(file, 0o644); _ = os.Chmod(folder, 0o755) })
g14.look(t)
state.pump(t)
for _, f := range []string{"MEMORY.md", "notes/deep.md"} {
if _, ok := shanks.read(t, project, f); !ok {
t.Errorf("%s, unreadable on g14, was removed on shanks", f)
}
}
not, _ := g14.Status()["not carried"].(map[string]string)
if len(not) == 0 {
t.Fatalf("nothing is said as not carried: %v", not)
}
keys, _ := state.Keys()
for _, k := range keys {
raw, _, _, _ := state.Get(k)
var v MemoryVersion
_ = json.Unmarshal(raw, &v)
if v.Removed {
t.Errorf("%s/%s was carried as a removal", v.Project, v.Path)
}
}
}
// Review of #276, 3: no text reaches the state without being screened, on the removal and the conflict paths too.
func TestNoUnscreenedTextReachesTheState(t *testing.T) {
token := "ghp_" + strings.Repeat("A1b2C3d4", 5) + "\n"
big := strings.Repeat("x", MemoryMostBytes+10) + "\n"
for _, text := range []string{token, big} {
// The removal path: removed on g14, changed on shanks to what is never carried.
state, c, g14, shanks := twoNodes(t)
carriedOnBoth(t, state, c, g14, shanks, "note.md", "v1\n")
_ = os.Remove(g14.path(project, "note.md"))
shanks.write(t, project, "note.md", text, c.now.Add(time.Minute))
g14.look(t)
state.pump(t)
if state.holds(text[:20]) {
t.Errorf("the removal path put text that is never carried into the state")
}
if got, _ := shanks.read(t, project, "note.md"); got != text {
t.Errorf("the change on shanks was not kept")
}
// The conflict path: changed on g14, and on shanks to what is never carried.
state, c, g14, shanks = twoNodes(t)
carriedOnBoth(t, state, c, g14, shanks, "note.md", "v1\n")
g14.write(t, project, "note.md", "v2\n", c.now.Add(time.Minute))
shanks.write(t, project, "note.md", text, c.now.Add(2*time.Minute))
g14.look(t)
state.pump(t)
shanks.look(t)
state.pump(t)
if state.holds(text[:20]) {
t.Errorf("the conflict path put text that is never carried into the state")
}
if got, _ := shanks.read(t, project, "note.md"); got != text {
t.Errorf("shanks's own version was not kept as it was")
}
}
}
// Review of #276, 4: a conflict whose file cannot be written leaves the state as it was, says so once, and merges
// once when the folder is writable again.
func TestAConflictThatCannotBeWrittenIsNotMergedAgainAndAgain(t *testing.T) {
if os.Geteuid() == 0 {
t.Skip("root writes a folder whatever its mode")
}
state, c, g14, shanks := twoNodes(t)
carriedOnBoth(t, state, c, g14, shanks, "MEMORY.md", "base\n")
g14.write(t, project, "MEMORY.md", "base\nfrom g14\n", c.now.Add(time.Minute))
shanks.write(t, project, "MEMORY.md", "base\nfrom shanks\n", c.now.Add(2*time.Minute))
folder := filepath.Dir(shanks.path(project, "MEMORY.md"))
_ = os.Chmod(folder, 0o500)
t.Cleanup(func() { _ = os.Chmod(folder, 0o700) })
g14.look(t)
state.pump(t)
for i := 0; i < 5; i++ {
_ = shanks.Look()
state.pump(t)
}
if state.holds("<!-- novox mesh") {
t.Fatal("a merge that could not be written here reached the state")
}
unwritten := 0
for _, n := range shanks.notices {
if strings.HasSuffix(n["key"].(string), ".unwritten") {
unwritten++
}
}
if unwritten != 1 {
t.Fatalf("%d warnings that the file could not be written", unwritten)
}
_ = os.Chmod(folder, 0o700)
shanks.look(t)
state.pump(t)
g14.look(t)
state.pump(t)
for _, n := range []*memNode{g14, shanks} {
got, _ := n.read(t, project, "MEMORY.md")
if strings.Count(got, "<!-- novox mesh") != 1 || !strings.Contains(got, "from g14") || !strings.Contains(got, "from shanks") {
t.Fatalf("%s does not hold both versions once:\n%s", n.Node, got)
}
}
}
// Review of #276, minor: a file that changed between its reading and its writing is not written over; text that is
// not UTF-8 is not carried.
func TestAFileChangedBeforeItsWritingIsNotWrittenOver(t *testing.T) {
state, c, g14, shanks := twoNodes(t)
_ = state
g14.write(t, project, "a.md", "read\n", c.now)
g14.write(t, project, "a.md", "changed meanwhile\n", c.now)
err := g14.writeLocal(project, "a.md", []byte("incoming\n"), "replaced", digestOf([]byte("read\n")))
if !errors.Is(err, errChangedHere) {
t.Fatalf("written over a change: %v", err)
}
if got, _ := g14.read(t, project, "a.md"); got != "changed meanwhile\n" {
t.Fatalf("the change was replaced: %q", got)
}
g14.write(t, project, "latin1.md", "caf\xe9\n", c.now)
g14.look(t)
state.pump(t)
if _, ok := shanks.read(t, project, "latin1.md"); ok {
t.Fatal("text that is not UTF-8 was carried")
}
not, _ := g14.Status()["not carried"].(map[string]string)
if !strings.Contains(not[project+"/latin1.md"], "UTF-8") {
t.Fatalf("it is not said why: %v", not)
}
}
// §9: a node that was away takes a removal of a version newer than the one it holds, when it holds no change of
// its own: the removed file does not come back.
func TestARemovalReachesANodeThatWasAway(t *testing.T) {
state, c, g14, shanks := twoNodes(t)
carriedOnBoth(t, state, c, g14, shanks, "old.md", "v1\n")
state.watchers = []*MemoryCarrier{g14.MemoryCarrier}
c.now = c.now.Add(time.Minute)
g14.write(t, project, "old.md", "v2\n", c.now)
g14.look(t)
state.pump(t)
_ = os.Remove(g14.path(project, "old.md"))
g14.look(t)
state.pump(t)
state.watchers = append(state.watchers, shanks.MemoryCarrier)
state.replay(t, shanks)
shanks.look(t)
state.pump(t)
g14.look(t)
if _, ok := shanks.read(t, project, "old.md"); ok {
t.Fatal("the removal did not reach the node that was away")
}
if _, ok := g14.read(t, project, "old.md"); ok {
t.Fatal("the removed file came back")
}
if !holdsText(shanks.copies(t), "v1\n") {
t.Fatal("the version removed on the node that was away was not kept")
}
}
// slowState answers every read only once it is let go, as a state a whole bus away does under load.
type slowState struct {
*memState
gate chan struct{}
}
func (s slowState) Get(key string) (json.RawMessage, uint64, bool, error) {
<-s.gate
return s.memState.Get(key)
}
// Issue 367, seen live: the runtime hands a watch its current values one at a time and gives them all ten seconds;
// shanks's handler read the state and waited for its look for each of a few hundred values, so the watch never
// started and nothing written on g14 reached it. The handler now only notes a change: it returns at once while a
// look holds the carrier and while the state is slow to answer, and Drain applies what it noted.
func TestTheWatchHandlerReturnsAtOnceAndDrainAppliesWhatItNoted(t *testing.T) {
state, c, g14, shanks := twoNodes(t)
g14.write(t, project, "MEMORY.md", "index\n", c.now)
g14.look(t)
state.mu.Lock()
state.pending = nil
state.mu.Unlock()
slow := slowState{memState: state, gate: make(chan struct{})}
shanks.Store = slow
key := MemoryKey(HomeOperator, project, "MEMORY.md")
raw, rev, _, _ := state.Get(key)
shanks.mu.Lock() // a look running
done := make(chan error, 1)
start := time.Now()
for i := 0; i < 400; i++ {
go func() { done <- shanks.Take(key, "put", raw, rev) }()
}
for i := 0; i < 400; i++ {
select {
case err := <-done:
if err != nil {
t.Fatal(err)
}
case <-time.After(2 * time.Second):
t.Fatalf("the watch handler waited for the look or the state (%d of 400 returned)", i)
}
}
if took := time.Since(start); took > time.Second {
t.Fatalf("400 values took %s to hand over", took)
}
shanks.mu.Unlock()
close(slow.gate)
if err := shanks.Drain(); err != nil {
t.Fatal(err)
}
if got, _ := shanks.read(t, project, "MEMORY.md"); got != "index\n" {
t.Fatalf("what the watch noted was not applied: %q", got)
}
}
@@ -124,6 +124,7 @@ func HomeScopeRefused(p Paths, target string) string {
func (p Paths) credentials() string { return filepath.Join(p.Home, ".claude", ".credentials.json") }
func (p Paths) account() string { return filepath.Join(p.Home, ".claude.json") }
func (p Paths) binding() string { return filepath.Join(p.State, "licence.json") }
func (p Paths) preferences() string { return filepath.Join(p.State, "preferences.json") }
func (p Paths) apiKey() string { return filepath.Join(p.keyDir(), "api-key") }
func (p Paths) helper() string { return filepath.Join(p.keyDir(), "api-key-helper") }
@@ -218,6 +219,7 @@ func RenderNow(p Paths, write WriteManaged) ([]string, error) {
}
var settings Settings
readJSON(p.Settings, &settings)
readJSON(p.preferences(), &settings.Preferences)
var binding *Binding
var b Binding
if readJSON(p.binding(), &b) {
@@ -42,6 +42,15 @@ type Settings struct {
MCPServers map[string]map[string]any `json:"mcp_servers"`
// ManagedSettings are keys of the agent's managed settings the operator sets, for the mesh or a node.
ManagedSettings map[string]any `json:"managed_settings"`
// Preferences are this module's own preferences, from its untrusted preferences file: set in one call
// with the controller's `settings` verb, for the mesh or a node, like any module's preference (ADR 0262).
Preferences Preferences `json:"-"`
}
// Preferences are the module's preferences as the mesh filled them; nil is a value the file did not carry.
type Preferences struct {
// Tips is whether Claude Code shows a tip while it works (`spinnerTipsEnabled`).
Tips *bool `json:"tips"`
}
// Binding is the licence this node holds, as it was last applied.
@@ -121,6 +130,11 @@ func RenderWithMesh(facts Facts, settings Settings, binding *Binding, helperPath
// The operator's `managed_settings` (ADR 0213), set at the controller's terminal alone: nothing registered
// through this module's tools is laid over it (novox/hq issue 340).
managed := mergeSettings(map[string]any{}, settings.ManagedSettings)
// The module's preferences: keys of how a session looks, never of what it may do, so they are set in one
// call through the controller rather than at the terminal, and laid over the operator's keys of the same name.
if settings.Preferences.Tips != nil {
managed["spinnerTipsEnabled"] = *settings.Preferences.Tips
}
// The mesh's own keys are laid last: a setting never replaces them.
managed["attribution"] = map[string]any{"commit": "", "pr": ""}
managed["allowAllClaudeAiMcps"] = true
+17 -3
View File
@@ -1,6 +1,13 @@
{
"module": "claude-code",
"version": "1",
"settings": {
"tips": {
"kind": "preference",
"default": true,
"why": "Claude Code shows a tip while it works; false turns tips off in every session on the machine, written as spinnerTipsEnabled in its managed settings"
}
},
"slug": "agent",
"capabilities": [
"package-manager"
@@ -19,8 +26,7 @@
"holdings",
"config",
"proposals",
"questions",
"memory"
"questions"
],
"reads": [
"claude-licence-manager.bindings"
@@ -33,7 +39,6 @@
"claude_code_grant",
"claude_code_add_api_key",
"claude_code_registrations",
"claude_code_memory",
"claude_code_mcp_list",
"claude_code_mcp_register",
"claude_code_mcp_unregister",
@@ -148,6 +153,15 @@
"owner": "${machine:account}",
"merge": "json",
"content": "{\n \"role\": \"\",\n \"mcp_servers\": {},\n \"managed_settings\": {},\n \"instructions\": {},\n \"output_styles\": {},\n \"skills\": {},\n \"commands\": {},\n \"agents\": {}\n}\n"
},
{
"id": "preferences",
"trusted": false,
"type": "file",
"path": "${dir:state}/preferences.json",
"mode": "0600",
"owner": "${machine:account}",
"content": "{\n \"tips\": ${setting:tips}\n}\n"
}
],
"shell": [
-7
View File
@@ -4,13 +4,6 @@ The forge, and the mesh's npm package registry: it claims the `git` and `npm-pac
Its tools, its events and the provisioner of registry accounts are the TypeScript bundle (`index.ts`,
`tools/`, `provisioner/`). This README covers the two Go bundles beside it.
**What a registry consumer is told** (`serves.npm-package-registry`): where the registry answers on the
private network (`scheme`, `port`, `npm-path`), and its `public-root`, the address the forge writes every
tarball at: `https://` and the forge's route's composed public name, under the npm path
(`${bound:route:name-web}`, novox/hq ADR 0346). A lock file holds that address, and npm 12 installs a
locked tarball only from the registry it is configured with, so a consumer that installs from lock files
trusts that root. On a machine with no public domain the key is left out, and the plan says so.
## Announcing pull requests to the merge check (novox/hq to-be 45 §9, issue 347)
Every new head of an open pull request is announced as `pull.updated`, once, and the controller has it checked.
+1 -2
View File
@@ -68,8 +68,7 @@
"npm-package-registry": {
"scheme": "http",
"port": 3000,
"npm-path": "/api/packages/novox/npm/",
"public-root": "https://${bound:route:name-web}/api/packages/novox/npm/"
"npm-path": "/api/packages/novox/npm/"
},
"git": {
"scheme": "http",
+1 -3
View File
@@ -104,9 +104,7 @@
],
"env": {
"MESH_LAB_WORK": "${dir:work}",
"MESH_LAB_ENV_FILE": "${dir:state}/lab.env",
"MESH_LAB_FORGE_BINDING": "${dir:state}/package-registry.json",
"MESH_LAB_FORGE_SECRET": "${dir:state}/package-registry.secret"
"MESH_LAB_ENV_FILE": "${dir:state}/lab.env"
}
}
]
-187
View File
@@ -194,190 +194,3 @@ test("the lab asks the package registry for read only", () => {
assert.ok(m.requires.includes("npm-package-registry"));
assert.equal(m.contributes?.["npm-package-registry"]?.access, "read");
});
// lab_check failed for ever on "destination path '<work>/check' already exists": it cloned into one fixed
// path and ignored whether removing the last one worked (novox/hq issue 624). Each check now works in a
// fresh directory, and what earlier checks left is cleared or named.
import { checkDir, clearChecks } from "../tools/runs.ts";
test("each check gets a fresh directory of its own", () => {
const work = mkdtempSync(join(tmpdir(), "lab-work-"));
const a = checkDir(work);
const b = checkDir(work);
assert.notEqual(a, b);
assert.ok(a.startsWith(join(work, "check-")) && existsSync(a) && existsSync(b));
rmSync(work, { recursive: true, force: true });
});
// As root nothing is locked against removal, so the unremovable tree cannot be made: skipped there, before
// it makes anything (the merge check runs as root).
test("what earlier checks left is cleared, and what cannot be is named rather than tripped over", {
skip: process.getuid?.() === 0 ? "running as root, which no directory mode stops from removing anything" : false,
}, () => {
const work = mkdtempSync(join(tmpdir(), "lab-work-"));
for (const d of ["check/src", "check-setup/mesh-lab", "check-abc", "run-x", "keep"]) mkdirSync(join(work, d), { recursive: true });
// A tree this account cannot empty: a directory it may not write, with something in it.
mkdirSync(join(work, "check-stuck", "locked"), { recursive: true });
writeFileSync(join(work, "check-stuck", "locked", "file"), "x");
chmodSync(join(work, "check-stuck", "locked"), 0o555);
const said = clearChecks(work);
chmodSync(join(work, "check-stuck", "locked"), 0o755);
for (const gone of ["check", "check-setup", "check-abc"]) assert.equal(existsSync(join(work, gone)), false, gone);
assert.ok(existsSync(join(work, "run-x")) && existsSync(join(work, "keep")));
assert.equal(said.length, 1);
assert.match(said[0] as string, /check-stuck, left by an earlier check, could not be removed/);
rmSync(work, { recursive: true, force: true });
});
// And as root too: what earlier checks left is cleared.
test("leftover checks are cleared, whoever runs the check", () => {
const work = mkdtempSync(join(tmpdir(), "lab-work-"));
for (const d of ["check/src", "check-setup/mesh-lab", "check-abc", "run-x"]) mkdirSync(join(work, d), { recursive: true });
assert.deepEqual(clearChecks(work), []);
for (const gone of ["check", "check-setup", "check-abc"]) assert.equal(existsSync(join(work, gone)), false, gone);
assert.ok(existsSync(join(work, "run-x")));
rmSync(work, { recursive: true, force: true });
});
// The lab's tools read the bound credential, so the runtime is given that file by the tools bundle's env
// (ADR 0192), as gitea and umami are given theirs: the runtime's account may then read exactly it, and no
// other credential changes hands (issue 624).
test("the lab's tools bundle names its bound credential and binding in its env", () => {
const m = JSON.parse(readFileSync(join(import.meta.dirname, "..", "module.json"), "utf8")) as {
secrets: Record<string, string>; binds: Record<string, string>;
build: { artifacts: { name: string; env?: Record<string, string> }[] };
};
const env = m.build.artifacts.find((a) => a.name === "code")?.env ?? {};
assert.equal(env.MESH_LAB_FORGE_SECRET, m.secrets["npm-package-registry"]);
assert.equal(env.MESH_LAB_FORGE_BINDING, m.binds["npm-package-registry"]);
});
// The next setup failure of novox/hq issue 624 (run-2026-10-13T13-15-26-634Z on shanks): npm 12 refused
// mesh-tools' locked @novox/mesh-sdk with EALLOWREMOTE. The lab named the registry by its forge setting's
// address (http://novox.internal:20000/…), the lock file holds the address the forge writes every tarball
// URL at (https://git.novox.be/…), and npm 12 installs a locked tarball only from its configured registry.
// A run names the registry by the address the registry names itself by.
import { npmRegistry } from "../tools/runs.ts";
function aRegistry(answer: () => Response | Promise<Response>): { get: typeof fetch; asked: string[] } {
const asked: string[] = [];
const get = (async (url: string | URL | Request) => {
asked.push(String(url));
return answer();
}) as typeof fetch;
return { get, asked };
}
const packument = (tarball: string) => new Response(JSON.stringify({
name: "@novox/mesh-sdk", "dist-tags": { latest: "0.1.14" },
versions: { "0.1.1": { dist: { tarball: "http://old.example/api/packages/novox/npm/x.tgz" } }, "0.1.14": { dist: { tarball } } },
}), { status: 200, headers: { "content-type": "application/json" } });
test("a run names the package registry by the address the registry gives its tarballs", async () => {
const r = aRegistry(() => packument("https://forge.example.org/api/packages/novox/npm/%40novox%2Fmesh-sdk/-/0.1.14/mesh-sdk-0.1.14.tgz"));
const got = await npmRegistry("http://forge.example.org:3000/", r.get, "https://forge.example.org/api/packages/novox/npm/");
assert.deepEqual(got, { registry: "https://forge.example.org/api/packages/novox/npm/" });
assert.deepEqual(r.asked, ["http://forge.example.org:3000/api/packages/novox/npm/@novox%2fmesh-sdk"]);
// The forge's own scheme is taken too, with its port.
const plain = await npmRegistry("http://forge.example.org:3000",
aRegistry(() => packument("http://forge.example.org:3000/api/packages/novox/npm/%40novox%2Fmesh-sdk/-/0.1.14/mesh-sdk-0.1.14.tgz")).get);
assert.deepEqual(plain, { registry: "http://forge.example.org:3000/api/packages/novox/npm/" });
});
// What the registry answers may come over plain HTTP, so it never decides where packages come from: only
// the forge's public root or its own address, scheme, host and port, under the npm path, with nothing after
// it (review of mesh-catalog #275, ADR 0346).
test("a registry that does not give an address the lab takes leaves the forge's, and the reason is said", async () => {
const sdk = "/api/packages/novox/npm/%40novox%2Fmesh-sdk/-/0.1.14/mesh-sdk-0.1.14.tgz";
for (const [answer, why] of [
[() => new Response("not found", { status: 404 }), /it answered 404/],
[() => new Response("{}", { status: 200 }), /no tarball/],
[() => { throw new Error("connect ECONNREFUSED"); }, /ECONNREFUSED/],
[() => packument(`https://forge.example.org/elsewhere/mesh-sdk-0.1.14.tgz`), /not under \/api\/packages\/novox\/npm\//],
[() => packument(`https://attacker.example.net${sdk}`), /at https:\/\/attacker\.example\.net, which is neither the forge's public root nor its own address/],
[() => packument(`https://attacker.example.net/x?u=https://novox.internal${sdk}`), /query or a fragment/],
[() => packument(`https://novox.internal${sdk}?x=1`), /query or a fragment/],
[() => packument(`https://novox.internal${sdk}#frag`), /query or a fragment/],
[() => packument(`https://attacker.example.net/#https://novox.internal${sdk}`), /query or a fragment/],
[() => packument(`ftp://novox.internal${sdk}`), /at ftp:\/\/novox\.internal, which is neither/],
[() => packument(`file:///api/packages/novox/npm/x.tgz`), /which is neither/],
[() => packument(`https://novox.internal/api/packages/novox/npm/\nregistry=https://attacker.example.net/\nallow-remote=all`), /whitespace or a control character/],
[() => packument(`https://user:pw@novox.internal${sdk}`), /user or password/],
] as [() => Response, RegExp][]) {
const got = await npmRegistry("http://novox.internal:20000", aRegistry(answer).get);
assert.equal(got.registry, "http://novox.internal:20000/api/packages/novox/npm/", String(why));
assert.match(got.problem ?? "", /did not give an address the lab takes/);
assert.match(got.problem ?? "", why);
}
});
test("a run's npm configuration names the registry it was given, and the forge's own address only without one", () => {
const given = script("/work", "run-x", "http://novox.internal:20000", ["t.test.ts"], {}, undefined,
"https://forge.example.org/api/packages/novox/npm/");
assert.match(given, / '@novox:registry=https:\/\/forge\.example\.org\/api\/packages\/novox\/npm\/' > '\/work\/run-x\/\.npmrc'\n/);
assert.doesNotMatch(given, /@novox:registry=http:\/\/novox\.internal/);
const none = script("/work", "run-x", "http://novox.internal:20000", ["t.test.ts"], {});
assert.match(none, /'@novox:registry=http:\/\/novox\.internal:20000\/api\/packages\/novox\/npm\/'/);
});
// The public root the forge serves in the lab's binding (novox/hq ADR 0346) is the one other origin a
// tarball may come from, by scheme, host and port: the review of #275 found the port ignored.
import { publicRootOf } from "../tools/runs.ts";
test("a tarball is taken at the forge's public root, and at no other port or scheme of its host", async () => {
const sdk = "/api/packages/novox/npm/%40novox%2Fmesh-sdk/-/0.1.14/mesh-sdk-0.1.14.tgz";
const root = "https://git.example.org/api/packages/novox/npm/";
const at = (tarball: string, publicRoot?: string) =>
npmRegistry("http://novox.internal:20000", aRegistry(() => packument(tarball)).get, publicRoot);
assert.deepEqual(await at(`https://git.example.org${sdk}`, root), { registry: root });
for (const tarball of [`https://git.example.org:8443${sdk}`, `http://git.example.org${sdk}`, `https://example.org${sdk}`]) {
const got = await at(tarball, root);
assert.equal(got.registry, "http://novox.internal:20000/api/packages/novox/npm/", tarball);
assert.match(got.problem ?? "", /neither the forge's public root nor its own address/, tarball);
}
// Without a public root, the forge's public name is not trusted on its own word.
assert.match((await at(`https://git.example.org${sdk}`)).problem ?? "", /neither/);
// And the forge's own address only at its own port.
assert.match((await at(`http://novox.internal${sdk}`, root)).problem ?? "", /neither/);
});
test("the public root is read from the binding, and only an https root at the npm path is taken", () => {
const dir = mkdtempSync(join(tmpdir(), "lab-root-"));
const binding = (root: unknown) => {
const f = join(dir, `b-${Math.random()}.json`);
writeFileSync(f, JSON.stringify({ provision: "npm-package-registry", at: "home", serves: { scheme: "http", port: 3000,
"npm-path": "/api/packages/novox/npm/", ...(root === undefined ? {} : { "public-root": root }) } }));
return f;
};
assert.deepEqual(publicRootOf(binding("https://git.example.org/api/packages/novox/npm/")),
{ root: "https://git.example.org/api/packages/novox/npm/" });
assert.deepEqual(publicRootOf(binding("https://git.example.org:8443/api/packages/novox/npm/")),
{ root: "https://git.example.org:8443/api/packages/novox/npm/" });
for (const [root, why] of [
[undefined, /serves no public-root/],
["http://git.example.org/api/packages/novox/npm/", /is not https:\/\/<host>/],
["https://git.example.org/elsewhere/", /is not https:\/\/<host>/],
["https://git.example.org/api/packages/novox/npm/?x=1", /is not https:\/\/<host>/],
["https://u:p@git.example.org/api/packages/novox/npm/", /is not https:\/\/<host>/],
["https://git.example.org/api/packages/novox/npm/\nallow-remote=all", /whitespace or a control character/],
["https://${bound:route:name-web}/api/packages/novox/npm/", /not a URL|is not https/],
] as [unknown, RegExp][]) {
const got = publicRootOf(binding(root));
assert.equal(got.root, undefined, String(root));
assert.match(got.problem ?? "", why, String(root));
}
assert.match(publicRootOf(join(dir, "none.json")).problem ?? "", /could not be read/);
assert.match(publicRootOf(undefined).problem ?? "", /no npm-package-registry binding/);
rmSync(dir, { recursive: true, force: true });
});
// A forge setting that is not http(s) is never a trusted origin (review of #280): a `file:` tarball's origin
// is "null", as is a `file:` forge's, and the two must not match.
test("a forge setting that is not http(s) trusts no origin of its own", async () => {
const got = await npmRegistry("file:///srv/forge", aRegistry(() => packument("file:///api/packages/novox/npm/x.tgz")).get);
assert.match(got.problem ?? "", /neither the forge's public root nor its own address/);
const root = "https://git.example.org/api/packages/novox/npm/";
const viaRoot = await npmRegistry("ftp://novox.internal", aRegistry(() =>
packument("ftp://novox.internal/api/packages/novox/npm/x.tgz")).get, root);
assert.match(viaRoot.problem ?? "", /neither/);
});
+10 -31
View File
@@ -8,7 +8,7 @@ import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import {
checkDir, cleanCredentials, clearChecks, credentialArgs, forgeCredential, installPlan, listRuns, npmRegistry, NPM_REPOSITORIES, publicRootOf, readStatus, REPOSITORIES, running, start,
cleanCredentials, credentialArgs, forgeCredential, installPlan, listRuns, NPM_REPOSITORIES, readStatus, REPOSITORIES, running, start,
stop, tail, writeCredential,
} from "./runs.js";
@@ -19,14 +19,6 @@ export function getLabTools(env: NodeJS.ProcessEnv): ToolDefinition[] {
// without restarting the runtime. MESH_LAB_FORGE itself still wins, for a hand-run instance.
const forgeOf = (): string => (env.MESH_LAB_FORGE ?? wordIn(env.MESH_LAB_ENV_FILE, "MESH_LAB_FORGE")).replace(/\/+$/, "");
// The forge credential the mesh binds and seals to the lab (novox/hq issue 624), read at each call.
// The registry's public root the forge serves in the lab's binding (novox/hq ADR 0346), read at each call.
const bindingOf = (): string | undefined =>
env.MESH_LAB_FORGE_BINDING ?? (wordIn(env.MESH_LAB_ENV_FILE, "MESH_LAB_FORGE_BINDING") || undefined);
const registryOf = async (forge: string) => {
const root = publicRootOf(bindingOf());
const registry = await npmRegistry(forge, fetch, root.root);
return registry.problem && root.problem ? { ...registry, problem: `${registry.problem}; ${root.problem}` } : registry;
};
const credentialOf = (forge: string): string | undefined => forgeCredential(forge,
env.MESH_LAB_FORGE_BINDING ?? wordIn(env.MESH_LAB_ENV_FILE, "MESH_LAB_FORGE_BINDING"),
env.MESH_LAB_FORGE_SECRET ?? wordIn(env.MESH_LAB_ENV_FILE, "MESH_LAB_FORGE_SECRET"));
@@ -38,24 +30,12 @@ export function getLabTools(env: NodeJS.ProcessEnv): ToolDefinition[] {
run: async () => {
const forge = forgeOf();
if (!forge) return { ok: false, output: "the lab's forge is not set: settings for lab, {\"forge\": \"<url>\"}" };
// What earlier checks left is cleared, and what could not be is said; this check works in a fresh
// directory of its own either way, and removes it when it is done.
const leftovers = clearChecks(work);
const dir = checkDir(work);
let check: ReturnType<typeof spawnSync>;
let problems: string[];
try {
const clone = spawnSync("git", ["clone", "--quiet", "--depth", "1", `${forge}/novox/mesh-lab.git`, dir], { encoding: "utf8" });
if (clone.status !== 0) return { ok: false, output: [String(clone.stderr).trim(), ...leftovers].join("\n") };
spawnSync("npm", ["ci", "--no-audit", "--no-fund", "--loglevel=error"], { cwd: dir, encoding: "utf8" });
check = spawnSync("node", ["--experimental-strip-types", "src/cli.ts", "check"], { cwd: dir, encoding: "utf8" });
problems = setupProblems(forge, `${dir}/setup`, credentialOf(forge));
// And the address a run names the package registry by (npmRegistry): said when the registry does not say it.
const registry = await registryOf(forge);
if (registry.problem) problems.push(registry.problem);
} finally {
rmSync(dir, { recursive: true, force: true });
}
const dir = `${work}/check`;
spawnSync("rm", ["-rf", dir]);
const clone = spawnSync("git", ["clone", "--quiet", "--depth", "1", `${forge}/novox/mesh-lab.git`, dir], { encoding: "utf8" });
if (clone.status !== 0) return { ok: false, output: clone.stderr };
spawnSync("npm", ["ci", "--no-audit", "--no-fund", "--loglevel=error"], { cwd: dir, encoding: "utf8" });
const check = spawnSync("node", ["--experimental-strip-types", "src/cli.ts", "check"], { cwd: dir, encoding: "utf8" });
// **And what a run would check out and install, before a run finds out** (novox/hq issue 624): every
// repository it clones answers on the forge, and every one it installs has a lock file where the run
// installs from.
@@ -63,8 +43,9 @@ export function getLabTools(env: NodeJS.ProcessEnv): ToolDefinition[] {
// by a running run is not.
const busy = running(work);
const left = cleanCredentials(work, busy ? `${work}/.${busy.id}.git-credentials` : undefined);
const problems = setupProblems(forge, `${work}/check-setup`, credentialOf(forge));
const removed = left.length > 0 ? `removed ${left.length} forge credential(s) a run left behind: ${left.join(", ")}` : "";
const output = [`${check.stdout}${check.stderr}`.trim(), removed, ...leftovers, ...problems].filter(Boolean).join("\n");
const output = [`${check.stdout}${check.stderr}`.trim(), removed, ...problems].filter(Boolean).join("\n");
return { ok: check.status === 0 && problems.length === 0, output };
},
},
@@ -98,9 +79,7 @@ export function getLabTools(env: NodeJS.ProcessEnv): ToolDefinition[] {
if (stranger.length > 0) return { started: false, reason: `the lab does not build ${stranger.join(", ")}` };
const busy = running(work);
if (busy) return { started: false, reason: `${busy.id} is still ${busy.state}; one run at a time`, running: busy };
const registry = await registryOf(forge);
return { started: true, run: start(work, forge, tests, refs, credentialOf(forge), registry.registry),
registry: registry.registry, ...(registry.problem ? { problem: registry.problem } : {}) };
return { started: true, run: start(work, forge, tests, refs, credentialOf(forge)) };
},
},
{
+7 -140
View File
@@ -7,7 +7,7 @@
// for it, and says it was lost rather than pretending it is still going.
import { spawn } from "node:child_process";
import { existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from "node:fs";
import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync } from "node:fs";
import { join } from "node:path";
/**
@@ -60,108 +60,6 @@ export function installStep(root: string, build: boolean): string {
fi`;
}
/** Where the forge serves the mesh's npm packages, under its own address (gitea's npm registry for the novox org). */
export const NPM_PATH = "/api/packages/novox/npm/";
/**
* The registry's public root, as the forge serves it in the lab's npm-package-registry binding
* (`serves.public-root`, novox/hq ADR 0346): the forge's route's composed public name under the npm path.
* The mesh writes the binding, and it is checked all the same: an https URL whose path is the npm path and
* nothing else. Undefined, with why, when the binding has none or it is not that shape — a forge on a
* machine with no public domain serves none (ADR 0346 §3).
*/
export function publicRootOf(bindingFile: string | undefined): { root?: string; problem?: string } {
if (!bindingFile) return { problem: "the lab has no npm-package-registry binding" };
let said: unknown;
try {
said = (JSON.parse(readFileSync(bindingFile, "utf8")) as { serves?: Record<string, unknown> }).serves?.["public-root"];
} catch (e) {
return { problem: `its npm-package-registry binding could not be read: ${e instanceof Error ? e.message : String(e)}` };
}
if (typeof said !== "string" || !said) return { problem: "its npm-package-registry binding serves no public-root" };
if (/[\s\u0000-\u001f\u007f]/.test(said)) return { problem: "the binding's public-root holds whitespace or a control character" };
let u: URL;
try {
u = new URL(said);
} catch {
return { problem: `the binding's public-root ${JSON.stringify(said)} is not a URL` };
}
if (u.protocol !== "https:" || u.username || u.password || u.search || u.hash || u.pathname !== NPM_PATH) {
return { problem: `the binding's public-root ${JSON.stringify(said)} is not https://<host>${NPM_PATH}` };
}
return { root: `${u.origin}${NPM_PATH}` };
}
/**
* The address a run names the @novox registry by: the one the registry names itself by, when that is the
* forge's public root or the forge's own address (novox/hq issue 624, ADR 0346).
*
* **npm 12 installs a locked tarball only from the registry it is configured with.** Its `allow-remote`
* defaults to `none`, and a lock file's `resolved` URL counts as the registry's only when its origin and
* path are the configured scope registry's; any other is a "remote" tarball and refused (EALLOWREMOTE).
* The forge writes every tarball URL — in what it answers, and so in every lock file made against it — at
* its own public address, whichever address it was asked at. So the lab asks the registry, at the forge,
* for the SDK's newest tarball and names the registry by the origin of that URL.
*
* **What the registry answers is not trusted for where packages come from** (review of mesh-catalog #275):
* it may be fetched over plain HTTP. The tarball URL is parsed, and taken only when it carries no
* whitespace, control character, user, password, query or fragment, its path is under the npm path, and
* its origin — scheme, host and port — is one the lab already trusts: the public root the forge serves in
* the lab's binding (`publicRoot`, ADR 0346), or the forge setting's own. The registry is then built from
* the parsed origin and the npm path alone, never from the string answered, so nothing the registry says
* reaches the run's npm configuration but a scheme, a host and a port. Anything else falls back to the
* forge's own address and says why, so a run still starts and lab_check names it.
*/
export async function npmRegistry(forge: string, get: typeof fetch = fetch, publicRoot?: string):
Promise<{ registry: string; problem?: string }> {
const asked = `${forge.replace(/\/+$/, "")}${NPM_PATH}`;
const url = `${asked}@novox%2fmesh-sdk`;
const fallback = (why: string) => ({
registry: asked,
problem: `the package registry at ${url} did not give an address the lab takes (${why}), so a run names it ${asked}, and npm 12 refuses a locked @novox tarball at any other`,
});
let own: URL;
try {
own = new URL(forge);
} catch {
return fallback(`the forge ${JSON.stringify(forge)} is not a URL`);
}
// The forge setting's own origin is trusted only as an http(s) one: any other scheme's origin is
// "null", or names no registry npm would fetch from.
const trusted = own.protocol === "https:" || own.protocol === "http:" ? [own.origin] : [];
if (publicRoot) {
try {
trusted.unshift(new URL(publicRoot).origin);
} catch {
// publicRootOf hands only a parsed root; a caller's other string is not trusted.
}
}
try {
const res = await get(url, { signal: AbortSignal.timeout(15_000) });
if (!res.ok) return fallback(`it answered ${res.status}`);
const doc = await res.json() as { "dist-tags"?: { latest?: string }; versions?: Record<string, { dist?: { tarball?: unknown } }> };
const latest = doc["dist-tags"]?.latest ?? "";
const tarball = doc.versions?.[latest]?.dist?.tarball;
if (typeof tarball !== "string" || !tarball) return fallback("no tarball for its latest version");
if (/[\s\u0000-\u001f\u007f]/.test(tarball)) return fallback("its tarball URL holds whitespace or a control character");
let t: URL;
try {
t = new URL(tarball);
} catch {
return fallback("its tarball is not a URL");
}
if (t.username || t.password) return fallback("its tarball URL carries a user or password");
if (t.search || t.hash) return fallback("its tarball URL carries a query or a fragment");
if (!t.pathname.startsWith(NPM_PATH)) return fallback(`its tarball is not under ${NPM_PATH}`);
if (!trusted.includes(t.origin)) {
return fallback(`its tarball is at ${t.origin}, which is neither the forge's public root nor its own address (${trusted.join(", ")})`);
}
return { registry: `${t.origin}${NPM_PATH}` };
} catch (e) {
return fallback(e instanceof Error ? e.message : String(e));
}
}
/**
* The forge credential a run's clones may present, as a git credential-store line —
* scheme://user:password@host[:port] — for the forge's own origin, or undefined when the lab holds none.
@@ -239,37 +137,6 @@ export function cleanCredentials(work: string, keep?: string): string[] {
return removed;
}
/**
* checkDir is a directory of lab_check's own for one check, fresh each time (novox/hq issue 624). The check
* cloned into the one fixed path `<work>/check`, removing it first and ignoring whether that worked; a tree
* an earlier check left that this account could not remove — one cloned as another account before the
* work directory was the operator's — made every check fail on "destination path already exists".
*/
export function checkDir(work: string): string {
mkdirSync(work, { recursive: true });
return mkdtempSync(join(work, "check-"));
}
/**
* clearChecks removes what earlier checks left in the work directory — the fixed `check` and `check-setup`
* of before, and any `check-*` — and says, for each it could not remove, which and why, so a check reports
* it rather than tripping over it.
*/
export function clearChecks(work: string): string[] {
if (!existsSync(work)) return [];
const said: string[] = [];
for (const name of readdirSync(work)) {
if (name !== "check" && name !== "check-setup" && !name.startsWith("check-")) continue;
const path = join(work, name);
try {
rmSync(path, { recursive: true, force: true });
} catch (err) {
said.push(`${path}, left by an earlier check, could not be removed: ${(err as Error).message}`);
}
}
return said;
}
/** The arguments that have git offer the credential in that file, and only that one. */
export function credentialArgs(path: string | undefined): string {
if (!path) return "";
@@ -342,7 +209,7 @@ const shellQuote = (s: string) => `'${s.replace(/'/g, `'\\''`)}'`;
* at this run's own tree, so what is built and claimed is exactly what was checked out.
*/
export function script(work: string, id: string, forge: string, tests: string[], refs: Record<string, string>,
credential?: string, registry = `${forge}${NPM_PATH}`): string {
credential?: string): string {
const dir = runDir(work, id);
const setState = (state: string) =>
`node -e ${shellQuote(
@@ -368,9 +235,9 @@ node -e ${shellQuote(
`const fs=require("fs");const f=${JSON.stringify(join(dir, "status.json"))};const s=JSON.parse(fs.readFileSync(f,"utf8"));s.commits=Object.fromEntries(fs.readFileSync(${JSON.stringify(join(dir, "commits.txt"))},"utf8").trim().split("\\n").map(l=>l.split(" ")));fs.writeFileSync(f,JSON.stringify(s,null,2))`,
)}
${setState("building")}
# The @novox scope resolves from the mesh's own package registry on the forge, named by the address it
# names itself by (npmRegistry), which is the address its lock files hold; nothing else is asked of it.
printf '%s\n' ${shellQuote(`@novox:registry=${registry}`)} > ${shellQuote(join(dir, ".npmrc"))}
# The @novox scope resolves from the mesh's own package registry on the forge, as the builder
# resolves it; nothing else is asked of it.
printf '%s\n' ${shellQuote(`@novox:registry=${forge}/api/packages/novox/npm/`)} > ${shellQuote(join(dir, ".npmrc"))}
export NPM_CONFIG_USERCONFIG=${shellQuote(join(dir, ".npmrc"))}
${NPM_REPOSITORIES.map((repo) => installStep(join(dir, repo), repo !== "mesh-lab")).join("\n")}
mkdir -p ${shellQuote(bin)}
@@ -396,7 +263,7 @@ node --experimental-strip-types src/cli.ts suite ${tests.map(shellQuote).join("
/** start begins a run and returns at once with its status. */
export function start(work: string, forge: string, tests: string[], refs: Record<string, string>,
credential?: string, registry?: string): RunStatus {
credential?: string): RunStatus {
const id = `run-${new Date().toISOString().replace(/[:.]/g, "-")}`;
const dir = runDir(work, id);
mkdirSync(dir, { recursive: true });
@@ -405,7 +272,7 @@ export function start(work: string, forge: string, tests: string[], refs: Record
// What a crashed or rebooted run left behind goes before this run writes its own.
cleanCredentials(work);
const credentials = writeCredential(credentialPath(work, id), credential);
writeFileSync(join(dir, "run.sh"), script(work, id, forge, tests, refs, credentials, registry), { mode: 0o700 });
writeFileSync(join(dir, "run.sh"), script(work, id, forge, tests, refs, credentials), { mode: 0o700 });
// The wrapper records how the run ended, then removes the checkouts and keeps the log and status: a
// run's tree is its own, and the next run starts from fresh ones (novox/hq ADR 0172).