From 7778afefc6cb9afd449cb0dcbe4c87948546735e Mon Sep 17 00:00:00 2001 From: admin Date: Sun, 25 Oct 2026 11:13:08 -0400 Subject: [PATCH 1/9] fix(dns): report ok:false in the JSON envelope when config trust dns is skipped headless (D-0830) --- cmd/commands/dns.go | 5 ++++- cmd/commands/dns_test.go | 14 ++++++++++++++ 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/cmd/commands/dns.go b/cmd/commands/dns.go index cdb3fc66a..f7ae0ad29 100644 --- a/cmd/commands/dns.go +++ b/cmd/commands/dns.go @@ -126,7 +126,10 @@ func runDNSSetup(cmd *cobra.Command, args []string) error { if err != nil { if errors.Is(err, sysexec.ErrSkippedHeadless) { ui.Warn("Headless mode (NSELF_HEADLESS=1 or CI=1): /etc/hosts not changed. Run `nself dns-setup` on a machine with admin rights, or set NSELF_HEADLESS=0.") - return nil + if jsonEnvelopeOn(cmd) { + setData(cmd, dataResult{OK: false}) // skipped: the envelope must not claim /etc/hosts was updated + } + return nil // start_config.go relies on skip-and-continue } if errors.Is(err, ssl.ErrSudoRequired) { if runtime.GOOS == "darwin" { diff --git a/cmd/commands/dns_test.go b/cmd/commands/dns_test.go index a77214e2a..d2f059aa5 100644 --- a/cmd/commands/dns_test.go +++ b/cmd/commands/dns_test.go @@ -77,3 +77,17 @@ func TestRerunWithOsascript_NonInteractiveReturnsEarly(t *testing.T) { t.Error("expected error for cancelled context, got nil") } } + +// TestDNSSetupHeadlessSkipEnvelope: under headless the /etc/hosts write is +// skipped, the command still exits 0 (start_config.go continues), but the v1.5 +// envelope must say ok:false instead of claiming success (D-0830). +func TestDNSSetupHeadlessSkipEnvelope(t *testing.T) { + run := runPilot(t, cfgFixtureProject(t), true, []string{"NSELF_HEADLESS=1"}, "config", "trust", "dns", "--json") + if run.code != 0 { + t.Fatalf("headless skip must exit 0, got %d\n%s", run.code, run.stderr) + } + data := validateEnvelope(t, run.stdout, "config trust dns") + if data["ok"] != false { + t.Errorf("headless-skipped dns setup: data = %v, want ok:false", data) + } +} From cfe25c94623fca1e397a708fd0c70b2e824a86ba Mon Sep 17 00:00:00 2001 From: admin Date: Sun, 25 Oct 2026 11:15:15 -0400 Subject: [PATCH 2/9] fix(registry): build the command registry with disabled builtin families mounted so --json survives nself remove admin (D-0863) --- cmd/commands/builtin_families.go | 68 +++++++++++++------ cmd/commands/registry.go | 4 ++ cmd/commands/registry_disabled_family_test.go | 33 +++++++++ 3 files changed, 84 insertions(+), 21 deletions(-) create mode 100644 cmd/commands/registry_disabled_family_test.go diff --git a/cmd/commands/builtin_families.go b/cmd/commands/builtin_families.go index d45e6a76c..3421e64a7 100644 --- a/cmd/commands/builtin_families.go +++ b/cmd/commands/builtin_families.go @@ -69,31 +69,57 @@ func mountBuiltinFamiliesFromList(root *cobra.Command) { if !builtinEnabled(f.slug) { continue } - alreadyMounted := false - for _, existing := range root.Commands() { - if existing.Annotations[mount.AnnSource] == sourceBuiltin && existing.Annotations[mount.AnnPlugin] == f.slug { - alreadyMounted = true - break - } + mountBuiltinFamily(root, f) + } +} + +// mountBuiltinFamily mounts one family on root and returns its node, or nil +// when the family is already mounted or builds nothing. +func mountBuiltinFamily(root *cobra.Command, f builtinFamily) *cobra.Command { + for _, existing := range root.Commands() { + if existing.Annotations[mount.AnnSource] == sourceBuiltin && existing.Annotations[mount.AnnPlugin] == f.slug { + return nil } - if alreadyMounted { - continue + } + node := f.build() + if node == nil { + return nil + } + annotateBuiltin(node, f.slug) + if node.GroupID == "" { + // compat.V15(P7-CANON-19): account group -> plugin commands group + if compat.V15() { + node.GroupID = groupPlugins + } else { + node.GroupID = groupAccount } - node := f.build() - if node == nil { - continue + } + ensureBuiltinGroups(root, node) + root.AddCommand(node) + return node +} + +// mountDisabledBuiltinFamilies mounts the builtin families whose disable +// marker exists, for the duration of a registry build only, and returns the +// undo that removes them again. The canon entries, data types and v1.5-only +// envelope paths of a family are declared whether or not it is installed, so +// the registry (and everything generated from it) must not depend on the +// developer's plugin directory; the runtime tree keeps the family unmounted +// (`nself ` still exits E407) (D-0863). +func mountDisabledBuiltinFamilies(root *cobra.Command) func() { + var added []*cobra.Command + for _, f := range builtinFamilies { + if builtinEnabled(f.slug) { + continue // prepareTree mounts it } - annotateBuiltin(node, f.slug) - if node.GroupID == "" { - // compat.V15(P7-CANON-19): account group -> plugin commands group - if compat.V15() { - node.GroupID = groupPlugins - } else { - node.GroupID = groupAccount - } + if node := mountBuiltinFamily(root, f); node != nil { + added = append(added, node) + } + } + return func() { + for _, c := range added { + root.RemoveCommand(c) } - ensureBuiltinGroups(root, node) - root.AddCommand(node) } } diff --git a/cmd/commands/registry.go b/cmd/commands/registry.go index 3592b3549..e6d010df7 100644 --- a/cmd/commands/registry.go +++ b/cmd/commands/registry.go @@ -91,6 +91,10 @@ func buildRegistry(v15 bool) (*cmdregistry.Registry, error) { if err != nil { return nil, fmt.Errorf("command canon: %w", err) } + // A disabled builtin family keeps its canon entries and data types, so the + // registry is built with every family mounted (D-0863). + undoFamilies := mountDisabledBuiltinFamilies(RootCmd) + defer undoFamilies() undo := prepareTree(RootCmd, v15, false) defer undo() reg, err := cmdregistry.Build(RootCmd, c, jsonDataTypes, cmdregistry.BuildOptions{ diff --git a/cmd/commands/registry_disabled_family_test.go b/cmd/commands/registry_disabled_family_test.go new file mode 100644 index 000000000..ce50d510b --- /dev/null +++ b/cmd/commands/registry_disabled_family_test.go @@ -0,0 +1,33 @@ +package commands + +// Purpose: a disabled builtin family (nself remove admin) must not break the +// v1.5 --json surface (D-0863). Runs the real binary: remove admin, then +// version --json must answer with one success envelope, and the family must +// stay unmounted (E407). +// Constraints: child process only; HOME is the test's temp dir. + +import ( + "strings" + "testing" +) + +func TestDisabledBuiltinFamilyKeepsJSONSurface(t *testing.T) { + proj := cfgFixtureProject(t) + rm := runPilot(t, proj, true, nil, "remove", "admin") + if rm.code != 0 { + t.Fatalf("remove admin: exit %d\n%s%s", rm.code, rm.stdout, rm.stderr) + } + ver := runPilot(t, proj, true, nil, "version", "--json") + if ver.code != 0 { + t.Fatalf("version --json after remove admin: exit %d\n%s%s", ver.code, ver.stdout, ver.stderr) + } + validateEnvelope(t, ver.stdout, "version") + if strings.Contains(ver.stdout, "command registry") { + t.Fatalf("registry build error leaked into the envelope:\n%s", ver.stdout) + } + // The disabled family stays unmounted: E407, not a registry problem. + adm := runPilot(t, proj, true, nil, "admin", "--json") + if adm.code == 0 || !strings.Contains(adm.stdout, `"E407"`) { + t.Fatalf("admin --json after remove admin: exit %d, want E407\n%s%s", adm.code, adm.stdout, adm.stderr) + } +} From 597f1ed3d76837d50811af6d48dd62b3d18386f4 Mon Sep 17 00:00:00 2001 From: admin Date: Sun, 25 Oct 2026 11:17:20 -0400 Subject: [PATCH 3/9] fix(start): read AI_AUTO_INSTALL from the project env cascade before the auto-install gate (D-0614) --- cmd/commands/start.go | 2 +- cmd/commands/start_checks.go | 35 ++++++++++++++++++++++--- cmd/commands/start_checks_ai_test.go | 39 +++++++++++++++++++++++++++- 3 files changed, 71 insertions(+), 5 deletions(-) diff --git a/cmd/commands/start.go b/cmd/commands/start.go index 29f285b3c..2551e8989 100644 --- a/cmd/commands/start.go +++ b/cmd/commands/start.go @@ -111,7 +111,7 @@ func runStartFlow(cmd *cobra.Command, _ []string, out *startOutcome) error { // ── AI auto-install (T-05-05) + Auto-build detection ────────────── // Both extracted to start_checks.go (T-P6-E2-W1-S1-T3). Auto-build runs // BEFORE the docker-compose.yml check because build creates it. - autoInstallAIIfNeeded(ctx) + autoInstallAIIfNeeded(ctx, projectDir) entitlementRefreshed := false if !opts.skipBuild { diff --git a/cmd/commands/start_checks.go b/cmd/commands/start_checks.go index ffd8adaef..3ab4c308a 100644 --- a/cmd/commands/start_checks.go +++ b/cmd/commands/start_checks.go @@ -17,6 +17,7 @@ import ( "strings" "github.com/nself-org/cli/internal/build" + "github.com/nself-org/cli/internal/config" "github.com/nself-org/cli/internal/errs" "github.com/nself-org/cli/internal/migration" "github.com/nself-org/cli/internal/plugin" @@ -70,12 +71,40 @@ func aiAutoInstallWanted(getenv func(string) string, pluginInstalled bool) bool return strings.EqualFold(v, "true") || v == "1" } +// projectAIAutoInstallEnv returns the getenv aiAutoInstallWanted reads: the +// process environment first (an operator, CI or --frozen that set +// AI_AUTO_INSTALL wins), then the project's env cascade. The gate runs before +// config.Load exports the cascade files, so without this read an +// AI_AUTO_INSTALL=false that lives only in the project .env is ignored +// (D-0614). Files are layered in cascade order, later over earlier; a file +// that cannot be read or parsed is skipped (config.Load reports it later). +func projectAIAutoInstallEnv(projectDir string) func(string) string { + var fromFiles string + env, _ := config.ResolveEnv(projectDir) + for _, name := range config.EnvCascadeOrder(env, config.LegacyOrderActive()) { + m, err := config.ReadEnvFile(filepath.Join(projectDir, name)) + if err != nil { + continue + } + if v, ok := m["AI_AUTO_INSTALL"]; ok { + fromFiles = v + } + } + return func(key string) string { + if v := os.Getenv(key); v != "" || key != "AI_AUTO_INSTALL" { + return v + } + return fromFiles + } +} + // autoInstallAIIfNeeded runs `doctor --ai --yes --skip-pool` to get Ollama // running before the stack boots, when aiAutoInstallWanted says the user opted // in (AI_AUTO_INSTALL=true or the ai plugin installed) and Ollama is not -// already healthy (T-05-05, D-0221). -func autoInstallAIIfNeeded(ctx context.Context) { - if !aiAutoInstallWanted(os.Getenv, aiPluginInstalledFn()) { +// already healthy (T-05-05, D-0221). projectDir supplies the project cascade +// for AI_AUTO_INSTALL (D-0614). +func autoInstallAIIfNeeded(ctx context.Context, projectDir string) { + if !aiAutoInstallWanted(projectAIAutoInstallEnv(projectDir), aiPluginInstalledFn()) { return } if ollamaHealthyFn(ctx) { diff --git a/cmd/commands/start_checks_ai_test.go b/cmd/commands/start_checks_ai_test.go index 39091d402..a1bc39399 100644 --- a/cmd/commands/start_checks_ai_test.go +++ b/cmd/commands/start_checks_ai_test.go @@ -7,6 +7,8 @@ package commands import ( "context" + "os" + "path/filepath" "testing" ) @@ -69,7 +71,42 @@ func TestAutoInstallAIIfNeeded(t *testing.T) { t.Setenv("NSELF_MASTER_SECRET", "fake-test-secret") t.Setenv("AI_AUTO_INSTALL", tc.aiEnv) // "" behaves as unset calls := stubAIInstall(t, tc.plugin, tc.ollamaUp) - autoInstallAIIfNeeded(context.Background()) + autoInstallAIIfNeeded(context.Background(), t.TempDir()) + if *calls != tc.wantInstall { + t.Fatalf("installer calls = %d, want %d", *calls, tc.wantInstall) + } + }) + } +} + +// TestAutoInstallAIIfNeededProjectEnv: AI_AUTO_INSTALL set only in the project +// env cascade is honored by the gate, which runs before config.Load (D-0614). +func TestAutoInstallAIIfNeededProjectEnv(t *testing.T) { + cases := []struct { + name string + files map[string]string + procEnv string + plugin bool + wantInstall int + }{ + {"project .env opts out with plugin installed", map[string]string{".env": "AI_AUTO_INSTALL=false\n"}, "", true, 0}, + {"project .env opts in", map[string]string{".env": "AI_AUTO_INSTALL=true\n"}, "", false, 1}, + {"env layer overrides .env", map[string]string{".env": "AI_AUTO_INSTALL=true\n", ".env.dev": "AI_AUTO_INSTALL=false\n"}, "", false, 0}, + {"process env wins over the project file", map[string]string{".env": "AI_AUTO_INSTALL=false\n"}, "true", false, 1}, + {"no setting and plugin installed installs", map[string]string{".env": "PROJECT_NAME=x\n"}, "", true, 1}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + dir := t.TempDir() + for f, body := range tc.files { + if err := os.WriteFile(filepath.Join(dir, f), []byte(body), 0o600); err != nil { + t.Fatal(err) + } + } + t.Setenv("ENV", "dev") + t.Setenv("AI_AUTO_INSTALL", tc.procEnv) + calls := stubAIInstall(t, tc.plugin, false) + autoInstallAIIfNeeded(context.Background(), dir) if *calls != tc.wantInstall { t.Fatalf("installer calls = %d, want %d", *calls, tc.wantInstall) } From ec9cf3c61e112d1955d726f952947387decc22d2 Mon Sep 17 00:00:00 2001 From: admin Date: Sun, 25 Oct 2026 11:17:21 -0400 Subject: [PATCH 4/9] fix(ops): arch preflight uses the deploy SSH key chain and checks only the compose file the ops push ships (D-0614) --- cmd/commands/ops.go | 9 +++--- cmd/commands/ops_test.go | 30 ++++++++++++++++++ internal/controlplane/probe_arch.go | 22 +++++++++----- internal/controlplane/probe_arch_key_test.go | 32 ++++++++++++++++++++ 4 files changed, 80 insertions(+), 13 deletions(-) create mode 100644 internal/controlplane/probe_arch_key_test.go diff --git a/cmd/commands/ops.go b/cmd/commands/ops.go index 67b6eb837..8e6ca03ac 100644 --- a/cmd/commands/ops.go +++ b/cmd/commands/ops.go @@ -187,9 +187,8 @@ func opsPreflight(ctx context.Context, workdir, host, composePath string) error inv := &controlplane.Inventory{Environments: map[string]controlplane.Environment{ opsEnvName: {Name: opsEnvName, Kind: "remote", Servers: []controlplane.Server{server}}, }} - files, err := controlplane.ComposeFilesForDeploy(composePath) - if err != nil { - return err - } - return opsControlplaneCheck(ctx, inv, opsEnvName, files) + // The ops push ships only composePath (deploy.DeployViaSsh), so that is the + // only file whose images the host must be able to run; plugin fragments of + // the project are not deployed here (D-0614). + return opsControlplaneCheck(ctx, inv, opsEnvName, []string{composePath}) } diff --git a/cmd/commands/ops_test.go b/cmd/commands/ops_test.go index 9bf492c35..930b4f6f8 100644 --- a/cmd/commands/ops_test.go +++ b/cmd/commands/ops_test.go @@ -8,6 +8,8 @@ package commands import ( "context" "errors" + "os" + "path/filepath" "strings" "testing" @@ -129,3 +131,31 @@ func TestOpsPreflight_RejectsBadHost(t *testing.T) { t.Fatal("want error for an invalid host spec") } } + +// TestOpsPreflight_ChecksOnlyShippedCompose: the ops push ships the one built +// compose file, so plugin fragments listed in the project manifest are not +// handed to the arch guard (D-0614). +func TestOpsPreflight_ChecksOnlyShippedCompose(t *testing.T) { + dir := t.TempDir() + t.Chdir(dir) + if err := os.MkdirAll(filepath.Join(dir, ".nself"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, ".nself", "compose-files.txt"), []byte("docker-compose.yml\ndocker-compose.plugin-x.yml\n"), 0o600); err != nil { + t.Fatal(err) + } + var got []string + orig := opsControlplaneCheck + opsControlplaneCheck = func(_ context.Context, _ *controlplane.Inventory, _ string, files []string) error { + got = files + return nil + } + t.Cleanup(func() { opsControlplaneCheck = orig }) + compose := filepath.Join(dir, "docker-compose.yml") + if err := opsPreflight(context.Background(), dir, "deploy@ops.example.com:/opt/nself", compose); err != nil { + t.Fatal(err) + } + if len(got) != 1 || got[0] != compose { + t.Fatalf("arch guard files = %v, want only %s", got, compose) + } +} diff --git a/internal/controlplane/probe_arch.go b/internal/controlplane/probe_arch.go index 1eed525cf..65eb4ce17 100644 --- a/internal/controlplane/probe_arch.go +++ b/internal/controlplane/probe_arch.go @@ -23,6 +23,19 @@ type archEntry struct { var archCaches sync.Map // project root and host -> archEntry +// archProbeKey is the identity the arch probe offers: the server's own key +// reference first, then the key the deploy itself uses (remote.DefaultKeyPath: +// NSELF_DEPLOY_KEY_PATH, NSELF_DEPLOY_SSH_KEY, ~/.ssh/id_ed25519), so the +// preflight never fails on a key the deploy would have accepted (D-0614). +func archProbeKey(s Server) string { + if s.SSHKeyRef != "" { + if key := os.Getenv(s.SSHKeyRef); key != "" { + return key + } + } + return remote.DefaultKeyPath() +} + // Architecture returns the inventory architecture or one cached SSH uname -m. func (sp *SSHProber) Architecture(ctx context.Context, s Server) (string, error) { if s.Arch != "" { @@ -44,14 +57,7 @@ func (sp *SSHProber) Architecture(ctx context.Context, s Server) (string, error) if err != nil { return "", err } - key := os.Getenv(s.SSHKeyRef) - if key == "" { - key = os.Getenv("NSELF_DEPLOY_SSH_KEY") - } - if key == "" { - home, _ := os.UserHomeDir() - key = home + "/.ssh/id_ed25519" - } + key := archProbeKey(s) tier, envName := probeTier(sp.projectRoot, s) policy, err := HostKeyOptions(ctx, envName, s.Name, tier, s.Host, sp.shipping.on()) if err != nil { diff --git a/internal/controlplane/probe_arch_key_test.go b/internal/controlplane/probe_arch_key_test.go new file mode 100644 index 000000000..1e6ad7295 --- /dev/null +++ b/internal/controlplane/probe_arch_key_test.go @@ -0,0 +1,32 @@ +package controlplane + +// Purpose: the arch probe offers the same SSH identity the deploy uses (D-0614). + +import ( + "path/filepath" + "testing" +) + +func TestArchProbeKeyMatchesDeployKey(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + t.Setenv("NSELF_DEPLOY_SSH_KEY", "") + t.Setenv("NSELF_DEPLOY_KEY_PATH", "") + t.Setenv("ARCH_PROBE_REF_KEY", "") + + if got, want := archProbeKey(Server{}), filepath.Join(home, ".ssh", "id_ed25519"); got != want { + t.Errorf("default key = %q, want %q", got, want) + } + t.Setenv("NSELF_DEPLOY_KEY_PATH", "/keys/legacy-deploy") + if got := archProbeKey(Server{}); got != "/keys/legacy-deploy" { + t.Errorf("key set only via NSELF_DEPLOY_KEY_PATH: probe uses %q", got) + } + t.Setenv("NSELF_DEPLOY_SSH_KEY", "/keys/other") + if got := archProbeKey(Server{}); got != "/keys/legacy-deploy" { + t.Errorf("both set: probe uses %q, deploy prefers NSELF_DEPLOY_KEY_PATH", got) + } + t.Setenv("ARCH_PROBE_REF_KEY", "/keys/server-ref") + if got := archProbeKey(Server{SSHKeyRef: "ARCH_PROBE_REF_KEY"}); got != "/keys/server-ref" { + t.Errorf("server key ref: probe uses %q", got) + } +} From 5c32d0ad687b6825700526ab8f25d6a0e37b27f7 Mon Sep 17 00:00:00 2001 From: admin Date: Sun, 25 Oct 2026 11:18:11 -0400 Subject: [PATCH 5/9] fix(doctor): VECTOR-SEARCH trusts function-permission inference off only when the running Hasura confirms it (D-0614) --- internal/database/hasura_container_env.go | 35 +++++++++++++++++++++++ internal/doctor/vector_search.go | 24 +++++++++++++++- internal/doctor/vector_search_test.go | 24 +++++++++++++++- 3 files changed, 81 insertions(+), 2 deletions(-) create mode 100644 internal/database/hasura_container_env.go diff --git a/internal/database/hasura_container_env.go b/internal/database/hasura_container_env.go new file mode 100644 index 000000000..283724d3b --- /dev/null +++ b/internal/database/hasura_container_env.go @@ -0,0 +1,35 @@ +package database + +// Purpose: reads one variable from the RUNNING Hasura container's environment +// (`docker inspect`), for checks that must know what Hasura actually runs with +// rather than what the project config would give it on the next recreate. +// Inputs: context, *config.Config, variable name. +// Outputs: the value and whether the container defines it, or an error when +// the container cannot be inspected. +// Constraints: read-only; the command's output is never logged. + +import ( + "context" + "fmt" + "os/exec" + "strings" + + "github.com/nself-org/cli/internal/config" +) + +// HasuraContainerEnvValue returns the value of key in the running Hasura +// container's environment. found is false when the container does not define it. +func HasuraContainerEnvValue(ctx context.Context, cfg *config.Config, key string) (value string, found bool, err error) { + container := hasuraContainer(cfg) + out, err := exec.CommandContext(ctx, "docker", "inspect", container, + "--format", "{{range .Config.Env}}{{println .}}{{end}}").Output() + if err != nil { + return "", false, fmt.Errorf("docker inspect %s (is the stack running?): %w", container, err) + } + for _, line := range strings.Split(string(out), "\n") { + if v, ok := strings.CutPrefix(strings.TrimRight(line, "\r"), key+"="); ok { + return v, true, nil + } + } + return "", false, nil +} diff --git a/internal/doctor/vector_search.go b/internal/doctor/vector_search.go index c728a0be1..bc112e5f0 100644 --- a/internal/doctor/vector_search.go +++ b/internal/doctor/vector_search.go @@ -10,6 +10,7 @@ import ( "strings" "time" + "github.com/nself-org/cli/internal/config" "github.com/nself-org/cli/internal/database" "github.com/nself-org/cli/internal/database/vectorsearch" ) @@ -41,6 +42,10 @@ const ( // test touches the network. var exportLiveMetadata = database.HasuraExportMetadata +// liveHasuraEnv reads one variable from the running Hasura container; tests +// replace it so no unit test touches docker. +var liveHasuraEnv = database.HasuraContainerEnvValue + // VectorSearchChecks is the DeepChecks entry point. func VectorSearchChecks(ctx context.Context, projectDir string) []CheckResult { if projectDir == "" { @@ -56,7 +61,24 @@ func VectorSearchChecks(ctx context.Context, projectDir string) []CheckResult { if err != nil { return vectorSearchUnread(projectDir, fmt.Sprintf("live Hasura metadata unreadable (is the stack running?): %v", err)) } - return vectorSearchResults(live, !strings.EqualFold(strings.TrimSpace(env[inferFunctionPerms]), "false")) + return vectorSearchResults(live, functionPermsInferred(ctx, cfg, env[inferFunctionPerms])) +} + +// functionPermsInferred reports whether Hasura infers function permissions. +// The project config only says what the NEXT recreate will run with: a Hasura +// that has not been recreated since the flag was set to "false" still infers. +// So "false" counts only when the running container confirms it; a config of +// "false" the container does not show (older container, docker unreadable) is +// audited as inferred, the fail-closed side (D-0614). +func functionPermsInferred(ctx context.Context, cfg *config.Config, desired string) bool { + if !strings.EqualFold(strings.TrimSpace(desired), "false") { + return true + } + live, found, err := liveHasuraEnv(ctx, cfg, inferFunctionPerms) + if err != nil || !found { + return true + } + return !strings.EqualFold(strings.TrimSpace(live), "false") } // vectorSearchResults turns an export_metadata document into check results. diff --git a/internal/doctor/vector_search_test.go b/internal/doctor/vector_search_test.go index b82dc1f63..09fdbfa36 100644 --- a/internal/doctor/vector_search_test.go +++ b/internal/doctor/vector_search_test.go @@ -69,6 +69,14 @@ func stubExport(t *testing.T, live []byte, err error) { exportLiveMetadata = func(context.Context, *config.Config) ([]byte, error) { return live, err } } +// stubLiveInfer replaces the running-container read of the inference flag. +func stubLiveInfer(t *testing.T, value string, found bool, err error) { + t.Helper() + saved := liveHasuraEnv + t.Cleanup(func() { liveHasuraEnv = saved }) + liveHasuraEnv = func(context.Context, *config.Config, string) (string, bool, error) { return value, found, err } +} + // TestVectorSearchChecksLive: no project gives nothing; an unreachable Hasura // is a skip only when disk names a search function (never a silent pass where // one is known), and silent otherwise; a reachable one is audited; the @@ -102,8 +110,22 @@ func TestVectorSearchChecksLive(t *testing.T) { if err := os.WriteFile(filepath.Join(dir, ".env"), []byte("PROJECT_NAME=fx\nENV=dev\n"+inferFunctionPerms+"=false\n"), 0o644); err != nil { t.Fatal(err) } + stubLiveInfer(t, "false", true, nil) if res := VectorSearchChecks(context.Background(), dir); len(res) != 1 || res[0].Status != "pass" { - t.Fatalf("inference off in the project env: %+v", res) + t.Fatalf("inference off in the project env and in the running Hasura: %+v", res) + } + // The config says false but the running Hasura has not been recreated + // (still infers, or does not report the flag, or cannot be inspected): the + // desired flag must not be trusted (D-0614). + for name, stub := range map[string]func(){ + "container still infers": func() { stubLiveInfer(t, "true", true, nil) }, + "container has no flag": func() { stubLiveInfer(t, "", false, nil) }, + "container not inspected": func() { stubLiveInfer(t, "", false, errors.New("no such container")) }, + } { + stub() + if res := VectorSearchChecks(context.Background(), dir); len(res) != 1 || res[0].Status != "fail" { + t.Fatalf("%s: unconfirmed false must audit as inferred: %+v", name, res) + } } if v, ok := os.LookupEnv(inferFunctionPerms); ok && v != "" { t.Errorf("the check leaked %s=%q into the process environment", inferFunctionPerms, v) From 03d27721472118d3de9ffb6d6759f81b6bb83e4b Mon Sep 17 00:00:00 2001 From: admin Date: Sun, 25 Oct 2026 11:19:01 -0400 Subject: [PATCH 6/9] fix(invoke): Exposure refuses a command whose set flags override json away from envelope (D-0614) --- internal/cmdregistry/surface.go | 22 ++++++++++++++++ internal/invoke/expose.go | 5 ++++ internal/invoke/expose_json_test.go | 41 +++++++++++++++++++++++++++++ 3 files changed, 68 insertions(+) create mode 100644 internal/invoke/expose_json_test.go diff --git a/internal/cmdregistry/surface.go b/internal/cmdregistry/surface.go index 6cc11d481..7b84b1855 100644 --- a/internal/cmdregistry/surface.go +++ b/internal/cmdregistry/surface.go @@ -94,6 +94,28 @@ func EffectiveSideEffect(cmd *Command, set map[string]bool) string { return best } +// EffectiveJSON is the command's JSON support after the flags in set: a set +// flag with a json override replaces the command value, and several overrides +// resolve none over legacy over envelope, the order the CLI itself uses when +// it refuses --json (D-0614). A command whose JSON is not envelope can still +// not become one through a flag. +func EffectiveJSON(cmd *Command, set map[string]bool) string { + if cmd == nil { + return "" + } + rank := map[string]int{canon.JSONEnvelope: 0, canon.JSONLegacy: 1, canon.JSONNone: 2} + kind, best := cmd.JSON, -1 + for _, flag := range cmd.Flags { + if !set[flag.Name] || flag.JSON == nil { + continue + } + if r := rank[*flag.JSON]; r > best { + best, kind = r, *flag.JSON + } + } + return kind +} + // EffectiveOutput selects stream when a set flag declares a stream override. func EffectiveOutput(cmd *Command, set map[string]bool) string { if cmd == nil { diff --git a/internal/invoke/expose.go b/internal/invoke/expose.go index 07a95cdac..f720c7421 100644 --- a/internal/invoke/expose.go +++ b/internal/invoke/expose.go @@ -46,6 +46,11 @@ func Exposure(cmd *cmdregistry.Command, transport string, set map[string]bool) ( if cmd.JSON != canon.JSONEnvelope { return false, "no JSON envelope" } + // A set flag can override json to legacy or none (cmdregistry.EffectiveJSON): + // the command then would not answer with an envelope (D-0614). + if cmdregistry.EffectiveJSON(cmd, set) != canon.JSONEnvelope { + return false, "no JSON envelope with these flags" + } side := cmdregistry.EffectiveSideEffect(cmd, set) switch cmdregistry.EffectiveOutput(cmd, set) { case canon.OutputDocument: diff --git a/internal/invoke/expose_json_test.go b/internal/invoke/expose_json_test.go new file mode 100644 index 000000000..4c985444a --- /dev/null +++ b/internal/invoke/expose_json_test.go @@ -0,0 +1,41 @@ +package invoke + +// Purpose: a flag that overrides json to legacy or none must close the machine +// surface when the request sets it (D-0614). + +import ( + "strings" + "testing" + + "github.com/nself-org/cli/internal/canon" + "github.com/nself-org/cli/internal/cmdregistry" +) + +func TestExposureRefusesFlagJSONOverride(t *testing.T) { + str := func(s string) *string { return &s } + cmd := *mustCmd(t, fixtureRegistry(t), "fx echo") + cmd.Flags = append(append([]cmdregistry.Flag{}, cmd.Flags...), + cmdregistry.Flag{Name: "plainout", Type: "bool", JSON: str(canon.JSONNone)}, + cmdregistry.Flag{Name: "oldjson", Type: "bool", JSON: str(canon.JSONLegacy)}, + cmdregistry.Flag{Name: "docjson", Type: "bool", JSON: str(canon.JSONEnvelope)}) + cases := []struct { + set []string + ok bool + }{ + {nil, true}, + {[]string{"docjson"}, true}, + {[]string{"plainout"}, false}, + {[]string{"oldjson"}, false}, + {[]string{"docjson", "plainout"}, false}, + } + for _, c := range cases { + set := map[string]bool{} + for _, n := range c.set { + set[n] = true + } + ok, reason := Exposure(&cmd, TransportMCP, set) + if ok != c.ok || (!ok && !strings.Contains(reason, "envelope")) { + t.Errorf("set=%v: ok=%v reason=%q, want ok=%v", c.set, ok, reason, c.ok) + } + } +} From 61406719f27a55fb19267d175850ec2949ef4356 Mon Sep 17 00:00:00 2001 From: admin Date: Sun, 25 Oct 2026 11:20:44 -0400 Subject: [PATCH 7/9] fix(plugin): record --json from the forwarded args of an installed plugin command so a proxy start failure still returns an error envelope (D-0614) --- cmd/commands/invocation.go | 17 ++++++++++++++ cmd/commands/plugin_mount_test.go | 37 +++++++++++++++++++++++++++++++ 2 files changed, 54 insertions(+) diff --git a/cmd/commands/invocation.go b/cmd/commands/invocation.go index 951ad7b19..17e2cfd7b 100644 --- a/cmd/commands/invocation.go +++ b/cmd/commands/invocation.go @@ -143,6 +143,12 @@ func locked(orig func(*cobra.Command, []string) error) func(*cobra.Command, []st // guardedPre checks before hooks can fetch, cache, or change directories. func guardedPre(orig func(*cobra.Command, []string) error) func(*cobra.Command, []string) error { return func(cmd *cobra.Command, args []string) error { + if cmd.Annotations["nself.mount.source"] == "installed" { + if err := enterInstalledInvocation(cmd, args); err != nil { + return err + } + return orig(cmd, args) + } if err := enterInvocation(cmd); err != nil { return err } @@ -164,6 +170,17 @@ func enterInvocation(cmd *cobra.Command) error { return imageSwitchGuard(cmd) // E512 while a postgres image switch is incomplete (P7-ADOPT-22) } +// enterInstalledInvocation records the invocation of an installed plugin +// command. The mount parses no flags (DisableFlagParsing), so the --json the +// plugin receives is only in the forwarded args: the intent is read from them, +// otherwise a proxy that fails to start (binary missing, not executable) would +// be reported as text although the caller asked for JSON (D-0614). Nothing is +// refused here: the plugin owns its output. +func enterInstalledInvocation(cmd *cobra.Command, args []string) error { + output.SetInvocation(invokedKey(cmd), output.JSONRequestedFromArgs(args)) + return imageSwitchGuard(cmd) +} + // invokedKey is the canonical registry path without the root name ("" for the // root command). func invokedKey(cmd *cobra.Command) string { diff --git a/cmd/commands/plugin_mount_test.go b/cmd/commands/plugin_mount_test.go index e26c1ea1a..ed440237e 100644 --- a/cmd/commands/plugin_mount_test.go +++ b/cmd/commands/plugin_mount_test.go @@ -11,6 +11,7 @@ import ( "github.com/nself-org/cli/internal/canon" "github.com/nself-org/cli/internal/oplock" + "github.com/nself-org/cli/internal/output" "github.com/nself-org/cli/internal/plugin" "github.com/nself-org/cli/internal/plugin/mount" "github.com/spf13/cobra" @@ -309,3 +310,39 @@ func TestDoctorPluginMount(t *testing.T) { t.Fatalf("doctor: %+v", results) } } + +// TestMountProxyFailureKeepsJSONIntent: an installed plugin command parses no +// flags, so the --json it was given must still be recorded as the invocation's +// JSON mode; otherwise a proxy that cannot start fails as plain text (D-0614). +func TestMountProxyFailureKeepsJSONIntent(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + cases := []struct { + args []string + json bool + }{ + {[]string{"demo", "--json"}, true}, + {[]string{"demo", "sub", "--json=true"}, true}, + {[]string{"demo", "--json=false"}, false}, + {[]string{"demo", "--", "--json"}, false}, + {[]string{"demo"}, false}, + } + for _, c := range cases { + r := &cobra.Command{Use: "nself", SilenceUsage: true, SilenceErrors: true} + r.AddGroup(&cobra.Group{ID: groupPlugins, Title: pluginsGroupTitle}) + r.AddCommand(installedNode(mount.Spec{ + Slug: "demo", Command: "demo", Binary: "nself-demo", + BinaryPath: filepath.Join(t.TempDir(), "missing", "nself-demo"), + Subcommands: []mount.Sub{{Path: []string{"sub"}}}, + })) + installInvocationDecorator(r) + output.SetInvocation("", false) + r.SetArgs(c.args) + if err := r.Execute(); err == nil { + t.Fatalf("%v: the missing plugin binary must fail", c.args) + } + command, on, known := output.Invocation() + if !known || on != c.json || !strings.HasPrefix(command, "demo") { + t.Errorf("%v: invocation = (%q, json=%v, known=%v), want json=%v", c.args, command, on, known, c.json) + } + } +} From e1aea7f42263cb08a7585ff274766b0d5db29893 Mon Sep 17 00:00:00 2001 From: admin Date: Sun, 25 Oct 2026 13:44:41 -0400 Subject: [PATCH 8/9] fix(registry): mount disabled builtin families when building the machine registry so mcp starts after remove admin (D-0863) --- cmd/commands/machine_registry.go | 4 ++++ cmd/commands/registry_disabled_family_test.go | 17 +++++++++++++++++ 2 files changed, 21 insertions(+) diff --git a/cmd/commands/machine_registry.go b/cmd/commands/machine_registry.go index 57b3a9a69..00225dbfb 100644 --- a/cmd/commands/machine_registry.go +++ b/cmd/commands/machine_registry.go @@ -88,6 +88,10 @@ func buildMachineRegistry() (*cmdregistry.Registry, error) { // installed plugins) were mounted under the process's own mode, which sets // their group; lift them off and mount afresh under v1.5. lifted := detachMounts(RootCmd) + // A disabled builtin family keeps its canon entries, so it is mounted for + // the build as in buildRegistry (D-0863); the detachMounts after the build + // removes it again. + mountDisabledBuiltinFamilies(RootCmd) undo := prepareTree(RootCmd, true, false) specs, problems := mount.Discover(resolvePluginDir(), canonTable.Verbs) mountInstalled(RootCmd, specs, problems) diff --git a/cmd/commands/registry_disabled_family_test.go b/cmd/commands/registry_disabled_family_test.go index ce50d510b..c5b792566 100644 --- a/cmd/commands/registry_disabled_family_test.go +++ b/cmd/commands/registry_disabled_family_test.go @@ -31,3 +31,20 @@ func TestDisabledBuiltinFamilyKeepsJSONSurface(t *testing.T) { t.Fatalf("admin --json after remove admin: exit %d, want E407\n%s%s", adm.code, adm.stdout, adm.stderr) } } + +// The machine registry (nself mcp, nself invoke) is built apart from the +// runtime one; it must mount a disabled family too (D-0863), or the family's +// canon entries are all unresolved and mcp exits before serving. +func TestDisabledBuiltinFamilyKeepsMachineRegistry(t *testing.T) { + proj := cfgFixtureProject(t) + rm := runPilot(t, proj, true, nil, "remove", "admin") + if rm.code != 0 { + t.Fatalf("remove admin: exit %d\n%s%s", rm.code, rm.stdout, rm.stderr) + } + // stdin is closed, so a server that built its tools exits cleanly. + mcp := runPilot(t, proj, true, nil, "mcp") + out := mcp.stdout + mcp.stderr + if mcp.code != 0 || strings.Contains(out, "machine registry") || strings.Contains(out, "unresolved") { + t.Fatalf("mcp after remove admin: exit %d, want a clean start\n%s", mcp.code, out) + } +} From 57503c1a4b8e7dc530f39d0b42286f3570a05ebf Mon Sep 17 00:00:00 2001 From: Aric Camarata Date: Sun, 25 Oct 2026 16:04:07 -0400 Subject: [PATCH 9/9] fix(ci): read the Hasura container env through the docker funnel and split invocation.go under the 300-line cap (D-0614) --- cmd/commands/invocation.go | 27 ----------------- cmd/commands/invocation_installed.go | 37 +++++++++++++++++++++++ internal/database/hasura_container_env.go | 18 ++++------- 3 files changed, 43 insertions(+), 39 deletions(-) create mode 100644 cmd/commands/invocation_installed.go diff --git a/cmd/commands/invocation.go b/cmd/commands/invocation.go index 17e2cfd7b..9aa364418 100644 --- a/cmd/commands/invocation.go +++ b/cmd/commands/invocation.go @@ -140,22 +140,6 @@ func locked(orig func(*cobra.Command, []string) error) func(*cobra.Command, []st return oplockcmd.Wrap(func(c *cobra.Command) (*cmdregistry.Command, error) { return jsonEntryFor(c) }, orig) } -// guardedPre checks before hooks can fetch, cache, or change directories. -func guardedPre(orig func(*cobra.Command, []string) error) func(*cobra.Command, []string) error { - return func(cmd *cobra.Command, args []string) error { - if cmd.Annotations["nself.mount.source"] == "installed" { - if err := enterInstalledInvocation(cmd, args); err != nil { - return err - } - return orig(cmd, args) - } - if err := enterInvocation(cmd); err != nil { - return err - } - return orig(cmd, args) - } -} - // enterInvocation records the invocation and refuses unsupported --json. It is // idempotent: the pre-run hook and RunE both call it. func enterInvocation(cmd *cobra.Command) error { @@ -170,17 +154,6 @@ func enterInvocation(cmd *cobra.Command) error { return imageSwitchGuard(cmd) // E512 while a postgres image switch is incomplete (P7-ADOPT-22) } -// enterInstalledInvocation records the invocation of an installed plugin -// command. The mount parses no flags (DisableFlagParsing), so the --json the -// plugin receives is only in the forwarded args: the intent is read from them, -// otherwise a proxy that fails to start (binary missing, not executable) would -// be reported as text although the caller asked for JSON (D-0614). Nothing is -// refused here: the plugin owns its output. -func enterInstalledInvocation(cmd *cobra.Command, args []string) error { - output.SetInvocation(invokedKey(cmd), output.JSONRequestedFromArgs(args)) - return imageSwitchGuard(cmd) -} - // invokedKey is the canonical registry path without the root name ("" for the // root command). func invokedKey(cmd *cobra.Command) string { diff --git a/cmd/commands/invocation_installed.go b/cmd/commands/invocation_installed.go new file mode 100644 index 000000000..474183a36 --- /dev/null +++ b/cmd/commands/invocation_installed.go @@ -0,0 +1,37 @@ +package commands + +// Pre-run entry of the invocation decorator. Installed plugin commands take a +// separate path: the mount parses no flags, so the invocation record is built +// from the forwarded args. + +import ( + "github.com/nself-org/cli/internal/output" + "github.com/spf13/cobra" +) + +// enterInstalledInvocation records the invocation of an installed plugin +// command. The mount parses no flags (DisableFlagParsing), so the --json the +// plugin receives is only in the forwarded args: the intent is read from them, +// otherwise a proxy that fails to start (binary missing, not executable) would +// be reported as text although the caller asked for JSON (D-0614). Nothing is +// refused here: the plugin owns its output. +func enterInstalledInvocation(cmd *cobra.Command, args []string) error { + output.SetInvocation(invokedKey(cmd), output.JSONRequestedFromArgs(args)) + return imageSwitchGuard(cmd) +} + +// guardedPre checks before hooks can fetch, cache, or change directories. +func guardedPre(orig func(*cobra.Command, []string) error) func(*cobra.Command, []string) error { + return func(cmd *cobra.Command, args []string) error { + if cmd.Annotations["nself.mount.source"] == "installed" { + if err := enterInstalledInvocation(cmd, args); err != nil { + return err + } + return orig(cmd, args) + } + if err := enterInvocation(cmd); err != nil { + return err + } + return orig(cmd, args) + } +} diff --git a/internal/database/hasura_container_env.go b/internal/database/hasura_container_env.go index 283724d3b..ff214e52b 100644 --- a/internal/database/hasura_container_env.go +++ b/internal/database/hasura_container_env.go @@ -1,7 +1,7 @@ package database // Purpose: reads one variable from the RUNNING Hasura container's environment -// (`docker inspect`), for checks that must know what Hasura actually runs with +// (via the docker funnel), for checks that must know what Hasura actually runs with // rather than what the project config would give it on the next recreate. // Inputs: context, *config.Config, variable name. // Outputs: the value and whether the container defines it, or an error when @@ -11,25 +11,19 @@ package database import ( "context" "fmt" - "os/exec" - "strings" "github.com/nself-org/cli/internal/config" + "github.com/nself-org/cli/internal/docker" ) // HasuraContainerEnvValue returns the value of key in the running Hasura // container's environment. found is false when the container does not define it. func HasuraContainerEnvValue(ctx context.Context, cfg *config.Config, key string) (value string, found bool, err error) { container := hasuraContainer(cfg) - out, err := exec.CommandContext(ctx, "docker", "inspect", container, - "--format", "{{range .Config.Env}}{{println .}}{{end}}").Output() + info, err := docker.InspectContainer(ctx, container) if err != nil { - return "", false, fmt.Errorf("docker inspect %s (is the stack running?): %w", container, err) + return "", false, fmt.Errorf("inspect %s (is the stack running?): %w", container, err) } - for _, line := range strings.Split(string(out), "\n") { - if v, ok := strings.CutPrefix(strings.TrimRight(line, "\r"), key+"="); ok { - return v, true, nil - } - } - return "", false, nil + value, found = info.Env[key] + return value, found, nil }