fix(extensions,presets,workflows): resolve private GHES release assets via /api/v3 (#3157)

* feat(auth): add github_provider_hosts() to enumerate GHES hosts from auth.json

Assisted-by: Claude Code (model: claude-sonnet-4-6, autonomous)

* fix(extensions): resolve GHES release assets via /api/v3

Generalizes resolve_github_release_asset_api_url to GitHub Enterprise
Server hosts (gated by auth.json github hosts), fixing private GHES
extension/preset downloads. github/spec-kit#3147

Assisted-by: Claude Code (model: claude-sonnet-4-6, autonomous)

* fix(extensions,presets): pass auth.json github hosts into release resolver

Assisted-by: Claude Code (model: claude-sonnet-4-6, autonomous)

* docs(auth): document GHES private catalog + release-asset auth

Assisted-by: Claude Code (model: claude-sonnet-4-6, autonomous)

* fix(presets,workflows): pass auth.json github hosts into remaining release resolvers

Wires preset add --from and workflow add through github_provider_hosts()
so private GHES release assets resolve via /api/v3 there too. github/spec-kit#3147

Assisted-by: Claude Code (model: claude-sonnet-4-6, autonomous)

* test(presets): use module-level io.BytesIO in GHES preset test

Addresses Copilot review on PR #3157: drop unnecessary __import__("io")
in test_preset_add_from_ghes_release_url_resolves_via_api_v3 since io is
already imported at module level.

* fix(github-http): pass through GHES asset API URLs by path shape

Addresses Copilot review on PR #3157. A direct GHES /api/v3 release asset
URL was only returned as already-resolved when its host was in the
allowlist; otherwise the resolver returned None and the caller downloaded
the same URL without 'Accept: application/octet-stream', fetching JSON
metadata instead of the binary.

Gate the passthrough on path shape alone, mirroring the github.com case.
This is safe: passthrough returns the input URL unchanged and the caller
fetches it either way, so no new request to an arbitrary host is induced;
the token stays independently gated by auth.json in open_url. The
allowlist remains the anti-SSRF gate on the tag-lookup resolving path.

Add test_passthrough_for_unlisted_ghes_api_asset_url.
This commit is contained in:
Si Zengyu
2026-06-25 23:44:30 +08:00
committed by GitHub
parent 7624dd6582
commit 1add20341d
12 changed files with 542 additions and 36 deletions

View File

@@ -17,9 +17,11 @@ import tempfile
import shutil
import warnings
import zipfile
from contextlib import contextmanager
from pathlib import Path
from datetime import datetime, timezone
from types import SimpleNamespace
from unittest.mock import MagicMock
import yaml
@@ -4752,6 +4754,69 @@ class TestPresetAddFromUrlResolution:
assert captured_urls[0][0] == "https://api.github.com/repos/org/repo/releases/assets/42"
assert captured_urls[0][1] == {"Accept": "application/octet-stream"}
def test_preset_add_from_ghes_release_url_resolves_via_api_v3(self, project_dir, monkeypatch):
"""'preset add --from <ghes-release-url>' resolves via GHES /api/v3 endpoint."""
from typer.testing import CliRunner
from unittest.mock import patch
from specify_cli import app
from specify_cli.authentication import http as _auth_http
from specify_cli.authentication.config import AuthConfigEntry
monkeypatch.setattr(_auth_http, "_config_override", [
AuthConfigEntry(hosts=("ghes.example",), provider="github", auth="bearer", token="t"),
])
manifest_content = yaml.dump({
"schema_version": "1.0",
"preset": {"id": "my-preset", "name": "My Preset", "version": "1.0.0", "description": "Test preset", "author": "Test", "license": "MIT"},
"requires": {"speckit_version": ">=0.1.0"},
"provides": {"templates": [{"type": "template", "name": "t", "file": "templates/t.md", "description": "t"}]},
})
zip_buf = io.BytesIO()
with zipfile.ZipFile(zip_buf, "w") as zf:
zf.writestr("preset.yml", manifest_content)
zip_bytes = zip_buf.getvalue()
captured_urls = []
class FakeResponse:
def __init__(self, data):
self._data = data
def read(self):
return self._data
def __enter__(self):
return self
def __exit__(self, *a):
return False
def fake_open_url(url, timeout=None, extra_headers=None, redirect_validator=None):
captured_urls.append((url, extra_headers))
if "releases/tags/" in url:
return FakeResponse(json.dumps({
"assets": [{"name": "preset.zip", "url": "https://ghes.example/api/v3/repos/org/repo/releases/assets/42"}]
}).encode())
return FakeResponse(zip_bytes)
runner = CliRunner()
with patch.object(Path, "cwd", return_value=project_dir), \
patch("specify_cli.get_speckit_version", return_value="1.0.0"), \
patch("specify_cli.authentication.http.open_url", side_effect=fake_open_url):
result = runner.invoke(app, [
"preset", "add",
"--from", "https://ghes.example/org/repo/releases/download/v1.0/preset.zip",
])
assert result.exit_code == 0, result.output
# The tag-lookup call must use the GHES /api/v3 endpoint
assert any("ghes.example/api/v3/repos/org/repo/releases/tags/v1.0" in url for url, _ in captured_urls)
# The asset download call must carry Accept: application/octet-stream
asset_calls = [(url, h) for url, h in captured_urls if "releases/assets/" in url]
assert len(asset_calls) >= 1
assert asset_calls[0][1] == {"Accept": "application/octet-stream"}
class TestWrapStrategy:
"""Tests for strategy: wrap preset command substitution."""
@@ -6021,3 +6086,36 @@ def _create_pack(temp_dir, valid_pack_data, pack_id, content,
(subdir / f"{template_name}.md").write_text(content)
return pack_dir
def test_preset_wrapper_resolves_ghes_asset_when_host_configured(tmp_path, monkeypatch):
"""End-to-end wiring for presets: auth.json github host → GHES asset resolution."""
from specify_cli.authentication import http as _auth_http
from specify_cli.authentication.config import AuthConfigEntry
from specify_cli.presets import PresetCatalog
monkeypatch.setattr(_auth_http, "_config_override", [
AuthConfigEntry(hosts=("ghes.example",), provider="github",
auth="bearer", token="t"),
])
catalog = PresetCatalog(tmp_path)
captured = []
@contextmanager
def fake_open(url, timeout=None, extra_headers=None):
captured.append(url)
resp = MagicMock()
resp.read.return_value = json.dumps({
"assets": [{"name": "pack.zip",
"url": "https://ghes.example/api/v3/repos/o/r/releases/assets/9"}]
}).encode()
yield resp
monkeypatch.setattr(catalog, "_open_url", fake_open)
resolved = catalog._resolve_github_release_asset_api_url(
"https://ghes.example/o/r/releases/download/v2/pack.zip"
)
assert resolved == "https://ghes.example/api/v3/repos/o/r/releases/assets/9"
assert captured == ["https://ghes.example/api/v3/repos/o/r/releases/tags/v2"]