Skip to content

fix(bundle): resolve file:// download_url via the file-URL helper#3344

Open
jawwad-ali wants to merge 2 commits into
github:mainfrom
jawwad-ali:fix/bundle-download-manifest-file-url
Open

fix(bundle): resolve file:// download_url via the file-URL helper#3344
jawwad-ali wants to merge 2 commits into
github:mainfrom
jawwad-ali:fix/bundle-download-manifest-file-url

Conversation

@jawwad-ali

Copy link
Copy Markdown
Contributor

Description

A catalog entry whose download_url is a file:// URL — the canonical URI Python itself produces via Path.as_uri() — always fails to resolve:

# _download_manifest, commands/bundle/__init__.py
local = Path(parsed.path if scheme == "file" else url)

Raw parsed.path has two problems:

  1. Windows: every path breaks. file:///C:/Users/x/bundle.ymlparsed.path = /C:/Users/x/bundle.ymlPath(...) = \C:\Users\x\bundle.yml, a leading-slash drive path that never exists.
  2. Every OS: percent-encoding is never decoded. file:///home/user/my%20bundles/bundle.yml keeps the literal %20.

Reproduced on main @ bba473c:

BundlerError: Bundle manifest not found: \C:\Users\...\my%20bundles\bundle.yml

The repo already fixed this exact class of bug for catalog file:// URLs: bundler/services/adapters.py::_file_url_to_path percent-decodes via url2pathname and preserves netloc for UNC/drive URLs (used by make_catalog_fetcher). _download_manifest is the one call site that never got the same treatment.

Fix

Route the file scheme through the existing helper (lazy import, matching the file's style); the bare-path/Windows-drive branch keeps Path(url) unchanged.

Testing

  • New test_download_manifest_resolves_file_url: manifest under my bundles/ (space in dirname so the percent-encoding failure reproduces on POSIX CI too), download_url=path.as_uri() → resolves bundle.id. Fails before (Bundle manifest not found: \C:\...my%20bundles\..., verified by source-stash), passes after.
  • New test_download_manifest_bare_windows_path_still_resolves: pins the non-URL branch (passes before and after).
  • Full tests/integration/test_bundler_local_install.py: 8 passed. uvx ruff check clean.

AI Disclosure

  • I did use AI assistance (describe below)

Found and fixed with Claude Code (Claude Fable 5) under my direction. AI identified the raw-parsed.path divergence from the adapters helper; I reproduced the failure on Windows, verified fail-before/pass-after, and reviewed the diff before submitting.

_download_manifest built the local path from raw parsed.path, which
keeps the leading slash of file:///C:/x (yielding a \C:\x path that
never exists on Windows) and skips percent-decoding (my%20bundles stays
encoded on every OS) — so a catalog entry whose download_url is the
canonical URI Python itself produces via Path.as_uri() always fails
with 'Bundle manifest not found'. Route the file scheme through the
existing bundler.services.adapters._file_url_to_path helper, which
already handles drive letters, UNC hosts, and percent-decoding for
catalog file:// URLs (make_catalog_fetcher). The bare-path branch is
unchanged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@jawwad-ali jawwad-ali requested a review from mnriem as a code owner July 5, 2026 14:21
@mnriem

mnriem commented Jul 6, 2026

Copy link
Copy Markdown
Collaborator

Thanks for the careful writeup — the diagnosis is genuinely good. You correctly spotted that _download_manifest diverged from adapters._file_url_to_path, and the Windows-drive / percent-encoding failures you documented are real.

Digging into this against the broader catalog architecture, though, we've realized the fix should go the other direction — and that's on us, not you. The real problem is that file:// in a catalog entry's download_url was never meant to exist. It slipped through review when the bundler's offline-first catalog work landed.

Context: Spec Kit has four catalog systems — bundles, extensions, presets, workflows — and the other three deliberately enforce HTTPS-only for catalog and download URLs (HTTP only for localhost). Local/dev sources go through a separate, explicit route: a real filesystem path (e.g. specify extension add <dir> --dev), never a file:// URL through URL resolution.

The bundle command already follows that convention — local installs are the positional path argument:

specify bundle install ./path/to/bundle.yml     # or a bundle dir, or a .zip

That's handled by _local_manifest_source() before catalog resolution and never touches download_url. So the file:// download_url branch isn't filling a real gap — it's a divergence from the rest of the codebase.

Rather than close this, would you be up for pivoting the PR to remove that branch instead of repairing it? You've already built the ideal harness for it. Concretely:

  • In _download_manifest, drop the scheme in ("", "file") / Windows-drive branch and route everything through _download_remote_manifest, so catalog download_url is HTTPS-only (http for localhost) — matching extensions/presets/workflows. The _require_https helper right below already does exactly this.
  • Give file:///bare-path download URLs a clear, actionable error (point users at the positional local-path install).
  • Update the docstring/help that currently advertises file:// download URLs as first-class.
  • Invert the tests: your test_download_manifest_resolves_file_url becomes an assertion that a file:// download_url is rejected; keep a test proving local installs still work via the positional path.

If you'd rather not take it that direction, no problem at all — we can close this and track the removal separately, still crediting your report. But since you did the hard part (finding the divergence), we'd love to have you land the fix. 🙏

Happy to answer questions on any of the above.

@jawwad-ali

Copy link
Copy Markdown
Contributor Author

Thanks @mnriem — that's a much better framing, and I'm happy to pivot. HTTPS-only catalog download_urls (http for localhost) matching extensions/presets/workflows, with disk installs going through the positional path, is clearly the right convention.

One wrinkle I hit while prototyping the removal, so we can decide scope deliberately: dropping the whole scheme in ("", "file") / Windows-drive branch (not just file://) breaks three existing contract tests — test_info_expands_full_component_set, test_info_expands_discovery_only_bundle, and test_info_resolves_local_zip_download_url in tests/contract/test_bundle_cli.py. They drive bundle info --offline by pointing a local catalog's download_url at a bare local path (str(bundle_dir / "bundle.yml") / a local .zip). Unlike install, info has no positional-path variant, so those can't just move to the positional route — offline info on a local bundle currently depends on that branch.

So there are two ways to land this, and I'd like your call:

  • A — reject file:// only (the actual slip): keep the bare local-path branch that offline bundle info relies on, but reject file:// (and, if you want, Windows-drive) download_urls with an actionable error pointing at specify bundle install <path>. Minimal, all tests stay green, kills the divergent URL scheme.
  • B — full HTTPS-only (your literal suggestion): route everything through _download_remote_manifest, reject both file:// and bare paths, and I migrate those three info tests onto an HTTPS-mocked fixture. Cleaner alignment, but it does remove the ability to bundle info a purely-local bundle offline — worth a conscious sign-off since it's a small UX reduction for info.

I've got the harness ready for either and can turn it around quickly. My lean is A (fixes the scheme divergence + the Windows/percent-encoding bug you confirmed, with zero collateral), but if you'd rather have the strict HTTPS-only contract everywhere I'm glad to do B and handle the info test migration. Which do you prefer?

(AI-assisted: analysis + prototyping with Claude Code under my direction; I verified the 3-test breakage locally before writing this.)

@mnriem

mnriem commented Jul 7, 2026

Copy link
Copy Markdown
Collaborator

I would prefer route B and then we probably should validate all catalog systems work similarly

…TPS-only

Per maintainer review (route B): file:// in a catalog download_url was
never intended — catalog URLs are HTTPS-only (http for localhost) across
the extensions/presets/workflows catalog systems, and disk installs go
through the positional path (specify bundle install <path>), handled by
_local_manifest_source before catalog resolution. Remove the
file:///bare-path branch from _download_manifest and route everything
through _download_remote_manifest (HTTPS-only via _require_https), with an
actionable error pointing at the positional install. Invert the file://
tests to assert rejection (+ a positional-path resolution test), and
migrate the three bundle-info contract tests off local download_urls onto
an HTTPS-only entry with a mocked manifest fetch.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@jawwad-ali

Copy link
Copy Markdown
Contributor Author

Done — pushed route B in 9f94be5.

Implementation:

  • _download_manifest now rejects file:// and bare-path download_urls with an actionable error pointing at the positional install, and routes everything else through _download_remote_manifest (HTTPS-only via _require_https). Docstring updated.
  • Inverted the tests: test_download_manifest_rejects_file_url / test_download_manifest_rejects_bare_path assert rejection, plus test_local_install_still_resolves_via_positional_path proves the supported disk route still works via _local_manifest_source.
  • Migrated the three bundle info contract tests (test_info_expands_full_component_set, test_info_expands_discovery_only_bundle, test_info_expands_zip_sourced_bundle) off local download_urls onto an HTTPS-only entry with a mocked manifest fetch — component-expansion coverage is preserved. Full test_bundle_cli.py + test_bundler_local_install.py: 39 passed, ruff clean.

On "validate all catalog systems work similarly" — I audited the other three, and the good news is they already enforce HTTPS on both the catalog URL and the per-entry download URL:

  • extensions — Extension download URL must use HTTPS (extensions/init.py ~2601)
  • presets — Preset download URL must use HTTPS (presets/init.py ~2497)
  • workflows — scheme checks on catalog + download URLs (workflows/catalog.py ~337/954)
  • shared catalogs.py / bundler adapters._validate_remote_url — catalog URLs HTTPS-only

Bundle's _download_manifest was the lone place that accepted a non-HTTPS download_url; this PR closes that gap, so all four are now consistent. Happy to add a small cross-system parity test (or a follow-up PR) if you'd like that invariant pinned explicitly — just say the word.

(AI-assisted: implemented + audited with Claude Code under my direction; verified the full contract/local-install suites locally.)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants