Compare commits

...

1 Commits

Author SHA1 Message Date
J
2aa9868642 fix(skills): authenticate raw.githubusercontent.com downloads for private repo imports
Skill import builds raw.githubusercontent.com URLs by hand and fetches them
via fetchRawFile, which sent no Authorization header. GitHub API calls were
authenticated by #2215 (doGitHubAPIGet/addGitHubAuthHeader) but the raw
content download path was missed, so importing a skill from a private/internal
GitHub repo listed the directory fine and then 404'd on the actual file
download, surfacing as a generic 502.

Attach the existing GITHUB_TOKEN bearer header in fetchRawFile, but only when
the URL host is raw.githubusercontent.com. fetchRawFile is shared with
clawhub.ai / skills.sh downloads, so the token must not leak to those hosts.
The host gate is extracted into newRawFileRequest so it is unit-testable
without a live round-trip.

MUL-3496

Co-authored-by: multica-agent <github@multica.ai>
2026-06-22 13:15:19 +08:00
2 changed files with 81 additions and 1 deletions

View File

@@ -1698,11 +1698,21 @@ func fetchFromGitHub(httpClient *http.Client, rawURL string) (*importedSkill, er
// --- Shared helpers ---
// rawGitHubContentHost serves raw GitHub file content. fetchRawFile attaches the
// GITHUB_TOKEN only for this host: the same function downloads files from
// non-GitHub skill sources (clawhub.ai, skills.sh), and an unconditional auth
// header would leak the token to those third-party hosts.
const rawGitHubContentHost = "raw.githubusercontent.com"
// fetchRawFile downloads a URL and returns the body bytes. Returns an error
// if the response exceeds maxImportFileSize so we never silently truncate a
// half-downloaded skill file into the workspace.
func fetchRawFile(httpClient *http.Client, fileURL string) ([]byte, error) {
resp, err := httpClient.Get(fileURL)
req, err := newRawFileRequest(fileURL)
if err != nil {
return nil, err
}
resp, err := httpClient.Do(req)
if err != nil {
return nil, err
}
@@ -1720,6 +1730,22 @@ func fetchRawFile(httpClient *http.Client, fileURL string) ([]byte, error) {
return body, nil
}
// newRawFileRequest builds the GET request for a raw skill file, attaching the
// GitHub auth header only when the URL targets GitHub's raw content host. The
// host gate lives here, separate from the round-trip, so it can be unit tested
// without a live network call — GITHUB_TOKEN must never reach a non-GitHub
// skill host.
func newRawFileRequest(fileURL string) (*http.Request, error) {
req, err := http.NewRequest(http.MethodGet, fileURL, nil)
if err != nil {
return nil, err
}
if strings.EqualFold(req.URL.Hostname(), rawGitHubContentHost) {
addGitHubAuthHeader(req)
}
return req, nil
}
// escapeRefPath percent-encodes each segment of a git ref individually so
// that slash-bearing refs like "release/v2" are sent to GitHub as
// "release/v2" (path separators preserved) rather than "release%2Fv2"

View File

@@ -833,6 +833,60 @@ func TestFetchFromGitHub_BlobURLImportsSpecificSkill(t *testing.T) {
}
}
// --- Raw file auth header host gating ---
// The GitHub token must reach raw.githubusercontent.com (so private-repo
// SKILL.md / file downloads authenticate) but must never be sent to the
// non-GitHub hosts (clawhub.ai, skills.sh) that share fetchRawFile.
func TestNewRawFileRequest_AttachesGitHubTokenOnlyForRawGitHubHost(t *testing.T) {
t.Setenv("GITHUB_TOKEN", "secret-token")
cases := []struct {
name string
url string
wantAuth string
}{
{
name: "raw github host authenticates",
url: "https://raw.githubusercontent.com/acme/private/main/skills/foo/SKILL.md",
wantAuth: "Bearer secret-token",
},
{
name: "clawhub host never receives the token",
url: "https://clawhub.ai/api/skills/foo/file?path=SKILL.md",
wantAuth: "",
},
{
name: "skills.sh host never receives the token",
url: "https://skills.sh/acme/foo/SKILL.md",
wantAuth: "",
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
req, err := newRawFileRequest(tc.url)
if err != nil {
t.Fatalf("newRawFileRequest(%q): %v", tc.url, err)
}
if got := req.Header.Get("Authorization"); got != tc.wantAuth {
t.Fatalf("Authorization = %q, want %q", got, tc.wantAuth)
}
})
}
}
func TestNewRawFileRequest_NoAuthHeaderWhenTokenUnset(t *testing.T) {
t.Setenv("GITHUB_TOKEN", "")
req, err := newRawFileRequest("https://raw.githubusercontent.com/acme/private/main/SKILL.md")
if err != nil {
t.Fatalf("newRawFileRequest: %v", err)
}
if got := req.Header.Get("Authorization"); got != "" {
t.Fatalf("Authorization = %q, want empty when GITHUB_TOKEN is unset", got)
}
}
// --- Bundle / file size cap tests ---
func TestFetchRawFile_ReturnsErrorOnOversizedFile(t *testing.T) {