Skip to content

Commit a2bc977

Browse files
bokelleyclaude
andauthored
feat(adcp): support ADCP_BASE_URL env override in sync_schemas.py (#285)
* feat(adcp): support ADCP_BASE_URL env override in sync_schemas.py Matches the JS SDK pattern so cross-SDK CI can set ADCP_BASE_URL once to point both clients at a fixture CDN or pre-release bundle server. Trailing slashes on the env var value are stripped to prevent a doubled /protocol path segment. Adds a print when the override is active. Closes #284 https://claude.ai/code/session_01KXfYPHYKr3R6EDqUbbo9ty * fix(test): guard test_default_value against ADCP_BASE_URL in env; use ! sigil test_default_value was relying on the already-loaded _mod which bakes in whatever ADCP_BASE_URL was when pytest started — exactly the CI case this feature enables. Switched to the same fresh-module-load pattern the other two env-override tests already use, with monkeypatch.delenv to guarantee the default code path. Also align the override print line with the established ! sigil convention. https://claude.ai/code/session_01KXfYPHYKr3R6EDqUbbo9ty * feat(adcp): reject ADCP_BASE_URL values ending in /protocol Acting on python-expert review of this PR: The script appends '/protocol' itself, so an override like 'https://fixture.example.com/protocol' would silently produce '/protocol/protocol' and 404 against any sensible CDN. Convert that into a loud ValueError at module import so the typo surfaces immediately rather than during the fetch. Two new tests cover the failure mode (suffix + suffix-with-trailing- slash). The existing trailing-slash-strip test still covers the common case where users add an accidental trailing slash on the host itself. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 54fd18b commit a2bc977

2 files changed

Lines changed: 90 additions & 38 deletions

File tree

scripts/sync_schemas.py

Lines changed: 23 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,12 @@
1616
The target version comes from `src/adcp/ADCP_VERSION`. If that version's
1717
bundle is not published, sync falls back to `latest.tgz` (the dev snapshot).
1818
19+
Environment variables:
20+
ADCP_BASE_URL Override the protocol host (default: https://adcontextprotocol.org).
21+
Set to point at a fixture CDN for cross-SDK CI or pre-release testing.
22+
Trailing slashes are stripped automatically. Do NOT include "/protocol".
23+
ADCP_SKIP_SIGNATURE Set to "1" to skip Sigstore verification and trust the SHA-256 only.
24+
1925
Usage:
2026
python scripts/sync_schemas.py # sync schemas + skills
2127
python scripts/sync_schemas.py --no-skills # schemas only (e.g. drift checks)
@@ -41,7 +47,17 @@
4147
CACHE_DIR = REPO_ROOT / "schemas" / "cache"
4248
SKILLS_DIR = REPO_ROOT / "skills"
4349
VERSION_FILE = REPO_ROOT / "src" / "adcp" / "ADCP_VERSION"
44-
BUNDLE_BASE_URL = "https://adcontextprotocol.org/protocol"
50+
_ADCP_BASE = os.environ.get("ADCP_BASE_URL", "https://adcontextprotocol.org").rstrip("/")
51+
# Reject overrides ending in /protocol — appending our own /protocol below
52+
# would silently produce //protocol and 404 against any sensible CDN. Fail
53+
# loud at module import so the typo surfaces immediately.
54+
if _ADCP_BASE.endswith("/protocol"):
55+
raise ValueError(
56+
f"ADCP_BASE_URL={_ADCP_BASE!r} ends with '/protocol'. The script "
57+
"appends '/protocol' itself — pass only the protocol host "
58+
"(e.g. https://adcontextprotocol.org)."
59+
)
60+
BUNDLE_BASE_URL = _ADCP_BASE + "/protocol"
4561
USER_AGENT = "adcp-python-sdk/3.0"
4662

4763
# Sigstore keyless verification identity. Must match the upstream release
@@ -118,9 +134,7 @@ def fetch_signature_sidecars(version: str) -> tuple[bytes | None, bytes | None]:
118134
return sig, crt
119135

120136

121-
def verify_cosign_signature(
122-
tgz_bytes: bytes, sig_bytes: bytes, crt_bytes: bytes
123-
) -> None:
137+
def verify_cosign_signature(tgz_bytes: bytes, sig_bytes: bytes, crt_bytes: bytes) -> None:
124138
"""Verify the bundle with `cosign verify-blob`.
125139
126140
Raises RuntimeError if cosign is not installed or verification fails.
@@ -193,9 +207,7 @@ def replace_cache_from_bundle(bundle_root: Path) -> int:
193207
"""
194208
schemas_src = bundle_root / "schemas"
195209
if not schemas_src.is_dir():
196-
raise RuntimeError(
197-
f"Bundle missing expected directory: {bundle_root.name}/schemas/"
198-
)
210+
raise RuntimeError(f"Bundle missing expected directory: {bundle_root.name}/schemas/")
199211

200212
if CACHE_DIR.exists():
201213
shutil.rmtree(CACHE_DIR)
@@ -274,6 +286,8 @@ def main() -> None:
274286
args = parser.parse_args()
275287

276288
target_version = get_target_adcp_version()
289+
if "ADCP_BASE_URL" in os.environ:
290+
print(f" ! ADCP_BASE_URL override active: {_ADCP_BASE}")
277291
print(f"Syncing AdCP protocol bundle from {BUNDLE_BASE_URL}...")
278292
print(f"Target version: {target_version}")
279293
print(f"Schema cache: {CACHE_DIR}")
@@ -283,9 +297,7 @@ def main() -> None:
283297

284298
try:
285299
print(f"Fetching {target_version}.tgz + checksum...")
286-
tgz_bytes, expected_sha, effective_version = fetch_bundle_with_fallback(
287-
target_version
288-
)
300+
tgz_bytes, expected_sha, effective_version = fetch_bundle_with_fallback(target_version)
289301
except (HTTPError, URLError) as exc:
290302
print(f"\n✗ Failed to download bundle: {exc}", file=sys.stderr)
291303
sys.exit(1)
@@ -311,10 +323,7 @@ def main() -> None:
311323
sys.exit(1)
312324

313325
if sig_bytes is None or crt_bytes is None:
314-
print(
315-
f" ! No Sigstore sidecars for adcp-{effective_version} "
316-
"(checksum-only trust)"
317-
)
326+
print(f" ! No Sigstore sidecars for adcp-{effective_version} " "(checksum-only trust)")
318327
else:
319328
try:
320329
verify_cosign_signature(tgz_bytes, sig_bytes, crt_bytes)

tests/test_sync_schemas.py

Lines changed: 67 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -124,9 +124,7 @@ def test_no_manifest_returns_zero(self, capsys: pytest.CaptureFixture[str]) -> N
124124
assert result == 0
125125
assert "No manifest.json" in capsys.readouterr().out
126126

127-
def test_empty_skills_list_returns_zero(
128-
self, capsys: pytest.CaptureFixture[str]
129-
) -> None:
127+
def test_empty_skills_list_returns_zero(self, capsys: pytest.CaptureFixture[str]) -> None:
130128
with tempfile.TemporaryDirectory() as tmp_str:
131129
tmp = Path(tmp_str)
132130
bundle_root = _make_bundle(tmp, manifest_skills=[])
@@ -179,15 +177,11 @@ def test_previous_snapshot_created_on_update(self) -> None:
179177
existing.mkdir()
180178
(existing / "SKILL.md").write_text("# Old Brand")
181179

182-
bundle_root = _make_bundle(
183-
tmp, skills={"adcp-brand": {"SKILL.md": "# New Brand"}}
184-
)
180+
bundle_root = _make_bundle(tmp, skills={"adcp-brand": {"SKILL.md": "# New Brand"}})
185181
sync_skills_from_bundle(bundle_root, skills_dir)
186182

187183
assert (skills_dir / "adcp-brand" / "SKILL.md").read_text() == "# New Brand"
188-
assert (skills_dir / "adcp-brand.previous" / "SKILL.md").read_text() == (
189-
"# Old Brand"
190-
)
184+
assert (skills_dir / "adcp-brand.previous" / "SKILL.md").read_text() == ("# Old Brand")
191185

192186
def test_previous_snapshot_replaced_on_second_update(self) -> None:
193187
with tempfile.TemporaryDirectory() as tmp_str:
@@ -204,14 +198,10 @@ def test_previous_snapshot_replaced_on_second_update(self) -> None:
204198
existing.mkdir()
205199
(existing / "SKILL.md").write_text("# Old Brand")
206200

207-
bundle_root = _make_bundle(
208-
tmp, skills={"adcp-brand": {"SKILL.md": "# New Brand"}}
209-
)
201+
bundle_root = _make_bundle(tmp, skills={"adcp-brand": {"SKILL.md": "# New Brand"}})
210202
sync_skills_from_bundle(bundle_root, skills_dir)
211203

212-
assert (skills_dir / "adcp-brand.previous" / "SKILL.md").read_text() == (
213-
"# Old Brand"
214-
)
204+
assert (skills_dir / "adcp-brand.previous" / "SKILL.md").read_text() == ("# Old Brand")
215205

216206
def test_local_only_skill_untouched(self) -> None:
217207
with tempfile.TemporaryDirectory() as tmp_str:
@@ -263,9 +253,7 @@ def test_path_traversal_slash_in_name_rejected(self) -> None:
263253
with pytest.raises(RuntimeError, match="Unsafe skill name rejected"):
264254
sync_skills_from_bundle(bundle_root, skills_dir)
265255

266-
def test_non_string_name_skipped(
267-
self, capsys: pytest.CaptureFixture[str]
268-
) -> None:
256+
def test_non_string_name_skipped(self, capsys: pytest.CaptureFixture[str]) -> None:
269257
with tempfile.TemporaryDirectory() as tmp_str:
270258
tmp = Path(tmp_str)
271259
bundle_root = _make_bundle(
@@ -280,9 +268,7 @@ def test_non_string_name_skipped(
280268
assert count == 1 # only the valid string entry is synced
281269
assert "Skipping non-string" in capsys.readouterr().out
282270

283-
def test_missing_bundle_skill_dir_skipped(
284-
self, capsys: pytest.CaptureFixture[str]
285-
) -> None:
271+
def test_missing_bundle_skill_dir_skipped(self, capsys: pytest.CaptureFixture[str]) -> None:
286272
with tempfile.TemporaryDirectory() as tmp_str:
287273
tmp = Path(tmp_str)
288274
# Manifest lists a skill that has no corresponding directory in the bundle
@@ -322,9 +308,7 @@ def test_missing_bundle_skill_preserves_existing_dst(
322308
sync_skills_from_bundle(bundle_root, skills_dir)
323309

324310
# dst must not be touched when src is absent
325-
assert (skills_dir / "adcp-brand" / "SKILL.md").read_text() == (
326-
"# Existing Brand"
327-
)
311+
assert (skills_dir / "adcp-brand" / "SKILL.md").read_text() == ("# Existing Brand")
328312
assert "missing in bundle" in capsys.readouterr().out
329313

330314
def test_multiple_skills_synced(self) -> None:
@@ -346,3 +330,62 @@ def test_multiple_skills_synced(self) -> None:
346330
assert (skills_dir / "adcp-brand" / "SKILL.md").exists()
347331
assert (skills_dir / "adcp-creative" / "SKILL.md").exists()
348332
assert (skills_dir / "call-adcp-agent" / "SKILL.md").exists()
333+
334+
335+
# ---------------------------------------------------------------------------
336+
# BUNDLE_BASE_URL env override (ADCP_BASE_URL)
337+
# ---------------------------------------------------------------------------
338+
339+
340+
class TestBundleBaseUrl:
341+
def test_default_value(self, monkeypatch: pytest.MonkeyPatch) -> None:
342+
# Fresh load with env var absent — guards against shell having ADCP_BASE_URL set.
343+
monkeypatch.delenv("ADCP_BASE_URL", raising=False)
344+
fresh_spec = importlib.util.spec_from_file_location("sync_schemas_default", _SCRIPT)
345+
assert fresh_spec is not None and fresh_spec.loader is not None
346+
fresh_mod = importlib.util.module_from_spec(fresh_spec)
347+
fresh_spec.loader.exec_module(fresh_mod) # type: ignore[union-attr]
348+
assert fresh_mod.BUNDLE_BASE_URL == "https://adcontextprotocol.org/protocol"
349+
350+
def test_env_override(self, monkeypatch: pytest.MonkeyPatch) -> None:
351+
# Fresh module load with ADCP_BASE_URL set to verify override is applied.
352+
monkeypatch.setenv("ADCP_BASE_URL", "https://fixture.example.com")
353+
fresh_spec = importlib.util.spec_from_file_location("sync_schemas_fresh", _SCRIPT)
354+
assert fresh_spec is not None and fresh_spec.loader is not None
355+
fresh_mod = importlib.util.module_from_spec(fresh_spec)
356+
fresh_spec.loader.exec_module(fresh_mod) # type: ignore[union-attr]
357+
assert fresh_mod.BUNDLE_BASE_URL == "https://fixture.example.com/protocol"
358+
359+
def test_env_override_strips_trailing_slash(self, monkeypatch: pytest.MonkeyPatch) -> None:
360+
# Trailing slash on ADCP_BASE_URL must not produce "//protocol".
361+
monkeypatch.setenv("ADCP_BASE_URL", "https://fixture.example.com/")
362+
fresh_spec = importlib.util.spec_from_file_location("sync_schemas_fresh2", _SCRIPT)
363+
assert fresh_spec is not None and fresh_spec.loader is not None
364+
fresh_mod = importlib.util.module_from_spec(fresh_spec)
365+
fresh_spec.loader.exec_module(fresh_mod) # type: ignore[union-attr]
366+
assert fresh_mod.BUNDLE_BASE_URL == "https://fixture.example.com/protocol"
367+
assert "//protocol" not in fresh_mod.BUNDLE_BASE_URL
368+
369+
def test_env_override_rejects_protocol_suffix(self, monkeypatch: pytest.MonkeyPatch) -> None:
370+
# Override ending in /protocol would double-append. Fail loud at
371+
# import rather than silently 404-ing later.
372+
monkeypatch.setenv("ADCP_BASE_URL", "https://fixture.example.com/protocol")
373+
fresh_spec = importlib.util.spec_from_file_location("sync_schemas_protocol_suffix", _SCRIPT)
374+
assert fresh_spec is not None and fresh_spec.loader is not None
375+
fresh_mod = importlib.util.module_from_spec(fresh_spec)
376+
with pytest.raises(ValueError, match="ends with '/protocol'"):
377+
fresh_spec.loader.exec_module(fresh_mod) # type: ignore[union-attr]
378+
379+
def test_env_override_rejects_protocol_suffix_with_trailing_slash(
380+
self, monkeypatch: pytest.MonkeyPatch
381+
) -> None:
382+
# Same guard, but with a trailing slash on the override — rstrip
383+
# runs first, so the /protocol still trips the check.
384+
monkeypatch.setenv("ADCP_BASE_URL", "https://fixture.example.com/protocol/")
385+
fresh_spec = importlib.util.spec_from_file_location(
386+
"sync_schemas_protocol_trailing", _SCRIPT
387+
)
388+
assert fresh_spec is not None and fresh_spec.loader is not None
389+
fresh_mod = importlib.util.module_from_spec(fresh_spec)
390+
with pytest.raises(ValueError, match="ends with '/protocol'"):
391+
fresh_spec.loader.exec_module(fresh_mod) # type: ignore[union-attr]

0 commit comments

Comments
 (0)