Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
23a3e7e
fix(entitlement): serialize timer and token writes (D-0947)
acamarata Oct 28, 2026
bae9059
test(plugin): pin archive snapshot contract from D-1326 (D-0844)
acamarata Oct 28, 2026
c5e31f8
fix(ci): refresh and verify published gate cache (D-1416)
acamarata Oct 28, 2026
4a374b6
fix(templates): use manifest steps in normal handlers (D-0916)
acamarata Oct 28, 2026
497c52a
fix(build): defer regenerated certificate bytes to apply (D-0930)
acamarata Oct 28, 2026
cab18fd
fix(config): recognize auth passthrough variables (D-0918)
acamarata Oct 28, 2026
73d398c
fix(maintenance): create lock dir before cron self-removal redirect (…
acamarata Oct 28, 2026
6e8723d
fix(plugins): move autoinstall receipt logic out of cmd/commands
acamarata Oct 28, 2026
c6e31a6
fix(plugin,maintenance): pin archive checksum and guard cron flock
acamarata Oct 28, 2026
0170db6
style(maintenance): gofmt renderCron in entitlement_timer
acamarata Oct 29, 2026
2e428b9
fix(ci): remove unused isRegularFile from ci_bootstrap
Oct 29, 2026
e010259
fix(maintenance): hold cron lock across the self-removal read-modify-…
acamarata Oct 29, 2026
c294699
fix(maintenance): keep entitlement_timer under the size budget and co…
acamarata Oct 29, 2026
cea3d28
test(plugin): derive archive snapshot hash at run time so it holds ac…
acamarata Oct 29, 2026
9ca1c39
fix(build): keep internal/build under its package-size ceiling
acamarata Oct 29, 2026
d13959e
chore(ci): regenerate facts.json after rebase onto main
acamarata Oct 29, 2026
466f27c
test(maintenance): take a real fd lock in the cron self-removal test …
acamarata Oct 29, 2026
b348116
chore(ci): regenerate facts.json after rebase onto main
acamarata Oct 29, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/facts.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
},
"schema_version": "1",
"sources": {
"openapi_sha256": "c78ebcd13343a9c05be443c7dbde6226cc8be9019a5384ad969b1522c62d945a",
"openapi_sha256": "1f47f7be7d98810ce4eca08fb61bd930cc662290040b162bfc96fe6e602a6059",
"registry_sha256": "37fe98fe4dfa26f0c8af15f5cc50e9a5b81f499976850ae2b6066c536e8fe6c9"
},
"verbs": [
Expand Down
18 changes: 18 additions & 0 deletions .github/openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -11095,6 +11095,15 @@
"installCount": {
"type": "integer"
},
"next_steps": {
"items": {
"type": "string"
},
"type": [
"null",
"array"
]
},
"previewUrl": {
"type": "string"
},
Expand Down Expand Up @@ -11208,6 +11217,15 @@
"installCount": {
"type": "integer"
},
"next_steps": {
"items": {
"type": "string"
},
"type": [
"null",
"array"
]
},
"previewUrl": {
"type": "string"
},
Expand Down
2 changes: 1 addition & 1 deletion .github/wiki/Plugin-Entitlement.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,4 +67,4 @@ The first two are checked by a shell guard in the unit, crontab line or LaunchAg

**Diagnostics.** E132 (doctor, exit class 1): a licensed v1.5 stack has no active timer. Reasons: `not-installed`, `unsafe-path`, `no-backend`. The hidden `nself license refresh --install-timer-dry-run` prints the units, crontab line and plist that would be installed.

**Limits.** The timer runs with an empty environment as the project owner, so it reads the licence key from the CLI's own store (`nself license set`), never from `NSELF_LICENSE_KEY_*` variables in a shell, and it looks for plugins in the owner's `~/.nself/plugins`. Build as the project owner, not as root on someone else's project: the root build would see root's plugins while the unit (running as the owner) sees none, and removes itself. The guard uses `test -x` on the binary, so a binary that is only briefly not executable during an upgrade also counts as gone. Two builds of different projects at the same instant can race on the user's crontab (the last writer wins); the next build repairs it. The in-place file rewrite is the one build already uses, and the guard keeps its last verified decision through a torn read. Root on a host without systemd gets no timer (`no-backend`): a root crontab would run a binary the project owner can replace. A cron line over about 900 bytes is refused as unsafe-path because cron truncates long lines. If the cache file cannot be written, the scheduled refresh fails with `cache-write-failed` and the licence library may print its own cache warning first.
**Limits.** The timer runs with an empty environment as the project owner, so it reads the licence key from the CLI's own store (`nself license set`), never from `NSELF_LICENSE_KEY_*` variables in a shell, and it looks for plugins in the owner's `~/.nself/plugins`. Build as the project owner, not as root on someone else's project: the root build would see root's plugins while the unit (running as the owner) sees none, and removes itself. The guard uses `test -e` on the binary, so a binary that is briefly missing during an upgrade also counts as gone, but one that is only briefly not executable does not. Cron install and removal share one `flock` (`~/.nself/entitlement-cron.lock`), so two builds of different projects at the same instant do not overwrite each other's crontab edit; on a host without `flock` the self-removal runs unlocked. The in-place file rewrite is the one build already uses, and the guard keeps its last verified decision through a torn read. Root on a host without systemd gets no timer (`no-backend`): a root crontab would run a binary the project owner can replace. A cron line over about 900 bytes is refused as unsafe-path because cron truncates long lines. If the cache file cannot be written, the scheduled refresh fails with `cache-write-failed` and the licence library may print its own cache warning first.
7 changes: 3 additions & 4 deletions .github/wiki/Plugin-Security.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,11 +67,10 @@ Two limits to know: the tier of an entry comes from the registry, so while a tie

### ci gate binary (v1.4)

`nself ci` runs its gates through the `nself-ci` binary of the free `ci` plugin. The CLI finds it in this order and stops at the first hit:
`nself ci` runs its gates through the `nself-ci` binary of the free `ci` plugin. The CLI finds it in this order and stops at the first hit. There is no unverified fast path: a binary already sitting in `~/.nself/plugins/bin/` is not trusted on its own, and the install seam re-checks the published binary under its operation lock.

1. The published plugin binary, `~/.nself/plugins/bin/nself-ci` (a regular file).
2. A dev build from an adjacent plugins checkout (`../plugins/free/ci`), as before.
3. The verified install seam: the CLI installs the signed `ci` plugin from the configured registry (`NSELF_PLUGIN_REGISTRY` or the default). It needs a release signature and a checksum, and the platform package it downloads must carry a platform signature for the CLI's own platform, all checked against the keys compiled into the CLI. If the platform package cannot be downloaded, the installer falls back to the signed source package and publishes a binary only if that package contains one (tracked in `quality/debt.yaml`). A release that fails any of these is refused (E107, E121 to E124) and the error names `nself add ci`.
1. A dev build from an adjacent plugins checkout (`../plugins/free/ci`), as before. A development checkout wins over the installed plugin.
2. The verified install seam: the CLI installs the signed `ci` plugin from the configured registry (`NSELF_PLUGIN_REGISTRY` or the default). It needs a release signature and a checksum, and the platform package it downloads must carry a platform signature for the CLI's own platform, all checked against the keys compiled into the CLI. If the platform package cannot be downloaded, the installer falls back to the signed source package and publishes a binary only if that package contains one (tracked in `quality/debt.yaml`). A release that fails any of these is refused (E107, E121 to E124) and the error names `nself add ci`.

`PATH` is never searched, and a Go toolchain is never used to fetch the gate: the old `go install ...@latest` fallback was unpinned and unverified. `nself ci serve` resolves the gate the same way.

Expand Down
18 changes: 2 additions & 16 deletions cmd/commands/ci_bootstrap.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,8 +25,6 @@ import (
"path/filepath"
"strings"
"sync"

"github.com/nself-org/cli/internal/plugin"
)

var (
Expand All @@ -40,8 +38,8 @@ var (

// ensureCIBinary returns the path to the nself-ci binary.
//
// Order: the published plugin binary (~/.nself/plugins/bin), then a dev build
// from an adjacent plugins checkout, then the verified auto-install seam.
// Order: an adjacent development checkout, then the verified auto-install
// seam, which checks the current published binary under its operation lock.
// PATH is never consulted: a stale or planted nself-ci there must not win.
func ensureCIBinary(verbose bool) (string, error) {
retireLegacyCICache()
Expand All @@ -52,10 +50,6 @@ func ensureCIBinary(verbose bool) (string, error) {
return "", fmt.Errorf("cannot locate the home directory for the nself-ci gate: %w\n(install it by hand: nself add ci)", err)
}

if p := plugin.PublishedBinaryPath("nself-ci"); isRegularFile(p) {
return p, nil
}

// Locate the plugin source relative to this binary.
// The CLI binary lives at, e.g., ~/Sites/nself/cli/nself (dev) or
// /usr/local/bin/nself (installed). In dev the plugin source is adjacent.
Expand Down Expand Up @@ -145,14 +139,6 @@ func retireLegacyCICache() {
})
}

// isRegularFile reports whether path is itself a regular file. A symlink does
// not count: the installer publishes a copy, so a link there was not put by a
// verified install and falls through to the seam, which replaces it.
func isRegularFile(path string) bool {
info, err := os.Lstat(path)
return err == nil && info.Mode().IsRegular()
}

// needsBuild returns true if the binary is missing or any source file is newer.
func needsBuild(binary, pluginDir string) bool {
info, err := os.Stat(binary)
Expand Down
50 changes: 46 additions & 4 deletions cmd/commands/ci_bootstrap_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ package commands

import (
"bytes"
"context"
"encoding/json"
"net/http"
"net/http/httptest"
Expand All @@ -25,6 +26,7 @@ import (
"time"

"github.com/nself-org/cli/internal/plugin"
"github.com/nself-org/cli/internal/plugin/autoinstall"
"github.com/nself-org/cli/internal/plugin/verify/signature"
"github.com/nself-org/cli/internal/plugin/verify/signature/signaturetest"
)
Expand Down Expand Up @@ -146,7 +148,7 @@ func cbRegistry(t *testing.T, o cbOpts) {
}

func TestCIBootstrapOrder(t *testing.T) {
t.Run("published plugin binary wins over PATH", func(t *testing.T) {
t.Run("unreceipted published binary is not trusted", func(t *testing.T) {
cbEnv(t)
log := cbStubs(t)
pub := plugin.PublishedBinaryPath("nself-ci")
Expand All @@ -156,9 +158,9 @@ func TestCIBootstrapOrder(t *testing.T) {
if err := os.WriteFile(pub, []byte("#!/bin/sh\n"), 0o700); err != nil {
t.Fatal(err)
}
got, err := ensureCIBinary(false)
if err != nil || got != pub {
t.Fatalf("got %q, %v; want %q", got, err, pub)
t.Setenv("NSELF_PLUGIN_REGISTRY", "http://127.0.0.1:1")
if got, err := ensureCIBinary(false); err == nil {
t.Fatalf("unverified binary returned: %q", got)
}
cbAssertNoStubRan(t, log)
})
Expand Down Expand Up @@ -446,3 +448,43 @@ func TestCIBootstrapNoGo(t *testing.T) {
})
}
}

func TestCIBootstrapRechecksPublishedReceipt(t *testing.T) {
cbEnv(t)
cbRegistry(t, cbOpts{sign: true, platSign: true})
bin, err := ensureCIBinary(false)
if err != nil {
t.Fatal(err)
}
dir := resolvePluginDir()
m, _, err := autoInstallResolve(context.Background(), "ci", "")
if err != nil {
t.Fatal(err)
}
if got, ok := autoinstall.Cached(dir, "ci", m); !ok || got != bin {
t.Fatal("verified install has no usable receipt")
}
if _, err := ensureCIBinary(false); err != nil {
t.Fatal(err)
}
asideRoot := filepath.Join(filepath.Dir(dir), "plugins.aside")
if entries, _ := os.ReadDir(asideRoot); len(entries) != 0 {
t.Fatal("second first-run caller reinstalled a current verified binary")
}
if err := os.WriteFile(bin, []byte("tampered"), 0700); err != nil {
t.Fatal(err)
}
if _, ok := autoinstall.Cached(dir, "ci", m); ok {
t.Fatal("tampered published bytes trusted")
}
if _, err := ensureCIBinary(false); err != nil {
t.Fatal(err)
}
if _, ok := autoinstall.Cached(dir, "ci", m); !ok {
t.Fatal("tampered binary was not repaired")
}
m.BinaryName = "nself-custom-ci"
if _, ok := autoinstall.Cached(dir, "ci", m); ok {
t.Fatal("cache ignored catalogue binary name")
}
}
11 changes: 6 additions & 5 deletions cmd/commands/dispatch.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,18 +17,18 @@ package commands

import (
"errors"
"io"
"strings"

"fmt"
"github.com/spf13/pflag"
"io"
"os"
"strings"

"github.com/nself-org/cli/internal/compat"
"github.com/nself-org/cli/internal/docker"
"github.com/nself-org/cli/internal/errs"
"github.com/nself-org/cli/internal/license/entitlement"
"github.com/nself-org/cli/internal/plugin"

"github.com/spf13/cobra"
"github.com/spf13/pflag"
)

// invokedCommandPath returns the command path as the user actually spelled it,
Expand Down Expand Up @@ -62,6 +62,7 @@ func Execute() (resultErr error) {
commandLogFinish = nil
}
}()
entitlement.SetMountProbe(docker.FileBindMounted)
// Secret values arrive by environment, not argv (D-0468): restore them first.
if err := restoreSecretArgv(); err != nil {
return err
Expand Down
39 changes: 0 additions & 39 deletions cmd/commands/init_templates.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,6 @@ import (
"github.com/nself-org/cli/internal/httptimeout"
clonetemplate "github.com/nself-org/cli/internal/templates/clone"
"github.com/nself-org/cli/internal/ui"
"github.com/spf13/cobra"
)

// runInitCloneTemplate scaffolds one of the six embedded app-clone templates
Expand Down Expand Up @@ -116,44 +115,6 @@ func printSteps(w io.Writer, dest string, m clonetemplate.Manifest, flutter bool
}
}

func init() {
legacyInit := initCmd.RunE // multi-app's `init --json` reports its manifest next_steps, never the generic metadata apply
initCmd.RunE = func(cmd *cobra.Command, args []string) error {
t, _ := cmd.Flags().GetString("template")
if t != "multi-app" {
return legacyInit(cmd, args)
}
m, err := clonetemplate.GetManifest(t)
if err != nil {
return err
}
res := InitResult{FilesWritten: []string{}, Next: []string{}}
res.ProjectDir, _ = os.Getwd()
if err := runInitFlow(cmd, args, &res); err != nil {
return err
}
res.Next = m.NextSteps
return emitLifecycle(cmd, "init", res)
}
registry := templateInfoCmd.RunE // `nself template info multi-app` answers offline; other names ask the registry
templateInfoCmd.RunE = func(cmd *cobra.Command, args []string) error {
m, err := clonetemplate.GetManifest(args[0])
if args[0] != "multi-app" || err != nil {
return registry(cmd, args)
}
e := templateEntry{Slug: m.Name, DisplayName: m.Name, Description: m.Description, Category: "bundled", Author: "nSelf", RequiredPlugins: nonNilStrings(m.RequiredPlugins), TemplateVersion: m.Version}
if jsonEnvelopeOn(cmd) {
return emitEnv(cmd, e, true)
}
if asJSON, _ := cmd.Flags().GetBool("json"); asJSON {
return ui.PrintJSON(e)
}
fmt.Printf("\n %s v%s (bundled, no network required)\n %s\n", m.Name, m.Version, m.Description)
printTemplateNextSteps(os.Stdout, "<dest-dir>", m)
return nil
}
}

// runInitMarketplaceTemplate downloads a marketplace template tarball, verifies
// its SHA256 checksum, and extracts it into destDir.
func runInitMarketplaceTemplate(slug, destDir string, force, quiet bool) error {
Expand Down
43 changes: 14 additions & 29 deletions cmd/commands/plugin_autoinstall.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,6 @@ package commands

import (
"context"
"encoding/json"
"fmt"
"os"
"path/filepath"
Expand All @@ -29,6 +28,7 @@ import (
"github.com/nself-org/cli/internal/config"
"github.com/nself-org/cli/internal/errs"
"github.com/nself-org/cli/internal/plugin"
"github.com/nself-org/cli/internal/plugin/autoinstall"
"github.com/nself-org/cli/internal/plugin/verify/signature"
)

Expand Down Expand Up @@ -62,6 +62,12 @@ func autoInstallPluginLocked(ctx context.Context, cfg *config.Config, slug, vers
return "", err
}

// This check is inside the operation lock: a concurrent first run may
// already have installed this exact signed catalogue entry.
if bin, ok := autoinstall.Cached(resolvePluginDir(), slug, manifest); ok {
return bin, nil
}

// plugin.Install fetches the catalogue again. Pin its tier to free, make a
// missing signature a refusal in 1.4 mode too, and drop the licence bypass,
// so a catalogue that changes between the two fetches cannot be installed
Expand Down Expand Up @@ -97,8 +103,8 @@ func autoInstallPluginLocked(ctx context.Context, cfg *config.Config, slug, vers
return "", fmt.Errorf("auto-install of %q %s failed: %w (install it by hand: nself add %s)", slug, manifest.Version, err, slug)
}

bin := plugin.PublishedBinaryPath(autoInstallBinaryName(slug, manifest))
if got := readInstalledVersion(pluginDir, slug); got != manifest.Version {
bin := plugin.PublishedBinaryPath(autoinstall.BinaryName(slug, manifest))
if got := autoinstall.ReadInstalledVersion(pluginDir, slug); got != manifest.Version {
// Unreadable or different: the catalogue changed under us. Remove what this
// call published (by the checked name; UndoInstall uses the archive's).
_ = plugin.UndoInstall(ctx, cfg, slug, pluginDir)
Expand All @@ -109,6 +115,9 @@ func autoInstallPluginLocked(ctx context.Context, cfg *config.Config, slug, vers
if st, err := os.Stat(bin); err != nil || st.IsDir() {
return "", fmt.Errorf("auto-install of %q: the installer reported success but %s was not published (install it by hand: nself add %s)", slug, bin, slug)
}
if err := autoinstall.Record(pluginDir, slug, manifest, bin); err != nil {
return "", err
}
return bin, nil
}

Expand All @@ -134,8 +143,8 @@ func autoInstallResolve(ctx context.Context, slug, version string) (*plugin.Plug
if m.RequiresLicense || m.IsCommercial || (m.Tier != "" && m.Tier != plugin.WireTierFree) {
return nil, nil, fmt.Errorf("auto-install of %q: the catalogue entry is licensed, and only free plugins are auto-installed (install it by hand: %s)", slug, manual)
}
if !autoInstallSafeBinaryName(autoInstallBinaryName(slug, m)) {
return nil, nil, fmt.Errorf("auto-install of %q: the catalogue entry names an unsafe command binary %q (install it by hand: %s)", slug, autoInstallBinaryName(slug, m), manual)
if !autoInstallSafeBinaryName(autoinstall.BinaryName(slug, m)) {
return nil, nil, fmt.Errorf("auto-install of %q: the catalogue entry names an unsafe command binary %q (install it by hand: %s)", slug, autoinstall.BinaryName(slug, m), manual)
}
if len(m.Dependencies) > 0 || len(m.OptionalDependencies) > 0 {
return nil, nil, fmt.Errorf("auto-install of %q: the entry has dependencies, which the installer would add before verifying it (install it by hand: %s)", slug, manual)
Expand All @@ -154,15 +163,6 @@ func autoInstallResolve(ctx context.Context, slug, version string) (*plugin.Plug
return m, reg, nil
}

// autoInstallBinaryName is the name the installer publishes a single-command
// plugin under: the manifest's binaryName, else nself-<slug>.
func autoInstallBinaryName(slug string, m *plugin.PluginManifest) string {
if m.BinaryName != "" {
return m.BinaryName
}
return "nself-" + slug
}

// autoInstallSetEnv sets each variable and returns a func that puts the
// previous values back (unsetting the ones that were absent).
func autoInstallSetEnv(vars map[string]string) (func(), error) {
Expand Down Expand Up @@ -190,21 +190,6 @@ func autoInstallSetEnv(vars map[string]string) (func(), error) {
return restore, nil
}

// readInstalledVersion returns the version in <pluginDir>/<slug>/plugin.json, or "".
func readInstalledVersion(pluginDir, slug string) string {
b, err := os.ReadFile(filepath.Join(pluginDir, slug, "plugin.json"))
if err != nil {
return ""
}
var m struct {
Version string `json:"version"`
}
if json.Unmarshal(b, &m) != nil {
return ""
}
return m.Version
}

// autoInstallMoveAside renames <pluginDir>/<slug> to ../plugins.aside/<slug>.pre-autoinstall-<ts>
// when it exists (and no install holds the lock), and returns a func that puts
// it back if nothing took its place. With nothing there the func does nothing.
Expand Down
10 changes: 6 additions & 4 deletions cmd/commands/registry_types_lifecycle.go
Original file line number Diff line number Diff line change
Expand Up @@ -189,15 +189,17 @@ func initScaffoldResult(dir, env string, created []string, demo bool, wroteAgent
return InitResult{ProjectDir: dir, Env: env, FilesWritten: files, Next: next}
}

// templateNext lists the commands that bring a scaffolded template up. A clone
// template mirrors printCloneNextSteps (init_templates.go), including the plugins
// its manifest requires.
// templateNext uses the bundled manifest steps, falling back to the generic clone commands.
func templateNext(template string) []string {
if !clonetemplate.IsCloneTemplate(template) {
return []string{"nself start"}
}
m, _ := clonetemplate.GetManifest(template)
if len(m.NextSteps) > 0 {
return append([]string(nil), m.NextSteps...)
}
next := []string{"nself start", "nself db migrate", "nself db hasura metadata apply"}
if m, err := clonetemplate.GetManifest(template); err == nil && len(m.RequiredPlugins) > 0 {
if len(m.RequiredPlugins) > 0 {
next = append(next, "nself plugin install "+strings.Join(m.RequiredPlugins, " "))
}
return next
Expand Down
Loading
Loading