Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
86bcbc48ad | ||
|
|
0d723484a8 | ||
|
|
8d8dcd5ebb | ||
|
|
cd23928320 | ||
|
|
8922c89059 | ||
|
|
789d2d9d64 | ||
|
|
1725ffc799 | ||
|
|
a72eb3dbb2 | ||
|
|
c0726ac264 |
@@ -285,6 +285,25 @@ func TestANewerGenerationFetchesTheTokenOnceByTheSeatsVerb(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// The node's line names the generation it applied and why the manager moved it, and who asked (novox/hq issue 625).
|
||||
func TestAnAppliedBindingSaysWhyAndWho(t *testing.T) {
|
||||
p, w := node(t, "laptop")
|
||||
var asked []string
|
||||
done, err := OnBinding(p, &BindingState{Licence: "personal", Kind: "subscription", Generation: 7,
|
||||
Why: "the switch verb", By: "g14.node-tools"}, seat(t, "personal", "at-7", 7, &asked), writer(w))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, want := range []string{`"generation":7`, `"why":"the switch verb"`, `"by":"g14.node-tools"`} {
|
||||
if !strings.Contains(done, want) {
|
||||
t.Fatalf("the line %s lacks %s", done, want)
|
||||
}
|
||||
}
|
||||
if strings.Contains(done, "at-7") {
|
||||
t.Fatal("the line carries the token")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheTokenANodeIsHandedReplacesALoginsGrantAndLeavesNoRefreshToken(t *testing.T) {
|
||||
p, w := node(t, "laptop")
|
||||
writeFile(t, p.credentials(), `{"claudeAiOauth":{"accessToken":"at-old","refreshToken":"rt-spent","expiresAt":`+
|
||||
@@ -476,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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
@@ -298,6 +300,9 @@ type BindingState struct {
|
||||
Licence string `json:"licence"`
|
||||
Kind string `json:"kind"`
|
||||
Generation int64 `json:"generation"`
|
||||
// Why and By are what moved the binding and who asked, as the manager recorded it (novox/hq issue 625).
|
||||
Why string `json:"why,omitempty"`
|
||||
By string `json:"by,omitempty"`
|
||||
}
|
||||
|
||||
// Current is what the seat answers to `current`: the licence this node is bound to and its token, sealed.
|
||||
@@ -433,6 +438,15 @@ func OnBinding(p Paths, b *BindingState, ask Ask, write WriteManaged) (string, e
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
// The line says which binding this was and why the manager moved it, so a node's journal alone answers
|
||||
// what changed here and who asked (novox/hq issue 625).
|
||||
out["generation"] = b.Generation
|
||||
if b.Why != "" {
|
||||
out["why"] = b.Why
|
||||
}
|
||||
if b.By != "" {
|
||||
out["by"] = b.By
|
||||
}
|
||||
raw, _ := json.Marshal(out)
|
||||
return string(raw), nil
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
@@ -146,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": [
|
||||
|
||||
@@ -253,6 +253,7 @@ func text(a map[string]any, k string) (string, error) {
|
||||
}
|
||||
|
||||
// verb is one of the seat's verbs: listed as `<seat>.<verb>`, so the runtime serves it on the seat's subject.
|
||||
// What it changes is recorded as asked by its caller, as the runtime proved it (novox/hq issues 365 and 625).
|
||||
func verb(name, description string, input map[string]any, run func(ctx context.Context, m *Manager, a map[string]any) (any, error)) stdio.Tool {
|
||||
return stdio.Tool{Name: Seat + "." + name, Description: description, Input: input,
|
||||
Run: func(a map[string]any) (any, error) {
|
||||
@@ -260,7 +261,8 @@ func verb(name, description string, input map[string]any, run func(ctx context.C
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return run(context.Background(), m, a)
|
||||
ctx := WithCause(context.Background(), Cause{Why: "the " + name + " verb", By: stdio.CallerOf(a)})
|
||||
return run(ctx, m, a)
|
||||
}}
|
||||
}
|
||||
|
||||
@@ -288,7 +290,7 @@ func tools() []stdio.Tool {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return m.Bind(ctx, c, l, "bind")
|
||||
return m.Bind(ctx, c, l)
|
||||
}),
|
||||
verb("switch", "Move a consumer to another licence. Its node fetches the new licence's token at once and points the agent's account at it. A login on a node does this by itself (ADR 0209); this is for moving one without a login, or back.",
|
||||
map[string]any{"consumer": consumer, "licence": str("the licence to move to")},
|
||||
@@ -297,7 +299,7 @@ func tools() []stdio.Tool {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return m.Bind(ctx, c, l, "switch")
|
||||
return m.Bind(ctx, c, l)
|
||||
}),
|
||||
verb("release", "Unbind a consumer. Its node keeps its last token, which expires within hours.",
|
||||
map[string]any{"consumer": consumer},
|
||||
@@ -306,7 +308,7 @@ func tools() []stdio.Tool {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return m.Release(ctx, c, "release")
|
||||
return m.Release(ctx, c)
|
||||
}),
|
||||
verb("refresh", "Refresh a licence now, or every due licence when none is named; under each licence's lease, so it never races the daemon. Answers the outcome, never a token.",
|
||||
map[string]any{"licence": str("a licence; absent for every due one")},
|
||||
|
||||
@@ -59,6 +59,54 @@ type BindingState struct {
|
||||
Licence string `json:"licence"`
|
||||
Kind string `json:"kind"`
|
||||
Generation int64 `json:"generation"`
|
||||
// Why and By are what moved it and who asked (novox/hq issue 625), so the node can say so when it applies it.
|
||||
Why string `json:"why,omitempty"`
|
||||
By string `json:"by,omitempty"`
|
||||
}
|
||||
|
||||
// Cause is why a consumer's binding changed and who or what asked for it (novox/hq issue 625): Why is the verb
|
||||
// called or the rule that fired, By the caller the runtime proved (stdio.CallerOf) or this module itself.
|
||||
type Cause struct {
|
||||
Why string
|
||||
By string
|
||||
}
|
||||
|
||||
// Itself names this module where it moved a binding by a rule of its own, with no caller.
|
||||
const Itself = "claude-licence-manager"
|
||||
|
||||
type causeKey struct{}
|
||||
|
||||
// WithCause is ctx carrying why what runs in it changes a binding, and who asked.
|
||||
func WithCause(ctx context.Context, c Cause) context.Context {
|
||||
return context.WithValue(ctx, causeKey{}, c)
|
||||
}
|
||||
|
||||
// CauseOf is the cause ctx carries, or "a rule of this module's own" when none: never empty, so a record always
|
||||
// says something, and a change nobody named a cause for reads as this module's own.
|
||||
func CauseOf(ctx context.Context) Cause {
|
||||
if c, ok := ctx.Value(causeKey{}).(Cause); ok && c.By != "" {
|
||||
return c
|
||||
}
|
||||
return Cause{Why: "a rule of this module's own", By: Itself}
|
||||
}
|
||||
|
||||
// record says one change of what a consumer holds, the same fields in three places (novox/hq issue 625): a line
|
||||
// in the journal, a row in the audit table, and the event `licence.<what>` on the bus. Never a token.
|
||||
func (m *Manager) record(ctx context.Context, what, line string, detail map[string]any) {
|
||||
_ = m.Store.Audit(ctx, what, detail)
|
||||
m.Log("%s", line)
|
||||
_ = m.Emit("licence."+what, detail)
|
||||
}
|
||||
|
||||
// moved records a consumer bound or switched: from which licence to which, at which generation, why and by whom.
|
||||
func (m *Manager) moved(ctx context.Context, consumer, from, licence string, generation int64, c Cause) {
|
||||
what, line := "bound", fmt.Sprintf("bound %s to %s", consumer, licence)
|
||||
if from != "" {
|
||||
what, line = "switched", fmt.Sprintf("switched %s from %s to %s", consumer, from, licence)
|
||||
}
|
||||
m.record(ctx, what, fmt.Sprintf("%s (generation %d): %s; asked by %s", line, generation, c.Why, c.By),
|
||||
map[string]any{"consumer": consumer, "licence": licence, "from": from, "generation": generation,
|
||||
"why": c.Why, "by": c.By})
|
||||
}
|
||||
|
||||
// Settings are the manager's own, declared with defaults (design 39 §8).
|
||||
@@ -267,11 +315,12 @@ func (m *Manager) bindReporters(ctx context.Context, reports []Holdings) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_ = m.Store.Audit(ctx, "bound", map[string]any{"consumer": rep.Node, "licence": l.Name, "by": "its account's report"})
|
||||
if err := m.PutBinding(ctx, rep.Node, BindingState{Licence: l.Name, Kind: l.Kind, Generation: b.Generation}); err != nil {
|
||||
c := Cause{Why: rep.Node + " reported the account of " + l.Name + " and was bound to nothing", By: Itself}
|
||||
m.moved(ctx, rep.Node, "", l.Name, b.Generation, c)
|
||||
if err := m.PutBinding(ctx, rep.Node, BindingState{Licence: l.Name, Kind: l.Kind, Generation: b.Generation,
|
||||
Why: c.Why, By: c.By}); err != nil {
|
||||
return err
|
||||
}
|
||||
m.Log("bound %s to %s, the licence its account already has", rep.Node, l.Name)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -351,21 +400,21 @@ func (m *Manager) adoptOne(ctx context.Context, c Candidate, reports []Holdings)
|
||||
|
||||
// A login moves its node (ADR 0209): the node this login came from is bound to its licence —
|
||||
// switched, if it was bound to another. Then every node reporting this account and bound to nothing.
|
||||
// Who asked is the person who logged in there; the mesh knows them only as that login (novox/hq issue 625).
|
||||
cause := Cause{Why: "a login to " + l.Name + " on " + c.Node + " (ADR 0209)", By: Itself}
|
||||
ctx = WithCause(ctx, cause)
|
||||
if b, err := m.Store.Binding(ctx, c.Node); err != nil {
|
||||
return "", err
|
||||
} else if b == nil || b.Licence != l.Name {
|
||||
if _, err := m.Store.Bind(ctx, c.Node, l.Name); err != nil {
|
||||
moved, err := m.Store.Bind(ctx, c.Node, l.Name)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
from := ""
|
||||
if b != nil {
|
||||
from = b.Licence
|
||||
}
|
||||
_ = m.Store.Audit(ctx, map[bool]string{true: "switched", false: "bound"}[b != nil],
|
||||
map[string]any{"consumer": c.Node, "licence": l.Name, "from": from, "by": "a login there"})
|
||||
if b != nil {
|
||||
m.Log("%s moved from %s to %s: a login there", c.Node, from, l.Name)
|
||||
}
|
||||
m.moved(ctx, c.Node, from, l.Name, moved.Generation, cause)
|
||||
}
|
||||
for _, rep := range reports {
|
||||
if rep.Identity == nil || rep.Identity.AccountUUID != c.Identity.AccountUUID {
|
||||
@@ -374,8 +423,9 @@ func (m *Manager) adoptOne(ctx context.Context, c Candidate, reports []Holdings)
|
||||
if b, err := m.Store.Binding(ctx, rep.Node); err != nil || b != nil {
|
||||
continue
|
||||
}
|
||||
if _, err := m.Store.Bind(ctx, rep.Node, l.Name); err == nil {
|
||||
_ = m.Store.Audit(ctx, "bound", map[string]any{"consumer": rep.Node, "licence": l.Name, "by": "its login"})
|
||||
if b, err := m.Store.Bind(ctx, rep.Node, l.Name); err == nil {
|
||||
m.moved(ctx, rep.Node, "", l.Name, b.Generation,
|
||||
Cause{Why: rep.Node + " reports the account of " + l.Name + ", logged in on " + c.Node + ", and was bound to nothing", By: Itself})
|
||||
}
|
||||
}
|
||||
if err := m.publishAdvance(ctx, l); err != nil {
|
||||
@@ -399,14 +449,17 @@ func (m *Manager) keep(l *Licence, g FullGrant) {
|
||||
l.RotatedAt = m.Now().UnixMilli()
|
||||
}
|
||||
|
||||
// publishAdvance gives every consumer of a licence a new generation, and tells each in the state.
|
||||
// publishAdvance gives every consumer of a licence a new generation, and tells each in the state with the
|
||||
// cause ctx carries, so the node says why it fetched again (novox/hq issue 625).
|
||||
func (m *Manager) publishAdvance(ctx context.Context, l Licence) error {
|
||||
bindings, err := m.Store.Advance(ctx, l.Name)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
c := CauseOf(ctx)
|
||||
for _, b := range bindings {
|
||||
if err := m.PutBinding(ctx, b.Consumer, BindingState{Licence: b.Licence, Kind: l.Kind, Generation: b.Generation}); err != nil {
|
||||
if err := m.PutBinding(ctx, b.Consumer, BindingState{Licence: b.Licence, Kind: l.Kind, Generation: b.Generation,
|
||||
Why: c.Why, By: c.By}); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
@@ -482,7 +535,19 @@ func (m *Manager) Rotate(ctx context.Context, name string, force bool) (map[stri
|
||||
if err := m.Store.SaveLicence(ctx, *l); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
_ = m.Store.Audit(ctx, "rotated", map[string]any{"licence": name, "expiresAt": l.AccessExpiresAt})
|
||||
c := CauseOf(ctx)
|
||||
if force {
|
||||
c.Why = "a refresh asked for: " + c.Why
|
||||
} else {
|
||||
c = Cause{Why: "due: older than its cadence or near its expiry", By: Itself}
|
||||
if by := CauseOf(ctx); by.By != Itself {
|
||||
c.By = by.By
|
||||
}
|
||||
}
|
||||
ctx = WithCause(ctx, Cause{Why: "a rotation of " + name + " (" + c.Why + ")", By: c.By})
|
||||
m.record(ctx, "rotated", fmt.Sprintf("rotated %s, its token now expiring %s: %s; asked by %s", name,
|
||||
time.UnixMilli(l.AccessExpiresAt).UTC().Format(time.RFC3339), c.Why, c.By),
|
||||
map[string]any{"licence": name, "expiresAt": l.AccessExpiresAt, "why": c.Why, "by": c.By})
|
||||
if err := m.publishAdvance(ctx, *l); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -594,8 +659,9 @@ func (m *Manager) Current(ctx context.Context, consumer, publicKey string) (map[
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// Bind binds or switches a consumer: a person's act (ADR 0183), told to the consumer as a new generation.
|
||||
func (m *Manager) Bind(ctx context.Context, consumer, licence, by string) (map[string]any, error) {
|
||||
// Bind binds or switches a consumer: a person's act (ADR 0183), told to the consumer as a new generation, and
|
||||
// recorded with the cause ctx carries — the verb and its proven caller (novox/hq issue 625).
|
||||
func (m *Manager) Bind(ctx context.Context, consumer, licence string) (map[string]any, error) {
|
||||
l, err := m.Store.Licence(ctx, licence)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -611,19 +677,21 @@ func (m *Manager) Bind(ctx context.Context, consumer, licence, by string) (map[s
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
from, what := "", "bound"
|
||||
from := ""
|
||||
if before != nil {
|
||||
from, what = before.Licence, "switched"
|
||||
from = before.Licence
|
||||
}
|
||||
_ = m.Store.Audit(ctx, what, map[string]any{"consumer": consumer, "licence": licence, "from": from, "by": by})
|
||||
if err := m.PutBinding(ctx, consumer, BindingState{Licence: licence, Kind: l.Kind, Generation: b.Generation}); err != nil {
|
||||
c := CauseOf(ctx)
|
||||
m.moved(ctx, consumer, from, licence, b.Generation, c)
|
||||
if err := m.PutBinding(ctx, consumer, BindingState{Licence: licence, Kind: l.Kind, Generation: b.Generation,
|
||||
Why: c.Why, By: c.By}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return map[string]any{"consumer": consumer, "licence": licence, "generation": b.Generation, "from": from}, nil
|
||||
}
|
||||
|
||||
// Release unbinds a consumer; its node keeps its last token, which expires within hours.
|
||||
func (m *Manager) Release(ctx context.Context, consumer, by string) (map[string]any, error) {
|
||||
func (m *Manager) Release(ctx context.Context, consumer string) (map[string]any, error) {
|
||||
was, err := m.Store.Binding(ctx, consumer)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -634,7 +702,9 @@ func (m *Manager) Release(ctx context.Context, consumer, by string) (map[string]
|
||||
if err := m.DeleteBinding(ctx, consumer); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
_ = m.Store.Audit(ctx, "released", map[string]any{"consumer": consumer, "licence": was.Licence, "by": by})
|
||||
c := CauseOf(ctx)
|
||||
m.record(ctx, "released", fmt.Sprintf("released %s from %s: %s; asked by %s", consumer, was.Licence, c.Why, c.By),
|
||||
map[string]any{"consumer": consumer, "licence": was.Licence, "generation": was.Generation, "why": c.Why, "by": c.By})
|
||||
return map[string]any{"consumer": consumer, "released": true, "was": was.Licence}, nil
|
||||
}
|
||||
|
||||
@@ -685,7 +755,10 @@ func (m *Manager) adoptKey(ctx context.Context, name, key, from string) (map[str
|
||||
if err := m.Store.SaveLicence(ctx, l); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
_ = m.Store.Audit(ctx, "adopted", map[string]any{"licence": name, "kind": "api-key", "from": from})
|
||||
c := CauseOf(ctx)
|
||||
_ = m.Store.Audit(ctx, "adopted", map[string]any{"licence": name, "kind": "api-key", "from": from, "why": c.Why, "by": c.By})
|
||||
m.Log("adopted the API key %s from %s; asked by %s", name, from, c.By)
|
||||
ctx = WithCause(ctx, Cause{Why: "the API key " + name + " adopted from " + from, By: c.By})
|
||||
if err := m.publishAdvance(ctx, l); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -132,8 +132,13 @@ func TestAManagerWithNoLicenceAdoptsTheNewestLoginThatRefreshesAndNeverExchanges
|
||||
if len(bs) != 2 || mm.state["laptop"].Licence != licence1 || mm.state["server"].Licence != licence1 {
|
||||
t.Fatalf("bindings %v, state %v", bs, mm.state)
|
||||
}
|
||||
if !strings.HasPrefix(strings.Join(mm.events, "|"), "licence.adopted") {
|
||||
t.Fatalf("events %v", mm.events)
|
||||
// The adoption is said, and so is each binding it made (novox/hq issue 625).
|
||||
events := strings.Join(mm.events, "|")
|
||||
for _, want := range []string{"licence.adopted ", `licence.bound {"by":"claude-licence-manager","consumer":"laptop"`,
|
||||
`licence.bound {"by":"claude-licence-manager","consumer":"server"`} {
|
||||
if !strings.Contains(events, want) {
|
||||
t.Fatalf("events lack %s: %v", want, mm.events)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -234,13 +239,13 @@ func TestARotationAndASwitchEachGiveANewerGeneration(t *testing.T) {
|
||||
if _, err := mm.m.AdoptKey(ctx, "api", file); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := mm.m.Bind(ctx, "laptop", "api", "test"); err != nil {
|
||||
if _, err := mm.m.Bind(ctx, "laptop", "api"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if mm.state["laptop"].Licence != "api" || mm.state["laptop"].Generation <= g1 {
|
||||
t.Fatalf("a switch to a licence rotated less often went backwards: %v", mm.state["laptop"])
|
||||
}
|
||||
if _, err := mm.m.Release(ctx, "laptop", "test"); err != nil {
|
||||
if _, err := mm.m.Release(ctx, "laptop"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, ok := mm.state["laptop"]; ok {
|
||||
@@ -454,3 +459,73 @@ func TestWhatIsNotAnAPIKeyIsRefused(t *testing.T) {
|
||||
t.Fatal("nothing was kept")
|
||||
}
|
||||
}
|
||||
|
||||
// A change of what a consumer holds says what, why and who in the journal, the audit and an event (novox/hq
|
||||
// issue 625): a login, a switch through the verb by a proven caller, a rotation and a release.
|
||||
func TestEveryMoveSaysWhatWhyAndWho(t *testing.T) {
|
||||
mm := newMesh(t)
|
||||
var lines []string
|
||||
mm.m.Log = func(format string, args ...any) { lines = append(lines, fmt.Sprintf(format, args...)) }
|
||||
ctx := context.Background()
|
||||
_, _ = mm.m.Consider(ctx, []Holdings{mm.login("laptop", "rt-a", true, t0)})
|
||||
if s := mm.state["laptop"]; !strings.Contains(s.Why, "a login to "+licence1+" on laptop") || s.By != Itself {
|
||||
t.Fatalf("a login's binding said %+v", s)
|
||||
}
|
||||
file := filepath.Join(t.TempDir(), "key")
|
||||
_ = os.WriteFile(file, []byte("sk-ant-api03-key-0123456789abcdefghij\n"), 0o600)
|
||||
if _, err := mm.m.AdoptKey(ctx, "api", file); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
asked := WithCause(ctx, Cause{Why: "the switch verb", By: "g14.node-tools"})
|
||||
if _, err := mm.m.Bind(asked, "laptop", "api"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if s := mm.state["laptop"]; s.Why != "the switch verb" || s.By != "g14.node-tools" {
|
||||
t.Fatalf("the node is told %+v", s)
|
||||
}
|
||||
said := strings.Join(lines, "\n")
|
||||
if !strings.Contains(said, "switched laptop from "+licence1+" to api (generation ") ||
|
||||
!strings.Contains(said, "the switch verb; asked by g14.node-tools") {
|
||||
t.Fatalf("the journal said:\n%s", said)
|
||||
}
|
||||
var switched string
|
||||
for _, e := range mm.events {
|
||||
if strings.HasPrefix(e, "licence.switched ") {
|
||||
switched = e
|
||||
}
|
||||
}
|
||||
for _, want := range []string{`"by":"g14.node-tools"`, `"from":"` + licence1 + `"`, `"licence":"api"`, `"consumer":"laptop"`, `"generation":`} {
|
||||
if !strings.Contains(switched, want) {
|
||||
t.Fatalf("the event %q lacks %s; events %v", switched, want, mm.events)
|
||||
}
|
||||
}
|
||||
last := mm.store.Audits[len(mm.store.Audits)-1]
|
||||
if raw, _ := json.Marshal(last); !strings.Contains(string(raw), `"by":"g14.node-tools"`) || !strings.Contains(string(raw), "switched") {
|
||||
t.Fatalf("the audit kept %s", raw)
|
||||
}
|
||||
|
||||
_, _ = mm.m.Rotate(WithCause(ctx, Cause{Why: "the refresh verb", By: "novox.node-tools"}), licence1, true)
|
||||
if !strings.Contains(strings.Join(mm.events, "|"), `licence.rotated {"by":"novox.node-tools"`) {
|
||||
t.Fatalf("a rotation asked for said no caller: %v", mm.events)
|
||||
}
|
||||
|
||||
if _, err := mm.m.Release(WithCause(ctx, Cause{Why: "the release verb", By: "person.operator"}), "laptop"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if said := strings.Join(lines, "\n"); !strings.Contains(said, "released laptop from api: the release verb; asked by person.operator") {
|
||||
t.Fatalf("the journal said:\n%s", said)
|
||||
}
|
||||
for _, e := range mm.events {
|
||||
if strings.Contains(e, "rt-a") || strings.Contains(e, "sk-ant-") || strings.Contains(e, "local-laptop") {
|
||||
t.Fatalf("an event carries a secret: %s", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A call with no cause is said as this module's own, never as nobody.
|
||||
func TestNoCauseReadsAsTheModulesOwn(t *testing.T) {
|
||||
if c := CauseOf(context.Background()); c.By != Itself || c.Why == "" {
|
||||
t.Fatalf("no cause read as %+v", c)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ module claude-licence-manager
|
||||
go 1.25.0
|
||||
|
||||
require (
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.7
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261012082113-9747f3e1b1ac
|
||||
github.com/jackc/pgx/v5 v5.11.0
|
||||
)
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.7 h1:C0sTQmtTiyYH7bnqZb7PusXnqA37gKuT7Nqjn9gG47w=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.7/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261012082113-9747f3e1b1ac h1:jnZ/n+fFMo7VQPNNXK1gjStHIJPw3SPbzPxsf/PpZqw=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261012082113-9747f3e1b1ac/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
|
||||
@@ -8,7 +8,11 @@
|
||||
],
|
||||
"contributes": {
|
||||
"postgres-database": {
|
||||
"name": "claude_licences"
|
||||
"name": "claude_licences",
|
||||
"readable": [
|
||||
"audit",
|
||||
"binding"
|
||||
]
|
||||
}
|
||||
},
|
||||
"binds": {
|
||||
@@ -59,6 +63,10 @@
|
||||
"emits": [
|
||||
"licence.adopted",
|
||||
"licence.refused",
|
||||
"licence.bound",
|
||||
"licence.switched",
|
||||
"licence.released",
|
||||
"licence.rotated",
|
||||
"licence.failing",
|
||||
"usage.read"
|
||||
],
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
# dev-tools
|
||||
|
||||
The tools a machine needs to develop the mesh's own code (novox/hq issue 626). Every node is a development
|
||||
machine: an agent working on a change builds it and runs its tests there, with these tools, before it opens a
|
||||
pull request. The lab is the QA step after that: it tests the work in a sandbox, through its seat at the mesh
|
||||
scope, and is not where development happens.
|
||||
|
||||
Assign it to every node.
|
||||
|
||||
## Owns
|
||||
|
||||
| what | package | class (ADR 0182) |
|
||||
|---|---|---|
|
||||
| the Go toolchain, for `go build` and `go test` in every Go module | `go` | package |
|
||||
| a C compiler, for `go test -race` and cgo | `gcc` | package |
|
||||
| make | `make` | package |
|
||||
| Node.js and npm, for the TypeScript modules' `npm ci` and `npm test` | `nodejs`, `npm` | package |
|
||||
| Python, for the scripts some checks run | `python` | package |
|
||||
| git and jq | `git`, `jq` | package |
|
||||
|
||||
All are in the distribution's official repositories, so the node-engine's package manager installs them on any
|
||||
node. The versions are the distribution's: the toolchain images that run `mesh/repo-check` stay the judge of a
|
||||
pull request, and a local run is the developer's early answer, not the verdict.
|
||||
|
||||
Nothing else is placed: no file, no service, no environment. A module's own dependencies come from its lock
|
||||
file (`go.mod`, `package-lock.json`), as in the toolchain images.
|
||||
@@ -0,0 +1,49 @@
|
||||
{
|
||||
"module": "dev-tools",
|
||||
"version": "1",
|
||||
"capabilities": [
|
||||
"package-manager"
|
||||
],
|
||||
"resources": [
|
||||
{
|
||||
"id": "go",
|
||||
"type": "package",
|
||||
"package": "go"
|
||||
},
|
||||
{
|
||||
"id": "c-compiler",
|
||||
"type": "package",
|
||||
"package": "gcc"
|
||||
},
|
||||
{
|
||||
"id": "make",
|
||||
"type": "package",
|
||||
"package": "make"
|
||||
},
|
||||
{
|
||||
"id": "nodejs",
|
||||
"type": "package",
|
||||
"package": "nodejs"
|
||||
},
|
||||
{
|
||||
"id": "npm",
|
||||
"type": "package",
|
||||
"package": "npm"
|
||||
},
|
||||
{
|
||||
"id": "python",
|
||||
"type": "package",
|
||||
"package": "python"
|
||||
},
|
||||
{
|
||||
"id": "git",
|
||||
"type": "package",
|
||||
"package": "git"
|
||||
},
|
||||
{
|
||||
"id": "jq",
|
||||
"type": "package",
|
||||
"package": "jq"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -905,6 +905,15 @@ export class GiteaAdmin {
|
||||
GiteaAdmin.fail(`/teams/${teamId}/members/${username}`, res);
|
||||
}
|
||||
|
||||
/** Take a user off a team; one not on it is already off. */
|
||||
async removeUserFromTeam(teamId: number, username: string): Promise<void> {
|
||||
const res = await this.request(`/teams/${teamId}/members/${encodeURIComponent(username)}`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
if (res.status === 204 || res.status === 200 || res.status === 404) return;
|
||||
GiteaAdmin.fail(`/teams/${teamId}/members/${username}`, res);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a consumer's user logs in with exactly this password and is still a member of the
|
||||
* package team. Read-only: the password is checked as the consumer presents it, basic auth on the
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
"type": "module",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"build": "tsc client.ts token.ts index.ts provisioner/index.ts tools/index.ts --module NodeNext --moduleResolution NodeNext --target ES2022 --outDir dist",
|
||||
"build": "tsc client.ts token.ts index.ts provisioner/index.ts provisioner/teams.ts tools/index.ts --module NodeNext --moduleResolution NodeNext --target ES2022 --outDir dist",
|
||||
"test": "npm run build && node --test --experimental-strip-types 'test/*.test.ts'"
|
||||
},
|
||||
"dependencies": {
|
||||
|
||||
@@ -11,8 +11,8 @@
|
||||
//
|
||||
// The `npm-package-registry` interface: a consumer authenticates to the npm registry at
|
||||
// `/api/packages/novox/npm/` with basic auth, as `as` with the password the mesh minted, and can
|
||||
// read and write packages under the `@novox` scope. The registry's npm owner is the gitea org
|
||||
// `novox`; a consumer is a gitea *user* placed on that org's package team.
|
||||
// read packages under the `@novox` scope, and write them unless it asked for read only (teams.ts). The
|
||||
// registry's npm owner is the gitea org `novox`; a consumer is a gitea *user* on one of that org's teams.
|
||||
//
|
||||
// **The user name and password are the mesh's, not the provisioner's (ADR 0048).** The mesh derives
|
||||
// the login and hands it to both ends, and mints the password. gitea creates a user under exactly
|
||||
@@ -24,11 +24,9 @@
|
||||
|
||||
import { runProvisioner, type Provision } from "@novox/mesh-sdk/provisioner";
|
||||
import { GiteaAdmin } from "../client.js";
|
||||
import { grant, holds } from "./teams.js";
|
||||
|
||||
// The npm registry owner: a gitea org named `novox`, whose package team every consumer joins so it
|
||||
// can read and write packages under the `@novox` scope (ADR 0076).
|
||||
const ORG = "novox";
|
||||
const PACKAGE_TEAM = "packages";
|
||||
// Which team a consumer joins — `packages` to publish, `readers` to read only — is teams.ts's.
|
||||
|
||||
const gitea = GiteaAdmin.fromEnv();
|
||||
|
||||
@@ -38,19 +36,10 @@ const gitea = GiteaAdmin.fromEnv();
|
||||
// module needs the mesh to say where each provision's file is — not yet possible, and not faked.
|
||||
runProvisioner("npm-package-registry", {
|
||||
async create(p: Provision): Promise<void> {
|
||||
// The org and its package team are the same for every consumer; ensuring them per-create is
|
||||
// idempotent and needs no separate bootstrap step.
|
||||
await gitea.ensureOrg(ORG);
|
||||
const teamId = await gitea.ensureTeam(ORG, PACKAGE_TEAM, true);
|
||||
// The user carries the consumer's login and the mesh's minted password, set every run so a
|
||||
// rotation takes. Membership of the package team is what grants read+write on packages.
|
||||
//
|
||||
// The address is gitea's own convention for one that is not real: its email validation
|
||||
// requires a dotted domain, so `@localhost` was refused at create — the fault that had this
|
||||
// grant retrying for a day — while `@noreply.localhost` is the shape gitea itself gives
|
||||
// hidden addresses.
|
||||
await gitea.ensureUser(p.as, p.password, `${p.as}@noreply.localhost`);
|
||||
await gitea.addUserToTeam(teamId, p.as);
|
||||
// The org and its teams are ensured per create: idempotent, and no separate bootstrap step. The
|
||||
// user carries the consumer's login and the mesh's minted password, set every run so a rotation
|
||||
// takes; membership of its team is what grants it what it may do.
|
||||
await grant(gitea, p);
|
||||
},
|
||||
|
||||
async remove(p: { as: string }): Promise<void> {
|
||||
@@ -60,6 +49,6 @@ runProvisioner("npm-package-registry", {
|
||||
// Asked every minute by the harness: whether the backend still holds this consumer exactly as
|
||||
// the mesh gave it, so a login lost behind the provisioner's back is made again (novox/hq issue 120).
|
||||
async holds(p: Provision): Promise<boolean> {
|
||||
return gitea.holdsTeamMember(ORG, PACKAGE_TEAM, p.as, p.password);
|
||||
return holds(gitea, p);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
// Which team of the forge's npm owner a consumer of `npm-package-registry` joins, and the per-consumer
|
||||
// half of the provisioner around it — pure of the forge's address, so it is tested with a stand-in.
|
||||
//
|
||||
// **Two teams, by what the consumer said it needs** (novox/hq issue 624). A consumer that publishes —
|
||||
// the build agent, whose builds publish the SDK and every TypeScript module's packages — joins `packages`:
|
||||
// code read on the org's repositories and write on its packages. A consumer that contributes
|
||||
// `{"access": "read"}` joins `readers`: code read and package read, and no write. The lab is the first: it
|
||||
// clones branches that are not yet reviewed and runs their code beside its credential, and code that is
|
||||
// not reviewed must not be able to publish the mesh's packages with it.
|
||||
|
||||
/** The npm registry's owner: the gitea org every consumer's packages live under (ADR 0076). */
|
||||
export const ORG = "novox";
|
||||
/** The team whose members read and write the org's packages. */
|
||||
export const PACKAGE_TEAM = "packages";
|
||||
/** The team whose members read the org's code and packages, and write nothing. */
|
||||
export const READ_TEAM = "readers";
|
||||
|
||||
/** The team a consumer joins, from what it contributed; an access it may not ask for is refused. */
|
||||
export function teamFor(values: Readonly<Record<string, unknown>> | undefined): { team: string; packageWrite: boolean } {
|
||||
const access = values?.access;
|
||||
if (access === undefined || access === "write") return { team: PACKAGE_TEAM, packageWrite: true };
|
||||
if (access === "read") return { team: READ_TEAM, packageWrite: false };
|
||||
throw new Error(`npm-package-registry: access ${JSON.stringify(access)} is not "read" or "write"`);
|
||||
}
|
||||
|
||||
/** What the adapter asks of the forge's admin API (client.ts GiteaAdmin). */
|
||||
export interface Forge {
|
||||
ensureOrg(org: string): Promise<void>;
|
||||
ensureTeam(org: string, team: string, packageWrite: boolean): Promise<number>;
|
||||
ensureUser(username: string, password: string, email: string): Promise<void>;
|
||||
addUserToTeam(teamId: number, username: string): Promise<void>;
|
||||
removeUserFromTeam(teamId: number, username: string): Promise<void>;
|
||||
holdsTeamMember(org: string, team: string, username: string, password: string): Promise<boolean>;
|
||||
}
|
||||
|
||||
export interface Consumer {
|
||||
readonly as: string;
|
||||
readonly password: string;
|
||||
readonly values: Readonly<Record<string, unknown>>;
|
||||
}
|
||||
|
||||
/**
|
||||
* grant makes the consumer's user, with exactly the mesh's password, a member of its one team. A consumer
|
||||
* that asks for read only is also taken out of the package team, so one that asked for write before and
|
||||
* read now loses the write at once.
|
||||
*/
|
||||
export async function grant(forge: Forge, p: Consumer): Promise<void> {
|
||||
const { team, packageWrite } = teamFor(p.values);
|
||||
await forge.ensureOrg(ORG);
|
||||
const teamId = await forge.ensureTeam(ORG, team, packageWrite);
|
||||
// The address is gitea's own convention for one that is not real: its email validation requires a
|
||||
// dotted domain, so `@localhost` was refused at create, while `@noreply.localhost` is the shape gitea
|
||||
// itself gives hidden addresses.
|
||||
await forge.ensureUser(p.as, p.password, `${p.as}@noreply.localhost`);
|
||||
await forge.addUserToTeam(teamId, p.as);
|
||||
if (!packageWrite) {
|
||||
await forge.removeUserFromTeam(await forge.ensureTeam(ORG, PACKAGE_TEAM, true), p.as);
|
||||
}
|
||||
}
|
||||
|
||||
/** holds is whether the forge still holds the consumer as the mesh gave it: its login, in its team. */
|
||||
export async function holds(forge: Forge, p: Consumer): Promise<boolean> {
|
||||
return forge.holdsTeamMember(ORG, teamFor(p.values).team, p.as, p.password);
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import { grant, holds, PACKAGE_TEAM, READ_TEAM, teamFor, type Forge } from "../provisioner/teams.ts";
|
||||
|
||||
// Defends novox/hq issue 624: the lab reads the forge with a credential of its own that can write no
|
||||
// package. A consumer that contributes {"access": "read"} joins a team with code and package read only.
|
||||
|
||||
function aForge(): { forge: Forge; did: string[] } {
|
||||
const did: string[] = [];
|
||||
const teams: Record<string, number> = {};
|
||||
const forge: Forge = {
|
||||
async ensureOrg(org) { did.push(`org ${org}`); },
|
||||
async ensureTeam(org, team, packageWrite) {
|
||||
did.push(`team ${org}/${team} packages:${packageWrite ? "write" : "read"}`);
|
||||
return (teams[team] ??= Object.keys(teams).length + 1);
|
||||
},
|
||||
async ensureUser(username) { did.push(`user ${username}`); },
|
||||
async addUserToTeam(id, username) { did.push(`add ${username} to ${id}`); },
|
||||
async removeUserFromTeam(id, username) { did.push(`remove ${username} from ${id}`); },
|
||||
async holdsTeamMember(org, team, username) { did.push(`holds ${username} in ${org}/${team}`); return true; },
|
||||
};
|
||||
return { forge, did };
|
||||
}
|
||||
|
||||
test("a consumer that asks nothing, or write, publishes: the package team", () => {
|
||||
assert.deepEqual(teamFor({}), { team: PACKAGE_TEAM, packageWrite: true });
|
||||
assert.deepEqual(teamFor({ access: "write" }), { team: PACKAGE_TEAM, packageWrite: true });
|
||||
});
|
||||
|
||||
test("a consumer that asks to read joins the readers, who write no package", () => {
|
||||
assert.deepEqual(teamFor({ access: "read" }), { team: READ_TEAM, packageWrite: false });
|
||||
assert.throws(() => teamFor({ access: "admin" }), /not "read" or "write"/);
|
||||
});
|
||||
|
||||
test("a reader is made, put on the readers team, and taken off the package team", async () => {
|
||||
const { forge, did } = aForge();
|
||||
await grant(forge, { as: "lab-shanks", password: "pw", values: { access: "read" } });
|
||||
assert.deepEqual(did, [
|
||||
"org novox",
|
||||
"team novox/readers packages:read",
|
||||
"user lab-shanks",
|
||||
"add lab-shanks to 1",
|
||||
"team novox/packages packages:write",
|
||||
"remove lab-shanks from 2",
|
||||
]);
|
||||
did.length = 0;
|
||||
await holds(forge, { as: "lab-shanks", password: "pw", values: { access: "read" } });
|
||||
assert.deepEqual(did, ["holds lab-shanks in novox/readers"]);
|
||||
});
|
||||
|
||||
test("a publisher is made and put on the package team, as before", async () => {
|
||||
const { forge, did } = aForge();
|
||||
await grant(forge, { as: "build-agent-ace", password: "pw", values: {} });
|
||||
assert.deepEqual(did, ["org novox", "team novox/packages packages:write", "user build-agent-ace", "add build-agent-ace to 1"]);
|
||||
});
|
||||
@@ -8,5 +8,5 @@
|
||||
"skipLibCheck": true,
|
||||
"noEmit": true
|
||||
},
|
||||
"include": ["client.ts", "token.ts", "pulls.ts", "protection.ts", "index.ts", "provisioner/index.ts", "tools/index.ts"]
|
||||
"include": ["client.ts", "token.ts", "pulls.ts", "protection.ts", "index.ts", "provisioner/index.ts", "provisioner/teams.ts", "tools/index.ts"]
|
||||
}
|
||||
|
||||
+15
-1
@@ -5,6 +5,20 @@
|
||||
"container-runtime",
|
||||
"virtualisation"
|
||||
],
|
||||
"requires": [
|
||||
"npm-package-registry"
|
||||
],
|
||||
"contributes": {
|
||||
"npm-package-registry": {
|
||||
"access": "read"
|
||||
}
|
||||
},
|
||||
"binds": {
|
||||
"npm-package-registry": "${dir:state}/package-registry.json"
|
||||
},
|
||||
"secrets": {
|
||||
"npm-package-registry": "${dir:state}/package-registry.secret"
|
||||
},
|
||||
"data": {
|
||||
"own": [
|
||||
{
|
||||
@@ -33,7 +47,7 @@
|
||||
"type": "file",
|
||||
"path": "${dir:state}/lab.env",
|
||||
"mode": "0600",
|
||||
"content": "MESH_LAB_FORGE=${setting:forge}\n"
|
||||
"content": "MESH_LAB_FORGE=${setting:forge}\nMESH_LAB_FORGE_BINDING=${dir:state}/package-registry.json\nMESH_LAB_FORGE_SECRET=${dir:state}/package-registry.secret\n"
|
||||
},
|
||||
{
|
||||
"id": "git",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { chmodSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
@@ -98,3 +98,99 @@ test("a run checks out mesh-tools-go and says where its checkouts are", () => {
|
||||
assert.doesNotMatch(s, /cd '\/work\/run-x'\/\$repo && npm ci/);
|
||||
for (const repo of NPM_REPOSITORIES) assert.ok(s.includes(`'/work/run-x/${repo}'/package-lock.json`), repo);
|
||||
});
|
||||
|
||||
// Defends novox/hq issue 624, its second half: mesh-tools-go is private, and the lab cloned over HTTP with
|
||||
// no credential ("could not read Username"). A run clones with the forge credential the mesh binds and
|
||||
// seals to the lab, through git's credential store, and the secret is in no script, URL or argument.
|
||||
import { credentialArgs, credentialPath, forgeCredential } from "../tools/runs.ts";
|
||||
|
||||
function aBinding(as: string, secret: string): { binding: string; secret: string } {
|
||||
const dir = mkdtempSync(join(tmpdir(), "lab-binding-"));
|
||||
writeFileSync(join(dir, "package-registry.json"), JSON.stringify({ provision: "npm-package-registry", at: "10.42.0.1", as,
|
||||
serves: { scheme: "http", port: 3000 } }));
|
||||
writeFileSync(join(dir, "package-registry.secret"), `${secret}\n`);
|
||||
return { binding: join(dir, "package-registry.json"), secret: join(dir, "package-registry.secret") };
|
||||
}
|
||||
|
||||
test("the forge credential is the bound account and its sealed secret, for the forge's own origin", () => {
|
||||
const b = aBinding("lab-shanks", "s3cret-of-forty-characters");
|
||||
assert.equal(forgeCredential("http://novox.internal:20000", b.binding, b.secret),
|
||||
"http://lab-shanks:s3cret-of-forty-characters@novox.internal:20000");
|
||||
assert.equal(forgeCredential("http://novox.internal:20000/", b.binding, ""), undefined);
|
||||
assert.equal(forgeCredential("", b.binding, b.secret), undefined);
|
||||
assert.equal(forgeCredential("http://novox.internal:20000", "/nowhere", b.secret), undefined);
|
||||
});
|
||||
|
||||
test("a run clones through git's credential store, and its script carries no secret", () => {
|
||||
const b = aBinding("lab-shanks", "s3cret-of-forty-characters");
|
||||
const credential = forgeCredential("http://novox.internal:20000", b.binding, b.secret);
|
||||
const path = credentialPath("/work", "run-x");
|
||||
const s = script("/work", "run-x", "http://novox.internal:20000", ["t.test.ts"], {}, path);
|
||||
assert.ok(credential);
|
||||
assert.doesNotMatch(s, /s3cret/);
|
||||
assert.match(s, /git -c credential\.helper= -c 'credential\.helper=store --file=\/work\/\.run-x\.git-credentials' clone --quiet --depth 50 --branch 'main' 'http:\/\/novox\.internal:20000\/novox\/mesh-tools-go\.git'/);
|
||||
assert.match(s, /export GIT_TERMINAL_PROMPT=0/);
|
||||
assert.equal(path.startsWith("/work/run-x/"), false, "the credential is kept out of the run's own directory");
|
||||
assert.equal(credentialArgs(undefined), "");
|
||||
});
|
||||
|
||||
// Held against git itself: the credential in the store is offered to the forge's origin when it asks, and
|
||||
// a clone without it fails at once rather than prompting.
|
||||
test("git presents the stored credential to the forge, and only when one is given", () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), "lab-git-"));
|
||||
const store = join(dir, "git-credentials");
|
||||
writeFileSync(store, "http://lab-shanks:pw@forge.invalid:20000\n", { mode: 0o600 });
|
||||
const fill = spawnSync("sh", ["-c", `printf 'protocol=http\\nhost=forge.invalid:20000\\n\\n' | git ${credentialArgs(store)}credential fill`],
|
||||
{ encoding: "utf8", env: { ...process.env, GIT_TERMINAL_PROMPT: "0" } });
|
||||
assert.match(fill.stdout, /username=lab-shanks/);
|
||||
assert.match(fill.stdout, /password=pw/);
|
||||
const other = spawnSync("sh", ["-c", `printf 'protocol=http\\nhost=elsewhere.invalid\\n\\n' | git ${credentialArgs(store)}credential fill`],
|
||||
{ encoding: "utf8", env: { ...process.env, GIT_TERMINAL_PROMPT: "0" } });
|
||||
assert.doesNotMatch(other.stdout, /password=pw/);
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
// Review 196 on mesh-catalog #272: the credential file outlived a stop, because SIGTERM to the run's
|
||||
// process group killed the wrapper before its cleanup. It is now removed right after the clones, on
|
||||
// every way the script ends, and at the next run's start and in lab_check for what a crash left.
|
||||
import { cleanCredentials, credentialGuard } from "../tools/runs.ts";
|
||||
|
||||
test("a stopped script removes the credential, whatever ends it", () => {
|
||||
for (const signal of ["SIGTERM", "SIGINT", "SIGHUP"] as const) {
|
||||
const dir = mkdtempSync(join(tmpdir(), "lab-guard-"));
|
||||
const file = join(dir, ".run-x.git-credentials");
|
||||
writeFileSync(file, "http://u:p@h\n", { mode: 0o600 });
|
||||
const ran = spawnSync("bash", ["-c", `${credentialGuard(file)}\nkill -${signal.slice(3)} $$\nsleep 5`], { encoding: "utf8", timeout: 10_000 });
|
||||
assert.notEqual(ran.status, 0, `${signal}: the script was not stopped`);
|
||||
assert.equal(existsSync(file), false, `${signal} left the credential on disk`);
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("the script removes the credential as soon as its clones are done", () => {
|
||||
const s = script("/work", "run-x", "http://forge", ["t.test.ts"], {}, "/work/.run-x.git-credentials");
|
||||
const lastClone = s.lastIndexOf("clone --quiet");
|
||||
const removed = s.indexOf("rm -f '/work/.run-x.git-credentials'", lastClone);
|
||||
const building = s.indexOf("npm ci");
|
||||
assert.ok(lastClone > 0 && removed > lastClone && removed < building, "not removed between the clones and the build");
|
||||
assert.match(s, /trap 'rm -f '\\''\/work\/\.run-x\.git-credentials'\\''' EXIT/);
|
||||
});
|
||||
|
||||
test("credentials a crashed run left are removed, but not the running one's", () => {
|
||||
const work = mkdtempSync(join(tmpdir(), "lab-work-"));
|
||||
for (const n of [".run-a.git-credentials", ".run-b.git-credentials", "run-a", "other"]) writeFileSync(join(work, n), "x");
|
||||
const removed = cleanCredentials(work, join(work, ".run-b.git-credentials"));
|
||||
assert.deepEqual(removed, [join(work, ".run-a.git-credentials")]);
|
||||
assert.ok(existsSync(join(work, ".run-b.git-credentials")) && existsSync(join(work, "run-a")) && existsSync(join(work, "other")));
|
||||
rmSync(work, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
// The lab asks the forge for read only (review 196): its credential is for clones of branches nobody has
|
||||
// reviewed yet, and must not be able to publish the mesh's packages.
|
||||
test("the lab asks the package registry for read only", () => {
|
||||
const m = JSON.parse(readFileSync(join(import.meta.dirname, "..", "module.json"), "utf8")) as {
|
||||
requires: unknown[]; contributes?: Record<string, Record<string, unknown>>;
|
||||
};
|
||||
assert.ok(m.requires.includes("npm-package-registry"));
|
||||
assert.equal(m.contributes?.["npm-package-registry"]?.access, "read");
|
||||
});
|
||||
|
||||
+53
-24
@@ -4,7 +4,13 @@
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { registerModuleTools, type ToolDefinition } from "@novox/mesh-sdk/tools";
|
||||
import { installPlan, listRuns, NPM_REPOSITORIES, readStatus, REPOSITORIES, running, start, stop, tail } from "./runs.js";
|
||||
import { mkdtempSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import {
|
||||
cleanCredentials, credentialArgs, forgeCredential, installPlan, listRuns, NPM_REPOSITORIES, readStatus, REPOSITORIES, running, start,
|
||||
stop, tail, writeCredential,
|
||||
} from "./runs.js";
|
||||
|
||||
export function getLabTools(env: NodeJS.ProcessEnv): ToolDefinition[] {
|
||||
const work = env.MESH_LAB_WORK ?? "/var/lib/mesh-lab-runs";
|
||||
@@ -12,6 +18,10 @@ export function getLabTools(env: NodeJS.ProcessEnv): ToolDefinition[] {
|
||||
// ADR 0192): read from the env-file the mesh fills, at each call, so a changed setting is used
|
||||
// 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.
|
||||
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"));
|
||||
return [
|
||||
{
|
||||
name: "lab_check",
|
||||
@@ -29,8 +39,13 @@ export function getLabTools(env: NodeJS.ProcessEnv): ToolDefinition[] {
|
||||
// **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.
|
||||
const problems = setupProblems(forge, `${work}/check-setup`);
|
||||
const output = [`${check.stdout}${check.stderr}`.trim(), ...problems].filter(Boolean).join("\n");
|
||||
// A credential a stopped, crashed or rebooted run left behind is removed, and said; one still in use
|
||||
// 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, ...problems].filter(Boolean).join("\n");
|
||||
return { ok: check.status === 0 && problems.length === 0, output };
|
||||
},
|
||||
},
|
||||
@@ -64,7 +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 };
|
||||
return { started: true, run: start(work, forge, tests, refs) };
|
||||
return { started: true, run: start(work, forge, tests, refs, credentialOf(forge)) };
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -99,30 +114,44 @@ export function getLabTools(env: NodeJS.ProcessEnv): ToolDefinition[] {
|
||||
* answer for, and a checkout it installs with nothing installable (installPlan). Empty when a run's setup
|
||||
* would go through.
|
||||
*/
|
||||
export function setupProblems(forge: string, dir: string): string[] {
|
||||
export function setupProblems(forge: string, dir: string, credential?: string): string[] {
|
||||
const problems: string[] = [];
|
||||
spawnSync("rm", ["-rf", dir]);
|
||||
for (const repo of REPOSITORIES) {
|
||||
const url = `${forge}/novox/${repo}.git`;
|
||||
const remote = spawnSync("git", ["ls-remote", "--heads", url, "main"], { encoding: "utf8" });
|
||||
if (remote.status !== 0 || !remote.stdout.includes("refs/heads/main")) {
|
||||
problems.push(`a run checks out ${repo}, and ${url} answers no main branch`);
|
||||
continue;
|
||||
}
|
||||
if (!NPM_REPOSITORIES.includes(repo)) continue;
|
||||
const at = `${dir}/${repo}`;
|
||||
const clone = spawnSync("git", ["clone", "--quiet", "--depth", "1", url, at], { encoding: "utf8" });
|
||||
if (clone.status !== 0) {
|
||||
problems.push(`${repo} could not be cloned from ${url}: ${clone.stderr.trim()}`);
|
||||
continue;
|
||||
}
|
||||
const plan = installPlan(at);
|
||||
for (const d of plan.unlocked) problems.push(`${repo}: ${d.slice(at.length) || "/"} has a package.json and no package-lock.json, so a run's npm ci cannot install it`);
|
||||
if (plan.install.length === 0 && plan.unlocked.length === 0) {
|
||||
problems.push(`${repo}: no package-lock.json at its root or one directory down, so a run installs nothing of it`);
|
||||
// The credential a run would offer, in a file of its own outside the check's clones, removed after.
|
||||
const keep = mkdtempSync(join(tmpdir(), "lab-check-"));
|
||||
const file = writeCredential(join(keep, "git-credentials"), credential);
|
||||
const git = (args: string[]) =>
|
||||
spawnSync("sh", ["-c", `git ${credentialArgs(file)}${args.map((a) => `'${a.replace(/'/g, `'\\''`)}'`).join(" ")}`], {
|
||||
encoding: "utf8", env: { ...process.env, GIT_TERMINAL_PROMPT: "0" },
|
||||
});
|
||||
try {
|
||||
for (const repo of REPOSITORIES) {
|
||||
const url = `${forge}/novox/${repo}.git`;
|
||||
const remote = git(["ls-remote", "--heads", url, "main"]);
|
||||
if (remote.status !== 0 || !remote.stdout.includes("refs/heads/main")) {
|
||||
const said = remote.stderr.trim().split("\n")[0] ?? "";
|
||||
problems.push(`a run checks out ${repo}, and ${url} could not be read` +
|
||||
(said ? `: ${said}` : ": no main branch") +
|
||||
(credential ? "" : " — the lab holds no forge credential (its npm-package-registry binding and secret)"));
|
||||
continue;
|
||||
}
|
||||
if (!NPM_REPOSITORIES.includes(repo)) continue;
|
||||
const at = `${dir}/${repo}`;
|
||||
const clone = git(["clone", "--quiet", "--depth", "1", url, at]);
|
||||
if (clone.status !== 0) {
|
||||
problems.push(`${repo} could not be cloned from ${url}: ${clone.stderr.trim()}`);
|
||||
continue;
|
||||
}
|
||||
const plan = installPlan(at);
|
||||
for (const d of plan.unlocked) problems.push(`${repo}: ${d.slice(at.length) || "/"} has a package.json and no package-lock.json, so a run's npm ci cannot install it`);
|
||||
if (plan.install.length === 0 && plan.unlocked.length === 0) {
|
||||
problems.push(`${repo}: no package-lock.json at its root or one directory down, so a run installs nothing of it`);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
rmSync(keep, { recursive: true, force: true });
|
||||
spawnSync("rm", ["-rf", dir]);
|
||||
}
|
||||
spawnSync("rm", ["-rf", dir]);
|
||||
return problems;
|
||||
}
|
||||
|
||||
|
||||
@@ -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, readFileSync, readdirSync, writeFileSync } from "node:fs";
|
||||
import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
/**
|
||||
@@ -60,6 +60,89 @@ export function installStep(root: string, build: boolean): string {
|
||||
fi`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*
|
||||
* **The mesh's credential for the forge, the way the build agent holds it** (novox/hq issue 624): the lab
|
||||
* requires the package registry, the forge's provisioner binds it an account (`as`) and seals that
|
||||
* account's password, and the forge that answers npm is the forge that hosts the repositories. Private
|
||||
* repositories (mesh-tools-go) are cloned with it; public ones clone as before. Written to a file git reads
|
||||
* through its credential store, never into a URL, an argument, the run's script or its log, and offered
|
||||
* only to the forge's own scheme, host and port.
|
||||
*/
|
||||
export function forgeCredential(forge: string, bindingFile: string, secretFile: string): string | undefined {
|
||||
if (!forge || !bindingFile || !secretFile) return undefined;
|
||||
let as = "";
|
||||
let secret = "";
|
||||
try {
|
||||
as = String((JSON.parse(readFileSync(bindingFile, "utf8")) as { as?: string }).as ?? "");
|
||||
secret = readFileSync(secretFile, "utf8").trim();
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
if (!as || !secret) return undefined;
|
||||
let origin: URL;
|
||||
try {
|
||||
origin = new URL(forge);
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
const line = new URL(`${origin.protocol}//${origin.host}`);
|
||||
line.username = as;
|
||||
line.password = secret;
|
||||
return line.toString().replace(/\/$/, "");
|
||||
}
|
||||
|
||||
/** Where a run keeps its forge credential: beside its directory, never in it, and removed when it ends. */
|
||||
export function credentialPath(work: string, id: string): string {
|
||||
return join(work, `.${id}.git-credentials`);
|
||||
}
|
||||
|
||||
/** writeCredential puts a credential-store line where git reads it, readable by this account alone. */
|
||||
export function writeCredential(path: string, line: string | undefined): string | undefined {
|
||||
if (!line) return undefined;
|
||||
writeFileSync(path, `${line}\n`, { mode: 0o600 });
|
||||
return path;
|
||||
}
|
||||
|
||||
/**
|
||||
* credentialGuard is the shell that removes the credential however the script ends — its end, an error,
|
||||
* or a stop's SIGTERM to the run's process group, which kills the wrapper before its own cleanup runs
|
||||
* (review 196 on mesh-catalog #272). The script also removes it as soon as its clones are done.
|
||||
*/
|
||||
export function credentialGuard(path: string | undefined): string {
|
||||
if (!path) return "";
|
||||
const rm = `rm -f ${shellQuote(path)}`;
|
||||
return `trap ${shellQuote(rm)} EXIT
|
||||
trap ${shellQuote(`${rm}; exit 143`)} TERM
|
||||
trap ${shellQuote(`${rm}; exit 130`)} INT
|
||||
trap ${shellQuote(`${rm}; exit 129`)} HUP`;
|
||||
}
|
||||
|
||||
/**
|
||||
* cleanCredentials removes every run's credential file left in the work directory but the one named — what
|
||||
* a crash or a reboot left behind, which no trap could remove. Answers what it removed.
|
||||
*/
|
||||
export function cleanCredentials(work: string, keep?: string): string[] {
|
||||
if (!existsSync(work)) return [];
|
||||
const removed: string[] = [];
|
||||
for (const name of readdirSync(work)) {
|
||||
if (!/^\.run-.*\.git-credentials$/.test(name)) continue;
|
||||
const path = join(work, name);
|
||||
if (path === keep) continue;
|
||||
rmSync(path, { force: true });
|
||||
removed.push(path);
|
||||
}
|
||||
return removed;
|
||||
}
|
||||
|
||||
/** 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 "";
|
||||
return `-c credential.helper= -c ${shellQuote(`credential.helper=store --file=${path}`)} `;
|
||||
}
|
||||
|
||||
export interface RunStatus {
|
||||
id: string;
|
||||
state: "checking-out" | "building" | "running" | "passed" | "failed" | "stopped" | "lost";
|
||||
@@ -125,7 +208,8 @@ const shellQuote = (s: string) => `'${s.replace(/'/g, `'\\''`)}'`;
|
||||
* The environment is the one the lab's README describes for a run against sibling checkouts, pointed
|
||||
* 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>): string {
|
||||
export function script(work: string, id: string, forge: string, tests: string[], refs: Record<string, string>,
|
||||
credential?: string): string {
|
||||
const dir = runDir(work, id);
|
||||
const setState = (state: string) =>
|
||||
`node -e ${shellQuote(
|
||||
@@ -134,15 +218,19 @@ export function script(work: string, id: string, forge: string, tests: string[],
|
||||
const clones = REPOSITORIES.map((repo) => {
|
||||
const ref = refs[repo] ?? "main";
|
||||
return [
|
||||
`git clone --quiet --depth 50 --branch ${shellQuote(ref)} ${shellQuote(`${forge}/novox/${repo}.git`)} ${shellQuote(join(dir, repo))}`,
|
||||
`git ${credentialArgs(credential)}clone --quiet --depth 50 --branch ${shellQuote(ref)} ${shellQuote(`${forge}/novox/${repo}.git`)} ${shellQuote(join(dir, repo))}`,
|
||||
`echo "${repo} $(git -C ${shellQuote(join(dir, repo))} rev-parse HEAD)" >> ${shellQuote(join(dir, "commits.txt"))}`,
|
||||
].join("\n");
|
||||
}).join("\n");
|
||||
const bin = join(dir, "bin");
|
||||
return `set -euo pipefail
|
||||
# A repository the forge will not show fails its clone at once, rather than waiting on a prompt.
|
||||
export GIT_TERMINAL_PROMPT=0
|
||||
${credentialGuard(credential)}
|
||||
cd ${shellQuote(dir)}
|
||||
${setState("checking-out")}
|
||||
${clones}
|
||||
${credential ? `# The forge credential is for the clones, and nothing after them runs with it on disk.\nrm -f ${shellQuote(credential)}` : ""}
|
||||
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))`,
|
||||
)}
|
||||
@@ -174,13 +262,17 @@ 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>): RunStatus {
|
||||
export function start(work: string, forge: string, tests: string[], refs: Record<string, string>,
|
||||
credential?: string): RunStatus {
|
||||
const id = `run-${new Date().toISOString().replace(/[:.]/g, "-")}`;
|
||||
const dir = runDir(work, id);
|
||||
mkdirSync(dir, { recursive: true });
|
||||
const status: RunStatus = { id, state: "checking-out", started: new Date().toISOString(), tests, refs };
|
||||
writeFileSync(statusPath(work, id), JSON.stringify(status, null, 2));
|
||||
writeFileSync(join(dir, "run.sh"), script(work, id, forge, tests, refs), { mode: 0o700 });
|
||||
// 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), { 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).
|
||||
@@ -188,6 +280,7 @@ export function start(work: string, forge: string, tests: string[], refs: Record
|
||||
node -e ${shellQuote(
|
||||
`const f=${JSON.stringify(statusPath(work, id))};const s=JSON.parse(require("fs").readFileSync(f,"utf8"));if(s.state!=="stopped"){s.state=process.argv[1]==="0"?"passed":"failed"};s.exit=Number(process.argv[1]);s.ended=new Date().toISOString();require("fs").writeFileSync(f,JSON.stringify(s,null,2))`,
|
||||
)} "$code"
|
||||
rm -f ${shellQuote(credentialPath(work, id))}
|
||||
cd ${shellQuote(dir)} && rm -rf ${REPOSITORIES.map(shellQuote).join(" ")} bin`;
|
||||
const child = spawn("bash", ["-c", wrapper], { detached: true, stdio: "ignore" });
|
||||
child.unref();
|
||||
|
||||
Reference in New Issue
Block a user