From 54be379229c414a90c8767f89292cc5cbaa5cb78 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 2 Jun 2026 05:53:04 +0000 Subject: [PATCH 1/3] fix: clone external zsh plugins referenced by dotfiles .zshrc MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a remote config carries no shell block (rc.Shell == nil) but does set a dotfiles_repo, the shell setup comes entirely from the stowed .zshrc. Its plugins=() list — e.g. zsh-autosuggestions, fast-syntax-highlighting, zsh-autocomplete — never flowed through RestoreFromSnapshot, so the external plugins it names were never git-cloned into $ZSH_CUSTOM/plugins. oh-my-zsh then logged "plugin '...' not found" on every shell startup. Add shell.CloneExternalPluginsFromZshrc: after dotfiles are linked, read the effective ~/.zshrc, extract plugins=(), and clone any catalog (external) plugins not already present. Built-in/unknown names are left untouched and a failed clone stays non-fatal, matching cloneExternalPlugins. No-op when oh-my-zsh isn't installed or .zshrc is absent, and dry-run safe. This is the path `openboot install ` takes for configs like fullstackjam, where #121's plan-level fix could not help because there was no shell block to carry through. --- internal/installer/step_shell.go | 10 +++++ internal/shell/clone_plugins_test.go | 57 ++++++++++++++++++++++++++++ internal/shell/shell.go | 41 ++++++++++++++++++++ 3 files changed, 108 insertions(+) diff --git a/internal/installer/step_shell.go b/internal/installer/step_shell.go index eae5f63..b08c09f 100644 --- a/internal/installer/step_shell.go +++ b/internal/installer/step_shell.go @@ -84,6 +84,16 @@ func applyDotfiles(plan InstallPlan, r Reporter) error { return fmt.Errorf("link dotfiles: %w", err) } + // Dotfiles commonly ship their own .zshrc whose plugins=() list references + // external oh-my-zsh plugins (zsh-autosuggestions, fast-syntax-highlighting, + // ...). When the remote config carries no shell block, that list never flows + // through the shell step, so those plugins were never cloned and zsh logs + // "plugin '...' not found" at every startup. Clone them off the linked + // .zshrc now (no-op if OMZ is absent or no external plugins are referenced). + if err := shell.CloneExternalPluginsFromZshrc(plan.DryRun); err != nil { + return fmt.Errorf("clone plugins referenced by dotfiles: %w", err) + } + if !plan.DryRun { r.Success("Dotfiles configured") } diff --git a/internal/shell/clone_plugins_test.go b/internal/shell/clone_plugins_test.go index dfafc93..fae90ba 100644 --- a/internal/shell/clone_plugins_test.go +++ b/internal/shell/clone_plugins_test.go @@ -134,6 +134,63 @@ func TestCloneExternalPlugins_CloneFailureIsNonFatal(t *testing.T) { require.NoError(t, err) } +func TestCloneExternalPluginsFromZshrc_ClonesPluginsFromDotfiles(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + calls := withFakes(t, map[string]string{ + "zsh-autosuggestions": "https://github.com/zsh-users/zsh-autosuggestions", + "fast-syntax-highlighting": "https://github.com/zdharma-continuum/fast-syntax-highlighting", + }) + require.NoError(t, os.MkdirAll(filepath.Join(home, ".oh-my-zsh"), 0700)) + zshrc := "export ZSH=\"$HOME/.oh-my-zsh\"\nZSH_THEME=\"robbyrussell\"\nplugins=(git helm kubectl zsh-autosuggestions fast-syntax-highlighting)\nsource $ZSH/oh-my-zsh.sh\n" + require.NoError(t, os.WriteFile(filepath.Join(home, ".zshrc"), []byte(zshrc), 0600)) + + require.NoError(t, CloneExternalPluginsFromZshrc(false)) + + require.Len(t, *calls, 2, "both external plugins from .zshrc should be cloned; built-ins skipped") + got := map[string]bool{(*calls)[0][0]: true, (*calls)[1][0]: true} + assert.True(t, got["https://github.com/zsh-users/zsh-autosuggestions"]) + assert.True(t, got["https://github.com/zdharma-continuum/fast-syntax-highlighting"]) +} + +func TestCloneExternalPluginsFromZshrc_NoOmzIsNoOp(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + calls := withFakes(t, map[string]string{ + "zsh-autosuggestions": "https://github.com/zsh-users/zsh-autosuggestions", + }) + // .zshrc present but ~/.oh-my-zsh absent → nothing to clone into. + require.NoError(t, os.WriteFile(filepath.Join(home, ".zshrc"), []byte("plugins=(zsh-autosuggestions)\n"), 0600)) + + require.NoError(t, CloneExternalPluginsFromZshrc(false)) + assert.Empty(t, *calls, "must not clone when oh-my-zsh is not installed") +} + +func TestCloneExternalPluginsFromZshrc_MissingZshrcIsNoOp(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + calls := withFakes(t, map[string]string{ + "zsh-autosuggestions": "https://github.com/zsh-users/zsh-autosuggestions", + }) + require.NoError(t, os.MkdirAll(filepath.Join(home, ".oh-my-zsh"), 0700)) + + require.NoError(t, CloneExternalPluginsFromZshrc(false)) + assert.Empty(t, *calls, "a missing .zshrc must be a no-op, not an error") +} + +func TestCloneExternalPluginsFromZshrc_DryRunDoesNotClone(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + calls := withFakes(t, map[string]string{ + "zsh-autosuggestions": "https://github.com/zsh-users/zsh-autosuggestions", + }) + require.NoError(t, os.MkdirAll(filepath.Join(home, ".oh-my-zsh"), 0700)) + require.NoError(t, os.WriteFile(filepath.Join(home, ".zshrc"), []byte("plugins=(zsh-autosuggestions)\n"), 0600)) + + require.NoError(t, CloneExternalPluginsFromZshrc(true)) + assert.Empty(t, *calls, "dry-run must not clone") +} + func TestCloneExternalPlugins_RestoreWritesBareNames(t *testing.T) { home := t.TempDir() t.Setenv("HOME", home) diff --git a/internal/shell/shell.go b/internal/shell/shell.go index ac9f951..9942d99 100644 --- a/internal/shell/shell.go +++ b/internal/shell/shell.go @@ -317,6 +317,47 @@ func cloneExternalPlugins(plugins []string, dryRun bool) error { return nil } +// zshrcPluginsRe extracts the names inside a plugins=(...) declaration from a +// .zshrc. Mirrors snapshot.zshPluginsRe but tolerates leading whitespace so it +// also matches indented declarations in user-authored dotfiles. +var zshrcPluginsRe = regexp.MustCompile(`(?m)^\s*plugins=\((?s:(.*?))\)`) + +// CloneExternalPluginsFromZshrc reads ~/.zshrc, extracts its plugins=() list, +// and git-clones any external (catalog) plugins not already present. It exists +// for the dotfiles path: when a user's shell setup comes entirely from a +// stowed .zshrc (the remote config carries no shell block), the plugin list +// never flows through RestoreFromSnapshot, so the external plugins it names are +// never cloned and oh-my-zsh logs "plugin '...' not found" at startup. +// +// It is a no-op when oh-my-zsh isn't installed or .zshrc is absent. Built-in +// and unknown plugins are left untouched; a failed clone is non-fatal (see +// cloneExternalPlugins). +func CloneExternalPluginsFromZshrc(dryRun bool) error { + if !IsOhMyZshInstalled() { + return nil + } + home, err := system.HomeDir() + if err != nil { + return fmt.Errorf("clone plugins from .zshrc: %w", err) + } + raw, err := os.ReadFile(filepath.Join(home, ".zshrc")) + if err != nil { + if os.IsNotExist(err) { + return nil + } + return fmt.Errorf("read .zshrc: %w", err) + } + m := zshrcPluginsRe.FindSubmatch(raw) + if len(m) < 2 { + return nil // no plugins=() declaration + } + plugins := strings.Fields(string(m[1])) + if len(plugins) == 0 { + return nil + } + return cloneExternalPlugins(plugins, dryRun) +} + func RestoreFromSnapshot(ohMyZsh bool, theme string, plugins []string, dryRun bool) error { if !ohMyZsh { return nil From 2a62199469d064b283ad96e7cdb537173fad43c4 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 2 Jun 2026 05:57:45 +0000 Subject: [PATCH 2/3] fix: guard plugin name against path traversal before clone MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit gosec G703 flagged cloneExternalPlugins now that plugin names can originate from a user-authored .zshrc (via CloneExternalPluginsFromZshrc) and flow into filepath.Join. Add an explicit path-segment guard rejecting names that aren't a plain single segment, and annotate the os.Stat with a justified nolint. A name only reaches here after matching the curated catalog, so this guard only ever rejects malicious input — it's defense in depth, not a behavior change. --- internal/shell/shell.go | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/internal/shell/shell.go b/internal/shell/shell.go index 9942d99..8c1511c 100644 --- a/internal/shell/shell.go +++ b/internal/shell/shell.go @@ -296,7 +296,19 @@ func cloneExternalPlugins(plugins []string, dryRun bool) error { continue } + // Defense in depth: a plugin name becomes a path segment under + // $ZSH_CUSTOM/plugins and may originate from a user-authored .zshrc + // (see CloneExternalPluginsFromZshrc). Reject anything that isn't a + // plain single segment so a crafted name can never escape the plugins + // directory. A matching catalog name is always a safe identifier, so + // this only ever rejects malicious input. + if name != filepath.Base(name) || name == "." || name == ".." || strings.ContainsAny(name, `/\`) { + ui.Warn(fmt.Sprintf("Skipping plugin %s: unsafe name", name)) + continue + } + dest := filepath.Join(customPlugins, name) + //nolint:gosec // name is gated by resolvePluginURL (curated catalog) and the path-segment guard above; it cannot traverse out of customPlugins. if _, err := os.Stat(dest); err == nil { continue // already cloned — idempotent skip } From 18f63a71f3f01ec23ab3b221584b7113d2a9c964 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 2 Jun 2026 06:03:10 +0000 Subject: [PATCH 3/3] fix: treat unreadable .zshrc as non-fatal in plugin clone An unreadable .zshrc now warns and returns nil instead of aborting the dotfiles step. By the time CloneExternalPluginsFromZshrc runs the dotfiles are already cloned and linked, and plugin setup is best-effort everywhere else (cloneExternalPlugins warns and continues on a failed clone), so a marginal read error should not fail the whole step. Add a test covering the non-NotExist read-error path. --- internal/shell/clone_plugins_test.go | 15 +++++++++++++++ internal/shell/shell.go | 9 ++++++--- 2 files changed, 21 insertions(+), 3 deletions(-) diff --git a/internal/shell/clone_plugins_test.go b/internal/shell/clone_plugins_test.go index fae90ba..56a943d 100644 --- a/internal/shell/clone_plugins_test.go +++ b/internal/shell/clone_plugins_test.go @@ -178,6 +178,21 @@ func TestCloneExternalPluginsFromZshrc_MissingZshrcIsNoOp(t *testing.T) { assert.Empty(t, *calls, "a missing .zshrc must be a no-op, not an error") } +func TestCloneExternalPluginsFromZshrc_UnreadableZshrcWarnsButSucceeds(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + calls := withFakes(t, map[string]string{ + "zsh-autosuggestions": "https://github.com/zsh-users/zsh-autosuggestions", + }) + require.NoError(t, os.MkdirAll(filepath.Join(home, ".oh-my-zsh"), 0700)) + // A directory at the .zshrc path makes os.ReadFile fail with a non-NotExist + // error — best-effort plugin setup must warn and continue, not abort. + require.NoError(t, os.MkdirAll(filepath.Join(home, ".zshrc"), 0700)) + + require.NoError(t, CloneExternalPluginsFromZshrc(false), "an unreadable .zshrc must not be fatal") + assert.Empty(t, *calls) +} + func TestCloneExternalPluginsFromZshrc_DryRunDoesNotClone(t *testing.T) { home := t.TempDir() t.Setenv("HOME", home) diff --git a/internal/shell/shell.go b/internal/shell/shell.go index 8c1511c..ad009de 100644 --- a/internal/shell/shell.go +++ b/internal/shell/shell.go @@ -342,8 +342,10 @@ var zshrcPluginsRe = regexp.MustCompile(`(?m)^\s*plugins=\((?s:(.*?))\)`) // never cloned and oh-my-zsh logs "plugin '...' not found" at startup. // // It is a no-op when oh-my-zsh isn't installed or .zshrc is absent. Built-in -// and unknown plugins are left untouched; a failed clone is non-fatal (see -// cloneExternalPlugins). +// and unknown plugins are left untouched. Like cloneExternalPlugins, plugin +// setup is best-effort: an unreadable .zshrc warns and returns nil rather than +// aborting the dotfiles step (the dotfiles are already cloned and linked by the +// time this runs). func CloneExternalPluginsFromZshrc(dryRun bool) error { if !IsOhMyZshInstalled() { return nil @@ -357,7 +359,8 @@ func CloneExternalPluginsFromZshrc(dryRun bool) error { if os.IsNotExist(err) { return nil } - return fmt.Errorf("read .zshrc: %w", err) + ui.Warn(fmt.Sprintf("Skipping plugin clone: could not read .zshrc: %v", err)) + return nil } m := zshrcPluginsRe.FindSubmatch(raw) if len(m) < 2 {