molecule-core/workspace-server/internal/plugins/github.go
Hongming Wang 479a027e4b chore: open-source restructure — rename dirs, remove internal files, scrub secrets
Renames:
- platform/ → workspace-server/ (Go module path stays as "platform" for
  external dep compat — will update after plugin module republish)
- workspace-template/ → workspace/

Removed (moved to separate repos or deleted):
- PLAN.md — internal roadmap (move to private project board)
- HANDOFF.md, AGENTS.md — one-time internal session docs
- .claude/ — gitignored entirely (local agent config)
- infra/cloudflare-worker/ → Molecule-AI/molecule-tenant-proxy
- org-templates/molecule-dev/ → standalone template repo
- .mcp-eval/ → molecule-mcp-server repo
- test-results/ — ephemeral, gitignored

Security scrubbing:
- Cloudflare account/zone/KV IDs → placeholders
- Real EC2 IPs → <EC2_IP> in all docs
- CF token prefix, Neon project ID, Fly app names → redacted
- Langfuse dev credentials → parameterized
- Personal runner username/machine name → generic

Community files:
- CONTRIBUTING.md — build, test, branch conventions
- CODE_OF_CONDUCT.md — Contributor Covenant 2.1

All Dockerfiles, CI workflows, docker-compose, railway.toml, render.yaml,
README, CLAUDE.md updated for new directory names.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-18 00:24:44 -07:00

157 lines
5.8 KiB
Go
Raw Permalink 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]
// Pinned-ref enforcement (#768 Control 2): reject bare "org/repo" specs
// without a "#ref" fragment. Only pinned refs are accepted in production.
// PLUGIN_ALLOW_UNPINNED=true bypasses this for local development.
if ref == "" && os.Getenv("PLUGIN_ALLOW_UNPINNED") != "true" {
return "", fmt.Errorf("github resolver: spec %q requires a pinned ref (e.g. %s/%s#v1.0.0); "+
"set PLUGIN_ALLOW_UNPINNED=true for local dev", spec, owner, repo)
}
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
}