router-for-me/CLIProxyAPI · error

stat target plugin: %w

Error message

stat target plugin: %w

What it means

Wrapped error from InstallArchive when os.Stat on the computed target path fails with anything other than os.ErrNotExist — i.e. the existence check itself is broken, typically a permission problem on a parent directory or a path component that is not a directory. Only a clean 'does not exist' is treated as 'will create'; ambiguous stat failures abort the install rather than risk overwriting blindly.

Source

Thrown at internal/pluginstore/install.go:272

	reader, errZip := zip.NewReader(bytes.NewReader(archiveData), int64(len(archiveData)))
	if errZip != nil {
		return InstallResult{}, fmt.Errorf("open zip: %w", errZip)
	}

	libraryData, mode, errLibrary := readTargetLibrary(reader, id, version, options.GOOS)
	if errLibrary != nil {
		return InstallResult{}, errLibrary
	}

	targetPath, errTarget := installTargetPath(options, id, version)
	if errTarget != nil {
		return InstallResult{}, errTarget
	}
	overwritten := false
	if _, errStat := os.Stat(targetPath); errStat == nil {
		overwritten = true
	} else if !errors.Is(errStat, os.ErrNotExist) {
		return InstallResult{}, fmt.Errorf("stat target plugin: %w", errStat)
	}
	if overwritten {
		existingData, errReadExisting := os.ReadFile(targetPath)
		if errReadExisting != nil {
			return InstallResult{}, fmt.Errorf("read target plugin: %w", errReadExisting)
		}
		if bytes.Equal(existingData, libraryData) {
			return InstallResult{
				ID:          id,
				Version:     strings.TrimSpace(plugin.Version),
				Path:        targetPath,
				Overwritten: true,
				Skipped:     true,
			}, nil
		}
	}
	// Re-check immediately before replacing an existing file: the same version
	// may have been loaded while the archive was being downloaded and verified.

View on GitHub (pinned to 78f0c4079e)

Solutions

  1. Check the underlying stat error (errors.Is(errStat, fs.ErrPermission) etc.) to identify the exact directory in the path that denies access
  2. Ensure options.PluginsDir and its GOOS/GOARCH subdirectories exist and are writable/searchable by the process user
  3. Remove or relocate any regular file occupying a needed directory component

Example fix

# before: plugins dir owned by root, process runs as app-user
ls -ld /opt/app/plugins # drwx------ root root

# after
mkdir -p /opt/app/plugins/linux/amd64 && chown -R app-user:app-user /opt/app/plugins
Defensive patterns

Strategy: try-catch

Validate before calling

func targetDirWritable(pluginsDir, goos, goarch string) error {
    dir := filepath.Join(pluginsDir, goos, goarch)
    info, err := os.Stat(dir)
    if err == nil && !info.IsDir() {
        return fmt.Errorf("%s exists but is not a directory", dir)
    }
    if err != nil {
        if err := os.MkdirAll(dir, 0o755); err != nil {
            return fmt.Errorf("cannot create %s: %w", dir, err)
        }
        return nil
    }
    probe := filepath.Join(dir, ".write-probe")
    if err := os.WriteFile(probe, nil, 0o644); err != nil {
        return fmt.Errorf("%s not writable: %w", dir, err)
    }
    _ = os.Remove(probe)
    return nil
}

Type guard

func isPermissionErr(err error) bool {
    return errors.Is(err, fs.ErrPermission) || strings.Contains(err.Error(), "stat target plugin:") && errors.Is(errors.Unwrap(err), fs.ErrPermission)
}

Try / catch

if _, err := pluginstore.InstallArchive(data, plugin, options); err != nil {
    if strings.Contains(err.Error(), "stat target plugin:") {
        if errors.Is(err, fs.ErrPermission) {
            // fix ownership/permissions of PluginsDir tree, then retry
        } else if errors.Is(err, syscall.ENOTDIR) {
            // a file occupies a directory component; remove or relocate it
        }
    }
}

Prevention

When it happens

Trigger: options.PluginsDir points into a directory without execute/search permission for the process user, or a non-directory file sits where a directory component is expected (e.g. 'plugins' is a regular file), producing EACCES/ENOTDIR/ELOOP from Stat.

Common situations: Running the installer as a different user than the one that owns the plugins directory; containers with read-only or mismounted volumes; a previous install created a file where a directory path now expects one; overly strict umask or SELinux/AppArmor denials.

Related errors


AI-assisted analysis of router-for-me/CLIProxyAPI@78f0c4079e (2026-08-15). Data as JSON: /api/errors/36535b4211ffd63a. Report an issue: GitHub.