diff --git a/CHANGELOG.md b/CHANGELOG.md index 6c172d1..eabf1da 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,10 @@ Please choose versions by [Semantic Versioning](http://semver.org/). * MINOR version when you add functionality in a backwards-compatible manner, and * PATCH version when you make backwards-compatible bug fixes. +## Unreleased + +- feat: **A vault can name its default A2A cluster.** A config entry gains `a2a_default_cluster`, a cluster **name** (`nukedev`, never a target string `k8s:nukedev`), set with `vault-cli config set-a2a-default-cluster ` and emitted in `config list --output json` so a consumer can resolve it without parsing the YAML. The value is validated on write as an RFC 1123 label (lowercase alphanumerics and interior hyphens, at most 63 characters), which is the shape a Kubernetes cluster identifier takes — an unknown-but-well-formed name is accepted here and refused by the consumer, because the cluster set is that side's knowledge, not this repo's. ⚠️ **Reading never validates**: a hand-edited malformed value still lists, emitted verbatim, because refusing it would turn a config typo into an unopenable vault. ⚠️ **Both tags are `omitempty`**, so a vault that names no default round-trips unchanged and `scenarios/001-config-list.md`'s exhaustive key set for the `minimal` vault still holds. A refused value or an unknown vault writes nothing — the config file is byte-identical afterwards. Documented in `docs/a2a-default-cluster.md`. + ## v0.176.2 - fix: `vault-cli task set claude_session_id ` appends the displaced owner to `metrics_sessions` before writing the new id, so a session handed off by a take-over stays resolvable by `session-self-exit.py` and `close-worker.py`. A first bind and a re-bind to the same value append nothing; the completion and spawn-failure clears are unchanged. diff --git a/README.md b/README.md index 95cd8ba..1d6d16f 100644 --- a/README.md +++ b/README.md @@ -184,6 +184,7 @@ vault-cli search "improve performance" --top-k 10 # Return more results vault-cli config list # List configured vaults vault-cli config current-user # Print the current user vault-cli config set-baseline personal "60 Baseline.md" # Point a vault at its baseline file +vault-cli config set-a2a-default-cluster personal nukedev # Name the cluster this vault's A2A workers default to ``` The path passed to `config set-baseline` is vault-relative — an absolute path, or @@ -191,6 +192,12 @@ one whose cleaned form escapes the vault root, is refused. The value is written to the named vault's entry in the config file under the key `baseline`; `docs/baseline-file.md` documents the file's frontmatter contract. +`config set-a2a-default-cluster` takes a cluster **name** (`nukedev`, not +`k8s:nukedev`) and writes it under the key `a2a_default_cluster`, which +`config list --output json` then emits for consumers. The name is validated as an +RFC 1123 label on write and never on read; `docs/a2a-default-cluster.md` documents +the contract. + ### filename ```bash diff --git a/docs/a2a-default-cluster.md b/docs/a2a-default-cluster.md new file mode 100644 index 0000000..6cf6f56 --- /dev/null +++ b/docs/a2a-default-cluster.md @@ -0,0 +1,83 @@ +# A2A Default Cluster + +A vault's config entry can name the cluster that an A2A worker for that vault +falls back to when the task carries no explicit spawn target. The value is +stored under the key `a2a_default_cluster` and set with: + +``` +vault-cli config set-a2a-default-cluster +``` + +The value is a cluster **name**, not a spawn target: `nukedev`, never +`k8s:nukedev`. The consumer forms the target from the name, so a value +containing a colon is refused rather than passed through. + +## What this repo does and does not do + +This repo stores a default, validates it on write, and emits it. It does not +consume it and does not enforce anything. + +- **Consuming it** — resolving an absent spawn target to the vault default, + forming `k8s:`, and refusing an unknown cluster — belongs to the + consumer (`claude-supervisor`'s `open-a2a` call site). +- **Enforcing an allowlist** — which clusters a vault *may* use — is a + supervisor-side fact. This repo records a default, not a permission. +- **The cluster-name set is not frozen.** `nukedev`, `nukeprod`, `octopus-dev` + and the rest are today's names. This repo validates the shape only, so an + unknown-but-well-formed name is accepted here and refused by the consumer. +- **Setting the per-vault values** is an operator act performed on the host + against the real config file, not something this repo seeds. + +## Validation + +The value is validated as an **RFC 1123 label**: lowercase alphanumerics and +interior hyphens, starting and ending with an alphanumeric, at most 63 +characters — the shape a Kubernetes cluster identifier takes. + +Accepted: `nukedev`, `nukeprod`, `octopus-dev`, `my-cluster-2`, a single +character, and any name up to 63 characters. + +Refused: a target string (`k8s:nukedev`), uppercase (`Nukedev`), an underscore +(`nuke_dev`), a space, a dot, a slash, a non-ASCII name, a leading or trailing +hyphen, a 64-character name, and the empty string. A refused value or an unknown +vault writes nothing at all — the config file is byte-identical afterwards. + +## Read-time tolerance + +**Reading never validates.** A value that is already on disk is emitted +verbatim, whatever it looks like — `config list --output json` still exits 0 +for a hand-edited `a2a_default_cluster: "not a cluster!"`. + +This is deliberate: refusing a malformed value at read time would turn a config +typo into an unopenable vault, and every command that loads the config would +fail. An unknown cluster is the consumer's to refuse, never this repo's list +path. + +## Where it lives + +- The on-disk key is `a2a_default_cluster` in the vault-cli config file + (`~/.config/vault-cli/config.yaml` by default). +- The JSON spelling is the same string, and it is what a consumer reads from + `config list --output json`. Both spellings are part of the contract: the + JSON one is the delivery surface, the YAML one is the on-disk surface. +- **Both tags are `omitempty`.** A vault that names no default round-trips + unchanged, and the key appears only on the vault it was set for. Without the + omission, re-serialization would inject an empty key into every vault. + +## What a write does to the file + +A successful write re-serializes the whole file from the config struct. Other +keys **the config struct models** survive; comments and any key the struct does +not model do not, and keys are reordered to struct order. This is the existing +`config set-baseline` behaviour, accepted here rather than re-engineered. + +The write is atomic: a temp file in the config's own directory is renamed over +it, so a reader observes either the old content or the new content and never a +truncated merge. An interrupted write leaves no temp file behind. + +## Related + +- `docs/baseline-file.md` — the sibling config-key plus set-verb plus doc-page + shape this one follows. +- The consumer side lives in `bborbe/claude-supervisor`'s + `commands/open-a2a.md` and `docs/fleet-surface.md`. diff --git a/integration/cli_test.go b/integration/cli_test.go index 3801be2..b0aee3f 100644 --- a/integration/cli_test.go +++ b/integration/cli_test.go @@ -562,6 +562,7 @@ var _ = Describe("vault-cli integration tests", func() { Entry("config list", "config", "list"), Entry("config current-user", "config", "current-user"), Entry("config set-baseline", "config", "set-baseline"), + Entry("config set-a2a-default-cluster", "config", "set-a2a-default-cluster"), ) }) diff --git a/pkg/cli/cli.go b/pkg/cli/cli.go index cdf3119..61a2285 100644 --- a/pkg/cli/cli.go +++ b/pkg/cli/cli.go @@ -204,6 +204,7 @@ func NewRootCommand(ctx context.Context) *cobra.Command { configCmd.AddCommand(createConfigListCommand(ctx, &configLoader, &vaultName, &outputFormat)) configCmd.AddCommand(createConfigCurrentUserCommand(ctx, &configLoader)) configCmd.AddCommand(createConfigSetBaselineCommand(ctx, &configPath)) + configCmd.AddCommand(createConfigSetA2ADefaultClusterCommand(ctx, &configPath)) rootCmd.AddCommand(configCmd) return rootCmd diff --git a/pkg/cli/config.go b/pkg/cli/config.go index c3fe0ef..98b56ad 100644 --- a/pkg/cli/config.go +++ b/pkg/cli/config.go @@ -34,3 +34,25 @@ func createConfigSetBaselineCommand( }, } } + +// createConfigSetA2ADefaultClusterCommand returns the "config +// set-a2a-default-cluster" leaf command. It writes the cluster NAME a vault's +// A2A spawns fall back to onto that vault's config entry, so a consumer reading +// `config list --output json` can resolve the default without parsing the YAML. +func createConfigSetA2ADefaultClusterCommand( + ctx context.Context, + configPath *string, +) *cobra.Command { + return &cobra.Command{ + Use: "set-a2a-default-cluster ", + Short: "Set a vault's default A2A cluster name", + Args: cobra.ExactArgs(2), + RunE: func(cmd *cobra.Command, args []string) error { + writer := config.NewA2ADefaultClusterWriter(*configPath) + if err := writer.SetA2ADefaultCluster(ctx, args[0], args[1]); err != nil { + return errors.Wrap(ctx, err, "set a2a default cluster") + } + return nil + }, + } +} diff --git a/pkg/cli/config_test.go b/pkg/cli/config_test.go index d44907c..36a22e0 100644 --- a/pkg/cli/config_test.go +++ b/pkg/cli/config_test.go @@ -7,8 +7,10 @@ package cli_test import ( "context" "fmt" + "io" "os" "path/filepath" + "strings" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" @@ -109,3 +111,168 @@ var _ = Describe("vault-cli config set-baseline", func() { Expect(err).NotTo(BeNil()) }) }) + +var _ = Describe("vault-cli config set-a2a-default-cluster", func() { + var ( + ctx context.Context + tempDir string + vaultDir string + configPath string + ) + + readBytes := func(path string) []byte { + b, err := os.ReadFile(path) + Expect(err).To(BeNil()) + return b + } + + captureStdout := func(run func() error) (string, error) { + origStdout := os.Stdout + r, w, err := os.Pipe() + Expect(err).To(BeNil()) + os.Stdout = w + DeferCleanup(func() { + os.Stdout = origStdout + }) + runErr := run() + Expect(w.Close()).To(Succeed()) + out, err := io.ReadAll(r) + Expect(err).To(BeNil()) + return string(out), runErr + } + + BeforeEach(func() { + ctx = context.Background() + var err error + tempDir, err = os.MkdirTemp("", "vault-cli-set-a2a-default-cluster-test-*") + Expect(err).To(BeNil()) + + vaultDir = filepath.Join(tempDir, "vault") + Expect(os.MkdirAll(vaultDir, 0750)).To(Succeed()) + + configPath = filepath.Join(tempDir, "config.yaml") + configData := fmt.Sprintf(`current_user: user@example.com +vaults: + test: + name: test + path: %s + tasks_dir: Tasks + other: + name: other + path: %s + tasks_dir: "25 Tasks" +`, vaultDir, vaultDir) + Expect(os.WriteFile(configPath, []byte(configData), 0600)).To(Succeed()) + }) + + AfterEach(func() { + _ = os.RemoveAll(tempDir) + }) + + It("persists the cluster name for a named vault", func() { + err := cli.Run( + ctx, + []string{"--config", configPath, "config", "set-a2a-default-cluster", "test", "nukedev"}, + ) + Expect(err).To(BeNil()) + + vault, err := config.NewLoader(configPath).GetVault(ctx, "test") + Expect(err).To(BeNil()) + Expect(vault.A2ADefaultCluster).To(Equal("nukedev")) + }) + + It("writes the literal on-disk key", func() { + Expect(cli.Run( + ctx, + []string{"--config", configPath, "config", "set-a2a-default-cluster", "test", "nukedev"}, + )).To(BeNil()) + + Expect(string(readBytes(configPath))). + To(ContainSubstring("a2a_default_cluster: nukedev")) + }) + + It("emits the key in config list --output json", func() { + Expect(cli.Run( + ctx, + []string{"--config", configPath, "config", "set-a2a-default-cluster", "test", "nukedev"}, + )).To(BeNil()) + + out, err := captureStdout(func() error { + return cli.Run(ctx, []string{"--config", configPath, "config", "list", "--output", "json"}) + }) + Expect(err).To(BeNil()) + Expect(out).To(ContainSubstring(`"a2a_default_cluster": "nukedev"`)) + }) + + It("omits the key for a vault that names no default", func() { + out, err := captureStdout(func() error { + return cli.Run(ctx, []string{"--config", configPath, "config", "list", "--output", "json"}) + }) + Expect(err).To(BeNil()) + // The positive guard is asserted before the absence, so an empty or + // failed run cannot satisfy this spec. + Expect(out).To(ContainSubstring(`"name": "test"`)) + Expect(out).NotTo(ContainSubstring("a2a_default_cluster")) + }) + + It("emits a hand-edited malformed value verbatim rather than refusing it", func() { + handEdited := fmt.Sprintf(`vaults: + test: + name: test + path: %s + a2a_default_cluster: "not a cluster!" +`, vaultDir) + Expect(os.WriteFile(configPath, []byte(handEdited), 0600)).To(Succeed()) + + out, err := captureStdout(func() error { + return cli.Run(ctx, []string{"--config", configPath, "config", "list", "--output", "json"}) + }) + Expect(err).To(BeNil()) + Expect(out).To(ContainSubstring(`"not a cluster!"`)) + }) + + It("refuses a target string rather than a bare name", func() { + before := readBytes(configPath) + + err := cli.Run( + ctx, + []string{"--config", configPath, "config", "set-a2a-default-cluster", "test", "k8s:nukedev"}, + ) + Expect(err).NotTo(BeNil()) + Expect(err.Error()).To(ContainSubstring("k8s:nukedev")) + Expect(readBytes(configPath)).To(Equal(before)) + }) + + It("refuses a vault absent from the config and leaves the file unchanged", func() { + before := readBytes(configPath) + + err := cli.Run( + ctx, + []string{"--config", configPath, "config", "set-a2a-default-cluster", "nosuchvault", "nukedev"}, + ) + Expect(err).NotTo(BeNil()) + Expect(readBytes(configPath)).To(Equal(before)) + }) + + It("leaves the other vault untouched", func() { + Expect(cli.Run( + ctx, + []string{"--config", configPath, "config", "set-a2a-default-cluster", "test", "nukedev"}, + )).To(BeNil()) + + Expect(strings.Count(string(readBytes(configPath)), "a2a_default_cluster")).To(Equal(1)) + + other, err := config.NewLoader(configPath).GetVault(ctx, "other") + Expect(err).To(BeNil()) + Expect(other.A2ADefaultCluster).To(BeEmpty()) + Expect(other.TasksDir).To(Equal("25 Tasks")) + }) + + It("requires a vault name and a cluster", func() { + err := cli.Run( + ctx, + []string{"--config", configPath, "config", "set-a2a-default-cluster", "test"}, + ) + Expect(err).NotTo(BeNil()) + }) +}) diff --git a/pkg/config/a2a_default_cluster.go b/pkg/config/a2a_default_cluster.go new file mode 100644 index 0000000..e433959 --- /dev/null +++ b/pkg/config/a2a_default_cluster.go @@ -0,0 +1,113 @@ +// Copyright (c) 2026 Benjamin Borbe All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package config + +import ( + "context" + "regexp" + + "github.com/bborbe/errors" + "gopkg.in/yaml.v3" +) + +// a2aClusterName is the RFC 1123 label shape a Kubernetes cluster identifier +// takes: lowercase alphanumerics and interior hyphens, starting and ending with +// an alphanumeric. It constrains the SHAPE only. Which cluster names exist is +// the consumer's knowledge, so an unknown-but-well-formed name is accepted here +// and refused there. +var a2aClusterName = regexp.MustCompile(`^[a-z0-9]([-a-z0-9]*[a-z0-9])?$`) + +// a2aClusterNameMaxLength is the RFC 1123 label limit. +const a2aClusterNameMaxLength = 63 + +// ValidateA2ADefaultCluster validates a vault's default A2A cluster name. The +// value is a cluster NAME such as `nukedev`, never a spawn target such as +// `k8s:nukedev` — the consumer forms the target from the name, so a colon is +// refused rather than passed through. The rule is the input boundary for the +// write path: every value accepted here is safe to store and to re-emit in +// `config list --output json`, and every value rejected here leaves the config +// file untouched. +func ValidateA2ADefaultCluster(ctx context.Context, cluster string) error { + if cluster == "" { + return errors.Errorf(ctx, "a2a default cluster is empty") + } + if len(cluster) > a2aClusterNameMaxLength { + return errors.Errorf( + ctx, + "a2a default cluster exceeds %d characters: %s", + a2aClusterNameMaxLength, + cluster, + ) + } + if !a2aClusterName.MatchString(cluster) { + return errors.Errorf( + ctx, + "a2a default cluster must be an RFC 1123 label (lowercase alphanumerics and interior hyphens), got %q", + cluster, + ) + } + return nil +} + +// A2ADefaultClusterWriter persists a vault's default A2A cluster name into the +// vault-cli config file. It is the only writer of that key. +type A2ADefaultClusterWriter interface { + // SetA2ADefaultCluster writes cluster onto the named vault's config entry + // under the key "a2a_default_cluster". The config file is read and validated + // before anything is written: a missing or unparseable config file, a vault + // the config does not name, or a rejected cluster name leaves the file + // exactly as it was. + SetA2ADefaultCluster(ctx context.Context, vaultName string, cluster string) error +} + +// NewA2ADefaultClusterWriter creates an A2ADefaultClusterWriter for the config +// file at configPath. An empty configPath resolves the config file by the +// loader's own rule. +func NewA2ADefaultClusterWriter(configPath string) A2ADefaultClusterWriter { + return &a2aDefaultClusterWriter{configPath: configPath} +} + +type a2aDefaultClusterWriter struct { + configPath string +} + +// SetA2ADefaultCluster reads the config file, sets the named vault's +// a2a_default_cluster key and writes the config file back atomically. Nothing is +// written on any failure. +func (w *a2aDefaultClusterWriter) SetA2ADefaultCluster( + ctx context.Context, + vaultName string, + cluster string, +) error { + if err := ValidateA2ADefaultCluster(ctx, cluster); err != nil { + return err + } + + configPath, err := resolveConfigPath(ctx, w.configPath) + if err != nil { + return errors.Wrap(ctx, err, "resolve config path") + } + + cfg, err := readConfigFile(ctx, configPath) + if err != nil { + return err + } + + matchedKey, err := findVaultKey(ctx, cfg, vaultName) + if err != nil { + return err + } + + vault := cfg.Vaults[matchedKey] + vault.A2ADefaultCluster = cluster + cfg.Vaults[matchedKey] = vault + + out, err := yaml.Marshal(cfg) + if err != nil { + return errors.Wrap(ctx, err, "marshal config") + } + + return writeFileAtomic(ctx, configPath, out) +} diff --git a/pkg/config/a2a_default_cluster_test.go b/pkg/config/a2a_default_cluster_test.go new file mode 100644 index 0000000..dfbe040 --- /dev/null +++ b/pkg/config/a2a_default_cluster_test.go @@ -0,0 +1,241 @@ +// Copyright (c) 2026 Benjamin Borbe All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package config_test + +import ( + "context" + "os" + "path/filepath" + "strings" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + "github.com/bborbe/vault-cli/pkg/config" +) + +var _ = Describe("ValidateA2ADefaultCluster", func() { + var ctx context.Context + + BeforeEach(func() { + ctx = context.Background() + }) + + It("accepts RFC 1123 label shapes", func() { + for _, value := range []string{ + "nukedev", + "nukeprod", + "octopus-dev", + "my-cluster-2", + "a", + "0", + "cluster123", + strings.Repeat("a", 63), + } { + Expect(config.ValidateA2ADefaultCluster(ctx, value)).To(BeNil(), "cluster: %s", value) + } + }) + + It("rejects a target string rather than a bare name", func() { + err := config.ValidateA2ADefaultCluster(ctx, "k8s:nukedev") + Expect(err).NotTo(BeNil()) + Expect(err.Error()).To(ContainSubstring("k8s:nukedev")) + }) + + It("rejects every other malformed shape", func() { + for _, value := range []string{ + "Nukedev", + "nuke_dev", + "-nukedev", + "nukedev-", + "nuke dev", + "nuke.dev", + "nuke/dev", + "nük edev", + strings.Repeat("a", 64), + } { + Expect(config.ValidateA2ADefaultCluster(ctx, value)).NotTo(BeNil(), "cluster: %s", value) + } + }) + + It("rejects an empty cluster name", func() { + Expect(config.ValidateA2ADefaultCluster(ctx, "")).NotTo(BeNil()) + }) +}) + +var _ = Describe("A2ADefaultClusterWriter", func() { + var ( + ctx context.Context + tempDir string + configPath string + writer config.A2ADefaultClusterWriter + ) + + readBytes := func(path string) []byte { + b, err := os.ReadFile(path) + Expect(err).To(BeNil()) + return b + } + + BeforeEach(func() { + ctx = context.Background() + var err error + tempDir, err = os.MkdirTemp("", "vault-cli-a2a-default-cluster-test-*") + Expect(err).To(BeNil()) + configPath = filepath.Join(tempDir, "config.yaml") + writer = config.NewA2ADefaultClusterWriter(configPath) + }) + + AfterEach(func() { + _ = os.RemoveAll(tempDir) + }) + + Context("with a personal vault", func() { + BeforeEach(func() { + configData := `current_user: user@example.com +default_vault: personal +vaults: + personal: + name: personal + path: /vault/personal + tasks_dir: "25 Tasks" +` + Expect(os.WriteFile(configPath, []byte(configData), 0600)).To(Succeed()) + }) + + It("writes the cluster name onto the named vault", func() { + Expect(writer.SetA2ADefaultCluster(ctx, "personal", "nukedev")).To(Succeed()) + + vault, err := config.NewLoader(configPath).GetVault(ctx, "personal") + Expect(err).To(BeNil()) + Expect(vault.A2ADefaultCluster).To(Equal("nukedev")) + }) + + It("writes the literal key the on-disk contract names", func() { + Expect(writer.SetA2ADefaultCluster(ctx, "personal", "nukedev")).To(Succeed()) + + Expect(string(readBytes(configPath))). + To(ContainSubstring("a2a_default_cluster: nukedev")) + }) + + It("preserves the vault's other keys", func() { + Expect(writer.SetA2ADefaultCluster(ctx, "personal", "nukedev")).To(Succeed()) + + loader := config.NewLoader(configPath) + vault, err := loader.GetVault(ctx, "personal") + Expect(err).To(BeNil()) + Expect(vault.TasksDir).To(Equal("25 Tasks")) + Expect(vault.Path).To(Equal("/vault/personal")) + + cfg, err := loader.Load(ctx) + Expect(err).To(BeNil()) + Expect(cfg.CurrentUser).To(Equal("user@example.com")) + Expect(cfg.DefaultVault).To(Equal("personal")) + }) + + It("rejects a malformed cluster name and leaves the file byte-identical", func() { + before := readBytes(configPath) + + err := writer.SetA2ADefaultCluster(ctx, "personal", "k8s:nukedev") + Expect(err).NotTo(BeNil()) + Expect(err.Error()).To(ContainSubstring("k8s:nukedev")) + Expect(readBytes(configPath)).To(Equal(before)) + }) + + It("leaves no temp file behind on a successful write", func() { + Expect(writer.SetA2ADefaultCluster(ctx, "personal", "nukedev")).To(Succeed()) + + entries, err := os.ReadDir(tempDir) + Expect(err).To(BeNil()) + Expect(entries).To(HaveLen(1)) + Expect(entries[0].Name()).To(Equal("config.yaml")) + }) + }) + + Context("with two vaults, only one of which is set", func() { + BeforeEach(func() { + configData := `current_user: user@example.com +vaults: + test: + name: test + path: /vault/test + tasks_dir: Tasks + other: + name: other + path: /vault/other + tasks_dir: "25 Tasks" +` + Expect(os.WriteFile(configPath, []byte(configData), 0600)).To(Succeed()) + }) + + It("writes the key only where it was set", func() { + Expect(writer.SetA2ADefaultCluster(ctx, "test", "nukedev")).To(Succeed()) + + content := string(readBytes(configPath)) + Expect(strings.Count(content, "a2a_default_cluster")).To(Equal(1)) + + loader := config.NewLoader(configPath) + other, err := loader.GetVault(ctx, "other") + Expect(err).To(BeNil()) + Expect(other.A2ADefaultCluster).To(BeEmpty()) + Expect(other.Path).To(Equal("/vault/other")) + Expect(other.TasksDir).To(Equal("25 Tasks")) + }) + }) + + Context("with a mixed-case vault key", func() { + BeforeEach(func() { + configData := `vaults: + Personal: + name: Personal + path: /vault/personal +` + Expect(os.WriteFile(configPath, []byte(configData), 0600)).To(Succeed()) + }) + + It("matches case-insensitively and preserves the file's key spelling", func() { + Expect(writer.SetA2ADefaultCluster(ctx, "PERSONAL", "nukedev")).To(Succeed()) + + content := string(readBytes(configPath)) + Expect(content).To(ContainSubstring("Personal:")) + Expect(content).NotTo(ContainSubstring("personal:")) + Expect(content).To(ContainSubstring("a2a_default_cluster: nukedev")) + }) + }) + + Context("with a vault absent from the config", func() { + BeforeEach(func() { + configData := `vaults: + personal: + name: personal + path: /vault/personal +` + Expect(os.WriteFile(configPath, []byte(configData), 0600)).To(Succeed()) + }) + + It("rejects the vault and leaves the file byte-identical", func() { + before := readBytes(configPath) + + err := writer.SetA2ADefaultCluster(ctx, "nosuchvault", "nukedev") + Expect(err).NotTo(BeNil()) + Expect(err.Error()).To(ContainSubstring("nosuchvault")) + Expect(readBytes(configPath)).To(Equal(before)) + }) + }) + + Context("with a malformed config", func() { + BeforeEach(func() { + Expect(os.WriteFile(configPath, []byte("vaults: [\n"), 0600)).To(Succeed()) + }) + + It("rejects the config and leaves the file byte-identical", func() { + before := readBytes(configPath) + + err := writer.SetA2ADefaultCluster(ctx, "personal", "nukedev") + Expect(err).NotTo(BeNil()) + Expect(readBytes(configPath)).To(Equal(before)) + }) + }) +}) diff --git a/pkg/config/config.go b/pkg/config/config.go index 986a650..75b8670 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -56,6 +56,12 @@ type Vault struct { // file. Empty means the vault has no baseline: `rollup weekly` then prints // exactly what it printed before the baseline feature existed. Baseline string `yaml:"baseline,omitempty" json:"baseline,omitempty"` + // A2ADefaultCluster is the cluster NAME (nukedev, not k8s:nukedev) that an + // A2A spawn for this vault falls back to when the task carries no explicit + // target. Empty means the vault names no default: the consumer keeps its own + // fallback behaviour. The value is validated when written and never when + // read, so a hand-edited config carrying a malformed value still lists. + A2ADefaultCluster string `yaml:"a2a_default_cluster,omitempty" json:"a2a_default_cluster,omitempty"` } // GetTasksDir returns the tasks directory, defaulting to "Tasks" if not set. diff --git a/specs/in-progress/063-a2a-default-cluster.md b/specs/in-progress/063-a2a-default-cluster.md new file mode 100644 index 0000000..7c62d8a --- /dev/null +++ b/specs/in-progress/063-a2a-default-cluster.md @@ -0,0 +1,136 @@ +--- +status: approved +approved: "2026-10-20T13:43:46Z" +branch: dark-factory/a2a-default-cluster +--- + +## Summary + +- Each vault's config entry can name a default A2A cluster. +- The value is a cluster name such as `nukedev`, never a target string like `k8s:nukedev`, and it is validated as a shape when written but never at read time. +- A new `vault-cli config set-a2a-default-cluster ` verb writes the value without hand-editing the config file. +- The value is visible in `config list --output json`, so a consumer that reads the config as JSON can see it. +- A vault that names no default behaves exactly as it does today. + +## Problem + +Today every vault-cli config entry describes where a vault's files live, but says nothing about where that vault's A2A workers are allowed to run. The consumer — the `claude-supervisor` `open-a2a` call site — therefore has no per-vault answer to fall back to when a task carries no explicit spawn target, and every target-less spawn in every vault inherits one fleet-wide behaviour. That is the isolation hole the parent goal exists to close: a `seibert-brogrammers` task opened from a personal session lands on a nuke cluster its own allowlist forbids, rather than on `octopus-dev` where it belongs. The allowlist on the supervisor side can already say which clusters a vault *may* use; nothing on the vault-cli side can say which one it should use by default, so the consumer has no value to read. + +## Goal + +A vault-cli config entry can carry an `a2a_default_cluster` value, set through a `config set-a2a-default-cluster` verb, validated as a cluster-name shape on write, and emitted in `config list --output json` — so that a consumer reading the config as JSON can resolve a vault's default A2A cluster without parsing the YAML itself, and a vault that names no default keeps behaving exactly as it does today. + +## Non-goals + +- **Consuming the value.** Resolving an absent spawn target to the vault default, forming `k8s:`, and refusing an unknown cluster are the `claude-supervisor` call site's work, not this repo's. This spec delivers the stored, validated, readable value only. +- **Enforcing an allowlist.** Which clusters a vault may use is a supervisor-side fact, already carried there. This repo records a default, not a permission. +- **Hardcoding the cluster-name set.** `nukedev`, `nukeprod`, `hell`, `sun`, `octopus-dev`, `octopus-staging`, `octopus-prod` are today's names, not a frozen list. This repo validates the shape only; an unknown name is the consumer's to refuse. +- **Setting the per-vault values themselves.** Seeding each real vault's default is an operator act performed on the operator rung of Verification. This spec's code writes whatever the operator asks it to write. +- **Read-time validation.** A hand-edited config carrying a malformed value must still list. Refusing it would turn a config typo into an unopenable vault. +- **An unset verb.** Removing a default is a config-file edit. If a consumer demands `config unset-a2a-default-cluster`, that is a separate spec. +- **Changing any existing config key** — name, shape, or default. +- **A new scenario file.** `scenarios/001-config-list.md` asserts that the `minimal` vault entry "contains only: `path`, `name`" — an exhaustive key set, not a mere absence check. The new field stays out of that entry because it is `omitempty`, so the assertion still holds; a field without `omitempty` would break it, which is exactly why DB1 requires the omission. No scenario work is in scope. + +## Assumptions + +- **The consumer reads `config list --output json`, not the YAML.** That is the delivery surface, so the JSON tag spelling is a contract and the YAML tag spelling is the on-disk contract. Both are pinned by an AC. +- **Cluster names are Kubernetes-identifier-shaped.** Every name in today's set is an RFC 1123 label, which is why that shape — not an enumerated list — is the validation rule. +- **A successful write re-serializes the whole file from the config struct.** The write path reads the file into the struct, mutates it, and marshals the struct back. Comments and any key the struct does not model are therefore not preserved, and keys are reordered to struct order. This is the existing baseline verb's behaviour, and it is accepted here rather than re-engineered. "Other keys survive" means other keys **the config struct models**. +- **The per-vault values are seeded by the operator**, on the operator rung, after the change lands. No code in this spec writes a real vault's default. +- **This mirrors an existing feature.** `docs/baseline-file.md` documents the sibling `baseline` key and its `config set-baseline` verb — the same config-key plus set-verb plus doc-page shape — and is the pattern to follow. `docs/dod.md` is the source of the artifact list AC5 checks. + +## Acceptance Criteria + +Each criterion states the evidence a verifier observes. + +- [ ] **AC1 — the JSON surface, three clauses.** Using `vault-cli --config config list --output json`: (a) a vault setting `a2a_default_cluster: nukedev` → exits 0 and stdout contains `"a2a_default_cluster": "nukedev"`; (b) a vault omitting the key → exits 0, stdout contains `"name": "test"`, and stdout contains no `a2a_default_cluster`; (c) a hand-edited vault carrying `a2a_default_cluster: "not a cluster!"` → exits 0 and stdout emits that value verbatim. *Evidence: exit code + stdout substring matches, including the space after the colon (the JSON printer indents). In (b) the positive guard is asserted before the absence, so an empty or failed run cannot satisfy it. (c) is the read-time-tolerance clause.* +- [ ] **AC2 — the verb writes the key, and matches the vault name case-insensitively.** With a temp config whose vault key is spelled `Personal`, `vault-cli --config config set-a2a-default-cluster PERSONAL nukedev` exits 0; the raw file text contains the literal key `a2a_default_cluster: nukedev`; and the map key in the raw file still reads `Personal`. *Evidence: exit code + raw-file grep for both the value and the original key spelling. The raw text is asserted, not a struct field — a symmetric typo in a serialization tag round-trips cleanly while breaking every external reader.* +- [ ] **AC3 — the validator accepts the shape, not a fixed list, and refuses everything else.** `vault-cli --config config set-a2a-default-cluster test my-cluster-2` exits 0 and the raw file contains `a2a_default_cluster: my-cluster-2` — a name that appears nowhere in this spec, so a hardcoded allowlist fails here; a 63-character valid name is likewise accepted. Each of `k8s:nukedev`, `Nukedev`, `nuke_dev`, `-nukedev`, `nukedev-`, `nuke dev`, `nuke.dev`, `nuke/dev`, a non-ASCII name, a 64-character name and the empty string, plus the vault name `nosuchvault`, exits non-zero and leaves the config file byte-identical. *Evidence: exit code + raw-file grep for the accepted names; a byte-for-byte comparison against a pre-run copy for each refusal.* +- [ ] **AC4 — the key appears only where it was set, and everything else is untouched.** After a successful set on vault `test`: `grep -c 'a2a_default_cluster' ` returns exactly `1`, and the `other` vault's block carries no `a2a_default_cluster` line — this is what forces the YAML `omitempty`, without which re-serialization injects an empty key into every vault; the file still carries `current_user`, and the written vault still carries its pre-existing `path` and `tasks_dir` values; a second vault `other` in the same file parses to its pre-run `path`, `name` and `tasks_dir` values; and `vault-cli --config --vault test config list` with no `--output json` prints exactly `namepath` for that one vault, byte-identical to its pre-change output. *Evidence: parsed values compared key by key and vault by vault; the plain listing is scoped to a single vault because the unfiltered listing ranges over a map and its line order is not stable. Values are compared after parsing, not byte-for-byte, because a successful write re-serializes the whole file (see Assumptions).* +- [ ] **AC5 — the repo's own Definition of Done artifacts exist, with content.** For each of `integration/cli_test.go`, `README.md` and `CHANGELOG.md`, `grep -c 'config set-a2a-default-cluster' ` returns at least `1`; and `docs/a2a-default-cluster.md` exists and mentions each of `a2a_default_cluster`, `RFC 1123`, and read-time tolerance. *Evidence: one grep per file, each with its own count — a single combined grep exits 0 when any file matches and proves nothing per-file. For the doc, one grep per required term, each returning at least one line; file presence alone is satisfiable by `touch` and would ship the code with the contract undocumented. The artifact list is `docs/dod.md`'s.* +- [ ] **AC6 — the existing suite still passes.** `make precommit` exits 0. *Evidence: exit code.* +- [ ] **Post-Deploy (Rung-2):** after the change is installed, `vault-cli config list --output json` shows `a2a_default_cluster` on all six of these vaults — `seibert-brogrammers`, `seibert-personal`, `seibert-agent` and `seibert-data` reading `octopus-dev`; `private-personal` and `private-trading` reading `nukedev`. No other vault is required to carry the key. *Evidence: stdout, asserted per vault by name. Precondition: the operator-executable rung of `## Verification` has been run — without it the criterion cannot distinguish "not yet seeded" from "feature broken".* + - `deploy_check:` `set -o pipefail; vault-cli --version | awk '{print $NF}'` + - `deploy_target:` `$(git fetch --tags -q && git describe --tags --abbrev=0)` + +## Verification + +### Container-executable (runs inside the YOLO container at prompt time) + +```bash +ROOTDIR=/workspace make precommit +``` + +AC1–AC6 are all reachable here: the JSON surface, the raw-file assertion, the refusal cases, the isolation checks and the artifact grep are each a Ginkgo spec, an integration-table entry, or a plain grep. + +### Operator-executable (runs on the host against the real config, after the change lands) + +```bash +vault-cli config set-a2a-default-cluster seibert-brogrammers octopus-dev +vault-cli config set-a2a-default-cluster seibert-personal octopus-dev +vault-cli config set-a2a-default-cluster seibert-agent octopus-dev +vault-cli config set-a2a-default-cluster seibert-data octopus-dev +vault-cli config set-a2a-default-cluster private-personal nukedev +vault-cli config set-a2a-default-cluster private-trading nukedev +# assert per vault, not by a global count — a count of 6 also passes if one vault +# is missing and a seventh gained the key +vault-cli config list --output json \ + | python3 -c 'import json,sys; [print(v["name"], v.get("a2a_default_cluster")) for v in json.load(sys.stdin)]' \ + | grep -cE '^(seibert-(brogrammers|personal|agent|data) octopus-dev|private-(personal|trading) nukedev)$' # expect 6 +``` + +Install the released build first: `.maintainer.yaml` sets `release.autoRelease: true` and `.dark-factory.yaml` sets `autoRelease: false`, so `github-releaser-agent` cuts the tag after merge — `git pull && make install` from the released tag, never a hand-cut tag. + +The Post-Deploy criterion is operator-only: it writes the operator's real config file, which the container does not have. Until it is run, it is blocked rather than failing. + +## Desired Behavior + +1. A vault's config entry may carry an A2A default cluster. An entry that omits it is valid, the JSON omits the key entirely, and the default plain-text listing is unchanged. +2. The value is a cluster **name**, not a spawn target: `nukedev`, not `k8s:nukedev`. A value containing `:` is refused, because a caller forms the target from the name. +3. The value is validated as an RFC 1123 label — lowercase alphanumerics and interior hyphens, at most 63 characters — the shape a Kubernetes cluster identifier takes. Empty, uppercase, underscore, space, dot, slash, non-ASCII, a leading or trailing hyphen, and over-length values are refused. +4. `config set-a2a-default-cluster ` writes the value onto the named vault's entry, matching the vault name case-insensitively and preserving the file's own key spelling. +5. A refused value or an unknown vault writes nothing at all — the config file is byte-identical afterwards, the same contract the existing baseline verb already honours. +6. A successful write leaves every other key **the config struct models** and every other vault untouched. +7. Reading never validates. A malformed value that is already on disk is emitted verbatim; refusing an unknown cluster is the consumer's job, never this repo's list path. + +## Constraints + +- **The on-disk key is `a2a_default_cluster`.** Both the YAML and JSON spellings are part of the contract — the JSON spelling is what the consumer reads. +- **The writer is additive.** No existing key changes shape, name or default; a config with no A2A key round-trips unchanged. +- **The write is atomic and all-or-nothing**, reusing the existing config-file write path rather than introducing a second one. It re-serializes the whole file from the struct, so comments and unmodelled keys are not preserved — accepted, and documented in the new `docs/` page. +- **The new verb's registration lives where the existing config subcommands are registered**, not in the file that builds the individual command. +- **One `func TestSuite` per package.** The packages already have exactly one; a new test file adds `Describe` blocks only. +- **Error style is the repo's**, `github.com/bborbe/errors`, matching the existing baseline validator and writer. +- **No new dependency.** Everything needed is already imported. +- **Concurrent writers are out of scope.** The atomic replace means a race loses one write rather than corrupting the file, and the operator does not run concurrent writers against one config. + +## Failure Modes + +| Trigger | Expected behavior | Recovery | +|---|---|---| +| Cluster name contains `:` (a target string) | Refused before any write; file byte-identical | Operator re-runs with the bare name | +| Cluster name uppercase, underscore, space, dot, slash, non-ASCII, edge hyphen, or >63 chars | Refused before any write; file byte-identical | Operator re-runs with a valid name | +| Empty cluster name | Refused before any write; file byte-identical | Operator supplies a name | +| Vault name not in the config | Refused; file byte-identical | Operator checks `config list` for the exact name | +| Config file missing or unparseable | Refused; nothing written, nothing created | Operator fixes or restores the config file | +| Config directory not writable, or the disk is full | Refused; original file byte-identical, no temp file left behind | Operator fixes permissions or space, then re-runs; `ls /.config-*.yaml` returns nothing | +| Hand-edited malformed value already on disk | `config list` still exits 0 and emits it verbatim | Consumer refuses the unknown name; the vault stays openable | +| Interrupted write | Atomic replace leaves either the old file or the new one, never a truncated merge | Re-run the verb | + +## Security / Abuse + +The verb takes a vault name and a cluster name from argv and writes them into a user-owned config file. The validator is the input boundary: it constrains the cluster value to `[a-z0-9-]` at 63 characters or fewer, so no value written through this verb can carry a path separator, a shell metacharacter, a newline, or a colon that would let a name impersonate a target string. The vault name is matched against keys already present in the config and never used to construct a path, so an unknown vault is a refusal rather than a file operation. The file is written through the existing atomic temp-file-and-rename path, so an interrupted write cannot leave a partially merged config that a later reader would parse as a different vault's settings. + +## Suggested Decomposition + +Two packages (`pkg/config` for the field, validator and writer; `pkg/cli` for the verb and its registration), one feature path, seven desired behaviors. One prompt: + +| # | Prompt focus | Covers DBs | Covers ACs | Depends on | +|---|---|---|---|---| +| 1 | Config field, shape validator, atomic writer, CLI verb, JSON surface, tests, `docs/` page, README and CHANGELOG | 1–7 | 1–6 | — | + +AC7 is operator-executable and runs after the change lands, not as a prompt. + +## Do-Nothing Option + +Without this, the `claude-supervisor` `open-a2a` call site has no per-vault default to read, so the parent goal's isolation criterion cannot be met at all: every target-less spawn keeps inheriting one fleet-wide behaviour, and a vault's allowlist has nothing to be checked against. The cost is not a missing convenience but a blocked goal — the allowlist task can only refuse a forbidden cluster once a default exists to be checked. The alternative, hardcoding the vault-to-cluster mapping in the consumer, puts a second copy of the mapping in a repo that cannot see the vault config, which is exactly the drift this spec exists to avoid.