From bf096fe9eddb7265b88622260a274e2a165b2b3a Mon Sep 17 00:00:00 2001 From: Aric Camarata Date: Wed, 28 Oct 2026 16:37:38 -0400 Subject: [PATCH 1/6] fix(license): restore legacy no-key and cached JSON shapes (D-0980) --- cmd/commands/account_status.go | 14 ++++++++---- cmd/commands/json_account_test.go | 22 +++++++++++++++++++ cmd/commands/license_envelope.go | 3 +++ cmd/commands/license_envelope_test.go | 21 ++++++++++++++++++ .../account-status-cached-legacy.golden.json | 7 ++++++ .../license-show-no-key-legacy.golden.json | 3 +++ 6 files changed, 66 insertions(+), 4 deletions(-) create mode 100644 cmd/commands/testdata/json/account-status-cached-legacy.golden.json create mode 100644 cmd/commands/testdata/json/license-show-no-key-legacy.golden.json diff --git a/cmd/commands/account_status.go b/cmd/commands/account_status.go index ded5b58fe..19c5ea880 100644 --- a/cmd/commands/account_status.go +++ b/cmd/commands/account_status.go @@ -63,6 +63,9 @@ func runAccountStatus(cmd *cobra.Command, _ []string) error { if infoErr != nil { // Fall back to cached data. if jsonEnvelopeOn(cmd) { + if legacyBare() { + return emitDoc(cmd, cachedAccountJSON(af), true) + } return emitAccountStatus(cmd, accountCachedStatus(af, "cached")) } if jsonOut { @@ -149,20 +152,23 @@ func printCachedAccount(af *auth.AuthFile) { } func printCachedAccountJSON(af *auth.AuthFile) error { + enc := json.NewEncoder(os.Stdout) + enc.SetIndent("", " ") + return enc.Encode(cachedAccountJSON(af)) +} + +func cachedAccountJSON(af *auth.AuthFile) map[string]interface{} { tier := af.Tier if tier == "" { tier = "free" } - out := map[string]interface{}{ + return map[string]interface{}{ "email": af.Email, "display_name": af.DisplayName, "tier": tier, "bundles": af.Bundles, "cached": true, } - enc := json.NewEncoder(os.Stdout) - enc.SetIndent("", " ") - return enc.Encode(out) } // ── account show (legacy alias — same as status) ───────────────────────────── diff --git a/cmd/commands/json_account_test.go b/cmd/commands/json_account_test.go index 489c3d91a..fe1a21640 100644 --- a/cmd/commands/json_account_test.go +++ b/cmd/commands/json_account_test.go @@ -261,3 +261,25 @@ func accountGoldenSample(path string, zero any) any { } return goldenSample(path, zero) } + +func TestAccountStatusLegacyCached(t *testing.T) { + want, err := os.ReadFile("testdata/json/account-status-cached-legacy.golden.json") + if err != nil { + t.Fatal(err) + } + compattest.Set(t, true) + t.Setenv(output.LegacyEnvVar, "1") + undo := prepareTreeWith(&canonTable, RootCmd, true, false) + defer undo() + home := isolateHome(t) + writeTestAuthFile(t, home, &auth.AuthFile{AccessToken: fixtureAccessToken, Email: "alice@example.test", Tier: "plus"}) + accountServer(t, false) + out, err := runLicenseJSON(t, accountStatusCmd, nil) + if err != nil { + t.Fatal(err) + } + noSecrets(t, "legacy cached status", out) + if out != string(want) { + t.Fatalf("got %q, want %q", out, want) + } +} diff --git a/cmd/commands/license_envelope.go b/cmd/commands/license_envelope.go index 6de35358f..381129505 100644 --- a/cmd/commands/license_envelope.go +++ b/cmd/commands/license_envelope.go @@ -57,6 +57,9 @@ func newLicenseStatusData() licenseStatusData { // emitLicenseShow writes the `license show` document in its legacy-compatible // form; out is nil when no key is configured. func emitLicenseShow(cmd *cobra.Command, out *licenseShowJSON) error { + if out == nil && legacyBare() { + return emitDoc(cmd, map[string]string{"error": "no license key configured"}, true) + } doc := licenseShowData{Bundles: []string{}} if out != nil { doc = licenseShowData{Configured: true, KeyPrefix: out.KeyPrefix, Tier: out.Tier, Bundles: nonNilStrings(out.Bundles), diff --git a/cmd/commands/license_envelope_test.go b/cmd/commands/license_envelope_test.go index 9576471ec..491213b65 100644 --- a/cmd/commands/license_envelope_test.go +++ b/cmd/commands/license_envelope_test.go @@ -370,3 +370,24 @@ func TestLicenseMaskedInGoldens(t *testing.T) { } } } + +func TestLicenseShowLegacyNoKey(t *testing.T) { + pkg, err := os.Getwd() + if err != nil { + t.Fatal(err) + } + newLicenseFixture(t) + t.Setenv(output.LegacyEnvVar, "1") + out, err := runLicenseJSON(t, licenseShowCmd, nil) + if err != nil { + t.Fatal(err) + } + want, err := os.ReadFile(filepath.Join(pkg, "testdata/json/license-show-no-key-legacy.golden.json")) + + if err != nil { + t.Fatal(err) + } + if out != string(want) { + t.Fatalf("got %q, want %q", out, want) + } +} diff --git a/cmd/commands/testdata/json/account-status-cached-legacy.golden.json b/cmd/commands/testdata/json/account-status-cached-legacy.golden.json new file mode 100644 index 000000000..d53aa0285 --- /dev/null +++ b/cmd/commands/testdata/json/account-status-cached-legacy.golden.json @@ -0,0 +1,7 @@ +{ + "bundles": null, + "cached": true, + "display_name": "", + "email": "alice@example.test", + "tier": "plus" +} diff --git a/cmd/commands/testdata/json/license-show-no-key-legacy.golden.json b/cmd/commands/testdata/json/license-show-no-key-legacy.golden.json new file mode 100644 index 000000000..99818005b --- /dev/null +++ b/cmd/commands/testdata/json/license-show-no-key-legacy.golden.json @@ -0,0 +1,3 @@ +{ + "error": "no license key configured" +} From f2d7bc8c26fa9279b6e1a15604564819233d6b2e Mon Sep 17 00:00:00 2001 From: Aric Camarata Date: Wed, 28 Oct 2026 16:38:18 -0400 Subject: [PATCH 2/6] fix(plugin): propagate execution environment in dev and debug (D-0834) --- cmd/commands/plugin_debug.go | 3 ++- cmd/commands/plugin_dev.go | 4 +++- internal/plugin/execenv_test.go | 5 ++++- 3 files changed, 9 insertions(+), 3 deletions(-) diff --git a/cmd/commands/plugin_debug.go b/cmd/commands/plugin_debug.go index e4c3115d5..aab1813bf 100644 --- a/cmd/commands/plugin_debug.go +++ b/cmd/commands/plugin_debug.go @@ -7,6 +7,7 @@ import ( "os/exec" "path/filepath" + "github.com/nself-org/cli/internal/compat" "github.com/nself-org/cli/internal/plugin" "github.com/nself-org/cli/internal/ui" @@ -102,7 +103,7 @@ func runPluginDebug(cmd *cobra.Command, args []string) error { dlvCmd.Dir = pluginPath dlvCmd.Stdout = os.Stdout dlvCmd.Stderr = os.Stderr - dlvCmd.Env = append(os.Environ(), "NSELF_PLUGIN_DEV=1") + dlvCmd.Env = append(plugin.ExecEnv(os.Environ(), compat.V15), "NSELF_PLUGIN_DEV=1") if err := dlvCmd.Run(); err != nil { if exitErr, ok := err.(*exec.ExitError); ok && exitErr.ExitCode() == 130 { diff --git a/cmd/commands/plugin_dev.go b/cmd/commands/plugin_dev.go index fcfcbe143..a31be002b 100644 --- a/cmd/commands/plugin_dev.go +++ b/cmd/commands/plugin_dev.go @@ -8,6 +8,7 @@ import ( "regexp" "strings" + "github.com/nself-org/cli/internal/compat" "github.com/nself-org/cli/internal/plugin" "github.com/nself-org/cli/internal/ui" @@ -74,6 +75,7 @@ func runPluginDev(cmd *cobra.Command, args []string) error { } uiInfof("Launching debugger via %s plugin debug %s", nself, name) debugCmd := exec.Command(nself, debugArgs...) + debugCmd.Env = plugin.ExecEnv(os.Environ(), compat.V15) debugCmd.Stdout = os.Stdout debugCmd.Stderr = os.Stderr return debugCmd.Run() @@ -111,7 +113,7 @@ func runPluginDev(cmd *cobra.Command, args []string) error { devCmd.Dir = pluginPath devCmd.Stdout = os.Stdout devCmd.Stderr = os.Stderr - devCmd.Env = append(os.Environ(), "NSELF_PLUGIN_DEV=1") + devCmd.Env = append(plugin.ExecEnv(os.Environ(), compat.V15), "NSELF_PLUGIN_DEV=1") if err := devCmd.Run(); err != nil { // Non-zero exit is expected when user Ctrl+Cs (signal). diff --git a/internal/plugin/execenv_test.go b/internal/plugin/execenv_test.go index caa39bf22..7c3d1f51e 100644 --- a/internal/plugin/execenv_test.go +++ b/internal/plugin/execenv_test.go @@ -140,7 +140,7 @@ func TestMountStubPluginEnv(t *testing.T) { // must call ExecEnv (contract:cli.plugin-exec-env v1). cmd/commands/relocate.go // carries the same check from P7-CANON-13. func TestExecSitesUseExecEnv(t *testing.T) { - for _, file := range []string{"router.go", "runtime_start.go"} { + for _, file := range []string{"router.go", "runtime_start.go", "../../cmd/commands/plugin_dev.go", "../../cmd/commands/plugin_debug.go"} { for _, f := range execSitesWithoutExecEnv(t, file) { t.Errorf("%s: func %s builds an exec.Cmd without calling ExecEnv", file, f) } @@ -189,6 +189,9 @@ func execSitesInSource(t *testing.T, name, src string) []string { } switch fun := call.Fun.(type) { case *ast.SelectorExpr: + if fun.Sel.Name == "ExecEnv" { + calls = true + } if x, ok := fun.X.(*ast.Ident); ok && x.Name == "exec" && (fun.Sel.Name == "Command" || fun.Sel.Name == "CommandContext") { builds = true } From c51dfb25f97b16412c9355ccb7b8d1a03a556e27 Mon Sep 17 00:00:00 2001 From: Aric Camarata Date: Wed, 28 Oct 2026 16:41:09 -0400 Subject: [PATCH 3/6] fix(output): consolidate legacy checks and close review gaps (D-0914) --- .github/openapi.json | 1 + .github/wiki/Plugins.md | 2 +- cmd/commands/account_status.go | 3 +- cmd/commands/bundle_list.go | 3 +- cmd/commands/json_extend_test.go | 16 +++++- cmd/commands/license_envelope.go | 3 +- cmd/commands/plugin_envelope.go | 8 --- cmd/commands/plugin_info.go | 2 +- cmd/commands/plugin_info_test.go | 14 +++-- cmd/commands/plugin_outdated.go | 3 +- cmd/commands/testdata/json/argv/extend.txt | 4 +- internal/builtin/admin/cmd_envelope.go | 8 --- internal/builtin/admin/cmd_logs.go | 5 +- internal/builtin/admin/cmd_logs_test.go | 65 ++++++++++++++++++++++ internal/builtin/admin/cmd_projects.go | 3 +- internal/httpapi/openapi.go | 3 + internal/httpapi/openapi_test.go | 13 +++++ internal/output/legacy.go | 6 +- internal/output/legacy_test.go | 14 +++++ 19 files changed, 141 insertions(+), 35 deletions(-) create mode 100644 internal/builtin/admin/cmd_logs_test.go diff --git a/.github/openapi.json b/.github/openapi.json index 98081f1ba..9d4763fdf 100644 --- a/.github/openapi.json +++ b/.github/openapi.json @@ -22238,6 +22238,7 @@ }, "/v1/commands/config/plugins/info": { "post": { + "description": "An unresolved tier returns an error envelope with the cause. The legacy bare tier_reason and tier_error fields are not a successful HTTP response.", "operationId": "nself_config_plugins_info", "requestBody": { "content": { diff --git a/.github/wiki/Plugins.md b/.github/wiki/Plugins.md index edc6176f5..caebbbdc7 100644 --- a/.github/wiki/Plugins.md +++ b/.github/wiki/Plugins.md @@ -10,7 +10,7 @@ Use `nself plugin search --licensed ` to find Licensed plugins. The older A plugin served as a Free and a Licensed pair (cron, notify) keeps the tier it was installed with. `nself plugin update notify` on a Free install stays Free even after you buy a licence, so a purchase never changes a running stack by itself. The same holds for `nself plugin install notify` over an installed copy and for a Bundle install that touches it. To move to the other tier on purpose, pass `--tier licensed` (or `--tier free`); `--tier licensed` still needs the licence. -The reason a tier was chosen is one of `installed`, `override`, `entitlement`, `only-tier` (the registry serves a single Licensed entry and no entitlement was looked up; install still checks the licence) or `default-free`. If the installed tier cannot be kept (the licence lapsed, or the registry now serves only the other tier), the update stops with E131 and changes nothing; pass `--tier` to choose. A failed entitlement lookup is reported as itself, never as a downgrade. In v1.4 mode an update keeps resolving by entitlement as before. In v1.5 mode `nself plugin info --json` reports `tier` (`free` or `licensed`) and `tier_reason` (v1.4 mode ignores `--json`); a tier that cannot be decided, or a licence lookup that failed, is reported as `tier_reason: "unresolved"` with the cause in `tier_error`, never as the first registry entry or a default; `nself plugin update --tier free|licensed` is the explicit switch. +The reason a tier was chosen is one of `installed`, `override`, `entitlement`, `only-tier` (the registry serves a single Licensed entry and no entitlement was looked up; install still checks the licence) or `default-free`. If the installed tier cannot be kept (the licence lapsed, or the registry now serves only the other tier), the update stops with E131 and changes nothing; pass `--tier` to choose. A failed entitlement lookup is reported as itself, never as a downgrade. In v1.4 mode an update keeps resolving by entitlement as before. In v1.5 mode `nself plugin info --json` reports `tier` (`free` or `licensed`) and `tier_reason` (v1.4 mode ignores `--json`); a tier that cannot be decided, or a licence lookup that failed, returns an error envelope with the reason and cause in its message (`NSELF_JSON_LEGACY=1` keeps the bare `tier_reason: "unresolved"` and `tier_error` fields), never as the first registry entry or a default; `nself plugin update --tier free|licensed` is the explicit switch. ## Compose fragment rules diff --git a/cmd/commands/account_status.go b/cmd/commands/account_status.go index 19c5ea880..6835b39ef 100644 --- a/cmd/commands/account_status.go +++ b/cmd/commands/account_status.go @@ -9,6 +9,7 @@ import ( "encoding/json" "errors" "fmt" + "github.com/nself-org/cli/internal/output" "os" "strings" @@ -63,7 +64,7 @@ func runAccountStatus(cmd *cobra.Command, _ []string) error { if infoErr != nil { // Fall back to cached data. if jsonEnvelopeOn(cmd) { - if legacyBare() { + if output.LegacyRequested() { return emitDoc(cmd, cachedAccountJSON(af), true) } return emitAccountStatus(cmd, accountCachedStatus(af, "cached")) diff --git a/cmd/commands/bundle_list.go b/cmd/commands/bundle_list.go index 1a26c59a2..0c85b65f7 100644 --- a/cmd/commands/bundle_list.go +++ b/cmd/commands/bundle_list.go @@ -16,6 +16,7 @@ package commands import ( "encoding/json" "fmt" + "github.com/nself-org/cli/internal/output" "strings" "github.com/nself-org/cli/internal/bundle" @@ -116,7 +117,7 @@ func runBundleList(cmd *cobra.Command, _ []string) error { } if jsonEnvelopeOn(cmd) { - if rows == nil && !legacyBare() { + if rows == nil && !output.LegacyRequested() { rows = []bundleListRow{} // the envelope data is an array; the bare legacy form printed null } return emitEnv(cmd, rows, true) // P7-SURF-13 diff --git a/cmd/commands/json_extend_test.go b/cmd/commands/json_extend_test.go index 62c7e7bbd..4230a54c4 100644 --- a/cmd/commands/json_extend_test.go +++ b/cmd/commands/json_extend_test.go @@ -350,9 +350,23 @@ func TestServicesBodies(t *testing.T) { }) t.Run("ps without a compose file is an error, not an empty document", func(t *testing.T) { fakeProject(t, "PROJECT_NAME=smoke\n") - if _, err := runExt(t, extendCmd(t, "config", "services", "ps"), nil, map[string]string{}); err == nil { + out, err := runExt(t, extendCmd(t, "config", "services", "ps"), nil, map[string]string{}) + if err == nil { t.Fatal("ps without docker-compose.yml succeeded") } + var envelope bytes.Buffer + if err := output.EmitError(output.Writer{Out: &envelope}, "config services ps", err); err != nil { + t.Fatal(err) + } + var doc struct { + Error struct{ Code, Message string } + } + if err := json.Unmarshal(envelope.Bytes(), &doc); err != nil { + t.Fatal(err) + } + if doc.Error.Code != "E400" || !strings.Contains(doc.Error.Message, "docker-compose.yml") || out != "" { + t.Fatalf("missing-compose failure: output=%q envelope=%s", out, envelope.String()) + } }) t.Run("group answers with its subcommands", func(t *testing.T) { out, err := runExt(t, extendCmd(t, "config", "services"), nil, map[string]string{}) diff --git a/cmd/commands/license_envelope.go b/cmd/commands/license_envelope.go index 381129505..04eef699a 100644 --- a/cmd/commands/license_envelope.go +++ b/cmd/commands/license_envelope.go @@ -16,6 +16,7 @@ package commands import ( "errors" + "github.com/nself-org/cli/internal/output" "github.com/nself-org/cli/internal/license" "github.com/spf13/cobra" @@ -57,7 +58,7 @@ func newLicenseStatusData() licenseStatusData { // emitLicenseShow writes the `license show` document in its legacy-compatible // form; out is nil when no key is configured. func emitLicenseShow(cmd *cobra.Command, out *licenseShowJSON) error { - if out == nil && legacyBare() { + if out == nil && output.LegacyRequested() { return emitDoc(cmd, map[string]string{"error": "no license key configured"}, true) } doc := licenseShowData{Bundles: []string{}} diff --git a/cmd/commands/plugin_envelope.go b/cmd/commands/plugin_envelope.go index 6182a5c15..aeae242e7 100644 --- a/cmd/commands/plugin_envelope.go +++ b/cmd/commands/plugin_envelope.go @@ -21,7 +21,6 @@ import ( "strings" "github.com/nself-org/cli/internal/errs" - "github.com/nself-org/cli/internal/output" "github.com/nself-org/cli/internal/plugin" "github.com/nself-org/cli/internal/version" "github.com/spf13/cobra" @@ -281,13 +280,6 @@ func pluginHubBody(run func(*cobra.Command, []string) error) func(*cobra.Command } } -// legacyBare reports whether NSELF_JSON_LEGACY asks for the pre-contract bare -// form: an empty list that printed `null` then must still print `null`. -func legacyBare() bool { - v := os.Getenv(output.LegacyEnvVar) - return v == "1" || strings.EqualFold(v, "true") -} - func init() { pluginCmd.RunE = pluginHubBody(pluginCmd.RunE) for _, c := range []*cobra.Command{pluginUpdateCmd, pluginRefreshCmd, pluginStartCmd, pluginStopCmd, pluginDisableCmd, pluginEnableCmd} { diff --git a/cmd/commands/plugin_info.go b/cmd/commands/plugin_info.go index af30ae984..bef610520 100644 --- a/cmd/commands/plugin_info.go +++ b/cmd/commands/plugin_info.go @@ -80,7 +80,7 @@ func runPluginInfo(cmd *cobra.Command, args []string) error { // compat.V15(P7-PLUG-17): --json is ignored by plugin info -> prints the info with its tier and why as JSON if asJSON, _ := cmd.Flags().GetBool("json"); asJSON && compat.V15() { - if tierErr != nil && jsonEnvelopeOn(cmd) && !legacyBare() { + if tierErr != nil && jsonEnvelopeOn(cmd) && !output.LegacyRequested() { // P7-SURF-13: a failed lookup is a failure for machine callers (MCP derives isError // from the error member): the v1 error envelope, one document, the cause's exit // status. The reason and the cause travel in its message. NSELF_JSON_LEGACY=1 keeps diff --git a/cmd/commands/plugin_info_test.go b/cmd/commands/plugin_info_test.go index 0b3539fe0..f419ac625 100644 --- a/cmd/commands/plugin_info_test.go +++ b/cmd/commands/plugin_info_test.go @@ -207,7 +207,7 @@ func runInfoRoot(t *testing.T, extra ...string) (string, error) { var stdout, stderr bytes.Buffer child.Stdout, child.Stderr = &stdout, &stderr if err := child.Run(); err != nil { - return stdout.String(), fmt.Errorf("%v: %s", err, strings.TrimSpace(stderr.String())) + return stdout.String(), fmt.Errorf("%w: %s", err, strings.TrimSpace(stderr.String())) } return stdout.String(), nil } @@ -263,8 +263,8 @@ func infoFailure(t *testing.T, out string) (reason, cause string) { } // TestPluginInfoJSONThroughRoot (CR M1, S2): `nself plugin info --json` -// works through the root command. v1.5 prints the JSON (the registry row is -// json legacy, so the E402 gate lets it through); v1.4 still ignores the flag +// works through the root command. v1.5 prints a data envelope on success +// and an error envelope for an unresolved tier; v1.4 still ignores the flag // and prints the table. func TestPluginInfoJSONThroughRoot(t *testing.T) { compattest.Both(t, func(t *testing.T) { @@ -322,15 +322,19 @@ func TestPluginInfoLegacyEnvFailureIsOneBareDocument(t *testing.T) { compattest.Set(t, true) t.Setenv(output.LegacyEnvVar, "1") infoFixtureRegistry(t, "", `{"plugins":[`+ - `{"name":"notify","tier":"free","version":"1.0.0","description":"first entry"},`+ + `{"name":"notify","tier":"free","version":"https://user:synthetic@license.example.test/check?token=abc123","description":"first entry"},`+ `{"name":"notify","tier":"pro","version":"2.0.0","description":"second entry"}]}`) out, err := runInfoRoot(t, "--json") if err == nil { t.Fatalf("an unresolved tier must still fail, got output %s", out) } - if !strings.Contains(err.Error(), "exit status") || !strings.Contains(err.Error(), "tier unresolved") { + var exitErr *exec.ExitError + if !errors.As(err, &exitErr) || exitErr.ExitCode() != errs.ExitUserError || !strings.Contains(err.Error(), "tier unresolved") { t.Fatalf("want a non-zero exit and the reported failure on stderr, got %v", err) } + if strings.Contains(out+err.Error(), "synthetic") || strings.Contains(out+err.Error(), "abc123") { + t.Fatal("legacy failure leaked a fixture credential") + } dec := json.NewDecoder(strings.NewReader(out)) var doc map[string]json.RawMessage if jerr := dec.Decode(&doc); jerr != nil { diff --git a/cmd/commands/plugin_outdated.go b/cmd/commands/plugin_outdated.go index 4e08d80c8..6c2681784 100644 --- a/cmd/commands/plugin_outdated.go +++ b/cmd/commands/plugin_outdated.go @@ -17,6 +17,7 @@ package commands import ( "fmt" + "github.com/nself-org/cli/internal/output" "os" "github.com/nself-org/cli/internal/errs" @@ -105,7 +106,7 @@ func runPluginOutdated(cmd *cobra.Command, args []string) error { } if jsonEnvelopeOn(cmd) { - if rows == nil && !legacyBare() { + if rows == nil && !output.LegacyRequested() { rows = []outdatedPluginRow{} // the envelope data is an array; the bare legacy form printed null } if err := emitEnv(cmd, rows, true); err != nil { // P7-SURF-13 diff --git a/cmd/commands/testdata/json/argv/extend.txt b/cmd/commands/testdata/json/argv/extend.txt index 80d93c36c..83ba55ec9 100644 --- a/cmd/commands/testdata/json/argv/extend.txt +++ b/cmd/commands/testdata/json/argv/extend.txt @@ -8,10 +8,10 @@ config services service list #| legacy-json no-json=envelope-checked-by-config-services-list config services list config services list --core -config services ps #| no-json=needs-a-docker-compose.yml-in-the-project-envelope-checked-by-TestServicesBodies +config services ps #| no-json=missing-compose-TestServicesBodies config services add myapi --dry-run remove waf --dry-run -remove admin #| no-json=disabling-admin-breaks-registry-resolution-for-the-next-json-run-envelope-checked-by-TestExtendBodies +remove admin #| no-json=disables-admin-TestExtendBodies add admin config plugins inventory config plugins status diff --git a/internal/builtin/admin/cmd_envelope.go b/internal/builtin/admin/cmd_envelope.go index 0a2fe12ab..de8ffa54e 100644 --- a/internal/builtin/admin/cmd_envelope.go +++ b/internal/builtin/admin/cmd_envelope.go @@ -13,7 +13,6 @@ package admin // SPORT: P7-SURF-13, contract:cli.json-envelope import ( - "os" "strings" "github.com/nself-org/cli/internal/compat" @@ -134,13 +133,6 @@ func emit(cmd *cobra.Command, data any, legacy bool) error { return output.EmitData(output.Default(), commandKey(cmd), data) } -// legacyBare reports whether NSELF_JSON_LEGACY asks for the pre-contract bare -// form, where an empty list printed `null`. -func legacyBare() bool { - v := os.Getenv(output.LegacyEnvVar) - return v == "1" || strings.EqualFold(v, "true") -} - // hubOf lists the visible subcommands of a group. func hubOf(cmd *cobra.Command) Hub { h := Hub{Subcommands: []HubEntry{}} diff --git a/internal/builtin/admin/cmd_logs.go b/internal/builtin/admin/cmd_logs.go index 49907eda0..e48499a82 100644 --- a/internal/builtin/admin/cmd_logs.go +++ b/internal/builtin/admin/cmd_logs.go @@ -77,8 +77,11 @@ func runAdminLogsJSON(cmd *cobra.Command, logsCmd *exec.Cmd, cid string, follow werr = rerr } if follow { - _ = output.StreamEnd(w.Out, commandKey(cmd), LogsEnd{Lines: count}, werr) + if endErr := output.StreamEnd(w.Out, commandKey(cmd), LogsEnd{Lines: count}, werr); endErr != nil { + return endErr + } if werr != nil { + output.RenderError(w.Err, werr) return silentReported{werr} } return nil diff --git a/internal/builtin/admin/cmd_logs_test.go b/internal/builtin/admin/cmd_logs_test.go new file mode 100644 index 000000000..91a1b1d06 --- /dev/null +++ b/internal/builtin/admin/cmd_logs_test.go @@ -0,0 +1,65 @@ +package admin + +import ( + "encoding/json" + "errors" + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" + "testing" + + "github.com/nself-org/cli/internal/errs" + "github.com/spf13/cobra" +) + +func TestFollowLogsProducerFailureDiagnostic(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("shell fixture") + } + dir := t.TempDir() + out, err := os.Create(filepath.Join(dir, "stdout")) + if err != nil { + t.Fatal(err) + } + defer out.Close() + diag, err := os.Create(filepath.Join(dir, "stderr")) + if err != nil { + t.Fatal(err) + } + defer diag.Close() + oldOut, oldErr := os.Stdout, os.Stderr + os.Stdout, os.Stderr = out, diag + defer func() { os.Stdout, os.Stderr = oldOut, oldErr }() + cmd := &cobra.Command{Use: "logs"} + err = runAdminLogsJSON(cmd, exec.Command("sh", "-c", "echo fixture-line; exit 7"), "fixture", true) + var silent errs.Silencer + if err == nil || !errors.As(err, &silent) || !silent.Silent() { + t.Fatalf("failure not marked reported: %v", err) + } + raw, rerr := os.ReadFile(out.Name()) + if rerr != nil { + t.Fatal(rerr) + } + lines := strings.Split(strings.TrimSpace(string(raw)), "\n") + if len(lines) != 2 { + t.Fatalf("want log then error envelope, got %s", raw) + } + var end struct { + Error struct{ Code, Message string } + } + if err := json.Unmarshal([]byte(lines[1]), &end); err != nil { + t.Fatal(err) + } + if end.Error.Code != "E400" || !strings.Contains(end.Error.Message, "exit status 7") { + t.Fatalf("closing envelope: %s", lines[1]) + } + stderr, err := os.ReadFile(diag.Name()) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(stderr), "exit status 7") { + t.Fatalf("missing stderr diagnostic: %q", stderr) + } +} diff --git a/internal/builtin/admin/cmd_projects.go b/internal/builtin/admin/cmd_projects.go index 0b8a7214d..e41c2cb62 100644 --- a/internal/builtin/admin/cmd_projects.go +++ b/internal/builtin/admin/cmd_projects.go @@ -8,6 +8,7 @@ package admin import ( "encoding/json" "fmt" + "github.com/nself-org/cli/internal/output" "github.com/nself-org/cli/internal/ui" @@ -35,7 +36,7 @@ func projectsCommand() *cobra.Command { } if jsonOn(cmd) { - if projects == nil && !legacyBare() { + if projects == nil && !output.LegacyRequested() { projects = []AdminProject{} // the envelope data is an array; the bare legacy form printed null } return emit(cmd, ProjectList(projects), true) diff --git a/internal/httpapi/openapi.go b/internal/httpapi/openapi.go index 00b4cabe5..23009ebba 100644 --- a/internal/httpapi/openapi.go +++ b/internal/httpapi/openapi.go @@ -87,6 +87,9 @@ func OpenAPI(reg *cmdregistry.Registry, dataSchema func(string) ([]byte, bool)) "default": jsonResponse("Error envelope", schemaRef("error_envelope")), }, }} + if cmd.Path == "nself config plugins info" { + paths[cmdregistry.RoutePath(cmd.Path)].(map[string]any)["post"].(map[string]any)["description"] = "An unresolved tier returns an error envelope with the cause. The legacy bare tier_reason and tier_error fields are not a successful HTTP response." + } } doc := map[string]any{ "openapi": "3.1.0", "info": map[string]any{"title": "nSelf CLI HTTP API", "version": "1"}, diff --git a/internal/httpapi/openapi_test.go b/internal/httpapi/openapi_test.go index b9502ecfd..07d78a4fd 100644 --- a/internal/httpapi/openapi_test.go +++ b/internal/httpapi/openapi_test.go @@ -95,3 +95,16 @@ func TestOpenAPIBuilder(t *testing.T) { t.Fatal("invalid data schema accepted") } } + +func TestOpenAPIPluginInfoFailureDescription(t *testing.T) { + reg := &cmdregistry.Registry{Commands: []cmdregistry.Command{{ + Path: "nself config plugins info", Runnable: true, Canon: "subcommand", SideEffect: "read", Output: "document", JSON: "envelope", + }}} + raw, err := OpenAPI(reg, nil) + if err != nil { + t.Fatal(err) + } + if !bytes.Contains(raw, []byte("An unresolved tier returns an error envelope")) { + t.Fatalf("missing failure description: %s", raw) + } +} diff --git a/internal/output/legacy.go b/internal/output/legacy.go index ba76012f9..1d52b04a5 100644 --- a/internal/output/legacy.go +++ b/internal/output/legacy.go @@ -29,7 +29,7 @@ func EmitLegacyCompatible(w Writer, command string, data any) error { if !compat.V15() { return writeBare(w, data) } - if !legacyRequested() { + if !LegacyRequested() { return EmitData(w, command, data) } if err := writeBare(w, data); err != nil { @@ -41,8 +41,8 @@ func EmitLegacyCompatible(w Writer, command string, data any) error { return nil } -// legacyRequested reports whether NSELF_JSON_LEGACY selects bare output. -func legacyRequested() bool { +// LegacyRequested reports whether NSELF_JSON_LEGACY selects bare output. +func LegacyRequested() bool { v := os.Getenv(LegacyEnvVar) return v == "1" || strings.EqualFold(v, "true") } diff --git a/internal/output/legacy_test.go b/internal/output/legacy_test.go index ff9b7eb6a..51bf9ab39 100644 --- a/internal/output/legacy_test.go +++ b/internal/output/legacy_test.go @@ -158,3 +158,17 @@ func TestLegacyMarshalFailureIsReturned(t *testing.T) { } } } + +func TestLegacyRequested(t *testing.T) { + for _, tc := range []struct { + value string + want bool + }{{"", false}, {"0", false}, {"1", true}, {"true", true}, {"TRUE", true}, {"yes", false}} { + t.Run(tc.value, func(t *testing.T) { + t.Setenv(LegacyEnvVar, tc.value) + if got := LegacyRequested(); got != tc.want { + t.Fatalf("LegacyRequested() = %v, want %v", got, tc.want) + } + }) + } +} From c695dd5b6234d0ba5b36d562c44dfdf201964102 Mon Sep 17 00:00:00 2001 From: Aric Camarata Date: Wed, 28 Oct 2026 16:43:34 -0400 Subject: [PATCH 4/6] fix(cli): record command outcomes and clarify machine secrets (D-0652) --- .github/wiki/Command-Registry.md | 2 + .github/wiki/MCP-Server.md | 2 + .github/wiki/error-codes.md | 2 +- cmd/commands/command_log_lifecycle_test.go | 88 ++++++++++++++++++++++ cmd/commands/dispatch.go | 9 ++- cmd/commands/root.go | 12 +-- cmd/commands/secrets_core.go | 2 +- cmd/commands/secrets_core_logsite_test.go | 2 +- cmd/commands/secrets_core_stdin.go | 10 +-- cmd/commands/secrets_core_stdin_test.go | 36 +++++++++ internal/errs/codes_gate.go | 4 +- 11 files changed, 152 insertions(+), 17 deletions(-) create mode 100644 cmd/commands/command_log_lifecycle_test.go diff --git a/.github/wiki/Command-Registry.md b/.github/wiki/Command-Registry.md index e276daa5c..32cb09a24 100644 --- a/.github/wiki/Command-Registry.md +++ b/.github/wiki/Command-Registry.md @@ -92,6 +92,8 @@ Fields are in this order. Consumers must ignore fields they do not know: new fie Declare `confirm`, `surface: cli-only`, and `secret_args` on the command row in its canon fragment. `confirm.flags` lists local or inherited boolean flags; an empty list is valid for nonce confirmation. A plan form names its boolean `flag` and string `id_flag`. A flag override may declare `cli_only: true` or `secret: true`, alongside the existing `output: stream` override for a follow-style flag. A deprecated shim receives none of these declarations. Installed plugin commands carry the same facts through their mount annotations. Invalid plugin confirmation is omitted with one E437 warning per plugin. +For `secrets set`, the machine invoker hands `secret_args` values to the child through its environment and a supplied VALUE of `"-"` is interpreted literally, subject to normal secret validation, instead of reading stdin. In a terminal, omitting VALUE or passing `-` reads stdin. Machine requests have no stdin, so a restored VALUE never uses that marker. + `cmdregistry.ToolName(path)` replaces spaces and hyphens with underscores and prefixes `nself_`; names must be unique, at most 64 characters, and contain only lowercase letters, digits, and underscores. `RoutePath(path)` separates path words under `/v1/commands/`. `EffectiveSideEffect` takes the highest class from the command and set flag overrides. `EffectiveOutput` returns `stream` when a set flag declares `output: stream`, otherwise the command output. ### Canon status diff --git a/.github/wiki/MCP-Server.md b/.github/wiki/MCP-Server.md index a4d6a2eb1..1f16df5cb 100644 --- a/.github/wiki/MCP-Server.md +++ b/.github/wiki/MCP-Server.md @@ -106,6 +106,8 @@ The `sse` and `http` transports of `nself mcp` check a bearer token on every req 2. The file `~/.nself/mcp-token` (source `file`). It must be a regular file (a symlink is refused), mode `0600` on macOS and Linux, and not empty. A file that group or others can read is refused with `E426`; the message names the path and `chmod 600`. 3. Neither. In v1.5 mode (`NSELF_V15=1`) the first loopback start generates 32 random bytes as 64 hex characters, creates `~/.nself` at `0700` if it is missing (an existing one is narrowed to `0700`, never widened; if that fails and group or others can write to it, the start is refused), writes the file at `0600` and prints its path and a `cat ` hint on standard error. The token value is never printed to any stream. Later starts read the file. In v1.4 mode (the default today) a start with no token serves an open loopback port and prints one warning. +Token lookup is read-only. If an existing `~/.nself` is group-writable (for example after umask `002`), run `chmod go-w ~/.nself` before starting the server; lookup refuses it with `E426` even when the token itself is `0600`. Run nself as the token file’s owner: `sudo nself` reading a user-owned token is also refused with `E426`, because the file owner must match the effective user. + Long-lived responses (SSE streams, a tool call that runs for minutes) are not cut by the read timeout: the request body is read under it, then the read deadline is cleared. A request without the right `Authorization: Bearer ` header gets `401 unauthorized` with `WWW-Authenticate: Bearer realm="nself-mcp"`. The comparison hashes both sides first, so it does not depend on the token length. With a token set, behaviour is the same in both modes. Both modes also read an existing token file, so a malformed or group-readable `~/.nself/mcp-token` stops a v1.4 start with `E426` too (the file is new in 1.5; nothing created it before). `stdio` needs no token. The invoker removes `NSELF_MCP_TOKEN` from the environment of the commands it runs. ## Transports and --host diff --git a/.github/wiki/error-codes.md b/.github/wiki/error-codes.md index f06c736e0..b9a8f19b9 100644 --- a/.github/wiki/error-codes.md +++ b/.github/wiki/error-codes.md @@ -237,7 +237,7 @@ These errors come from the command line itself: usage, JSON support, destructive | E423 | 2 | Command invocation timed out | The command did not finish before the invocation timeout and was stopped. | Raise the server invocation timeout, or use a command form that returns sooner. | | E424 | 4 | Confirmation does not match | The confirm value is not a live server-issued nonce or plan id for this exact request: it was reused, expired, unknown, computed by the client, or issued for a different request. | Send the request again without confirm, read the confirmation it returns, and repeat the identical request with that confirm value. | | E425 | 1 | Consent or plan-id flag set by the caller | Consent flags (such as --yes or --force) and plan-id flags belong to the invoker; a machine request cannot set them. | Remove the flag from the request and use the confirmation round trip: the server adds it once you confirm. | -| E426 | 3 | Machine token missing, invalid or insecure | The machine surface needs a bearer token it can trust: the token file is unreadable or readable by others, or a non-loopback bind lacks --allowed-host and a configured token. | Set NSELF_MCP_TOKEN or keep ~/.nself/mcp-token at mode 0600; for a non-loopback --host also pass --allowed-host . | +| E426 | 3 | Machine token missing, invalid or insecure | The machine surface needs a bearer token it can trust: the token file is unreadable, readable by others, owned by another effective user (including sudo), or in a writable parent directory; or a non-loopback bind lacks --allowed-host and a configured token. | Run as the token file owner; set NSELF_MCP_TOKEN or keep ~/.nself/mcp-token at mode 0600 and run chmod go-w ~/.nself; for a non-loopback --host also pass --allowed-host . | | E428 | 1 | Host not allowed | The request Host is outside the server's allowed hosts and port. | Use the configured listener host or pass --allowed-host explicitly. | | E429 | 1 | Origin not allowed | The request Origin is outside the server's allowed HTTP origins. | Use an allowed HTTP origin on the listener port. | | E430 | 1 | Malformed HTTP request | The method, JSON body, content type, body size or Accept header is invalid. | POST a JSON machine request no larger than 1 MiB and accept application/json or application/x-ndjson. | diff --git a/cmd/commands/command_log_lifecycle_test.go b/cmd/commands/command_log_lifecycle_test.go new file mode 100644 index 000000000..916af6739 --- /dev/null +++ b/cmd/commands/command_log_lifecycle_test.go @@ -0,0 +1,88 @@ +package commands + +import ( + "encoding/json" + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + + "github.com/nself-org/cli/internal/cmdlog" + "github.com/nself-org/cli/internal/errs" + "github.com/spf13/cobra" +) + +func TestCommandLogLifecycleChild(t *testing.T) { + if os.Getenv("NSELF_TEST_LOG_CHILD") != "1" { + t.Skip("child fixture") + } + c := &cobra.Command{Use: "log-fixture", RunE: func(c *cobra.Command, args []string) error { + home, _ := os.UserHomeDir() + if _, err := os.Stat(filepath.Join(home, ".nself/logs/nself.log")); !os.IsNotExist(err) { + return fmt.Errorf("log was written before command completion") + } + mode, _ := c.Flags().GetString("mode") + switch mode { + case "error": + secret, _ := c.Flags().GetString("api-token") + return errs.New("E426", "fixture failure "+secret) + case "state": + return errs.Exit(12) + } + return nil + }} + c.Flags().String("api-token", "", "fixture") + c.Flags().String("mode", "", "fixture") + RootCmd.AddCommand(c) + os.Args = []string{"nself", "log-fixture", "--api-token", "FAKE-log-lifecycle-secret", "--mode", os.Getenv("NSELF_TEST_LOG_MODE")} + err := Execute() + os.Exit(errs.ExitCodeFor(err)) +} + +func TestCommandLogLifecycle(t *testing.T) { + for _, tc := range []struct { + mode string + code int + }{{"success", 0}, {"error", errs.ExitAuthError}, {"state", 12}} { + t.Run(tc.mode, func(t *testing.T) { + home := t.TempDir() + child := exec.Command(os.Args[0], "-test.run=^TestCommandLogLifecycleChild$") + child.Dir = t.TempDir() + child.Env = append(os.Environ(), "HOME="+home, "USERPROFILE="+home, "NSELF_V15=0", "NSELF_CMD_LOG_ENABLED=1", "NSELF_TEST_LOG_CHILD=1", "NSELF_TEST_LOG_MODE="+tc.mode) + err := child.Run() + code := 0 + if err != nil { + if e, ok := err.(*exec.ExitError); ok { + code = e.ExitCode() + } else { + t.Fatal(err) + } + } + if code != tc.code { + t.Fatalf("exit = %d, want %d", code, tc.code) + } + raw, err := os.ReadFile(filepath.Join(home, ".nself/logs/nself.log")) + if err != nil { + t.Fatal(err) + } + if strings.Count(string(raw), "\n") != 1 { + t.Fatalf("want one entry, got %s", raw) + } + var entry cmdlog.Entry + if err := json.Unmarshal(raw, &entry); err != nil { + t.Fatal(err) + } + if entry.ExitCode != tc.code { + t.Fatalf("logged exit = %d, want %d", entry.ExitCode, tc.code) + } + if strings.Contains(string(raw), "FAKE-log-lifecycle-secret") { + t.Fatal("fixture secret reached command log") + } + if tc.mode == "error" && (!strings.Contains(entry.Error, "fixture failure") || !strings.Contains(entry.Error, "[REDACTED]")) { + t.Fatalf("error missing or not scrubbed: %s", raw) + } + }) + } +} diff --git a/cmd/commands/dispatch.go b/cmd/commands/dispatch.go index 18d09b7df..346beea43 100644 --- a/cmd/commands/dispatch.go +++ b/cmd/commands/dispatch.go @@ -54,7 +54,14 @@ func invokedCommandPath(cmd *cobra.Command) string { // Execute adds all child commands to the root command and sets flags appropriately. // This is called by main.main(). -func Execute() error { +func Execute() (resultErr error) { + commandLogFinish = nil + defer func() { + if commandLogFinish != nil { + commandLogFinish(errs.ExitCodeFor(resultErr), resultErr) + commandLogFinish = nil + } + }() // Secret values arrive by environment, not argv (D-0468): restore them first. if err := restoreSecretArgv(); err != nil { return err diff --git a/cmd/commands/root.go b/cmd/commands/root.go index 17c6fb7fc..4e39e208f 100644 --- a/cmd/commands/root.go +++ b/cmd/commands/root.go @@ -29,6 +29,9 @@ var deprecationRegistry *deprecation.Registry // PreRunE → RunE → PostRunE cobra lifecycle so spans are not flushed early. var tracerShutdown func(context.Context) error +// commandLogFinish is completed by Execute; Cobra skips post-run hooks on failure. +var commandLogFinish func(int, error) + // contextKey is an unexported type for context keys in this package. type contextKey int @@ -121,18 +124,15 @@ func init() { } } // ── Command execution log ───────────────────────────────────────────── - // Write to ~/.nself/logs/ — a fixed, user-scoped path independent of - // the working directory. Using cwd caused stray logs/ directories to - // appear in arbitrary project folders (cross-project pollution). - // Fallback to os.TempDir() if the home dir is unavailable. + // Begin in the user-scoped log directory, independent of cwd. + // Execute records the outcome after Cobra returns. var logDir string if home, err := os.UserHomeDir(); err == nil { logDir = filepath.Join(home, ".nself", "logs") } else { logDir = filepath.Join(os.TempDir(), "nself", "logs") } - finishLog := cmdlog.New(logDir).Begin(os.Args, commandSecretValues(cmd, args)...) - defer func() { finishLog(0, nil) }() + commandLogFinish = cmdlog.New(logDir).Begin(os.Args, commandSecretValues(cmd, args)...) // `nself --version` prints and stops. Returning a silent exit-0 error // rather than calling os.Exit keeps the single-exit-point rule and lets diff --git a/cmd/commands/secrets_core.go b/cmd/commands/secrets_core.go index f28cfe048..8e7cc3986 100644 --- a/cmd/commands/secrets_core.go +++ b/cmd/commands/secrets_core.go @@ -54,7 +54,7 @@ command line, where the shell history and the process list can show it.`, } key := args[0] var value string - if len(args) > 1 && args[1] != "-" { + if len(args) > 1 && (args[1] != "-" || restoredInvokeSecrets) { value = args[1] } else { v, err := readSecretValue(cmd, key) diff --git a/cmd/commands/secrets_core_logsite_test.go b/cmd/commands/secrets_core_logsite_test.go index 4f1cee00d..771ea8554 100644 --- a/cmd/commands/secrets_core_logsite_test.go +++ b/cmd/commands/secrets_core_logsite_test.go @@ -36,7 +36,7 @@ func TestRootLogsNoSecretPositional(t *testing.T) { env = append(env, "HOME="+home, "USERPROFILE="+home, "NSELF_CMD_LOG_ENABLED=true") cmd := exec.Command(bin, "secrets", "set", "FAKEKEY", fakeCallsite0473) cmd.Dir, cmd.Env = proj, env - _ = cmd.Run() // the exit code is not the point: the log line is written before RunE + _ = cmd.Run() // the failure is also logged after RunE returns data, err := os.ReadFile(filepath.Join(home, ".nself", "logs", "nself.log")) if err != nil { diff --git a/cmd/commands/secrets_core_stdin.go b/cmd/commands/secrets_core_stdin.go index 328afe12a..e53e6f1a3 100644 --- a/cmd/commands/secrets_core_stdin.go +++ b/cmd/commands/secrets_core_stdin.go @@ -9,12 +9,12 @@ package commands // may carry the --json envelope. import ( - "errors" "fmt" "io" "os" "strings" + "github.com/nself-org/cli/internal/errs" "github.com/spf13/cobra" "golang.org/x/term" ) @@ -30,16 +30,16 @@ func readSecretValue(cmd *cobra.Command, key string) (string, error) { raw, err := term.ReadPassword(int(f.Fd())) _, _ = fmt.Fprintln(cmd.ErrOrStderr()) if err != nil { - return "", fmt.Errorf("reading value: %w", err) + return "", errs.Wrap("E401", "reading value", err) } return nonEmptySecret(string(raw)) } raw, err := io.ReadAll(io.LimitReader(in, maxSecretStdinBytes+1)) if err != nil { - return "", fmt.Errorf("reading value from stdin: %w", err) + return "", errs.Wrap("E401", "reading value from stdin", err) } if len(raw) > maxSecretStdinBytes { - return "", fmt.Errorf("the value on stdin is larger than %d bytes", maxSecretStdinBytes) + return "", errs.New("E401", fmt.Sprintf("the value on stdin is larger than %d bytes", maxSecretStdinBytes)) } text := strings.TrimSuffix(string(raw), "\n") if len(text) < len(raw) { @@ -50,7 +50,7 @@ func readSecretValue(cmd *cobra.Command, key string) (string, error) { func nonEmptySecret(v string) (string, error) { if v == "" { - return "", errors.New("the secret value is empty: pass it on stdin or at the prompt") + return "", errs.New("E401", "the secret value is empty: pass it on stdin or at the prompt") } return v, nil } diff --git a/cmd/commands/secrets_core_stdin_test.go b/cmd/commands/secrets_core_stdin_test.go index a7400629d..72f019793 100644 --- a/cmd/commands/secrets_core_stdin_test.go +++ b/cmd/commands/secrets_core_stdin_test.go @@ -5,6 +5,9 @@ package commands // Inputs: fake values on an in-memory reader or a real pipe. Outputs: pass/fail. import ( + "errors" + "github.com/nself-org/cli/internal/errs" + "io" "os" "strings" "testing" @@ -101,3 +104,36 @@ func TestSecretsSet_ValueFromStdin(t *testing.T) { } }) } + +func TestSecretStdinErrorsAreCoded(t *testing.T) { + for _, in := range []io.Reader{strings.NewReader(""), strings.NewReader(strings.Repeat("a", maxSecretStdinBytes+1)), brokenSecretInput{}} { + c := &cobra.Command{} + c.SetIn(in) + _, err := readSecretValue(c, "FIXTURE") + var coded *errs.CLIError + if !errors.As(err, &coded) || coded.Code != "E401" { + t.Errorf("want E401, got %v", err) + } + } +} + +type brokenSecretInput struct{} + +func (brokenSecretInput) Read([]byte) (int, error) { return 0, io.ErrUnexpectedEOF } + +func TestSecretsSetMachineLiteralDash(t *testing.T) { + requireAge(t) + withProjectRoot(t, func(root string) { + initSecretsProject(t, root) + old := restoredInvokeSecrets + t.Cleanup(func() { restoredInvokeSecrets = old }) + restoredInvokeSecrets = true + cmd := stdinCmd("") + // A literal dash is too short for the secret store. Reaching that + // validation proves VALUE was preserved instead of reading empty stdin. + err := secretsSetCmd.RunE(cmd, []string{"FAKE_MACHINE_DASH", "-"}) + if err == nil || !strings.Contains(err.Error(), "value is only 1 characters") { + t.Fatalf("literal dash did not reach secret validation: %v", err) + } + }) +} diff --git a/internal/errs/codes_gate.go b/internal/errs/codes_gate.go index bb0e13f88..d89912055 100644 --- a/internal/errs/codes_gate.go +++ b/internal/errs/codes_gate.go @@ -27,8 +27,8 @@ func init() { Code: "E426", Category: "cli", Summary: "Machine token missing, invalid or insecure", - DefaultWhy: "The machine surface needs a bearer token it can trust: the token file is unreadable or readable by others, or a non-loopback bind lacks --allowed-host and a configured token.", - DefaultFix: "Set NSELF_MCP_TOKEN or keep ~/.nself/mcp-token at mode 0600; for a non-loopback --host also pass --allowed-host .", + DefaultWhy: "The machine surface needs a bearer token it can trust: the token file is unreadable, readable by others, owned by another effective user (including sudo), or in a writable parent directory; or a non-loopback bind lacks --allowed-host and a configured token.", + DefaultFix: "Run as the token file owner; set NSELF_MCP_TOKEN or keep ~/.nself/mcp-token at mode 0600 and run chmod go-w ~/.nself; for a non-loopback --host also pass --allowed-host .", DocsPath: "reference/error-codes#e426", Exit: ExitAuthError, }, From 81e13f64aaf137d7440c235468bbb0e70729c839 Mon Sep 17 00:00:00 2001 From: Aric Camarata Date: Wed, 28 Oct 2026 18:00:56 -0400 Subject: [PATCH 5/6] fix: move cachedAccountJSON to internal/auth to satisfy package-size ratchet --- .github/facts.json | 2 +- cmd/commands/account_status.go | 18 ++---------------- internal/auth/storage.go | 17 +++++++++++++++++ 3 files changed, 20 insertions(+), 17 deletions(-) diff --git a/.github/facts.json b/.github/facts.json index 91818ddc4..a335b79cb 100644 --- a/.github/facts.json +++ b/.github/facts.json @@ -9,7 +9,7 @@ }, "schema_version": "1", "sources": { - "openapi_sha256": "01ea16e2ac6cebd687983d758b80a9f1ddffe3b6a3a85bda4fc70ce077ca71b6", + "openapi_sha256": "2c0de95f09fd6b06b1031a9fc2ed8ebacfc635e975ca487c2803903ddb120e7f", "registry_sha256": "37fe98fe4dfa26f0c8af15f5cc50e9a5b81f499976850ae2b6066c536e8fe6c9" }, "verbs": [ diff --git a/cmd/commands/account_status.go b/cmd/commands/account_status.go index 6835b39ef..f8d9c4036 100644 --- a/cmd/commands/account_status.go +++ b/cmd/commands/account_status.go @@ -65,7 +65,7 @@ func runAccountStatus(cmd *cobra.Command, _ []string) error { // Fall back to cached data. if jsonEnvelopeOn(cmd) { if output.LegacyRequested() { - return emitDoc(cmd, cachedAccountJSON(af), true) + return emitDoc(cmd, auth.CachedAccountJSON(af), true) } return emitAccountStatus(cmd, accountCachedStatus(af, "cached")) } @@ -155,21 +155,7 @@ func printCachedAccount(af *auth.AuthFile) { func printCachedAccountJSON(af *auth.AuthFile) error { enc := json.NewEncoder(os.Stdout) enc.SetIndent("", " ") - return enc.Encode(cachedAccountJSON(af)) -} - -func cachedAccountJSON(af *auth.AuthFile) map[string]interface{} { - tier := af.Tier - if tier == "" { - tier = "free" - } - return map[string]interface{}{ - "email": af.Email, - "display_name": af.DisplayName, - "tier": tier, - "bundles": af.Bundles, - "cached": true, - } + return enc.Encode(auth.CachedAccountJSON(af)) } // ── account show (legacy alias — same as status) ───────────────────────────── diff --git a/internal/auth/storage.go b/internal/auth/storage.go index fdce256cc..b837f52a2 100644 --- a/internal/auth/storage.go +++ b/internal/auth/storage.go @@ -139,3 +139,20 @@ func GetAuthFilePath() string { } return path } + +// CachedAccountJSON returns the legacy bare-JSON map for a cached auth file. +// It lives here (not in cmd/commands) so the commands package stays under its +// size ceiling; callers encode it themselves. +func CachedAccountJSON(af *AuthFile) map[string]interface{} { + tier := af.Tier + if tier == "" { + tier = "free" + } + return map[string]interface{}{ + "email": af.Email, + "display_name": af.DisplayName, + "tier": tier, + "bundles": af.Bundles, + "cached": true, + } +} From e1780689ec74685993724db4f4e62a1fab82784b Mon Sep 17 00:00:00 2001 From: Aric Camarata Date: Wed, 28 Oct 2026 18:23:19 -0400 Subject: [PATCH 6/6] fix: correct gofmt indentation in account_status legacy return --- cmd/commands/account_status.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cmd/commands/account_status.go b/cmd/commands/account_status.go index f8d9c4036..040da520a 100644 --- a/cmd/commands/account_status.go +++ b/cmd/commands/account_status.go @@ -65,7 +65,7 @@ func runAccountStatus(cmd *cobra.Command, _ []string) error { // Fall back to cached data. if jsonEnvelopeOn(cmd) { if output.LegacyRequested() { - return emitDoc(cmd, auth.CachedAccountJSON(af), true) + return emitDoc(cmd, auth.CachedAccountJSON(af), true) } return emitAccountStatus(cmd, accountCachedStatus(af, "cached")) }