molecule-core/platform/internal/plugins/github.go
Hongming Wang 24fec62d7f initial commit — Molecule AI platform
Forked clean from public hackathon repo (Starfire-AgentTeam, BSL 1.1)
with full rebrand to Molecule AI under github.com/Molecule-AI/molecule-monorepo.

Brand: Starfire → Molecule AI.
Slug: starfire / agent-molecule → molecule.
Env vars: STARFIRE_* → MOLECULE_*.
Go module: github.com/agent-molecule/platform → github.com/Molecule-AI/molecule-monorepo/platform.
Python packages: starfire_plugin → molecule_plugin, starfire_agent → molecule_agent.
DB: agentmolecule → molecule.

History truncated; see public repo for prior commits and contributor
attribution. Verified green: go test -race ./... (platform), pytest
(workspace-template 1129 + sdk 132), vitest (canvas 352), build (mcp).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 11:55:37 -07:00

149 lines
5.4 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package plugins
import (
"context"
"fmt"
"os"
"os/exec"
"path/filepath"
"regexp"
"strings"
)
// GithubResolver fetches plugins from a GitHub repository by shallow-
// cloning at the specified ref (default branch if no ref is given).
//
// Spec format: "<owner>/<repo>" or "<owner>/<repo>#<ref>"
// - "foo/bar" → clone https://github.com/foo/bar at default branch
// - "foo/bar#v1.2.0" → clone at tag v1.2.0
// - "foo/bar#main" → clone at branch main
// - "foo/bar#sha" → fetch + checkout a specific commit
//
// The resolver shells out to the `git` binary; the platform's Dockerfile
// installs git for this reason. A mockable GitRunner lets tests inject a
// fake without requiring git on the test host.
type GithubResolver struct {
// GitRunner runs git commands. Defaults to shelling out to the
// system `git`. Overridable in tests.
GitRunner func(ctx context.Context, dir string, args ...string) error
// BaseURL defaults to https://github.com. Tests point it at a local
// file:// bare repo.
BaseURL string
}
// NewGithubResolver constructs a resolver with sensible defaults.
func NewGithubResolver() *GithubResolver {
return &GithubResolver{
GitRunner: defaultGitRunner,
BaseURL: "https://github.com",
}
}
// Scheme returns "github".
func (r *GithubResolver) Scheme() string { return "github" }
// repoRE matches "<owner>/<repo>" with optional "#<ref>" suffix.
//
// - Owner / repo: must start with alphanumeric, then 099 chars from
// [a-zA-Z0-9_.-]. Matches GitHub's validation.
// - Ref: must NOT start with `-` (prevents ref-as-flag injection like
// "-exec=/evil"). Then 0254 chars from [a-zA-Z0-9_./-]. Disallows
// whitespace and shell metacharacters. The handler additionally
// passes `--` before the URL when invoking git, for defense in depth.
var repoRE = regexp.MustCompile(
`^([a-zA-Z0-9][a-zA-Z0-9_.\-]{0,99})/([a-zA-Z0-9][a-zA-Z0-9_.\-]{0,99})(?:#([a-zA-Z0-9_.][a-zA-Z0-9_./\-]{0,254}))?$`,
)
// Fetch clones the repository and copies its contents (minus .git) into dst.
// Returns the repository name (second path segment) as the plugin name.
func (r *GithubResolver) Fetch(ctx context.Context, spec string, dst string) (string, error) {
spec = strings.TrimSpace(spec)
m := repoRE.FindStringSubmatch(spec)
if m == nil {
return "", fmt.Errorf("github resolver: spec %q must be <owner>/<repo>[#<ref>]", spec)
}
owner, repo, ref := m[1], m[2], m[3]
runner := r.GitRunner
if runner == nil {
runner = defaultGitRunner
}
base := r.BaseURL
if base == "" {
base = "https://github.com"
}
url := fmt.Sprintf("%s/%s/%s.git", base, owner, repo)
// Clone into a sibling temp dir, then move contents to dst minus
// .git. We use a sibling (not dst itself) because `git clone` wants
// to create the target; dst may already exist as an empty dir.
workDir, err := os.MkdirTemp("", "molecule-gh-clone-*")
if err != nil {
return "", fmt.Errorf("github resolver: tempdir: %w", err)
}
defer os.RemoveAll(workDir)
cloneTarget := filepath.Join(workDir, "repo")
args := []string{"clone", "--depth=1"}
if ref != "" {
args = append(args, "--branch", ref)
}
// `--` unconditionally separates flags from positional args; URL +
// target are positional. Defense in depth against any future arg-
// parser quirks.
args = append(args, "--", url, cloneTarget)
if err := runner(ctx, workDir, args...); err != nil {
// Map common "repository / ref doesn't exist" outputs to
// ErrPluginNotFound so the handler returns 404. Everything else
// stays as a 502 (network, auth, etc.).
msg := strings.ToLower(err.Error())
if strings.Contains(msg, "repository not found") ||
strings.Contains(msg, "could not find remote branch") ||
strings.Contains(msg, "remote branch") && strings.Contains(msg, "not found") {
return "", fmt.Errorf("github resolver: %s: %w", url, ErrPluginNotFound)
}
return "", fmt.Errorf("github resolver: clone %s failed: %w", url, err)
}
// Strip .git so the plugin dir doesn't become a nested repo in the
// workspace container's filesystem.
if err := os.RemoveAll(filepath.Join(cloneTarget, ".git")); err != nil {
return "", fmt.Errorf("github resolver: remove .git: %w", err)
}
// Move contents to dst.
if err := copyTree(ctx, cloneTarget, dst); err != nil {
return "", fmt.Errorf("github resolver: copy to dst: %w", err)
}
return repo, nil
}
// defaultGitRunner shells out to the system `git`. `dir` is the working
// directory for the command (nil/empty means current process cwd).
func defaultGitRunner(ctx context.Context, dir string, args ...string) error {
cmd := exec.CommandContext(ctx, "git", args...)
if dir != "" {
cmd.Dir = dir
}
// Build a per-child env. We never mutate os.Environ()'s backing
// slice.
childEnv := os.Environ()
// - HOME: `git clone` touches HOME for credential helpers even on
// anonymous HTTPS; set to work dir if the parent process has none.
if os.Getenv("HOME") == "" && dir != "" {
childEnv = append(childEnv, "HOME="+dir)
}
// - LANG=C / LC_ALL=C: force English output so our ErrPluginNotFound
// mapping ("repository not found", "remote branch ... not found")
// doesn't silently stop working under a different locale.
childEnv = append(childEnv, "LANG=C", "LC_ALL=C")
cmd.Env = childEnv
out, err := cmd.CombinedOutput()
if err != nil {
return fmt.Errorf("git %v: %w (output: %s)", args, err, string(out))
}
return nil
}