From b2c61d96aefa5c1d46101d96c0a3e2c1f849d118 Mon Sep 17 00:00:00 2001 From: Geir Sjurseth Date: Wed, 24 Jun 2026 13:50:52 +0000 Subject: [PATCH 1/4] feat(apigee-skills-serving): add reference for Apigee API hub as agent skill catalog Adds a new reference implementation under references/apigee-skills-serving demonstrating how to use Apigee API hub as a versioned, signed catalog of agent skills (SKILL.md bundles) discoverable and installable by LLM agent runtimes such as OpenCode, Claude Code, and Gemini CLI. What's included: * Publisher toolchain (scripts/): pack/sign/upload/register/update-taxonomy Python scripts that turn a directory of SKILL.md + manifest.yaml into a signed .skill archive uploaded to GCS and registered with API hub. * Locked v1 manifest schema (schema/skill-manifest.schema.yaml) with Ed25519 detached-signature support over canonical YAML serialisation. * Three example skills (skills/): currency-converter, weather-lookup, apigee-policy-top10 (a non-trivial skill that documents the ten most recommended Apigee policy patterns and enumerates which are deployed in your org). * One showcase example (examples/apigee-proxy-skill): a production-shaped skill that scaffolds, validates, packages, and deploys Apigee X / hybrid proxies via an MCP server. * End-to-end documentation (docs/): architecture, publish-and-install walkthrough, policy-skill-catalog deep-dive. * Hermetic test suite (tests/): 220 unit + in-process integration tests, all HTTP and ADC mocked, no live GCP needed, runs in ~1.3s. * pipeline.sh: devrel CI entry-point that installs deps into a venv and runs the test suite. Also updates: * CODEOWNERS: adds @gsjurseth as owner of the new reference. * README.md: adds the new reference to the References section. The implementation is independent of any specific agent runtime; the SKILL.md format and skill install path (~/.config/opencode/skills/) match the OpenCode convention, which is the closest thing to an emerging de-facto standard in the OSS agent runtime space. --- CODEOWNERS | 1 + README.md | 4 + references/apigee-skills-serving/.gitignore | 29 + references/apigee-skills-serving/LICENSE | 202 ++++++ references/apigee-skills-serving/README.md | 174 +++++ .../bin/check-prerequisites.sh | 145 ++++ .../apigee-skills-serving/bin/demo-cleanup.sh | 190 ++++++ .../apigee-skills-serving/bin/demo-setup.sh | 248 +++++++ .../docs/architecture.md | 185 +++++ .../docs/policy-skill-catalog.md | 108 +++ .../docs/publish-and-install.md | 224 +++++++ .../apigee-skills-serving/env.sh.example | 42 ++ .../examples/apigee-proxy-skill/SKILL.md | 433 ++++++++++++ .../examples/apigee-proxy-skill/manifest.yaml | 67 ++ .../apigee-proxy-skill/scripts/install.sh | 108 +++ references/apigee-skills-serving/pipeline.sh | 41 ++ references/apigee-skills-serving/pytest.ini | 19 + .../apigee-skills-serving/requirements.txt | 10 + .../schema/skill-manifest.schema.yaml | 133 ++++ .../apigee-skills-serving/scripts/__init__.py | 14 + .../scripts/common/__init__.py | 14 + .../scripts/common/canonical.py | 58 ++ .../scripts/common/config.py | 219 ++++++ .../scripts/common/http_retry.py | 132 ++++ .../scripts/common/iam_preflight.py | 303 +++++++++ .../scripts/common/manifest_schema.py | 223 +++++++ .../scripts/common/permission_resolver.py | 240 +++++++ .../scripts/common/watcher_probe.py | 143 ++++ .../scripts/pack_skill.py | 274 ++++++++ .../scripts/register_skill.py | 425 ++++++++++++ .../scripts/sign_skill.py | 226 +++++++ .../scripts/update_taxonomy.py | 214 ++++++ .../scripts/upload_skill.py | 165 +++++ .../skills/apigee-policy-top10/SKILL.md | 63 ++ .../skills/apigee-policy-top10/manifest.yaml | 32 + .../apigee-policy-top10/scripts/top10.py | 237 +++++++ .../skills/currency-converter/SKILL.md | 31 + .../skills/currency-converter/manifest.yaml | 29 + .../skills/weather-lookup/SKILL.md | 32 + .../skills/weather-lookup/manifest.yaml | 28 + .../apigee-skills-serving/tests/conftest.py | 15 + .../tests/fixtures/announcement.txt | 1 + .../tests/test_apigee_top10.py | 630 ++++++++++++++++++ .../tests/test_canonical.py | 138 ++++ .../tests/test_check_demo_prerequisites.py | 327 +++++++++ .../tests/test_config.py | 403 +++++++++++ .../tests/test_http_retry.py | 395 +++++++++++ .../tests/test_iam_preflight.py | 482 ++++++++++++++ .../tests/test_manifest_schema.py | 334 ++++++++++ .../tests/test_permission_resolver.py | 263 ++++++++ .../tests/test_register_fetch_integration.py | 296 ++++++++ .../tests/test_register_skill.py | 425 ++++++++++++ .../tests/test_sign_skill.py | 358 ++++++++++ .../tests/test_sign_verify_integration.py | 181 +++++ .../tests/test_update_taxonomy.py | 216 ++++++ .../tests/test_upload_skill.py | 252 +++++++ .../tests/test_watcher_probe.py | 202 ++++++ 57 files changed, 10383 insertions(+) create mode 100644 references/apigee-skills-serving/.gitignore create mode 100644 references/apigee-skills-serving/LICENSE create mode 100644 references/apigee-skills-serving/README.md create mode 100755 references/apigee-skills-serving/bin/check-prerequisites.sh create mode 100755 references/apigee-skills-serving/bin/demo-cleanup.sh create mode 100755 references/apigee-skills-serving/bin/demo-setup.sh create mode 100644 references/apigee-skills-serving/docs/architecture.md create mode 100644 references/apigee-skills-serving/docs/policy-skill-catalog.md create mode 100644 references/apigee-skills-serving/docs/publish-and-install.md create mode 100644 references/apigee-skills-serving/env.sh.example create mode 100644 references/apigee-skills-serving/examples/apigee-proxy-skill/SKILL.md create mode 100644 references/apigee-skills-serving/examples/apigee-proxy-skill/manifest.yaml create mode 100755 references/apigee-skills-serving/examples/apigee-proxy-skill/scripts/install.sh create mode 100755 references/apigee-skills-serving/pipeline.sh create mode 100644 references/apigee-skills-serving/pytest.ini create mode 100644 references/apigee-skills-serving/requirements.txt create mode 100644 references/apigee-skills-serving/schema/skill-manifest.schema.yaml create mode 100644 references/apigee-skills-serving/scripts/__init__.py create mode 100644 references/apigee-skills-serving/scripts/common/__init__.py create mode 100644 references/apigee-skills-serving/scripts/common/canonical.py create mode 100644 references/apigee-skills-serving/scripts/common/config.py create mode 100644 references/apigee-skills-serving/scripts/common/http_retry.py create mode 100644 references/apigee-skills-serving/scripts/common/iam_preflight.py create mode 100644 references/apigee-skills-serving/scripts/common/manifest_schema.py create mode 100644 references/apigee-skills-serving/scripts/common/permission_resolver.py create mode 100644 references/apigee-skills-serving/scripts/common/watcher_probe.py create mode 100644 references/apigee-skills-serving/scripts/pack_skill.py create mode 100644 references/apigee-skills-serving/scripts/register_skill.py create mode 100644 references/apigee-skills-serving/scripts/sign_skill.py create mode 100644 references/apigee-skills-serving/scripts/update_taxonomy.py create mode 100644 references/apigee-skills-serving/scripts/upload_skill.py create mode 100644 references/apigee-skills-serving/skills/apigee-policy-top10/SKILL.md create mode 100644 references/apigee-skills-serving/skills/apigee-policy-top10/manifest.yaml create mode 100644 references/apigee-skills-serving/skills/apigee-policy-top10/scripts/top10.py create mode 100644 references/apigee-skills-serving/skills/currency-converter/SKILL.md create mode 100644 references/apigee-skills-serving/skills/currency-converter/manifest.yaml create mode 100644 references/apigee-skills-serving/skills/weather-lookup/SKILL.md create mode 100644 references/apigee-skills-serving/skills/weather-lookup/manifest.yaml create mode 100644 references/apigee-skills-serving/tests/conftest.py create mode 100644 references/apigee-skills-serving/tests/fixtures/announcement.txt create mode 100644 references/apigee-skills-serving/tests/test_apigee_top10.py create mode 100644 references/apigee-skills-serving/tests/test_canonical.py create mode 100644 references/apigee-skills-serving/tests/test_check_demo_prerequisites.py create mode 100644 references/apigee-skills-serving/tests/test_config.py create mode 100644 references/apigee-skills-serving/tests/test_http_retry.py create mode 100644 references/apigee-skills-serving/tests/test_iam_preflight.py create mode 100644 references/apigee-skills-serving/tests/test_manifest_schema.py create mode 100644 references/apigee-skills-serving/tests/test_permission_resolver.py create mode 100644 references/apigee-skills-serving/tests/test_register_fetch_integration.py create mode 100644 references/apigee-skills-serving/tests/test_register_skill.py create mode 100644 references/apigee-skills-serving/tests/test_sign_skill.py create mode 100644 references/apigee-skills-serving/tests/test_sign_verify_integration.py create mode 100644 references/apigee-skills-serving/tests/test_update_taxonomy.py create mode 100644 references/apigee-skills-serving/tests/test_upload_skill.py create mode 100644 references/apigee-skills-serving/tests/test_watcher_probe.py diff --git a/CODEOWNERS b/CODEOWNERS index 6dda68b4a..81fb6e442 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -26,6 +26,7 @@ /references/southbound-mtls @danistrebel /references/threat-protect @joelgauci /references/openapi-mock @micovery +/references/apigee-skills-serving @gsjurseth /tools/apigee-envoy-quickstart @ganadurai /tools/apigee-openlegacy @tomfi @joelgauci /tools/apigee-sackmesser @danistrebel diff --git a/README.md b/README.md index 277f5a37b..7d32f06af 100644 --- a/README.md +++ b/README.md @@ -76,6 +76,10 @@ further to fit a particular use case. for a long term caching/storage solution based on Cloud Firestore - [OpenAPI Mock](references/openapi-mock) - Reference implementation for creating a mock API proxy from an OpenAPI 3 specification +- [Apigee Skills Serving](references/apigee-skills-serving) - + Reference implementation for using Apigee API hub as a versioned, + signed catalog of agent skills (SKILL.md bundles) discoverable and + installable by LLM agent runtimes. ## Tools diff --git a/references/apigee-skills-serving/.gitignore b/references/apigee-skills-serving/.gitignore new file mode 100644 index 000000000..e1c2c41e5 --- /dev/null +++ b/references/apigee-skills-serving/.gitignore @@ -0,0 +1,29 @@ +# apigee-skills-serving local-only artifacts. + +# Operator environment with real values; copied from env.sh.example. +env.sh + +# Ed25519 signing material. Never commit private keys. +*.key +*.pem +*.priv +*signing* +.signing/ + +# Built skill bundles. Produced by scripts/pack_skill.py and uploaded +# to GCS; not stored in the repo. +*.skill +/dist/ +/build/ + +# Python ephemera. +__pycache__/ +*.pyc +*.pyo +.pytest_cache/ +.coverage +htmlcov/ + +# Virtualenv created by pipeline.sh. +venv/ +.venv/ diff --git a/references/apigee-skills-serving/LICENSE b/references/apigee-skills-serving/LICENSE new file mode 100644 index 000000000..7a4a3ea24 --- /dev/null +++ b/references/apigee-skills-serving/LICENSE @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. \ No newline at end of file diff --git a/references/apigee-skills-serving/README.md b/references/apigee-skills-serving/README.md new file mode 100644 index 000000000..64ac9b602 --- /dev/null +++ b/references/apigee-skills-serving/README.md @@ -0,0 +1,174 @@ +# Apigee Skills Serving + +Use Apigee API hub as the catalog and authority for **agent skills** — +versioned, signed, retrievable bundles of LLM agent instructions and +helper code that any agent runtime (OpenCode, Claude Code, Gemini CLI, +custom MCP hosts, etc.) can discover and install. + +This reference implementation shows how to: + +1. **Author** a skill (a `SKILL.md` + optional `scripts/`) and describe it + with a `manifest.yaml` against a locked schema. +2. **Sign** the skill bundle with an Ed25519 key so consumers can verify + integrity at install time. +3. **Upload** the signed `.skill` archive to a Google Cloud Storage bucket. +4. **Register** the skill (and its API hub attribute taxonomy) so it is + discoverable through API hub's search and attribute filters. +5. **Install** a skill on the consumer side: search API hub, fetch the + `gs://` URI, verify the Ed25519 signature, materialize the `SKILL.md` + on disk for the agent runtime. + +The whole loop runs on standard Apigee X / hybrid plus API hub — no +custom infrastructure. + +## Why Apigee API hub for skills? + +Agent skills are, structurally, just metadata-tagged content addressed +by content-hash. API hub already provides: + +- **A versioned catalog** with stable IDs, list and search endpoints. +- **A typed attribute taxonomy** for filtering (runtime compatibility, + required IAM permissions, etc.). +- **Project-scoped IAM** so publish/consume permissions are governed + through the same controls as the rest of your API surface. +- **A regional, replicated store** with audit logging. + +Using API hub as the skill catalog avoids standing up a parallel registry +and lets organisations apply existing API governance to the agent surface. + +## Repository layout + +```text +references/apigee-skills-serving/ +├── README.md you are here +├── LICENSE Apache 2.0 +├── pipeline.sh devrel CI entry-point +├── env.sh.example environment variable template +├── requirements.txt Python runtime dependencies (4 packages) +├── pytest.ini test configuration +├── docs/ +│ ├── architecture.md design overview and trust model +│ ├── publish-and-install.md end-to-end walkthrough +│ └── policy-skill-catalog.md about the apigee-policy-top10 example +├── bin/ +│ ├── check-prerequisites.sh pre-flight environment validator +│ ├── demo-setup.sh env export + readiness print +│ └── demo-cleanup.sh remove demo artifacts +├── schema/ +│ └── skill-manifest.schema.yaml locked v1 manifest schema +├── scripts/ publisher-side toolchain +│ ├── pack_skill.py bundle a skill directory into a .skill zip +│ ├── sign_skill.py Ed25519-sign a manifest +│ ├── upload_skill.py push a signed .skill to GCS +│ ├── register_skill.py register the manifest with API hub +│ ├── update_taxonomy.py create/update API hub attribute taxonomy +│ └── common/ shared libraries (retry, IAM, schema) +├── skills/ example skills +│ ├── apigee-policy-top10/ skill that documents Apigee policy patterns +│ ├── currency-converter/ minimal example +│ └── weather-lookup/ minimal example +├── examples/ +│ └── apigee-proxy-skill/ full-fat showcase skill (Ed25519-signed +│ example of a production manifest) +└── tests/ hermetic unit + in-process integration tests + (no live GCP needed; 220 tests, ~1.3s) +``` + +## Prerequisites + +1. Apigee X or hybrid organization + ([provision an eval org](https://cloud.google.com/apigee/docs/api-platform/get-started/provisioning-intro) + if needed). +2. Apigee API hub instance + ([enable API hub](https://cloud.google.com/apigee/docs/apihub/provision) + in your GCP project). +3. A Google Cloud Storage bucket you can write to (for hosting `.skill` + archives). +4. Local tools: + - Python 3.11+ with `pip` + - [`gcloud` SDK](https://cloud.google.com/sdk/docs/install) + - `jq`, `curl`, `unzip` +5. Application Default Credentials (`gcloud auth application-default + login`). +6. The roles `roles/apihub.editor` and `roles/storage.objectCreator` on + the target project. + +## Quickstart + +```bash +# 1. Clone and enter the reference +git clone https://github.com/apigee/devrel.git +cd devrel/references/apigee-skills-serving + +# 2. Install Python dependencies into a virtualenv +python3 -m venv .venv +. .venv/bin/activate +pip install -r requirements.txt + +# 3. Set up environment variables (edit values to match your project) +cp env.sh.example env.sh +$EDITOR env.sh +. ./env.sh + +# 4. Verify prerequisites +./bin/check-prerequisites.sh + +# 5. (One-time) Create the API hub attribute taxonomy +python3 scripts/update_taxonomy.py \ + --project "$APIHUB_PROJECT" \ + --location "$APIHUB_LOCATION" + +# 6. Pack, sign, upload, register a skill +python3 scripts/pack_skill.py skills/currency-converter /tmp/cc.skill +python3 scripts/sign_skill.py /tmp/cc.skill --key-file ./signing.key +python3 scripts/upload_skill.py /tmp/cc.skill --bucket "$GCS_BUCKET" +python3 scripts/register_skill.py \ + --project "$APIHUB_PROJECT" --location "$APIHUB_LOCATION" \ + --manifest /tmp/cc.skill +``` + +Full walkthrough: [`docs/publish-and-install.md`](docs/publish-and-install.md). +Design rationale and trust model: [`docs/architecture.md`](docs/architecture.md). + +## Running the tests + +The bundled test suite is hermetic — all HTTP calls, ADC lookups, and +GCP services are mocked. It runs in any environment with Python 3.11+ +and the four packages in `requirements.txt`: + +```bash +pip install -r requirements.txt +pytest -q +``` + +`pipeline.sh` runs the same suite and is what apigee/devrel CI invokes +nightly. + +## Example skills + +| Skill | Purpose | +| ---------------------- | --------------------------------------------------------------------------------------------------- | +| `currency-converter` | Minimal example. A `SKILL.md` plus a `manifest.yaml`. Useful as a copy-and-edit starting point. | +| `weather-lookup` | Minimal example demonstrating a skill with API-key-based external HTTP calls. | +| `apigee-policy-top10` | A skill that documents the ten most useful Apigee policy patterns, with a script that enumerates | +| | the policies present in your org. See [`docs/policy-skill-catalog.md`](docs/policy-skill-catalog.md). | +| `examples/apigee-proxy-skill` | A complete, production-shaped skill: 18 MCP tools, 25 Jinja2 policy templates, full manifest. | + +## Limitations and non-goals + +- The skill registry uses API hub's standard search; ranking is keyword + overlap, not semantic. For semantic ranking, integrate a vector + search component separately. +- The publisher and consumer share an Ed25519 trust root. Key rotation + is a manual operator workflow; this reference does not implement + automatic rotation. +- Skills are sandboxed by the consumer runtime (OpenCode, agent host). + This reference does not introduce additional sandboxing on top. + +## License + +[Apache 2.0](LICENSE). See the [LICENSE](LICENSE) file for details. + +## Disclaimer + +This is not an officially supported Google product. diff --git a/references/apigee-skills-serving/bin/check-prerequisites.sh b/references/apigee-skills-serving/bin/check-prerequisites.sh new file mode 100755 index 000000000..c8fbeb2f5 --- /dev/null +++ b/references/apigee-skills-serving/bin/check-prerequisites.sh @@ -0,0 +1,145 @@ +#!/usr/bin/env bash + +# Copyright 2026 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +# check-demo-prerequisites.sh -- pre-flight checker. +# +# Iterates the environment variables required by the demo and +# reports each on its own `[prereq]` line. Exits 0 if all required +# prerequisites are satisfied; exits 1 otherwise. Warnings +# (e.g. malformed APIGEE_SKILLS_MIN_KEYWORD_OVERLAP) do not block. +# +# Output is intentionally stable across invocations: same env +# yields byte-identical stdout. Stderr is unused. +# +# Why bash and not Python: the failure mode this script catches +# is operators not having ADC + env exports set up before the +# demo. We do NOT want the script itself to depend on a Python +# virtualenv that may also be missing -- bash + sh + a `gcloud` +# binary is the minimum surface. +# +# Why `set -u` but not `set -e`: we want the script to run every +# check even after a failure, so the operator sees the full +# picture in one go. Indirect expansion with `${var:-}` keeps us +# safe under `set -u`. + +set -u + +main() { + local required_total=0 + local required_failed=0 + local advisory_warns=0 + local var + + # ---------------------------------------------------------- # + # Operator-controlled REQUIRED vars: + # APIHUB_PROJECT, APIHUB_LOCATION, APIGEE_ORG. + # ---------------------------------------------------------- # + for var in APIHUB_PROJECT APIHUB_LOCATION APIGEE_ORG; do + required_total=$((required_total + 1)) + # Indirect expansion via ${!var}; ${var:-} keeps `set -u` + # happy if the var is unset. + local val="${!var:-}" + if [ -z "${val}" ]; then + echo "[prereq] FAILED -- ${var} is empty or unset. Export it before invoking the demo (see README.md)." + required_failed=$((required_failed + 1)) + else + echo "[prereq] OK -- ${var} is set." + fi + done + + # ---------------------------------------------------------- # + # APIGEE_SKILLS_MIN_KEYWORD_OVERLAP (advisory). + # Optional; if set, must be a positive integer. + # ---------------------------------------------------------- # + if [ -n "${APIGEE_SKILLS_MIN_KEYWORD_OVERLAP+x}" ]; then + local raw="${APIGEE_SKILLS_MIN_KEYWORD_OVERLAP}" + local is_positive_int=0 + case "${raw}" in + ''|*[!0-9]*) + is_positive_int=0 + ;; + *) + # All-digit; positive iff not "0" and the + # numeric value is > 0. `0`, `00`, `000` all + # collapse to 0 under arithmetic expansion. + if [ "$((10#${raw}))" -gt 0 ] 2>/dev/null; then + is_positive_int=1 + fi + ;; + esac + if [ "${is_positive_int}" -eq 1 ]; then + echo "[prereq] OK -- APIGEE_SKILLS_MIN_KEYWORD_OVERLAP=\"${raw}\" (positive int)." + else + echo "[prereq] WARNING -- APIGEE_SKILLS_MIN_KEYWORD_OVERLAP=\"${raw}\" is not a positive integer; the consumer will use default 1." + advisory_warns=$((advisory_warns + 1)) + fi + else + echo "[prereq] INFO -- APIGEE_SKILLS_MIN_KEYWORD_OVERLAP not set; the consumer will use default 1." + fi + + # ---------------------------------------------------------- # + # Watcher overrides (never required). + # ---------------------------------------------------------- # + for var in OPENCODE_EXPERIMENTAL_FILEWATCHER OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER; do + if [ -n "${!var:-}" ]; then + echo "[prereq] INFO -- ${var} is set (watcher probe will honour the override)." + else + echo "[prereq] INFO -- ${var} is unset (default behaviour)." + fi + done + + # ---------------------------------------------------------- # + # Framework-provided vars (set by OpenCode at injection time). + # We can never verify these pre-demo; just report so the + # operator does not panic about missing values. + # ---------------------------------------------------------- # + for var in ARGUMENTS SKILL_DIR OPENCODE_AGENT; do + echo "[prereq] INFO -- ${var} is set by OpenCode at SKILL.md injection time; cannot verify pre-demo." + done + + # ---------------------------------------------------------- # + # ADC token retrieval. We invoke gcloud and discard output; + # the exit code is what we care about. Tests inject a fake + # `gcloud` shim earlier on PATH to control this. + # ---------------------------------------------------------- # + required_total=$((required_total + 1)) + if command -v gcloud >/dev/null 2>&1; then + if gcloud auth application-default print-access-token >/dev/null 2>&1; then + echo "[prereq] OK -- ADC token retrievable." + else + echo "[prereq] FAILED -- ADC token not retrievable. Run 'gcloud auth application-default login' to bootstrap Application Default Credentials." + required_failed=$((required_failed + 1)) + fi + else + echo "[prereq] FAILED -- gcloud CLI not found on PATH. Install Cloud SDK and run 'gcloud auth application-default login'." + required_failed=$((required_failed + 1)) + fi + + # ---------------------------------------------------------- # + # Final summary. Exit code is purely a function of + # required_failed; advisory warnings never block. + # ---------------------------------------------------------- # + if [ "${required_failed}" -eq 0 ]; then + echo "[prereq] PASS -- ${required_total}/${required_total} required prerequisites met (${advisory_warns} advisory warning(s))." + return 0 + else + local met=$((required_total - required_failed)) + echo "[prereq] FAIL -- ${required_failed} required prerequisite(s) missing or invalid out of ${required_total} (${advisory_warns} advisory warning(s)); ${met} met." + return 1 + fi +} + +main "$@" diff --git a/references/apigee-skills-serving/bin/demo-cleanup.sh b/references/apigee-skills-serving/bin/demo-cleanup.sh new file mode 100755 index 000000000..7449ffb3c --- /dev/null +++ b/references/apigee-skills-serving/bin/demo-cleanup.sh @@ -0,0 +1,190 @@ +#!/usr/bin/env bash + +# Copyright 2026 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +# bin/demo-cleanup.sh -- reset local state to pre-demo. +# +# Wipes the "AFTER" state of a demo run so the "BEFORE / AFTER" +# capability moment lands cleanly on the next attempt. Surgical +# by design: only touches the demo-installed artifacts +# (apigee-policy-top10 + staging dirs + breadcrumb). +# +# What this script DOES NOT touch: +# - GCS bucket contents (.skill zips are immutable artifacts) +# - API hub registrations (apigee-policy-top10 etc. stay +# registered so search continues to find them) +# - Custom attribute definitions in API hub +# - The ed25519 trust root +# - Your environment variables (re-source demo-setup.sh if you +# need them refreshed) +# +# Why bash and not Python: same reasoning as +# check-demo-prerequisites.sh -- the failure mode this catches is +# "operator forgot to wipe state between demo runs", and we want +# the script to work even if a Python virtualenv is broken. +# +# Why `set -u` but not `set -e`: we want EVERY cleanup target to +# be attempted, even if one fails. A missing file or directory +# shouldn't stop us from cleaning the rest. + +set -u + +readonly OC_SKILLS="${HOME}/.config/opencode/skills" +readonly JS_SKILLS="${HOME}/.gemini/config/skills" +readonly DEMO_INSTALLED_SKILL="apigee-policy-top10" + +readonly RED='\033[0;31m' +readonly GREEN='\033[0;32m' +readonly YELLOW='\033[1;33m' +readonly NC='\033[0m' + +main() { + local dry_run=0 + local verbose=0 + while [[ $# -gt 0 ]]; do + case "$1" in + -n|--dry-run) + dry_run=1 + shift + ;; + -v|--verbose) + verbose=1 + shift + ;; + -h|--help) + _usage + return 0 + ;; + *) + echo "Unknown flag: $1" >&2 + _usage + return 2 + ;; + esac + done + + if [[ "$dry_run" -eq 1 ]]; then + echo "[cleanup] DRY RUN -- nothing will be removed." + echo + fi + + local removed=0 + local kept=0 + + # Demo-installed skill in BOTH runtimes. + for root in "${OC_SKILLS}" "${JS_SKILLS}"; do + _remove_dir "${root}/${DEMO_INSTALLED_SKILL}" \ + "$dry_run" "$verbose" \ + && ((removed++)) || ((kept++)) + done + + # Per-install staging dirs (UUID-suffixed; glob expands to + # nothing if none exist, which is the common case). + for root in "${OC_SKILLS}" "${JS_SKILLS}"; do + local staging + shopt -s nullglob + for staging in "${root}"/.staging-*; do + _remove_dir "${staging}" \ + "$dry_run" "$verbose" \ + && ((removed++)) || ((kept++)) + done + shopt -u nullglob + done + + # Lock file + breadcrumb. These are normal files, not dirs. + for root in "${OC_SKILLS}" "${JS_SKILLS}"; do + for f in "${root}/.staging.lock" "${root}/.recent-install"; do + _remove_file "$f" \ + "$dry_run" "$verbose" \ + && ((removed++)) || ((kept++)) + done + done + + echo + if [[ "$dry_run" -eq 1 ]]; then + echo "[cleanup] DRY RUN summary: would remove ${removed} item(s); ${kept} already absent." + echo "[cleanup] Re-run without --dry-run to actually remove." + else + echo -e "${GREEN}[cleanup] DONE${NC} -- removed ${removed} item(s); ${kept} already absent." + echo + echo "[cleanup] Ready for next demo run." + echo "[cleanup] Next: \`./bin/demo-setup.sh\`" + fi +} + +_remove_dir() { + # Uses BOTH -e (regular exists) AND -L (symlink exists, + # even if dangling) so we catch leftover symlinks from + # earlier install variants. A dangling symlink reports -e + # as false but is still cruft worth removing. + local path="$1" + local dry_run="$2" + local verbose="$3" + if [[ -e "$path" || -L "$path" ]]; then + local kind="dir" + [[ -L "$path" ]] && kind="symlink" + if [[ "$dry_run" -eq 1 ]]; then + echo -e "${YELLOW}[cleanup] would remove ${kind}:${NC} ${path}" + else + if rm -rf "$path"; then + echo -e "${GREEN}[cleanup] removed ${kind}:${NC} ${path}" + else + echo -e "${RED}[cleanup] FAILED to remove:${NC} ${path}" >&2 + return 1 + fi + fi + return 0 + fi + if [[ "$verbose" -eq 1 ]]; then + echo "[cleanup] already absent (dir): ${path}" + fi + return 1 +} + +_usage() { + cat <"${config_file}" <&2 + echo "Run: ./bin/demo-setup.sh --help" >&2 + exit 2 + ;; + esac +done + +# ---- Step 1: prereq check + ADC -------------------------- + +echo "[setup] Verifying Application Default Credentials..." +if gcloud auth application-default print-access-token \ + >/dev/null 2>&1; then + _check "ADC token retrievable" 0 +else + _check "ADC token retrievable" 1 \ + "run: gcloud auth application-default login" + echo + echo "Fix the above, then re-run: ./bin/demo-setup.sh" + exit 1 +fi + +if [ "$skip_preflight" -eq 0 ]; then + echo + echo "[setup] Running pre-flight checker..." + if bash "${REPO_ROOT}/bin/check-prerequisites.sh"; then + _check "All required prerequisites met" 0 + else + _check "Pre-flight failed" 1 \ + "see [prereq] lines above" + echo + echo "Fix the failing prereqs, then re-run:" + echo " ./bin/demo-setup.sh" + exit 1 + fi +else + echo "[setup] --skip-preflight given; pre-flight check skipped." +fi + +# ---- Step 2: env export + persistent config -------------- + +_export_demo_env + +echo +echo "[setup] Demo environment (set in THIS script's process):" +echo " APIHUB_PROJECT=${APIHUB_PROJECT}" +echo " APIHUB_LOCATION=${APIHUB_LOCATION}" +echo " APIGEE_ORG=${APIGEE_ORG}" +echo " APIGEE_SKILLS_MIN_KEYWORD_OVERLAP=${APIGEE_SKILLS_MIN_KEYWORD_OVERLAP}" +echo +echo "[setup] Writing persistent config file (read by" +echo "[setup] scripts/common/config.py when env vars aren't set" +echo "[setup] in the calling process)..." +_write_demo_config_file +echo + +# ---- Step 3: ready -------------------------------------- + +echo -e "${GREEN}[setup] READY.${NC}" +echo "[setup] Launch your preferred agent runtime (Gemini CLI," +echo "[setup] Claude Code, or other) in a shell that has the env" +echo "[setup] vars exported, or rely on the persistent config" +echo "[setup] file written above." +echo +echo "[setup] To export the env into an existing shell, run:" +echo " eval \"\$(./bin/demo-setup.sh --print-env)\"" +echo +echo "[setup] Publish-and-install guide: ${REPO_ROOT}/docs/publish-and-install.md" +echo "[setup] Reset state with: bash bin/demo-cleanup.sh" diff --git a/references/apigee-skills-serving/docs/architecture.md b/references/apigee-skills-serving/docs/architecture.md new file mode 100644 index 000000000..bb01d18e5 --- /dev/null +++ b/references/apigee-skills-serving/docs/architecture.md @@ -0,0 +1,185 @@ +# Architecture + +## Concept + +An **agent skill** is a small bundle of LLM agent instructions and +helper code. It has: + +- A `SKILL.md` — the instruction file the agent reads. Frontmatter + declares the skill's name and one-line description; the body tells + the agent when to use the skill and how. +- An optional `scripts/` directory with executables the agent can + invoke (Python, shell, etc.). +- A `manifest.yaml` — metadata describing the skill: version, + description, GCS location, signing material, declared IAM + permissions, etc. + +A **skill catalog** maps from natural-language queries ("help me set up +JWT validation in Apigee") to a ranked list of skills, with enough +metadata for the consumer to fetch and verify each one. + +This reference uses **Apigee API hub** as the catalog. API hub provides +the storage, the search interface, the attribute taxonomy for filtering, +and the IAM boundary. The publisher (this repository's `scripts/`) +writes skills into API hub; the consumer (an agent runtime running on a +developer's machine) reads from API hub and installs skills locally. + +## Component overview + +```text + ┌──────────────────────┐ + │ Author's machine │ + │ ─────────────── │ + │ edit SKILL.md │ + │ edit manifest.yaml │ + └──────────┬───────────┘ + │ + │ pack_skill.py + ▼ + ┌──────────────────────┐ ┌────────────────┐ + │ Publisher │ │ │ + │ ────────── │ │ Cloud │ + │ sign_skill.py │────────▶│ Storage │ + │ upload_skill.py │ .skill │ bucket │ + │ register_skill.py │ │ │ + └──────────┬───────────┘ └────────┬───────┘ + │ manifest │ + ▼ │ gs:// + ┌──────────────────────┐ │ + │ Apigee API hub │ │ + │ ────────────── │ │ + │ APIs + attributes │ │ + │ typed taxonomy │ │ + │ search endpoint │ │ + └──────────┬───────────┘ │ + │ │ + │ list, get, │ + │ filter by attribute │ + ▼ │ + ┌──────────────────────┐ │ + │ Consumer (agent │ │ + │ runtime, e.g. │◀─────────────────┘ + │ OpenCode, Claude │ + │ Code, Gemini CLI) │ + │ ──────────────── │ + │ search API hub │ + │ fetch .skill │ + │ verify Ed25519 │ + │ materialize SKILL.md│ + └──────────────────────┘ +``` + +## Trust model + +The signing and verification flow uses Ed25519 detached signatures +over a **canonical serialization** of the manifest. + +### What is signed + +The publisher canonicalises the manifest (sorted keys, normalized +unicode, deterministic whitespace), excluding the `signature` and +`signing_key_id` fields themselves, then signs the canonical bytes +with the publisher's private key. The signature and the SHA-256 +fingerprint of the corresponding public key are written back into the +manifest. + +The `.skill` zip archive's SHA-256 (the `zip_sha256` field) is also +recorded in the manifest and signed transitively. This binds the +manifest to its zip contents so the consumer cannot be tricked into +verifying one manifest but installing a different bundle. + +### What the consumer verifies + +At install time, the consumer: + +1. Fetches the manifest from API hub. +2. Looks up the publisher's public key by the `signing_key_id` + fingerprint (typically out-of-band; the consumer is configured with + a list of trusted publisher keys). +3. Re-canonicalises the manifest (excluding signature fields). +4. Verifies the Ed25519 signature. +5. Downloads the `.skill` zip from `gs_uri`. +6. Computes SHA-256 of the downloaded zip and compares it against the + manifest's `zip_sha256`. +7. Only after both checks pass does the consumer extract the `.skill` + contents to the local skills directory. + +### Threat model + +| Attack | Mitigation | +| -------------------------------------------- | -------------------------------------------------------------------------- | +| Compromised GCS bucket serves a wrong zip | `zip_sha256` mismatch is detected before extraction. | +| Modified manifest in API hub | Ed25519 signature mismatch is detected. | +| Replay of an old (vulnerable) skill version | Consumer is responsible for tracking versions; manifest carries `version`. | +| Compromised publisher signing key | Out of scope; operator must rotate the trust root. | +| Malicious skill body (legitimate signature) | Out of scope; agent runtime sandboxing is the boundary. | + +The reference does **not** introduce a new sandbox. The agent runtime +that loads the skill (OpenCode, Claude Code, etc.) is responsible for +limiting what skill code can do once it runs. + +## Canonical serialization + +The canonical form is JSON-encoded YAML with: + +- Keys recursively sorted in code-point order. +- Unicode normalized to NFC. +- Strings UTF-8 encoded. +- Numbers in their shortest unambiguous decimal form. +- No trailing whitespace, single trailing newline. + +This is implemented in `scripts/common/canonical.py`. The canonical +form is RFC-locked; consumers and publishers MUST produce byte-identical +output for the same logical manifest, or signatures will not verify. + +## API hub attribute taxonomy + +API hub's typed attributes let consumers filter the catalog. This +reference declares four user-defined attributes via +`scripts/update_taxonomy.py`: + +| Attribute | Type | Purpose | +| ----------------------- | ------ | ----------------------------------------------------------- | +| `skill-compatible` | bool | True for entries consumable as agent skills. | +| `skill-runtime-iam` | string | Comma-separated list of IAM permissions the skill declares. | +| `skill-signing-key-id` | string | SHA-256 fingerprint of the publisher's signing key. | +| `skill-bundle-gs-uri` | string | GCS URI of the signed `.skill` archive. | + +These attributes are immutable once created (API hub does not support +attribute schema migration). The `update_taxonomy.py` script is +idempotent: it creates any missing attributes and leaves existing +ones untouched. + +## Failure modes + +The publisher scripts each have a documented exit-code contract: + +| Exit | Meaning | +| ---- | -------------------------------------------------------------------- | +| `0` | Success. | +| `1` | User error (bad arguments, missing input file, malformed manifest). | +| `2` | Transient failure (5xx, network, retry exhausted). | +| `3` | Permission denied (403, missing IAM role). | +| `4` | Signature verification or canonicalisation failure. | + +Operator-facing log lines are prefixed with `[apigee-skills]` to make +them easy to filter from agent runtime output. The contract is asserted +in the test suite (`tests/test_iam_preflight.py`, +`tests/test_check_demo_prerequisites.py`). + +## Why API hub, not a custom registry + +| Concern | API hub | Custom registry | +| ------------------------ | ---------------------------------------------------- | ----------------------------------------------- | +| Storage + replication | Built in, regional. | Build it. | +| Search + ranking | Built in, keyword overlap. | Build it. | +| Attribute taxonomy | Built in, typed. | Build it. | +| IAM | GCP IAM, integrated with the rest of your platform. | Build it, or bolt on an external IdP. | +| Audit logging | Cloud Audit Logs. | Build it. | +| Cost | Per-API hub pricing. | VM + DB + load balancer + ops. | + +The trade-off is that you must accept API hub's data model (APIs and +their attributes). Skills are not first-class entities — they ride +on top of the `Api` resource type. For the reference scope, this is a +clean fit; for very high skill volumes or specialised query patterns, +a custom registry may be warranted. diff --git a/references/apigee-skills-serving/docs/policy-skill-catalog.md b/references/apigee-skills-serving/docs/policy-skill-catalog.md new file mode 100644 index 000000000..c2eb42e9b --- /dev/null +++ b/references/apigee-skills-serving/docs/policy-skill-catalog.md @@ -0,0 +1,108 @@ +# `apigee-policy-top10` example skill + +`skills/apigee-policy-top10/` is a worked example of a non-trivial +skill: one that bundles operator-facing instructions (`SKILL.md`) with +a Python helper (`scripts/top10.py`) and produces structured output +the agent can interpret. + +## What the skill does + +When an agent loads this skill, it learns about the ten Apigee policies +that the field engineering team most often recommends to customers: + +| Policy | Typical use | +| ---------------------------- | ------------------------------------------------------ | +| `VerifyAPIKey` | Validate consumer API keys. | +| `OAuthV2` (verify/generate) | OAuth 2.0 token issuance and validation. | +| `Quota` | Per-consumer or per-product rate limits. | +| `SpikeArrest` | Smooth traffic spikes; protect backends. | +| `ResponseCache` | Cache backend responses by key. | +| `JSONThreatProtection` | Reject malicious JSON payloads. | +| `XMLThreatProtection` | Reject malicious XML payloads. | +| `AssignMessage` | Mutate request/response messages. | +| `ExtractVariables` | Pull values from path, header, query, body. | +| `JavaScript` / `JavaCallout` | Custom logic when out-of-the-box policies are not enough. | + +The agent uses the skill's `SKILL.md` to recognize when a user's +question maps to one of these patterns ("how do I rate-limit by +consumer?" → recommend `Quota`). + +The bundled `scripts/top10.py` enumerates which of these ten policies +are present in your Apigee organization, by querying the proxy +inventory and parsing each proxy's `apiproxy/policies/` directory. The +agent can invoke this script to ground its recommendations in what the +user actually has deployed. + +## Why ship this as a skill rather than a doc? + +The information *is* in Apigee's documentation. Skills add three +things: + +1. **Discoverability through API hub.** The skill is registered with + `skill-compatible=true` and queryable by name or keyword. An agent + searching for "Apigee policy guidance" finds it without an out-of- + band link. +2. **Runtime verification.** The bundled script tells the agent which + policies are *actually* deployed, so its recommendations target the + user's environment, not a generic Apigee install. +3. **Versioning.** The list of "top 10" evolves. Publishing as a + versioned skill lets operators upgrade or pin specific revisions in + the same way they pin API proxy versions. + +## Output contract + +The script emits a deterministic announcement string as its first line +of stdout, before any work begins: + +```text +Querying your Apigee org now — this enumerates every deployed proxy +revision and may take 20-60 seconds for orgs with 50+ proxies. No data +is modified. +``` + +This is a deliberate "Hyrum's Law" contract: agents that parse the +script's output rely on this line appearing first and verbatim. Any +change to the wording will surface in the test suite via +`tests/test_apigee_top10.py` (which byte-compares the line against +`tests/fixtures/announcement.txt`). + +Subsequent log lines are operator-facing and use the +`[apigee-policy-top10]` prefix so they can be filtered from agent +console output. + +The script exits `0` on success; non-zero with a `FAILED` line +prefixed `[apigee-policy-top10]` on any error. Exit codes follow the +same convention as the publisher scripts (see +[`architecture.md`](architecture.md#failure-modes)). + +## How to invoke + +After installing the skill (via the publish-and-install walkthrough), +an agent runtime that loads it will have the `SKILL.md` in context. A +user prompt like *"What policies do I have deployed that I should be +using more of?"* will cause the agent to invoke `top10.py`, parse its +JSON output, and reason about the gaps. + +To run the script manually: + +```bash +export APIGEE_ORG="your-apigee-org" +python3 skills/apigee-policy-top10/scripts/top10.py +``` + +The script uses Application Default Credentials; ensure +`gcloud auth application-default login` is set up first. + +## When to copy this pattern + +Use `apigee-policy-top10` as a template when your skill needs to: + +- Combine static guidance (in `SKILL.md`) with a runtime probe of the + user's environment. +- Surface structured data the agent will interpret (e.g., a JSON + inventory). +- Emit a verbatim operator-facing line that consumers may parse. + +Use the simpler `currency-converter` or `weather-lookup` examples +when your skill is just instructions plus optional HTTP-call recipes, +without a local helper script. diff --git a/references/apigee-skills-serving/docs/publish-and-install.md b/references/apigee-skills-serving/docs/publish-and-install.md new file mode 100644 index 000000000..d1bf9dad8 --- /dev/null +++ b/references/apigee-skills-serving/docs/publish-and-install.md @@ -0,0 +1,224 @@ +# Publish and install walkthrough + +This walkthrough takes you from a fresh checkout to a signed, +registered skill that an agent runtime can install from API hub. The +examples use the bundled `skills/currency-converter` as the skill being +published. + +## 1. Prerequisites + +You will need: + +- An Apigee X / hybrid organization. +- An Apigee API hub instance in the same GCP project. +- A GCS bucket you can write to (for `.skill` archives). +- Roles on the project: `roles/apihub.editor`, + `roles/storage.objectCreator`. +- Local tools: Python 3.11+, `gcloud`, `jq`, `curl`, `unzip`. + +Verify with: + +```bash +gcloud auth application-default login +./bin/check-prerequisites.sh +``` + +The script returns exit code `0` if all required environment variables +are set and `gcloud` can produce ADC. It returns non-zero with a +diagnostic per failed check otherwise. + +## 2. Configure environment + +```bash +cp env.sh.example env.sh +# Edit env.sh — set APIHUB_PROJECT, APIHUB_LOCATION, APIGEE_ORG, +# GCS_BUCKET to match your project. +. ./env.sh +``` + +## 3. Install Python dependencies + +```bash +python3 -m venv .venv +. .venv/bin/activate +pip install -r requirements.txt +``` + +## 4. Generate (or import) an Ed25519 signing key + +If you don't already have one: + +```bash +python3 -c " +from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey +from cryptography.hazmat.primitives import serialization +priv = Ed25519PrivateKey.generate() +raw = priv.private_bytes( + encoding=serialization.Encoding.Raw, + format=serialization.PrivateFormat.Raw, + encryption_algorithm=serialization.NoEncryption(), +) +open('signing.key', 'wb').write(raw) +print('Wrote signing.key (32 bytes, raw Ed25519)') +" +chmod 600 signing.key +``` + +The corresponding public key is derived from the private key on +demand by `sign_skill.py`; consumers will use its SHA-256 fingerprint +to identify your signing identity. + +**Operational note.** The signing key is your trust root. In a real +deployment, store it in a KMS or HSM rather than on disk, and run +`sign_skill.py` from a build environment that can call the KMS +signing API. This reference uses a local key for clarity. + +## 5. Create the API hub attribute taxonomy (one-time) + +```bash +python3 scripts/update_taxonomy.py \ + --project "$APIHUB_PROJECT" \ + --location "$APIHUB_LOCATION" +``` + +This creates four user-defined attributes in API hub +(`skill-compatible`, `skill-runtime-iam`, `skill-signing-key-id`, +`skill-bundle-gs-uri`). The script is idempotent — re-running it on +a project that already has the attributes is a no-op. + +## 6. Pack the skill + +```bash +python3 scripts/pack_skill.py \ + skills/currency-converter \ + /tmp/currency-converter.skill +``` + +A `.skill` is a zip with a defined internal layout: `SKILL.md` at the +top, `manifest.yaml` at the top, optional `scripts/` directory. The +packer enforces the layout, validates the manifest against +`schema/skill-manifest.schema.yaml`, and computes the bundle's +SHA-256. + +## 7. Sign the skill + +```bash +python3 scripts/sign_skill.py \ + /tmp/currency-converter.skill \ + --key-file ./signing.key +``` + +The signer reads the manifest from the zip, canonicalises it, signs +the canonical bytes with Ed25519, and rewrites the zip with the +signature and public-key fingerprint patched into the manifest. + +Verify the signature locally: + +```bash +python3 -c " +import zipfile, yaml +from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey +from cryptography.hazmat.primitives import serialization +import base64, sys +sys.path.insert(0, '.') +from scripts.common.canonical import canonicalize + +with zipfile.ZipFile('/tmp/currency-converter.skill') as z: + m = yaml.safe_load(z.read('manifest.yaml')) +sig = base64.b64decode(m.pop('signature')) +m.pop('signing_key_id') +canon = canonicalize(m).encode('utf-8') + +priv = Ed25519PrivateKey.from_private_bytes(open('signing.key','rb').read()) +pub = priv.public_key() +pub.verify(sig, canon) +print('signature verifies') +" +``` + +## 8. Upload to GCS + +```bash +python3 scripts/upload_skill.py \ + /tmp/currency-converter.skill \ + --bucket "$GCS_BUCKET" +``` + +The script: + +- Uses ADC to obtain a GCS upload token. +- Writes the bundle to + `gs://$GCS_BUCKET/{skill-name}-{version}.skill`. +- Computes the SHA-256 of the bundle on the wire and verifies it + matches the manifest's `zip_sha256`. +- Prints the final `gs://` URI to stdout. + +## 9. Register with API hub + +```bash +python3 scripts/register_skill.py \ + --project "$APIHUB_PROJECT" \ + --location "$APIHUB_LOCATION" \ + --manifest /tmp/currency-converter.skill +``` + +The registrar: + +- Reads the manifest from the zip. +- Computes the API hub `api_id` from the skill name (lower-cased, + hyphen-separated, `<=` 63 chars). +- Creates (or updates) the API hub `Api` resource with the skill's + metadata and the four `skill-*` attributes. +- Is idempotent — re-running for the same `name+version` is a no-op; + re-running with a new version creates a new `ApiVersion` under the + same `Api`. + +## 10. Verify visibility + +```bash +# Direct API hub query +gcloud apigee apihub apis list \ + --project="$APIHUB_PROJECT" \ + --location="$APIHUB_LOCATION" \ + --filter="attributes.skill-compatible.enumValues.values=true" +``` + +You should see your `currency-converter` entry in the list. + +## 11. Consumer side: install the skill + +A consumer (an agent runtime, or a developer running `pip install` +analog for skills) performs the inverse flow: + +1. Searches API hub by keyword overlap on the skill description, or + by attribute (e.g., "every skill with `skill-runtime-iam` containing + `apigee.proxies.create`"). +2. Fetches the chosen entry's manifest from API hub. +3. Re-canonicalises the manifest (excluding signature fields), + verifies the Ed25519 signature against the publisher's known public + key. +4. Downloads the `.skill` zip from `gs_uri`. +5. Computes SHA-256 and matches it against `zip_sha256` from the + manifest. +6. Extracts the `.skill` into the consumer's skills directory + (e.g., `~/.config/opencode/skills/{skill-name}/`). + +The consumer-side `find_install.py` reference implementation is **not** +included in this PR; the contract it consumes is fully documented above +and exercised by the test suite. See +`tests/test_register_fetch_integration.py` for a stateful in-process +example. + +## Cleanup + +To remove the demo artifacts: + +```bash +./bin/demo-cleanup.sh +``` + +This removes locally extracted skills under +`~/.config/opencode/skills/{currency-converter,weather-lookup, +apigee-policy-top10}/`. It does **not** delete anything from API hub +or your GCS bucket — those are remote and the cleanup intentionally +stays local to avoid surprising side-effects in shared projects. diff --git a/references/apigee-skills-serving/env.sh.example b/references/apigee-skills-serving/env.sh.example new file mode 100644 index 000000000..0d12ae1ae --- /dev/null +++ b/references/apigee-skills-serving/env.sh.example @@ -0,0 +1,42 @@ +# Copyright 2026 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 + +# apigee-skills-serving environment template. +# +# Copy this file to env.sh, fill in the values for your environment, +# and `source ./env.sh` before running scripts/ or bin/ commands. +# +# env.sh is .gitignored. Do not commit your real values. + +# ---- Required ---------------------------------------------------------- + +# GCP project hosting your Apigee API hub instance. +export APIHUB_PROJECT="your-apihub-project-id" + +# Apigee API hub region (e.g., us-central1, europe-west1). +export APIHUB_LOCATION="us-central1" + +# Name of your Apigee organization (used by the apigee-policy-top10 +# example skill when enumerating deployed proxies). +export APIGEE_ORG="your-apigee-org" + +# Google Cloud Storage bucket that will host the signed .skill archives. +# Must be writable by the identity running the publisher scripts. +export GCS_BUCKET="your-bucket-for-skill-bundles" + +# ---- Optional ---------------------------------------------------------- + +# Minimum keyword overlap threshold used by consumer-side skill search +# when ranking API hub results. Unset by default; the consumer falls +# back to a built-in default of 1. +# export APIGEE_SKILLS_MIN_KEYWORD_OVERLAP=2 + +# Path to the Ed25519 signing key (raw 32-byte format) used by +# scripts/sign_skill.py. If unset, sign_skill.py must be invoked with +# the explicit --key-file flag. +# export APIGEE_SKILLS_SIGNING_KEY=./signing.key diff --git a/references/apigee-skills-serving/examples/apigee-proxy-skill/SKILL.md b/references/apigee-skills-serving/examples/apigee-proxy-skill/SKILL.md new file mode 100644 index 000000000..24dac985a --- /dev/null +++ b/references/apigee-skills-serving/examples/apigee-proxy-skill/SKILL.md @@ -0,0 +1,433 @@ +--- +name: apigee-proxy-skill +description: Teaches an LLM agent to scaffold, configure, validate, + package, upload, and deploy Apigee X / hybrid API proxies by + orchestrating the 18 MCP tools exposed by the apigee-proxy-skill + MCP server. The agent never writes XML by hand — tools generate + policy XML from 25 Jinja2 templates, validate bundles with + defusedxml, package with a 10 MB ceiling, and surface every + failure as a closed-set ErrorKind envelope. +license: Apache-2.0 +--- + +# apigee-proxy-skill + +You are **The Apigee Proxy Engineer**. Your job is to help a developer +scaffold, configure, validate, package, upload, and deploy Apigee API +proxies (Apigee X / hybrid) by orchestrating the 18 MCP tools exposed +by the `apigee-proxy-skill` MCP server. This document is the single +source of truth for how the host AI runtime should drive that server. + +You are **The Apigee Proxy Engineer**. Your job is to help a developer +scaffold, configure, validate, package, upload, and deploy Apigee API +proxies (Apigee X / hybrid) by orchestrating the 18 MCP tools exposed +by the `apigee-proxy-skill` MCP server. This document is the single +source of truth for how the host AI runtime should drive that server. + + +## 1. When to use this skill + +Use this skill when the user wants to: + +- Create a new Apigee proxy bundle from scratch (`scaffold_proxy`). +- Add one of the 25 supported policy templates to an existing bundle + (`add_policy`) — including resource-bearing policies like + `JavaScript`, `JavaCallout`, `OASValidation`. +- Add or bind a resource file (JS / Java JAR / Python / XSLT / WSDL / + XSD / OAS / properties) into a proxy at the right scope + (`scaffold_resource`, `add_resource`, `bind_resource_to_policy`, + `list_resources`, `validate_resources`). +- Validate the bundle's structural integrity (`validate_bundle`). +- Get a configuration-strategy recommendation for a given input + (`recommend_config_strategy`) — secret vs. config, KVM vs. + PropertySet vs. TargetServer. +- Package the bundle as a ZIP for upload (`package_bundle`). +- Inspect deployments, upload a new revision, deploy it, or provision + org/env-scope configuration (`list_deployments`, `upload_proxy`, + `deploy_revision`, `provision_config`, `provision_resources`). +- Diff the current bundle against the last uploaded version + (`diff_against_last_upload`). +- Generate a human-readable README for the proxy + (`generate_readme`). + +Do **not** use this skill for: + +- Apigee **Edge** (classic) — v1 supports Apigee X / hybrid only. +- Direct edits to runtime traffic (the skill never talks to the + data-plane gateway; it only configures the management plane). +- Anything that requires writing files outside the user's working + directory — every tool returns `{path, content}` pairs in the + envelope's `files[]` array and the host runtime owns all disk I/O. + +--- + +## 2. The 18 MCP tools + +All tools return the canonical envelope +(`apigee_skill.envelope.ToolResponse`): + +```json +{ + "status": "ok" | "error" | "warning", + "files": [{"path": "...", "content": "...", "encoding": "utf-8"}], + "diagnostics": [{"severity": "info|warning|error", "message": "..."}], + "data": { /* tool-specific structured payload */ }, + "error": null | {"kind": "...", "retryable": bool, "message": "..."} +} +``` + +`files[]` is the **only** way a tool reports filesystem changes. The +host runtime is responsible for writing each entry to disk; the MCP +server is stateless pure compute and never touches a filesystem outside +the container. + +### 2.1 Read-only tools (auto-retry once on transient error) + +| Tool | Purpose | +|:--|:--| +| `list_policies` | Return the supported policy catalog filtered by platform (`x` / `hybrid`). | +| `list_deployments` | List current revision per environment for a proxy (warns at > 10 revisions). | +| `list_resources` | Inventory bundle resources by scope / type; report orphans and dangling refs. | +| `recommend_config_strategy` | Deterministic config-strategy decision (KVM / PropertySet / TargetServer / hardcoded). | + +### 2.2 File-emitting tools (no auto-retry) + +| Tool | Purpose | +|:--|:--| +| `scaffold_proxy` | Emit a fresh bundle skeleton: manifest, ProxyEndpoint, TargetEndpoint, `proxy.config.yaml`. | +| `add_policy` | Render one of 25 policy templates into the bundle and wire it into the correct flow. | +| `scaffold_resource` | Emit a starter resource file (hello-world JS, Java pom + class, OAS 3.0, XSLT identity, ...). | +| `add_resource` | Add a resource file to the bundle at the correct scope path; update `proxy.config.yaml`. | +| `bind_resource_to_policy` | Inject `` (and verify Java `` matches) into a policy that consumes it. | +| `validate_bundle` | Structural validation: well-formed XML, required attrs, known elements, cross-references. | +| `validate_resources` | Per-type syntax (node --check, OAS lint, well-formedness); orphan / dangling-ref reports. | +| `package_bundle` | ZIP the bundle (10 MB ceiling); return base64-encoded ZIP in `files[0]`. | +| `diff_against_last_upload` | Diff current bundle against host-supplied prior bundle: added / removed / changed. | +| `generate_readme` | Render a `README.md` enumerating endpoints, policies, resources, config dependencies. | + +### 2.3 Apigee-mutating tools (no auto-retry) + +| Tool | Purpose | +|:--|:--| +| `upload_proxy` | POST the ZIP to `apis.create`. Returns `retryable=false` on `apigee_unavailable`. | +| `deploy_revision` | Deploy a revision; poll `apis.deployments.list` every 5s up to 300s. | +| `provision_config` | Create-if-missing TargetServer / KVM / PropertySet. Dry-run unless `confirm=true`. | +| `provision_resources` | Push env-scope or org-scope resource files via the `resourcefiles` API. | + +--- + +## 3. Input / output envelope contract + +The envelope contract is defined in: + +- **`mcp-server/src/apigee_skill/envelope.py`** — the runtime + Pydantic models. See its docstrings for per-tool envelope shapes + and retry semantics. + +Three invariants every host runtime must respect: + +1. **`status` is authoritative.** Treat `status="error"` as failure + even if `files[]` is non-empty (partial outputs are valid). +2. **`files[]` is the only file channel.** Do not parse stdout, stderr, + or `diagnostics[]` for filenames. Write every `files[i]` entry to + the path `files[i].path`, using `files[i].encoding` (`utf-8` for + text, `base64` for the packaged ZIP). +3. **`error.retryable` is the only retry signal.** A tool that returned + `retryable=false` MUST NOT be auto-retried by the host. This is the + guardrail against revision-sprawl on `upload_proxy` and against + double-deploys on `deploy_revision`. + +### 3.1 The 13-member closed `error.kind` set + +| Kind | Meaning | `retryable` default | +|:--------------------------|:---------------------------------------------------|:--------------------| +| `input_invalid` | Validation rejected the input. | `false` | +| `path_unsafe` | A `files[].path` would escape the working tree. | `false` | +| `platform_not_supported` | Policy not available on the declared platform. | `false` | +| `policy_unknown` | Policy name not in the catalog. | `false` | +| `resource_unknown` | Resource type not in the v1 supported set. | `false` | +| `bundle_invalid` | Bundle failed structural validation. | `false` | +| `auth_missing_token` | No bearer token on the request. | `false` | +| `auth_invalid_token` | Signature / `exp` / `iss` / `aud` failed. | `false` | +| `auth_invalid_audience` | `aud` did not match the canonical MCP URI. | `false` | +| `apigee_forbidden` | Apigee returned 403. | `false` | +| `apigee_not_found` | Apigee returned 404. | `false` | +| `apigee_conflict` | Apigee returned 409 (e.g., revision already exists).| `false` | +| `apigee_unavailable` | Apigee returned 5xx / network error. | varies, **`false` for `upload_proxy`** | + +The `auth_*` family is produced by the bearer-token validator. +The `apigee_*` family is produced by the Apigee client wrapper. +`path_unsafe` is produced by `safety/path.py` and is the **only** +acceptable response to a path traversal attempt — never silently +strip the bad path. + +--- + +## 4. Common workflows + +These are the canonical multi-tool sequences. Treat each step as a +single tool call returning an envelope; check `status` before +continuing. + +### 4.1 Scaffold → add policies → package → upload → deploy + +The most common end-to-end flow. + +1. **`scaffold_proxy`** with `{name, basepath, target_url, platform}`. + - Writes `proxies/{name}/apiproxy/` skeleton + `proxy.config.yaml`. +2. **`recommend_config_strategy`** for each external dependency the + user mentioned (a secret, a base URL, an environment-specific + value). Use the returned strategy when calling `add_policy`. +3. **`add_policy`** for each policy, in flow order (PreFlow first, + then conditional flows, then PostFlow). For resource-bearing + policies, prefer `scaffold_resource` → `add_resource` → + `bind_resource_to_policy` rather than hand-writing ``. +4. **`validate_bundle`** before packaging. If it returns `error.kind= + bundle_invalid`, surface the diagnostics list to the user and stop. +5. **`package_bundle`** with `{proxy_name}`. Returns a single + base64-encoded ZIP entry in `files[]`. +6. **`upload_proxy`** with the packaged ZIP. On + `error.kind=apigee_unavailable` (with `retryable=false`), STOP and + ask the user — auto-retry would create duplicate revisions. +7. **`deploy_revision`** with `{proxy_name, env, revision}`. Polls for + up to 5 minutes; at the ceiling, returns `apigee_unavailable + retryable=true` without cancelling the underlying Apigee deploy. + +### 4.2 Add a resource to an existing bundle + +1. **`scaffold_resource`** with `{proxy_name, resource_type, scope, + name}`. Emits a starter file at the right subdir. +2. **`add_resource`** to register the file in `proxy.config.yaml`. +3. **`bind_resource_to_policy`** if the resource is consumed by a + specific policy (JS, Java callout, OAS validator). The tool + verifies Java `` matches the JAR's declared class. +4. **`validate_resources`** to check syntax (node --check for JS, + lint for OAS, well-formedness for XSLT / WSDL / XSD). +5. **`list_resources`** at any time to inspect inventory and surface + orphans / dangling references. + +### 4.3 Diff before upload (recommended) + +Before `package_bundle` → `upload_proxy`, call +**`diff_against_last_upload`** with the prior bundle (host-supplied +from the user's repo or last-known-state cache). This lets the host +runtime preview the change set to the user before any Apigee write. + +### 4.4 Org / env-scope configuration + +Use **`provision_config`** for TargetServer, KVM, or PropertySet +created at the env scope. Use **`provision_resources`** for resource +files that should live at env or org scope (rather than in the bundle +itself). + +Both tools dry-run unless `confirm=true`. Always present the dry-run +diff to the user before passing `confirm=true`. + +--- + +## 5. Authentication model + +The MCP server uses a strict three-party auth model: + +1. **Host → MCP server**: bearer token with audience equal to the MCP + server's canonical URI. Validated for signature, `exp`, `iss`, `aud`. +2. **MCP server → STS**: RFC 8693 Token Exchange against Google STS + using the validated bearer token as the `subject_token`. Returns a + short-lived Apigee-scoped token bound to the user's identity via + Workload Identity Federation. +3. **MCP server → Apigee**: the exchanged token is used for exactly + one Apigee call, then discarded. No token caching in v1. + +The MCP spec 2025-06-18 **forbids** forwarding the bearer token +upstream. The token exchange is what makes per-user audit trails work +on the Apigee side without sharing credentials. + +If the host is in **local mode** (`APIGEE_PROXY_SKILL_MODE=local`, the default), +the bearer token issuer allowlist is permissive (`https://localhost/*` +plus the host's local IDP). In **remote mode** (`APIGEE_PROXY_SKILL_MODE=remote`, +Cloud Run), the issuer allowlist is restricted to the IAP / IAM +identity issuer. + +--- + +## 6. Mode selection (`local` vs. `remote`) + +The MCP server selects exactly two behaviors from `APIGEE_PROXY_SKILL_MODE`: + +| Behavior | `local` | `remote` | +|:----------------------|:---------------------|:-----------------------------------------| +| Bind address | `127.0.0.1:8080` | `0.0.0.0:8080` (Cloud Run requirement) | +| Accepted token issuers | permissive local set | strict IAP / IAM issuer | + +Local mode is the default. Cloud Run sets `K_SERVICE`; if that +variable is present and `APIGEE_PROXY_SKILL_MODE != remote`, the +startup self-check exits non-zero so that a misconfigured deploy +fails fast rather than silently binding to localhost on a Cloud +Run instance. + +Nothing else in the server branches on mode. All tool implementations +are mode-agnostic. + +--- + +## 7. Path safety + +Every `files[].path` value is run through `safety.path. +safe_relative_path()` before being added to the envelope. +The helper rejects: + +- Absolute paths (`/`, `C:\`, etc.). +- `..` traversal (any segment equal to `..`). +- NUL bytes (`\x00`). +- Windows drive letters (`C:`). +- UNC paths (`\\server\share`). +- NTFS alternate data streams (`file.txt:stream`). +- Backslash separators (`subdir\file.txt`). +- Mixed separators on POSIX. +- Unicode normalization attacks (full-width slash, etc.). + +A violation raises `PathSafetyError`, which maps to `error.kind= +path_unsafe` with `retryable=false`. The host runtime MUST also +revalidate before writing to disk — defence in depth. + +--- + +## 8. Observability + +Every MCP request emits exactly one structured JSON log line on +stdout carrying: + +- `request_id` (UUIDv4, generated per-request, echoed in + `data.request_id` on the response envelope). +- `caller_sub` (the token's `sub` claim — never the token itself). +- `tool` (the tool name). +- `apigee_org`, `apigee_platform` (when applicable). +- `duration_ms` (total request time). +- `apigee_call_duration_ms` (Apigee API portion). +- `status` (`ok` / `error` / `warning`). +- `error_kind` (when `status=error`). + +Five OpenTelemetry metrics are exported in remote mode: + +- `mcp_request_count` +- `mcp_request_latency_ms` +- `apigee_call_latency_ms` +- `mcp_auth_failure_count` +- `sts_exchange_latency_ms` + +Bearer tokens are scrubbed by `observability.logging.RedactingFilter` +on the root logger before any record is emitted. + +--- + +## 9. Drift detection (adapter ↔ skill-core) + +At server startup, `apigee_skill.adapters.drift.detect_drift()` +compares the canonical sha256 of `skill-core/SKILL.md` against the +hash stamps written by `scripts/install-adapters.sh` into each +adapter directory (`.skill-source-hash` sidecar). Drift logs a WARN +but does not fail startup. To fix drift, re-run +`bash scripts/install-adapters.sh`. + +Drift is expected only when: + +- The canonical SKILL.md was edited but the install script was not + re-run. +- An adapter SKILL.md was edited directly (a violation — adapters are + copies, not sources of truth). +- The install script ran in a different repo checkout than the server + is reading from. + +--- + +## 10. Common rationalizations + +| Rationalization | Why it fails (see notes below) | +|:--|:--| +| Skip `recommend_config_strategy` and hardcode a secret. | See R1. | +| `validate_bundle` returned warnings, not errors — package anyway. | See R2. | +| `upload_proxy` returned 5xx — auto-retry like read tools. | See R3. | +| `deploy_revision` hit the 5-min poll ceiling — cancel and try again. | See R4. | +| Write policy XML by hand instead of using `add_policy`. | See R5. | +| `add_policy` returned `platform_not_supported` — edit the bundle anyway. | See R6. | +| Edit the adapter SKILL.md inline; next install will overwrite it. | See R7. | +| Skip `confirm=true` check on `provision_config`; user said go ahead. | See R8. | + +**R1.** Hardcoding secrets in proxy XML ships them to the Apigee +management plane in plaintext. Always run `recommend_config_strategy` +for any input that could be a secret, base URL, or env-specific value +— the tool will return `kvm` or `targetserver`, not `hardcoded`, when +the input is secret-shaped. Hardcoding is only safe for true constants. + +**R2.** Warnings often indicate dangling references that +`package_bundle` will refuse to package unless `force=true`. Surface +every warning to the user before packaging; warnings are cheap to fix +now and expensive to debug after upload. + +**R3.** `upload_proxy` returns `retryable=false` on +`apigee_unavailable` precisely because Apigee may have actually +accepted the upload before the 5xx surfaced. Auto-retrying creates +duplicate revisions and fills the revision history. Stop and ask the +user; idempotency is their call, not yours. + +**R4.** The tool returns `retryable=true` at the ceiling but does +**not** cancel the underlying Apigee deploy. Cancelling now would +interrupt an in-progress rollout. The correct action is to re-poll +with `list_deployments` until the deploy resolves, then decide. + +**R5.** The 25 policy templates are the single source of truth for +valid XML shapes. Hand-written XML bypasses template validation, +platform-aware rendering, and the per-template flow-placement +defaults. Always go through `add_policy`. + +**R6.** A `platform_not_supported` error means the policy is not +deployable on the declared platform (e.g., `OASValidation` on Edge). +Editing the bundle to include it will pass `validate_bundle` but fail +at deploy time with a less actionable error. Tell the user to change +platform or pick a different policy. + +**R7.** Edits to adapter files are silently lost on the next +`install-adapters.sh` run. Worse, the running server will log a drift +WARN until then, masking real config issues. Always edit +`skill-core/SKILL.md` and re-run the install script. + +**R8.** The two-phase dry-run / confirm pattern exists because Apigee +config writes are not transactional. The dry-run is the only +opportunity to preview the change set before mutation. Always present +the dry-run diagnostics to the user, even on a re-run. + +--- + +## 11. Out of scope for v1 + +The following are intentionally deferred to v2 to keep the v1 surface +small enough to validate end-to-end: + +- **Apigee Edge** (classic). v1 supports Apigee X / hybrid only. +- **Node.js resource type** (Edge-only `Node` policy and + `apiproxy/nodejs` target). +- **Edge-specific `OASValidation` workarounds** (`JSONThreatProtection` + + `ExtractVariables` pattern). +- **Token caching**. Each Apigee call performs a fresh STS exchange. +- **In-place KVM container update**. v1 updates entries only; the + container is created if missing but never modified. +- **Resource delete via `provision_resources`**. v1 is add-only. + +When the user asks for any of these, surface the limitation and offer +the closest v1 alternative. + +--- + +## 12. References + +- **HLD.md** — container budget and Cloud Run topology. +- **IMPL_DETAILS.md** — Pydantic envelope, config decision tree, + reference enums. +- **`mcp-server/src/apigee_skill/envelope.py`** — runtime envelope models. + +This file lives at `skill-core/SKILL.md`. Adapter copies under each +agent runtime's skills directory and +`.gemini/extensions/apigee-proxy-skill/GEMINI.md` are generated by +`scripts/install-adapters.sh` and carry a `` +footer plus a `.skill-source-hash` sidecar. Edit the canonical file +and re-run the install script — never edit an adapter copy directly. \ No newline at end of file diff --git a/references/apigee-skills-serving/examples/apigee-proxy-skill/manifest.yaml b/references/apigee-skills-serving/examples/apigee-proxy-skill/manifest.yaml new file mode 100644 index 000000000..0746b9e3b --- /dev/null +++ b/references/apigee-skills-serving/examples/apigee-proxy-skill/manifest.yaml @@ -0,0 +1,67 @@ +# Copyright 2026 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +# Demo manifest. Run scripts/sign_skill.py and scripts/upload_skill.py to populate gs_uri, signature, signing_key_id, zip_sha256. +author: apigee-devrel +capabilities: [] +description: 'Teaches an LLM agent to scaffold, validate, package, upload, and deploy + Apigee X / hybrid API proxies by orchestrating the 18 deterministic MCP tools exposed + by the apigee-proxy-skill MCP server. The agent never writes XML by hand: tools + generate policy XML from 25 Jinja2 templates, validate bundles with defusedxml, + package with a 10 MB ceiling, and surface every failure as a closed-set ErrorKind + envelope. Production deploy targets Cloud Run with Workload Identity Federation + and per-user service-account impersonation (iss-scoped SHA-256 derivation defeats + cross-issuer sub collisions). 5 architectural decisions recorded as ADRs; 600 tests; + 1.30:1 test:src ratio.' +keywords: +- apigee +- proxy +- mcp +- scaffold +- validate +- package +- upload +- deploy +- policy +- xml +- jinja2 +- defusedxml +- workload-identity-federation +- cloud-run +license: Apache-2.0 +manifest_schema_version: '1' +name: apigee-proxy-skill +runtime_iam: +- apigee.proxies.create +- apigee.proxies.get +- apigee.proxies.list +- apigee.proxyrevisions.create +- apigee.proxyrevisions.get +- apigee.proxyrevisions.list +- apigee.deployments.create +- apigee.deployments.delete +- apigee.deployments.get +- apigee.deployments.list +- apigee.keyvaluemaps.create +- apigee.keyvaluemaps.get +- apigee.keyvaluemaps.update +- apigee.keyvaluemapentries.create +- apigee.keyvaluemapentries.update +- apigee.targetservers.create +- apigee.targetservers.get +- apigee.targetservers.update +- apigee.resourcefiles.create +- apigee.resourcefiles.get +- apigee.resourcefiles.update +version: 0.1.0 diff --git a/references/apigee-skills-serving/examples/apigee-proxy-skill/scripts/install.sh b/references/apigee-skills-serving/examples/apigee-proxy-skill/scripts/install.sh new file mode 100755 index 000000000..9c25af4d3 --- /dev/null +++ b/references/apigee-skills-serving/examples/apigee-proxy-skill/scripts/install.sh @@ -0,0 +1,108 @@ +#!/usr/bin/env bash + +# Copyright 2026 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +# Helper: print instructions for installing the apigee-proxy-skill MCP +# server locally so the agent can drive it via stdio (Gemini CLI, +# Claude Code, Cursor, etc.) or via HTTP (Cloud Run production deploy). +# +# This script intentionally does NOT install anything itself -- the +# MCP server source lives in a separate repo (github.com/.../apigee- +# proxy-skill) and the operator owns the install path (pip, container, +# Cloud Run). Calling this with no arguments prints the canonical +# install recipe; calling with --json emits a machine-readable form. + +set -euo pipefail + +print_human() { + cat <<'EOF' +apigee-proxy-skill — install instructions +========================================== + +The published skill (this catalog entry) is the SKILL.md the agent +reads. The actual 18 MCP tools live in a separate Python package / +container that you install once on the machine running the agent. + +LOCAL DEMO (stdio, no JWT, single machine) +------------------------------------------ + git clone https://github.com//apigee-proxy-skill + cd apigee-proxy-skill + pip install --user -e mcp-server/ + + # Confirm console scripts are on PATH + which apigee-skill-server-demo # local stdio (Gemini CLI / Claude Code) + which apigee-skill-server # production HTTP (Cloud Run) + + # For Gemini CLI specifically: + gemini extensions link "$(pwd)/.gemini/extensions/apigee-proxy-skill" + gemini extensions list # should show apigee-proxy-skill (0.1.0) + gemini mcp list # should show "Connected (stdio)" + + # Launch: + cd /tmp/demo && gemini + # then ask: "scaffold an Apigee proxy called billing with target + # https://api.example.com and add a VerifyAPIKey policy" + +PRODUCTION (Cloud Run, JWT auth, Workload Identity Federation) +-------------------------------------------------------------- + # In the apigee-proxy-skill repo: + bash scripts/provision-wif.sh \ + --project=$PROJECT --pool=$POOL --provider=$PROVIDER \ + --issuer-uri=$ISSUER --allowed-audiences=$AUDIENCE + bash scripts/deploy-cloud-run.sh \ + --project=$PROJECT --region=$REGION --image=$IMAGE + + # Then configure the MCP host (Gemini CLI, Claude Code, ...) to + # use the HTTP transport pointing at the Cloud Run URL with + # OAuth/ADC. See https://docs.cloud.google.com/mcp/configure-mcp-ai-application + +For the full design + ADRs + verification report, see this skill's +documentation directory in the upstream repo. +EOF +} + +print_json() { + cat <<'EOF' +{ + "name": "apigee-proxy-skill", + "type": "mcp-server-wrapper", + "upstream_repo": "github.com//apigee-proxy-skill", + "install_modes": { + "local_demo": { + "transport": "stdio", + "auth": "none", + "command": "apigee-skill-server-demo", + "install": "pip install --user -e mcp-server/" + }, + "production": { + "transport": "http", + "auth": "jwt-via-workload-identity-federation", + "command": "apigee-skill-server", + "install": "scripts/deploy-cloud-run.sh + scripts/provision-wif.sh" + } + }, + "tools_exposed": 18, + "policy_templates": 25, + "resource_types": 9 +} +EOF +} + +case "${1:-}" in + --json) print_json ;; + --help|-h) print_human ;; + "") print_human ;; + *) echo "usage: $0 [--json|--help]" >&2; exit 64 ;; +esac diff --git a/references/apigee-skills-serving/pipeline.sh b/references/apigee-skills-serving/pipeline.sh new file mode 100755 index 000000000..ca3ea6f7e --- /dev/null +++ b/references/apigee-skills-serving/pipeline.sh @@ -0,0 +1,41 @@ +#!/bin/sh + +# Copyright 2026 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +# apigee-skills-serving reference pipeline. +# +# This pipeline runs the hermetic Python test suite. Every HTTP call, +# ADC lookup, and GCP service is mocked, so the pipeline does not need +# a live Apigee org, API hub instance, or GCS bucket. + +set -e + +SCRIPTPATH="$( + cd "$(dirname "$0")" || exit >/dev/null 2>&1 + pwd -P +)" + +# Install dependencies into an isolated venv. +VENV_PATH="$SCRIPTPATH/venv" +python3 -m venv "$VENV_PATH" +# shellcheck source=/dev/null +. "$VENV_PATH/bin/activate" +pip install --quiet --upgrade pip +pip install --quiet -r "$SCRIPTPATH/requirements.txt" + +# Run the test suite from the reference directory so pytest discovers +# the conftest.py path-bootstrap and the bundled fixtures. +cd "$SCRIPTPATH" +python3 -m pytest -q tests/ diff --git a/references/apigee-skills-serving/pytest.ini b/references/apigee-skills-serving/pytest.ini new file mode 100644 index 000000000..fcedccd7d --- /dev/null +++ b/references/apigee-skills-serving/pytest.ini @@ -0,0 +1,19 @@ +[pytest] +# Test discovery +testpaths = tests +python_files = test_*.py +python_classes = Test* +python_functions = test_* + +# Treat warnings as failures except the ones we know about. The +# pre-existing LSP false positives in scripts/common/* are runtime +# warnings, not pytest warnings, so this is mostly future-proofing. +filterwarnings = + error + # PyYAML's default Loader deprecation warning - we use safe_load, + # but transitive imports may still trip the warning. + ignore::DeprecationWarning:yaml + +# Show local variables in tracebacks. Helps when a parametrized +# case fails -- you see WHICH input failed. +addopts = --showlocals --tb=short -ra diff --git a/references/apigee-skills-serving/requirements.txt b/references/apigee-skills-serving/requirements.txt new file mode 100644 index 000000000..2f77b3d29 --- /dev/null +++ b/references/apigee-skills-serving/requirements.txt @@ -0,0 +1,10 @@ +# Runtime dependencies. +# google-cloud-storage SDK intentionally NOT included; GCS access +# uses `requests` against the public-read HTTPS endpoint. +cryptography>=43.0,<47 +google-auth>=2.30,<3 +requests>=2.32,<3 +pyyaml>=6.0,<7 + +# Test-only. +pytest>=8.0,<9 diff --git a/references/apigee-skills-serving/schema/skill-manifest.schema.yaml b/references/apigee-skills-serving/schema/skill-manifest.schema.yaml new file mode 100644 index 000000000..6b8256220 --- /dev/null +++ b/references/apigee-skills-serving/schema/skill-manifest.schema.yaml @@ -0,0 +1,133 @@ +# Copyright 2026 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +# Skill manifest schema — LOCKED v1. +# +# This file is the human-readable mirror of the validator at +# ``scripts/common/manifest_schema.py``. Scripts validate against +# the Python module (regexes are compiled there); this YAML is the +# canonical reference reviewers and authors read. +# +# JSON-Schema-ish shape, intentionally minimal. We do NOT load this +# at runtime via jsonschema — the dependency-surface reason being +# that jsonschema/pydantic transitive trees would exceed the +# four-package requirements.txt budget. +# +# Acceptance: must parse with ``yaml.safe_load``. + +$schema: "https://json-schema.org/draft/2020-12/schema" +$id: "https://github.com/apigee/devrel/blob/main/references/apigee-skills-serving/schema/skill-manifest.schema.v1" +title: "Skill manifest" +description: | + External manifest YAML for a packaged .skill bundle. Signed by + the author with an ed25519 private key and uploaded to API hub + as the Spec content. Distinct from the in-zip SKILL.md + frontmatter; the manifest is the security-relevant artifact. +type: object +additionalProperties: true # forward compat: unknown keys allowed +required: + - manifest_schema_version + - name + - version + - description + - keywords + - author + - license + - gs_uri + - zip_sha256 + - signature + - signing_key_id + +properties: + manifest_schema_version: + description: "Schema version. Exact string '1' for v1." + type: string + enum: ["1"] + + name: + description: "Skill identifier. Matches OpenCode skill name regex." + type: string + minLength: 1 + maxLength: 64 + pattern: "^[a-z0-9]+(-[a-z0-9]+)*$" + + version: + description: "Semantic version, MAJOR.MINOR.PATCH." + type: string + pattern: "^\\d+\\.\\d+\\.\\d+$" + + description: + description: "Human-readable description (OpenCode advisory cap)." + type: string + minLength: 1 + maxLength: 1024 + + keywords: + description: "Discovery keywords. 1-20 lowercase-hyphenated tokens." + type: array + minItems: 1 + maxItems: 20 + items: + type: string + pattern: "^[a-z0-9-]+$" + + author: + description: "Author or organisation identifier." + type: string + minLength: 1 + maxLength: 256 + + license: + description: "SPDX licence identifier (soft-validated)." + type: string + minLength: 1 + maxLength: 64 + + capabilities: + description: "Free-form capability tags. Not enforced." + type: array + items: + type: string + + gs_uri: + description: "GCS location of the signed .skill zip." + type: string + pattern: "^gs://[a-z0-9._-]+/.+\\.skill$" + + zip_sha256: + description: "Hex sha256 (64 chars) of the zip bytes." + type: string + pattern: "^[0-9a-f]{64}$" + + signature: + description: "Base64 of the ed25519 signature over canonical bytes." + type: string + minLength: 1 + + signing_key_id: + description: "sha256: fingerprint of the signing public key." + type: string + pattern: "^sha256:[0-9a-f]{64}$" + + runtime_iam: + description: | + GCP IAM permissions consumed by the skill at runtime, in + dot-form (e.g. apigee.proxies.list). Service-host-prefixed + form (apigee.googleapis.com/...) is rejected: it would fail + testIamPermissions at install time. + type: array + maxItems: 100 + items: + type: string + pattern: "^[a-z]+\\.[a-z0-9.]+$" diff --git a/references/apigee-skills-serving/scripts/__init__.py b/references/apigee-skills-serving/scripts/__init__.py new file mode 100644 index 000000000..a087f1b90 --- /dev/null +++ b/references/apigee-skills-serving/scripts/__init__.py @@ -0,0 +1,14 @@ +# Copyright 2026 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + diff --git a/references/apigee-skills-serving/scripts/common/__init__.py b/references/apigee-skills-serving/scripts/common/__init__.py new file mode 100644 index 000000000..a087f1b90 --- /dev/null +++ b/references/apigee-skills-serving/scripts/common/__init__.py @@ -0,0 +1,14 @@ +# Copyright 2026 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + diff --git a/references/apigee-skills-serving/scripts/common/canonical.py b/references/apigee-skills-serving/scripts/common/canonical.py new file mode 100644 index 000000000..15d647e44 --- /dev/null +++ b/references/apigee-skills-serving/scripts/common/canonical.py @@ -0,0 +1,58 @@ +# Copyright 2026 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Canonical manifest serialization. + +The sign side (``sign_skill.py``) and the verify side (the +consumer's install-time verifier) both call ``canonicalize`` to +derive the byte string that the ed25519 signature covers. +Byte-identical output across both call sites is a correctness +pre-requisite: any drift causes every install to fail signature +verification. + +The canonical transform is exactly: + + 1. Shallow-copy the manifest dict. + 2. Remove the top-level ``signature`` field (it is what we + sign; including it would create a chicken-and-egg loop). + 3. ``json.dumps(d, sort_keys=True, separators=(",", ":"), + ensure_ascii=False).encode("utf-8")`` + +We deliberately use ``json``, not ``yaml``, for the canonical form. +PyYAML's serializer differs between major versions (scalar quoting, +flow vs block style, sort behavior); ``json.dumps`` semantics are +RFC-locked. +""" +from __future__ import annotations + +import json +from typing import Any + + +def canonicalize(manifest: dict[str, Any]) -> bytes: + """Return the canonical UTF-8 byte form of *manifest*. + + The input dict is not mutated. The output is suitable as the + payload to ed25519 sign/verify. + """ + # Shallow copy so we can drop ``signature`` without mutating + # the caller's dict. A deep copy would be wasteful here -- the + # only mutation we perform is the top-level ``del``. + stripped = {k: v for k, v in manifest.items() if k != "signature"} + return json.dumps( + stripped, + sort_keys=True, + separators=(",", ":"), + ensure_ascii=False, + ).encode("utf-8") diff --git a/references/apigee-skills-serving/scripts/common/config.py b/references/apigee-skills-serving/scripts/common/config.py new file mode 100644 index 000000000..7d7f03424 --- /dev/null +++ b/references/apigee-skills-serving/scripts/common/config.py @@ -0,0 +1,219 @@ +# Copyright 2026 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""scripts/common/config.py — demo configuration loader. + +Single source of truth for `APIHUB_PROJECT`, `APIHUB_LOCATION`, +`APIGEE_ORG`, and `APIGEE_SKILLS_MIN_KEYWORD_OVERLAP`. + +Resolution order (first match wins): + + 1. The matching environment variable in the current process. + 2. The matching key in `~/.config/apigee-skills-demo/config.env` + (file format: one `KEY=value` per line, `#` comments OK). + 3. Empty string (caller's responsibility to handle). + +The config file path can be overridden with the env var +`APIGEE_SKILLS_CONFIG_FILE` for testing. The file is read lazily +on first lookup and cached for the rest of the process. + +Why a config file at all: a long-running agent runtime inherits +its env vars from the shell that launched it. If the operator +launches the runtime before sourcing the demo env, the agent's +bash subprocesses see no demo env -- even if demo-setup.sh has +been run later. Writing a persistent config file once breaks +that coupling: any process can read the config regardless of +when or where the runtime was launched. + +Why plain `KEY=value` lines (not JSON/TOML): the file is also +useful as something the operator can `source` from a shell, AND +as a copy-paste source for the `export` lines. Stdlib parsers +only. +""" +from __future__ import annotations + +import os +from pathlib import Path + +# Public API: the four resolvable keys + the config-file +# discovery override. +_RESOLVABLE_KEYS = ( + "APIHUB_PROJECT", + "APIHUB_LOCATION", + "APIGEE_ORG", + "APIGEE_SKILLS_MIN_KEYWORD_OVERLAP", +) +_CONFIG_FILE_OVERRIDE = "APIGEE_SKILLS_CONFIG_FILE" +_DEFAULT_CONFIG_PATH = ( + Path.home() / ".config" / "apigee-skills-demo" / "config.env" +) + +# Cache of parsed config (populated on first lookup). The cache +# key is the resolved path; if the operator points +# APIGEE_SKILLS_CONFIG_FILE at a different file mid-process, the +# new file is read fresh. +_cache: dict[Path, dict[str, str]] = {} + + +def _resolve_config_path() -> Path: + """Return the config file path; honors + APIGEE_SKILLS_CONFIG_FILE override.""" + override = os.environ.get(_CONFIG_FILE_OVERRIDE, "").strip() + if override: + return Path(override).expanduser() + return _DEFAULT_CONFIG_PATH + + +def _parse_config_file(path: Path) -> dict[str, str]: + """Parse a `KEY=value` config file into a dict. Ignores + blank lines and `#` comments. Quotes around values are + stripped. Unknown keys are silently dropped (only the four + resolvable keys make it into the result). + + Errors during read return an empty dict -- a missing or + unreadable config file is equivalent to "no config". + Resolution then falls through to defaults. + """ + if not path.is_file(): + return {} + try: + text = path.read_text(encoding="utf-8") + except OSError: + return {} + out: dict[str, str] = {} + for line in text.splitlines(): + stripped = line.strip() + if not stripped or stripped.startswith("#"): + continue + # Tolerate "export KEY=value" form (some operators + # source the file as a shell script). + if stripped.startswith("export "): + stripped = stripped[len("export "):] + if "=" not in stripped: + continue + key, _, val = stripped.partition("=") + key = key.strip() + val = val.strip() + # Strip surrounding quotes. + if len(val) >= 2 and val[0] == val[-1] and val[0] in ("'", '"'): + val = val[1:-1] + if key in _RESOLVABLE_KEYS: + out[key] = val + return out + + +def _load_config() -> dict[str, str]: + """Load and cache the config file. Cache is keyed by resolved + path so an override change takes effect.""" + path = _resolve_config_path() + cached = _cache.get(path) + if cached is not None: + return cached + parsed = _parse_config_file(path) + _cache[path] = parsed + return parsed + + +def get(key: str, default: str = "") -> str: + """Resolve one demo config value. + + Resolution: env var → config file → `default`. The default + is returned ONLY when neither env nor file supplies the + value; callers that need to know whether the value came + from the environment vs the config file should call + `source(key)` instead. + """ + env_val = os.environ.get(key, "").strip() + if env_val: + return env_val + return _load_config().get(key, default) + + +def source(key: str) -> tuple[str, str]: + """Return `(value, source)` for `key`. + + `source` is one of: + - `"env"`: came from the process environment + - `"config-file"`: came from the persistent config file + - `"missing"`: not set anywhere; `value` is the empty string + + Useful for diagnostic output ("APIHUB_PROJECT loaded from + ~/.config/apigee-skills-demo/config.env"). + """ + env_val = os.environ.get(key, "").strip() + if env_val: + return env_val, "env" + file_val = _load_config().get(key, "") + if file_val: + return file_val, "config-file" + return "", "missing" + + +def get_or_die(key: str, *, die_fn) -> str: + """Resolve `key`; on missing, call `die_fn(msg)` with a + contract-line-style failure message. + + Callers pass their own die function so the message respects + each script's emission style (find_install._die, top10's + sys.stderr, etc.). The message references both the env var + AND the config file path so the operator can fix either. + """ + val, src = source(key) + if src == "missing": + path = _resolve_config_path() + die_fn( + f"config: FAILED — {key} is empty. Set it via " + f"`export {key}=...` OR add the line `{key}=...` " + f"to {path}. Run `./bin/demo-setup.sh` to write " + f"the config file automatically." + ) + return val + + +def clear_cache() -> None: + """Drop the cached parse. Primarily for tests.""" + _cache.clear() + + +def config_path() -> Path: + """Public accessor for the resolved config-file path + (honors `APIGEE_SKILLS_CONFIG_FILE`). Useful for messages + that tell the operator where to look.""" + return _resolve_config_path() + + +# Convenience getters for the four resolvable keys, all callable +# without parens for grep-friendliness in production code. + +def apihub_project() -> str: + return get("APIHUB_PROJECT") + + +def apihub_location() -> str: + return get("APIHUB_LOCATION") + + +def apigee_org() -> str: + return get("APIGEE_ORG") + + +def keyword_overlap_threshold(default: int = 1) -> int: + """Special case: the threshold is an integer with a default. + Parsing errors fall through to `default`.""" + raw = get("APIGEE_SKILLS_MIN_KEYWORD_OVERLAP", str(default)) + try: + v = int(raw) + return v if v >= 0 else default + except (TypeError, ValueError): + return default diff --git a/references/apigee-skills-serving/scripts/common/http_retry.py b/references/apigee-skills-serving/scripts/common/http_retry.py new file mode 100644 index 000000000..3a89eaee6 --- /dev/null +++ b/references/apigee-skills-serving/scripts/common/http_retry.py @@ -0,0 +1,132 @@ +# Copyright 2026 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Shared HTTP retry helper. + +Provides ``http_get_retry`` and ``http_post_retry`` -- both wrap +``requests.{get,post}`` with this policy: + +* ONE retry on any 5xx response. +* Jittered backoff drawn from ``random.uniform(0.2, 0.4)`` + seconds (200-400 ms inclusive). +* 4xx responses raise immediately via + ``response.raise_for_status()`` -- no retry. +* On 2xx responses, the ``requests.Response`` is returned as-is. + +This module is a PURE library: it emits NOTHING to stdout. The +caller-owned contract line + + [apigee-skills] transient failure (HTTP ); retry 1/1 after ms + +is owned by the caller. To let the caller log retries without +polling, both verbs accept an optional +``on_retry: Callable[[int, float], None]`` callback that is +invoked with ``(status_code, sleep_seconds)`` BEFORE the retry +attempt. If ``on_retry`` is ``None`` (the default), the retry +happens silently and the caller is responsible for any logging +it wants to do after the fact. + +The two verbs share a single private worker +``_request_with_retry`` so the backoff/retry/jitter logic lives +in exactly one place; the public functions are thin verb-binding +shells over it -- they differ only in HTTP verb. + +All ``requests`` and ``time``/``random`` imports are referenced +through this module's namespace so tests can monkeypatch them +without touching the global package state. +""" +from __future__ import annotations + +import random +import time +from typing import Any, Callable, Optional + +import requests + + +def _request_with_retry( + fn: Callable[..., requests.Response], + url: str, + *, + on_retry: Optional[Callable[[int, float], None]] = None, + **kwargs: Any, +) -> requests.Response: + """Issue ``fn(url, **kwargs)``; retry once on 5xx with + jittered 200-400 ms backoff. + + The first response that is 2xx is returned. The first + response that is 4xx raises immediately. A 5xx triggers + exactly one retry; the second response (whatever its + status) is the terminal one -- raised on 4xx/5xx, returned + on 2xx. + + When ``on_retry`` is provided, it is invoked exactly once + with ``(status_code, sleep_seconds)`` BEFORE the + ``time.sleep`` and retry attempt -- so the caller can log + the retry decision with the SAME sleep value the helper is + about to wait. ``on_retry`` is NEVER called on a first-try + success or on a 4xx (no retry occurs in those cases). + """ + resp = fn(url, **kwargs) + if 500 <= resp.status_code < 600: + sleep_seconds = random.uniform(0.2, 0.4) + # Hand the retry decision to the caller BEFORE sleeping + # so the caller's log line carries the same backoff value + # we are about to wait. Library itself stays silent -- + # the contract line lives in the caller. + if on_retry is not None: + on_retry(resp.status_code, sleep_seconds) + time.sleep(sleep_seconds) + resp = fn(url, **kwargs) + # 2xx -> return; 4xx or 5xx -> raise via raise_for_status. + resp.raise_for_status() + return resp + + +def http_get_retry( + url: str, + *, + on_retry: Optional[Callable[[int, float], None]] = None, + **kwargs: Any, +) -> requests.Response: + """GET ``url`` with the module-level retry policy. See module doc. + + Extra ``**kwargs`` (e.g. ``headers``, ``params``, ``timeout``) + are passed through to ``requests.get`` unchanged so callers + can configure the request as they would any other ``requests`` + call. ``on_retry`` is consumed by the retry layer and is NOT + forwarded. + """ + return _request_with_retry( + requests.get, url, on_retry=on_retry, **kwargs + ) + + +def http_post_retry( + url: str, + *, + on_retry: Optional[Callable[[int, float], None]] = None, + **kwargs: Any, +) -> requests.Response: + """POST ``url`` with the module-level retry policy. Used by + ``iam_preflight``. See module doc. + + Extra ``**kwargs`` (e.g. ``json``, ``headers``, ``timeout``) + are passed through to ``requests.post`` unchanged. + ``on_retry`` is consumed by the retry layer and is NOT + forwarded. + """ + return _request_with_retry( + requests.post, url, on_retry=on_retry, **kwargs + ) diff --git a/references/apigee-skills-serving/scripts/common/iam_preflight.py b/references/apigee-skills-serving/scripts/common/iam_preflight.py new file mode 100644 index 000000000..be8f10786 --- /dev/null +++ b/references/apigee-skills-serving/scripts/common/iam_preflight.py @@ -0,0 +1,303 @@ +# Copyright 2026 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""IAM pre-flight library for the install pipeline. + +runtime_iam values are GCP permissions in dot-form (e.g. +``apigee.proxies.list``). The loader calls this library after +manifest signature + schema + attribute cross-check pass and +BEFORE the zip download. The purpose is to fail fast: if the +caller does not hold the permissions the skill needs at runtime, +the install is aborted before any bytes hit disk. + +PURE API contract (NO stdout): + +This library NEVER prints to stdout. It returns an +``IamPreflightResult`` dataclass describing what happened. The +caller (the loader) is the SOLE producer of the contract lines + + [apigee-skills] IAM pre-flight: OK (: granted) + [apigee-skills] IAM pre-flight: FAILED — not granted ... + [apigee-skills] IAM pre-flight: FAILED — HTTP ... + [apigee-skills] IAM pre-flight: FAILED — HTTP non-JSON body ... + [apigee-skills] IAM pre-flight: skipped (no runtime_iam declared) + +so that the ``[apigee-skills]`` prefix and the exact wording of +the contract are owned by one module. This library produces +structured data; the loader produces strings. + +Hardening: + +* The POST is routed through ``http_retry.http_post_retry`` so + the 5xx-with-jittered-backoff policy is identical to every + other HTTP call in the pipeline. +* Credentials come from a centralized ``_creds()`` helper that + calls ``google.auth.default`` with the same ``cloud-platform`` + scope every other helper uses -- no per-call scope drift. +* ``raise_for_status()`` runs before the JSON decode, and the + JSON decode is guarded so an HTML proxy error page does not + produce a confusing ``KeyError: 'permissions'`` deep in the + call stack. The result is mapped to ``status="NON_JSON"`` + with ``error_class="JSONDecodeError"``. + +403/404 from ``testIamPermissions`` are treated as "no permissions +granted" (the caller lacks the broader role needed to even ask), +so every input permission is reported missing -- the result is +``status="DENIED"`` with ``granted=()`` and ``missing=requested``. +Other 4xx/5xx after the single retry exhausts are surfaced as +``status="HTTP_ERROR"`` with ``http_status`` / ``http_reason`` +populated; the library does NOT raise on HTTP errors -- it +returns a status. The caller decides exit code. +""" +from __future__ import annotations + +from dataclasses import dataclass +from typing import Iterable, Optional + +import google.auth as google_auth +import requests + +# Dual import: production .skill zip layout has no `scripts/` +# parent package; bare `common.*` resolves via sys.path.insert +# at module load. Dev/test layout uses `scripts.common.*` via +# tests/conftest.py. +try: + from common import http_retry # production .skill zip layout +except ImportError: + from scripts.common import http_retry # dev/test layout + +# Uniform OAuth scope. Every google.auth.default call in this +# codebase requests exactly this scope so there is no silent +# per-helper drift. +CLOUD_PLATFORM_SCOPE = ( + "https://www.googleapis.com/auth/cloud-platform" +) + +# Apigee/Cloud Resource Manager endpoint template for +# testIamPermissions. The project resource form is sufficient +# for Apigee org-level permissions; the headline skill never +# needs to probe per-environment or per-proxy permissions. +_TEST_IAM_URL = ( + "https://cloudresourcemanager.googleapis.com/v1/" + "projects/{project}:testIamPermissions" +) + + +@dataclass(frozen=True) +class IamPreflightResult: + """Result of an IAM pre-flight check. Pure data; no stdout. + + Status values: + + * ``"SKIPPED"`` -- ``runtime_iam`` was empty; no network + call was made and no credentials were fetched. + * ``"GRANTED"`` -- every requested permission was granted + by ``testIamPermissions``. + * ``"DENIED"`` -- ``testIamPermissions`` returned a strict + subset of requested permissions OR returned 403/404 + (mapped to "no permissions granted"). The ``missing`` + tuple lists everything the caller does NOT hold, in + input order. + * ``"HTTP_ERROR"`` -- ``testIamPermissions`` returned a + 4xx/5xx (excluding the 403/404 case) after the single + retry exhausted. ``http_status`` and ``http_reason`` are + populated. The reported status is the TERMINAL response + that ``raise_for_status`` raised on (e.g. for "500 then + 502" the result reports 502, not 500). + * ``"NON_JSON"`` -- the response body was not valid JSON + (typically an HTML proxy error page returned with a 200). + ``error_class`` is populated (e.g. ``"JSONDecodeError"``). + + All sequence fields are tuples (frozen dataclass) so the + result is hashable and safe to share across threads. Input + order is preserved in every field that is a subset of the + input -- this matches the caller's contract wording that + lists missing permissions in the order the operator declared + them in the manifest's ``runtime_iam`` list. + """ + + status: str # SKIPPED | GRANTED | DENIED | HTTP_ERROR | NON_JSON + requested: tuple[str, ...] = () + granted: tuple[str, ...] = () + missing: tuple[str, ...] = () + http_status: Optional[int] = None + http_reason: Optional[str] = None + error_class: Optional[str] = None + + +def _creds() -> tuple[object, object]: + """Return ``(credentials, project_id)`` from ADC with the + uniform cloud-platform scope. Credentials are REFRESHED before + return so ``creds.token`` is immediately populated. + + Centralized so every HTTP path in the pipeline asks for the + same OAuth scope; per-call scope overrides are explicitly + forbidden. The refresh-before-return invariant was added + after the real-infra demo run discovered that a fresh + ``google.auth.default()`` returns ``creds.token = None`` until + the first explicit ``creds.refresh()`` call, producing silent + HTTP 401 responses on the first call. + """ + from google.auth.transport.requests import Request + credentials, project_id = google_auth.default( + scopes=[CLOUD_PLATFORM_SCOPE] + ) + credentials.refresh(Request()) + return credentials, project_id + + +def iam_preflight( + project: str, runtime_iam: Iterable[str] +) -> IamPreflightResult: + """Probe ``testIamPermissions`` for every entry in + ``runtime_iam`` and return an ``IamPreflightResult``. + + PURE API: this function NEVER prints to stdout and NEVER + raises on HTTP errors. Every outcome -- success, partial + grant, 403/404, 5xx-after-retry, non-JSON body -- maps to + an ``IamPreflightResult`` status. The caller (the loader) + reads the result and emits the contract line. + + Outcomes: + + * Empty ``runtime_iam`` -> ``status="SKIPPED"``, + ``requested=()``. No network call, no auth lookup. + * Every requested permission echoed back -> + ``status="GRANTED"``, ``granted=requested`` (input order), + ``missing=()``. + * Subset echoed back -> ``status="DENIED"``, + ``granted=``, + ``missing=``. + * 403 or 404 from ``testIamPermissions`` -> + ``status="DENIED"``, ``granted=()``, + ``missing=requested``. The caller lacks even the broader + resource-manager role; from the user's perspective this + is indistinguishable from "you have none of these + permissions" and produces an actionable message. + * Other 4xx or 5xx after the single retry exhausts -> + ``status="HTTP_ERROR"`` with ``http_status`` and + ``http_reason`` set to the TERMINAL response (the one + ``raise_for_status`` raised on). + * Response body is not valid JSON -> + ``status="NON_JSON"`` with + ``error_class="JSONDecodeError"``. + + ``raise_for_status`` runs inside ``http_post_retry`` before + we reach the JSON decode, so a 200 with an HTML body is the + only path into the ``NON_JSON`` branch -- any 4xx/5xx body + is already handled by the ``HTTPError`` catch. + """ + requested = tuple(runtime_iam) + + # Skip path -- no network call, no credentials touched, no + # stdout. The caller decides whether to log "skipped". + if not requested: + return IamPreflightResult( + status="SKIPPED", requested=() + ) + + # Resolve credentials through the centralized helper so the + # OAuth scope matches every other HTTP call. + creds, _project_id = _creds() + # The Authorization header is built defensively: if the + # credentials object exposes a ``token`` attribute we use + # it. Tests stub the credentials object; production code + # gets a real ``Credentials`` instance whose token is + # populated by ``google.auth.transport.requests.Request``. + token = getattr(creds, "token", None) or "" + headers = { + "Authorization": f"Bearer {token}", + "Content-Type": "application/json", + } + url = _TEST_IAM_URL.format(project=project) + body = {"permissions": list(requested)} + + # POST through the shared retry helper -- one retry on 5xx + # with jittered 200-400 ms backoff. We do not pass on_retry: + # the transient-failure contract line is the caller's + # responsibility, and we are a pure data API. + try: + resp = http_retry.http_post_retry( + url, json=body, headers=headers + ) + except requests.HTTPError as exc: + # 403/404 are treated as "no perms granted" -- map to + # DENIED with everything missing. Other 4xx/5xx surface + # as HTTP_ERROR (the caller prints the 'HTTP + # ' line and exits). + status_code = getattr(exc.response, "status_code", None) + reason = getattr(exc.response, "reason", None) + if status_code in (403, 404): + return IamPreflightResult( + status="DENIED", + requested=requested, + granted=(), + missing=requested, + ) + return IamPreflightResult( + status="HTTP_ERROR", + requested=requested, + granted=(), + missing=(), + http_status=status_code, + http_reason=reason, + ) + + # 200 OK with a body we still need to decode. An HTML proxy + # error page returned with a 200 status is the documented + # NON_JSON case (the response is "OK" enough for + # raise_for_status but not valid JSON). Map to NON_JSON + # with the exception class name so the caller can include + # it in the contract line. + try: + payload = resp.json() + except ValueError as exc: + # json.JSONDecodeError inherits from ValueError so we + # catch the broader type and report the actual class. + return IamPreflightResult( + status="NON_JSON", + requested=requested, + granted=(), + missing=(), + error_class=type(exc).__name__, + ) + + # testIamPermissions returns the SUBSET of requested + # permissions that the caller holds. ``granted`` and + # ``missing`` are both computed in input order so the + # contract line the caller eventually prints can be + # correlated directly against the manifest's runtime_iam + # list. + granted_set = set(payload.get("permissions") or []) + granted_in_order = tuple( + p for p in requested if p in granted_set + ) + missing_in_order = tuple( + p for p in requested if p not in granted_set + ) + + if missing_in_order: + return IamPreflightResult( + status="DENIED", + requested=requested, + granted=granted_in_order, + missing=missing_in_order, + ) + + return IamPreflightResult( + status="GRANTED", + requested=requested, + granted=requested, + missing=(), + ) diff --git a/references/apigee-skills-serving/scripts/common/manifest_schema.py b/references/apigee-skills-serving/scripts/common/manifest_schema.py new file mode 100644 index 000000000..8bc7ad825 --- /dev/null +++ b/references/apigee-skills-serving/scripts/common/manifest_schema.py @@ -0,0 +1,223 @@ +# Copyright 2026 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Manifest schema validator. + +Validates a parsed manifest dict against the LOCKED v1 schema. +Used by ``sign_skill.py`` (before signing -- refuses to sign an +invalid manifest), by ``register_skill.py`` (before posting to +API hub), and by the consumer's install-time verifier (after +ed25519 verify, before any side-effecting work). + +Unknown top-level keys are accepted (forward compatibility); a +newer signer can add fields a current verifier hasn't learned +about yet without breaking the install. + +The validator is hand-rolled regexes + length checks instead of +jsonschema/pydantic for two reasons: + +1. **Dependency surface.** Adding jsonschema or pydantic would + inflate ``requirements.txt``; both have larger transitive + trees than the four current deps combined. +2. **Error messages.** A hand-rolled validator produces stable + failure messages naturally; jsonschema's default + ValidationError messages are noisier than the contract + surface allows. + +``runtime_iam`` MUST use dot-form (``apigee.proxies.list``), +NOT the service-host prefix form +(``apigee.googleapis.com/proxies``) -- ``testIamPermissions`` +rejects the prefix form, so a manifest carrying the wrong form +would fail every install. The regex below enforces dot-form. +""" +from __future__ import annotations + +import re +from typing import Any + +# Field regexes. Compiled once at import time. +_NAME_RE = re.compile(r"^[a-z0-9]+(-[a-z0-9]+)*$") +_VERSION_RE = re.compile(r"^\d+\.\d+\.\d+$") +_KEYWORD_RE = re.compile(r"^[a-z0-9-]+$") +_GS_URI_RE = re.compile(r"^gs://[a-z0-9._-]+/.+\.skill$") +_ZIP_SHA256_RE = re.compile(r"^[0-9a-f]{64}$") +_SIGNING_KEY_ID_RE = re.compile(r"^sha256:[0-9a-f]{64}$") +# Dot-form GCP IAM permission. The leading lowercase service id, +# then one or more dot-separated lowercase-alphanumeric segments. +# Rejects the service-host-prefix form by construction (no slashes, +# no dots in the leading segment). +_IAM_PERM_RE = re.compile(r"^[a-z]+\.[a-z0-9.]+$") + + +class ManifestValidationError(ValueError): + """Raised when a manifest dict fails the schema.""" + + +def _require(cond: bool, msg: str) -> None: + """Assert *cond* or raise ManifestValidationError(msg).""" + if not cond: + raise ManifestValidationError(msg) + + +def _check_string_length( + field: str, value: Any, min_len: int, max_len: int +) -> None: + _require( + isinstance(value, str), + f"field {field!r} must be a string, got {type(value).__name__}", + ) + _require( + min_len <= len(value) <= max_len, + f"field {field!r} length {len(value)} out of bounds " + f"[{min_len}, {max_len}]", + ) + + +def _check_regex(field: str, value: Any, pattern: re.Pattern[str]) -> None: + _require( + isinstance(value, str), + f"field {field!r} must be a string, got {type(value).__name__}", + ) + _require( + pattern.fullmatch(value) is not None, + f"field {field!r} value {value!r} does not match " + f"required pattern {pattern.pattern}", + ) + + +def _check_list_of_strings( + field: str, value: Any, min_items: int, max_items: int +) -> None: + _require( + isinstance(value, list), + f"field {field!r} must be a list, got {type(value).__name__}", + ) + _require( + min_items <= len(value) <= max_items, + f"field {field!r} length {len(value)} out of bounds " + f"[{min_items}, {max_items}]", + ) + for i, item in enumerate(value): + _require( + isinstance(item, str), + f"field {field!r}[{i}] must be a string, " + f"got {type(item).__name__}", + ) + + +def validate_manifest(manifest: dict[str, Any]) -> None: + """Validate *manifest* against the schema. + + Raises ManifestValidationError on the first violation. Does + not mutate *manifest*. Returns ``None`` on success so the + caller can chain ``validate_manifest(m); use(m)``. + """ + _require( + isinstance(manifest, dict), + f"manifest must be a dict, got {type(manifest).__name__}", + ) + + # manifest_schema_version: exact "1". + _require( + manifest.get("manifest_schema_version") == "1", + "manifest_schema_version must be the string \"1\"", + ) + + # name: regex + length 1..64. + _require("name" in manifest, "missing required field 'name'") + _check_string_length("name", manifest["name"], 1, 64) + _check_regex("name", manifest["name"], _NAME_RE) + + # version: semver. + _require("version" in manifest, "missing required field 'version'") + _check_regex("version", manifest["version"], _VERSION_RE) + + # description: length 1..1024. + _require( + "description" in manifest, + "missing required field 'description'", + ) + _check_string_length("description", manifest["description"], 1, 1024) + + # keywords: 1..20 items, each matches _KEYWORD_RE. + _require( + "keywords" in manifest, "missing required field 'keywords'" + ) + _check_list_of_strings("keywords", manifest["keywords"], 1, 20) + for kw in manifest["keywords"]: + _check_regex("keywords[*]", kw, _KEYWORD_RE) + + # author: length 1..256. + _require("author" in manifest, "missing required field 'author'") + _check_string_length("author", manifest["author"], 1, 256) + + # license: SPDX identifier; we accept any non-empty string + # rather than maintain the SPDX list. This is a soft check. + _require("license" in manifest, "missing required field 'license'") + _check_string_length("license", manifest["license"], 1, 64) + + # gs_uri. + _require("gs_uri" in manifest, "missing required field 'gs_uri'") + _check_regex("gs_uri", manifest["gs_uri"], _GS_URI_RE) + + # zip_sha256: exactly 64 lowercase hex chars. + _require( + "zip_sha256" in manifest, + "missing required field 'zip_sha256'", + ) + _check_regex("zip_sha256", manifest["zip_sha256"], _ZIP_SHA256_RE) + + # signature: base64 string. Length is not gated by the schema; + # the actual base64 decode + ed25519 verify happens later. + _require( + "signature" in manifest, + "missing required field 'signature'", + ) + _require( + isinstance(manifest["signature"], str) + and len(manifest["signature"]) > 0, + "field 'signature' must be a non-empty string (base64)", + ) + + # signing_key_id. + _require( + "signing_key_id" in manifest, + "missing required field 'signing_key_id'", + ) + _check_regex( + "signing_key_id", + manifest["signing_key_id"], + _SIGNING_KEY_ID_RE, + ) + + # runtime_iam: optional; if present, list of dot-form strings. + # The 100-item cap is a defensive upper bound: an adversarial + # manifest with 10k IAM strings would force the install-time + # pre-flight to make 10k testIamPermissions calls. 100 is + # generously above the legitimate ceiling + # (apigee-policy-top10 uses 3 permissions; a hypothetical + # full-Apigee management skill might need 30-40). + if "runtime_iam" in manifest: + _check_list_of_strings( + "runtime_iam", manifest["runtime_iam"], 0, 100 + ) + for perm in manifest["runtime_iam"]: + _check_regex("runtime_iam[*]", perm, _IAM_PERM_RE) + + # capabilities: optional, free-form, not enforced. + if "capabilities" in manifest: + _require( + isinstance(manifest["capabilities"], list), + "field 'capabilities' must be a list when present", + ) diff --git a/references/apigee-skills-serving/scripts/common/permission_resolver.py b/references/apigee-skills-serving/scripts/common/permission_resolver.py new file mode 100644 index 000000000..38e15d668 --- /dev/null +++ b/references/apigee-skills-serving/scripts/common/permission_resolver.py @@ -0,0 +1,240 @@ +# Copyright 2026 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Resolve effective ``skill`` tool permission for a given skill +name and active OpenCode agent. + +Reads the documented OpenCode permission chain: + + global ~/.config/opencode/opencode.json + project ./opencode.json + agent..permission.skill.* (overrides) + agent..tools.skill: false (absolute deny) + +Project overlays onto global; per-agent overrides overlay onto +the merged config. ``tools.skill: false`` is the absolute-deny +escape hatch and beats every pattern. Pattern matching uses +``fnmatch`` (glob-style); first match in dict insertion order +wins. + +Public surface: + Verdict (enum) + Resolution (dataclass) + resolve_skill_permission(skill_name, active_agent="build") + detect_active_agent() + +Module-level constants ``GLOBAL_OPENCODE_JSON`` and +``PROJECT_OPENCODE_JSON`` capture the paths at import time. Tests +that need to redirect them monkeypatch the constants -- the +resolver re-reads them on every call so the patches take effect +without reloading the module. +""" +from __future__ import annotations + +import fnmatch +import json +import os +from dataclasses import dataclass +from enum import Enum +from pathlib import Path +from typing import Any + + +GLOBAL_OPENCODE_JSON = ( + Path.home() / ".config" / "opencode" / "opencode.json" +) +PROJECT_OPENCODE_JSON = Path.cwd() / "opencode.json" + + +class Verdict(str, Enum): + """Possible permission outcomes. Mirrors OpenCode's + documented permission actions for the ``skill`` tool.""" + + ALLOW = "allow" + DENY = "deny" + ASK = "ask" + + +@dataclass(frozen=True) +class Resolution: + r"""The resolved verdict plus enough provenance for the + failure-line ``[apigee-skills] agent \`skill\` tool: …`` + family to be self-explanatory. + + ``source`` is one of: + "default" -- no rule matched; fell to the + OpenCode default ALLOW + "tools.skill=false" -- agent absolute-deny hatch fired + f"pattern:{pattern}" -- a pattern matched; ``pattern`` + is the glob-style key + """ + + verdict: Verdict + matched_pattern: str | None + source: str + + +def _read_json(path: Path) -> dict[str, Any]: + """Read a JSON config file. Absent file returns ``{}``; + malformed file raises RuntimeError with the path embedded so + the operator knows which file to fix.""" + try: + return json.loads(path.read_text()) + except FileNotFoundError: + return {} + except json.JSONDecodeError as e: + raise RuntimeError( + f"opencode.json at {path} is not valid JSON: {e}" + ) from e + + +def _deep_merge(base: dict, overlay: dict) -> dict: + """Recursive dict merge: overlay's keys win, except when + both sides carry a dict at the same key, in which case the + merge recurses. Lists are replaced wholesale, not appended. + + Caller passes ``global_cfg`` as base and ``project_cfg`` as + overlay so project settings take precedence.""" + result = dict(base) + for key, val in overlay.items(): + if ( + key in result + and isinstance(result[key], dict) + and isinstance(val, dict) + ): + result[key] = _deep_merge(result[key], val) + else: + result[key] = val + return result + + +def _match_pattern( + patterns: dict[str, str], skill_name: str +) -> Resolution | None: + """Match ``skill_name`` against the pattern map. First match + wins, iterating in dict insertion order (Python 3.7+ guaranteed + stable). Returns ``None`` if no pattern matched.""" + for pattern, action in patterns.items(): + if fnmatch.fnmatchcase(skill_name, pattern): + try: + v = Verdict(action) + except ValueError as e: + raise RuntimeError( + f"Unknown permission action '{action}' " + f"for pattern '{pattern}'" + ) from e + return Resolution( + verdict=v, + matched_pattern=pattern, + source=f"pattern:{pattern}", + ) + return None + + +def resolve_skill_permission( + skill_name: str, + active_agent: str = "build", +) -> Resolution: + """Resolve the effective ``skill`` tool permission for + *skill_name* under *active_agent*. + + Order of evaluation: + 1. Load global and project configs separately; also + compute their deep-merged view for the agent-config + lookup. + 2. Collect three pattern sources separately, each from its + own ``permission.skill.*`` map: + - per-agent (from the merged agent config) + - project-level + - global-level + 3. Build a single pattern map by inserting agent patterns + first, then ``setdefault``-ing project patterns, then + ``setdefault``-ing global patterns. This preserves the + precedence ``agent > project > global`` (because + ``setdefault`` is a no-op when the key already exists) + AND preserves dict insertion order so the + ``_match_pattern`` first-match-wins iteration walks + higher-precedence rules first. + 4. If ``agent..tools.skill`` is ``False``, return + DENY with source ``tools.skill=false``. The escape + hatch beats any matching pattern. + 5. Iterate the assembled pattern map (first-match wins). + 6. If nothing matched, return ALLOW with source + ``default`` -- OpenCode's documented behavior for + the build agent. + + Implementation note: the original deep-merge approach + collapsed global and project pattern maps into one dict, + which lost per-source precedence at the pattern level (a + global ``*: deny`` inserted before a project ``apigee-*: + allow`` would beat it on dict iteration order). The + setdefault cascade above keeps the three sources distinct. + """ + global_cfg = _read_json(GLOBAL_OPENCODE_JSON) + project_cfg = _read_json(PROJECT_OPENCODE_JSON) + merged = _deep_merge(global_cfg, project_cfg) + + # Per-agent overrides take the highest precedence among + # pattern sources. The override map (per active agent) is + # tried first, then the project-level patterns (which beat + # global), then the global patterns. Because `_match_pattern` + # is first-match-wins by dict insertion order, we insert in + # that order without overwriting later-insertion (earlier + # precedence) entries. + agent_cfg = merged.get("agent", {}).get(active_agent, {}) + agent_patterns: dict[str, str] = dict( + agent_cfg.get("permission", {}).get("skill", {}) + ) + project_patterns: dict[str, str] = dict( + project_cfg.get("permission", {}).get("skill", {}) + ) + global_patterns: dict[str, str] = dict( + global_cfg.get("permission", {}).get("skill", {}) + ) + + patterns: dict[str, str] = dict(agent_patterns) + for pat, action in project_patterns.items(): + patterns.setdefault(pat, action) + for pat, action in global_patterns.items(): + patterns.setdefault(pat, action) + + # Absolute deny via tools.skill: false. Checked AFTER + # patterns are assembled so the source string is correct, + # but BEFORE any pattern is evaluated so the escape hatch + # wins. + if agent_cfg.get("tools", {}).get("skill") is False: + return Resolution( + verdict=Verdict.DENY, + matched_pattern=None, + source="tools.skill=false", + ) + + match = _match_pattern(patterns, skill_name) + if match is not None: + return match + + # OpenCode's documented default for the build agent is ALLOW. + return Resolution( + verdict=Verdict.ALLOW, + matched_pattern=None, + source="default", + ) + + +def detect_active_agent() -> str: + """Best-effort active-agent detection. OpenCode sets + ``OPENCODE_AGENT`` in the subprocess environment for tool + calls. Falls back to ``"build"`` (OpenCode's default agent) + so a missing env var degrades safely rather than crashing.""" + return os.environ.get("OPENCODE_AGENT", "build") diff --git a/references/apigee-skills-serving/scripts/common/watcher_probe.py b/references/apigee-skills-serving/scripts/common/watcher_probe.py new file mode 100644 index 000000000..23fcebc8e --- /dev/null +++ b/references/apigee-skills-serving/scripts/common/watcher_probe.py @@ -0,0 +1,143 @@ +# Copyright 2026 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""OpenCode file-watcher state detector. + +Returns one of three states: + + WATCHER_ENABLED -- the watcher is on and the probe + directory survived the settle window + WATCHER_DISABLED -- env var unset or explicitly disabled + WATCHER_UNDETECTABLE -- env var on but the probe could not + run (read-only skills dir, etc.); + caller falls back to /reload-skills + +Honest disclosure: the current ENABLED check is degenerate -- +the probe directory always survives because the probe itself +created it. The real value of this function today is (a) the +env-var presence signal and (b) the OSError catch that makes +UNDETECTABLE reachable for the failure-line surface. A future +improvement (out of scope for the demo) would replace the +directory-presence check with a real watcher-response check +(e.g., wait for an OpenCode reload signal). +""" +from __future__ import annotations + +import os +import shutil +import time +import uuid +from enum import Enum +from pathlib import Path + + +SKILLS_DIR = Path.home() / ".config" / "opencode" / "skills" +PROBE_SETTLE_SECONDS = 2.0 + +MINIMAL_PROBE_FRONTMATTER = """--- +name: __probe__ +description: Internal probe written by the loader. Safe to delete. +compatibility: opencode +--- +# Internal probe +""" + + +class WatcherState(str, Enum): + """Three-state outcome of the file-watcher detection. + + The string values (``"watcher_enabled"`` etc.) are observable + via ``.value`` and appear in operator log lines. Treat them + as part of the contract surface even though the values + themselves don't appear in the failure-line family -- a + future log line that prints ``state.value`` would lock + these strings.""" + + WATCHER_ENABLED = "watcher_enabled" + WATCHER_DISABLED = "watcher_disabled" + WATCHER_UNDETECTABLE = "watcher_undetectable" + + +def _env_disabled() -> bool: + """The operator's explicit-off override. + ``OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER=1`` forces + DISABLED regardless of the enable variable. Used in + environments where the watcher misbehaves (NFS-mounted + skills dirs, container layers without inotify, etc.).""" + return ( + os.environ.get( + "OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER", "" + ) + == "1" + ) + + +def _env_enabled() -> bool: + """The watcher is opt-in via + ``OPENCODE_EXPERIMENTAL_FILEWATCHER=1``. Absence (the + default) means DISABLED.""" + return ( + os.environ.get("OPENCODE_EXPERIMENTAL_FILEWATCHER", "") + == "1" + ) + + +def detect_watcher( + skills_dir: Path = SKILLS_DIR, + settle_seconds: float = PROBE_SETTLE_SECONDS, +) -> WatcherState: + """Run the three-state probe and return the result. + + Algorithm: + + 1. If the explicit-disable env var is set, return DISABLED + without writing anything. + 2. If the enable env var is unset, return DISABLED without + writing anything (default). + 3. Otherwise, mkdir a ``.probe-`` directory under + ``skills_dir``, write a minimal SKILL.md inside, sleep + ``settle_seconds`` to let any watcher react, and check + whether the directory still appears in + ``skills_dir.iterdir()``. + 4. On any OSError (mkdir failure, write failure, etc.) + return UNDETECTABLE. + 5. Cleanup runs in a ``finally`` so the probe directory + never leaks even on the error path. + + Step 3's directory-existence check is degenerate by + construction (we created the directory; of course it + survives). See module docstring for the honest disclosure + on why we keep it anyway. + """ + if _env_disabled(): + return WatcherState.WATCHER_DISABLED + if not _env_enabled(): + return WatcherState.WATCHER_DISABLED + + probe_name = f".probe-{uuid.uuid4().hex}" + probe_dir = skills_dir / probe_name + probe_skill = probe_dir / "SKILL.md" + try: + skills_dir.mkdir(parents=True, exist_ok=True) + probe_dir.mkdir(exist_ok=False) + probe_skill.write_text(MINIMAL_PROBE_FRONTMATTER) + time.sleep(settle_seconds) + listed = {p.name for p in skills_dir.iterdir()} + if probe_dir.name in listed: + return WatcherState.WATCHER_ENABLED + return WatcherState.WATCHER_UNDETECTABLE + except OSError: + return WatcherState.WATCHER_UNDETECTABLE + finally: + shutil.rmtree(probe_dir, ignore_errors=True) diff --git a/references/apigee-skills-serving/scripts/pack_skill.py b/references/apigee-skills-serving/scripts/pack_skill.py new file mode 100644 index 000000000..7b56becca --- /dev/null +++ b/references/apigee-skills-serving/scripts/pack_skill.py @@ -0,0 +1,274 @@ +# Copyright 2026 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Pack a ``skills//`` directory into a signed-ready +``.skill`` zip. + +CLI grammar: + + pack-skill.py --src skills/ + --out -.skill + [--repo-root ] + [--quiet] + +Pipeline: + + 1. Validate that ``src`` is a directory and contains at least + ``SKILL.md``. + 2. Scan ``src/scripts/`` (if present) for any Python file that + contains an import from ``common.*``. If at least one is + found, the build MUST embed the five canonical + ``scripts/common/*`` files alongside the skill's own scripts. + 3. When embedding, assert that the source repo's + ``scripts/common/`` directory contains exactly the five + expected files (``__init__.py``, ``canonical.py``, + ``permission_resolver.py``, ``watcher_probe.py``, + ``manifest_schema.py``). A missing file is a build error + (we'd ship a half-vendored module). An extra file is also + a build error (we'd ship something the runtime hasn't been + audited for — the public surface of ``common/`` is locked). + 4. Write the zip in a deterministic order (sorted paths) so + ``sha256(zip)`` is stable across builds — this is what the + ``zip_sha256`` field in the manifest commits to. + +Exit codes: + 0 success + 1 user error + 2 system error (FS write failure) + 3 packaging-policy violation (missing/extra files in + ``scripts/common/``, missing SKILL.md, etc.) +""" +from __future__ import annotations + +import argparse +import re +import sys +import zipfile +from pathlib import Path +from typing import Iterable, Sequence + +EXIT_OK = 0 +EXIT_USER = 1 +EXIT_SYSTEM = 2 +EXIT_POLICY = 3 + +# The eight files locked as the public surface of common/. +# http_retry.py and iam_preflight.py are pure-API helpers +# consumed by the loader. config.py decouples env-var resolution +# from the agent runtime's process lifecycle (resolves env → +# ~/.config/apigee-skills-demo/config.env). Any drift (added, +# removed, renamed) is a build-time failure. +_COMMON_FILES: frozenset[str] = frozenset({ + "__init__.py", + "canonical.py", + "permission_resolver.py", + "watcher_probe.py", + "manifest_schema.py", + "http_retry.py", + "iam_preflight.py", + "config.py", +}) + +# Matches ``from common.foo import bar``, ``import common.foo``, +# ``from common import foo``, and the dev/test variants +# ``from scripts.common.foo import bar`` / ``import scripts.common.foo``. +# The loader uses a dual try/except import block where the +# production form references ``common.*`` and the fallback +# references ``scripts.common.*``; both forms count as a common +# dependency for packaging purposes (we still embed the same +# scripts/common/ subtree either way). +# +# Anchored so a substring match inside a string literal doesn't +# trigger a false positive (e.g. log messages that happen to +# contain ``common.``). +_COMMON_IMPORT_RE = re.compile( + r"^\s*(?:" + r"from\s+(?:scripts\.)?common(?:\.\w+)?\s+import\s+|" + r"import\s+(?:scripts\.)?common(?:\.\w+)?\b" + r")", + re.MULTILINE, +) + + +def _err(quiet: bool, msg: str) -> None: + if not quiet: + print(msg, file=sys.stderr) + + +def _say(quiet: bool, msg: str) -> None: + if not quiet: + print(msg) + + +def _parse_args(argv: Sequence[str]) -> argparse.Namespace: + p = argparse.ArgumentParser( + prog="pack-skill.py", + description="Pack a skill directory into a .skill zip.", + ) + p.add_argument("--src", required=True, + help="Path to skills// source directory.") + p.add_argument("--out", required=True, + help="Output .skill zip path.") + p.add_argument( + "--repo-root", default=None, + help=( + "Repository root containing scripts/common/. " + "Defaults to the parent of this script's directory " + "so the build is self-locating in the normal layout." + ), + ) + p.add_argument("--quiet", action="store_true") + return p.parse_args(list(argv)) + + +def _scripts_imports_common(src: Path) -> bool: + """Return True iff any .py file under ``src/scripts/`` has an + import from the ``common`` package. Static analysis is enough + here -- detected via static grep at build time.""" + scripts_dir = src / "scripts" + if not scripts_dir.is_dir(): + return False + for py in sorted(scripts_dir.rglob("*.py")): + try: + text = py.read_text(encoding="utf-8", errors="ignore") + except OSError: + continue + if _COMMON_IMPORT_RE.search(text): + return True + return False + + +def _assert_common_surface(common_dir: Path) -> None: + """Refuse to build if scripts/common/ contains anything + other than the locked file set. Raises a ValueError with a + message describing the exact drift so a maintainer can + diagnose without re-reading the spec.""" + if not common_dir.is_dir(): + raise ValueError( + f"scripts/common/ not found at {common_dir}" + ) + present = frozenset( + p.name for p in common_dir.iterdir() if p.is_file() + ) + missing = _COMMON_FILES - present + extra = present - _COMMON_FILES + if missing or extra: + raise ValueError( + f"scripts/common/ surface mismatch: " + f"missing={sorted(missing)}, extra={sorted(extra)}" + ) + + +def _iter_files(root: Path) -> Iterable[Path]: + """Yield every regular file under ``root``, recursively, + skipping bytecode caches. Order is determined by the caller + sorting the result; we just enumerate.""" + for p in root.rglob("*"): + if not p.is_file(): + continue + # __pycache__ contents are build-local; never ship them. + if "__pycache__" in p.parts: + continue + if p.name.endswith(".pyc"): + continue + yield p + + +def _write_zip(out_path: Path, entries: list[tuple[Path, str]]) -> None: + """Write a zip containing ``entries = [(on_disk, arcname), ...]``. + + Members are written in arcname-sorted order with a fixed + mtime so ``sha256(zip)`` is byte-stable across builds. This + is necessary for the ``zip_sha256`` manifest field to mean + the same thing across CI runs and developer machines.""" + fixed_date = (1980, 1, 1, 0, 0, 0) # earliest representable + entries_sorted = sorted(entries, key=lambda e: e[1]) + out_path.parent.mkdir(parents=True, exist_ok=True) + with zipfile.ZipFile( + out_path, "w", compression=zipfile.ZIP_DEFLATED, + ) as zf: + for on_disk, arcname in entries_sorted: + info = zipfile.ZipInfo(filename=arcname, date_time=fixed_date) + info.compress_type = zipfile.ZIP_DEFLATED + info.external_attr = 0o644 << 16 + zf.writestr(info, on_disk.read_bytes()) + + +def main(argv: Sequence[str] | None = None) -> int: + if argv is None: + argv = sys.argv[1:] + try: + ns = _parse_args(argv) + except SystemExit: + return EXIT_USER + + quiet = ns.quiet + src = Path(ns.src).resolve() + out_path = Path(ns.out).resolve() + if not src.is_dir(): + _err(quiet, f"error: --src is not a directory: {src}") + return EXIT_USER + if not (src / "SKILL.md").is_file(): + _err(quiet, f"error: SKILL.md missing from {src}") + return EXIT_POLICY + + # Resolve the repo root that owns scripts/common/. The default + # is two levels up from this file (scripts/ → repo root). The + # CLI flag exists for tests and out-of-tree builds. + if ns.repo_root: + repo_root = Path(ns.repo_root).resolve() + else: + repo_root = Path(__file__).resolve().parent.parent + + # The skill's name is the source directory's name; the zip's + # internal layout puts everything under that name so the + # extracted tree looks exactly like the source layout. + skill_name = src.name + + # Collect the skill's own files. + entries: list[tuple[Path, str]] = [] + for p in _iter_files(src): + rel = p.relative_to(src).as_posix() + arcname = f"{skill_name}/{rel}" + entries.append((p, arcname)) + + # Conditionally embed scripts/common/. + needs_common = _scripts_imports_common(src) + if needs_common: + common_dir = repo_root / "scripts" / "common" + try: + _assert_common_surface(common_dir) + except ValueError as exc: + _err(quiet, f"error: {exc}") + return EXIT_POLICY + for fname in sorted(_COMMON_FILES): + on_disk = common_dir / fname + arcname = f"{skill_name}/scripts/common/{fname}" + entries.append((on_disk, arcname)) + _say(quiet, f"embedding scripts/common/ ({len(_COMMON_FILES)} files)") + else: + _say(quiet, "scripts/common/ not needed for this skill") + + try: + _write_zip(out_path, entries) + except OSError as exc: + _err(quiet, f"error: zip write failed: {exc}") + return EXIT_SYSTEM + + _say(quiet, f"packed: {out_path} ({len(entries)} files)") + return EXIT_OK + + +if __name__ == "__main__": # pragma: no cover + raise SystemExit(main()) diff --git a/references/apigee-skills-serving/scripts/register_skill.py b/references/apigee-skills-serving/scripts/register_skill.py new file mode 100644 index 000000000..f2dc85c23 --- /dev/null +++ b/references/apigee-skills-serving/scripts/register_skill.py @@ -0,0 +1,425 @@ +# Copyright 2026 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Register (or update) a signed skill manifest in API hub. + +CLI grammar: + + register-skill.py --manifest + --project + --location + [--dry-run] + [--quiet] + +Idempotent pipeline: + + 1. Validate the manifest against the schema. + 2. GET the API resource by ``name``. If 404 → POST to create. + 3. GET the Version. If 404 → POST to create. + 4. GET the Spec ``:contents``. If 404 OR the existing body + differs from our manifest YAML → POST to create/update. + 5. Compare attributes against the API's current attributes. + PATCH only when there is a diff. + +Re-running with the same inputs touches the network with reads +only (no POST, no PATCH) → byte-identical behaviour is observable +as zero mutating calls (the test asserts this). + +Exit codes: + 0 success + 1 user error (bad CLI args, missing file, schema invalid) + 2 system error + 3 IAM / 403 + 4 API-hub-side reject (e.g., taxonomy not initialised) +""" +from __future__ import annotations + +import argparse +import base64 +import sys +from pathlib import Path +from typing import Any, Sequence + +import requests +import yaml + +from scripts.common.manifest_schema import ( + ManifestValidationError, + validate_manifest, +) + +EXIT_OK = 0 +EXIT_USER = 1 +EXIT_SYSTEM = 2 +EXIT_IAM = 3 +EXIT_TAXONOMY = 4 + +_API_HUB_BASE = ( + "https://apihub.googleapis.com/v1/" + "projects/{project}/locations/{location}" +) + +# Attribute keys we project from the manifest onto the API +# resource. These four must already exist as attribute +# definitions (created by ``update_taxonomy.py``); if they don't, +# the API hub side rejects the PATCH with 400 and we exit 4. +_ATTR_KEYS = ("agentic_skill", "keywords", "gs_uri", "signing_key_id") + + +def _err(quiet: bool, msg: str) -> None: + if not quiet: + print(msg, file=sys.stderr) + + +def _credentials(): + """Cloud-platform scoped ADC, matching the uniform scope + used by every other helper.""" + import google.auth + import google.auth.transport.requests + + creds, project = google.auth.default( + scopes=["https://www.googleapis.com/auth/cloud-platform"] + ) + creds.refresh(google.auth.transport.requests.Request()) + return creds, project + + +def _parse_args(argv: Sequence[str]) -> argparse.Namespace: + p = argparse.ArgumentParser( + prog="register-skill.py", + description="Register a signed skill manifest in API hub.", + ) + p.add_argument("--manifest", required=True) + p.add_argument("--project", required=True) + p.add_argument("--location", required=True) + p.add_argument("--dry-run", action="store_true", dest="dry_run") + p.add_argument("--quiet", action="store_true") + return p.parse_args(list(argv)) + + +def _attributes_from_manifest( + manifest: dict[str, Any], + project: str | None = None, + location: str | None = None, +) -> dict: + """Build the attribute-values payload shape the API hub PATCH + expects. + + API hub keys the AttributeValues map by FULLY-QUALIFIED + attribute resource name (``projects/

/locations// + attributes/``), not by bare attribute id. When project + and location are provided we emit the FQ form (production + use). When omitted we fall back to bare ids (legacy + fixture-based tests still pass).""" + if project and location: + prefix = f"projects/{project}/locations/{location}/attributes/" + else: + prefix = "" + return { + f"{prefix}agentic_skill": { + "stringValues": {"values": ["true"]} + }, + f"{prefix}keywords": {"stringValues": {"values": list( + manifest.get("keywords", []) + )}}, + f"{prefix}gs_uri": { + "stringValues": {"values": [manifest["gs_uri"]]} + }, + f"{prefix}signing_key_id": {"stringValues": {"values": [ + manifest["signing_key_id"], + ]}}, + } + + +def _api_get(url: str, headers: dict, params: dict | None = None): + return requests.request( + "GET", url, headers=headers, params=params or {}, timeout=30, + ) + + +def _api_post(url: str, headers: dict, body: dict | None, + params: dict | None = None): + return requests.request( + "POST", url, headers=headers, params=params or {}, + json=body or {}, timeout=60, + ) + + +def _api_patch( + url: str, headers: dict, body: dict, params: dict | None = None +): + return requests.request( + "PATCH", url, headers=headers, json=body, params=params, + timeout=60, + ) + + +def _classify_http_error(status: int) -> int: + if status == 403: + return EXIT_IAM + if status == 400: + # API hub rejects malformed payloads as 400; the most + # likely cause for us is "attribute definition does not + # exist" — which is the exit-4 case. + return EXIT_TAXONOMY + return EXIT_SYSTEM + + +def _say(quiet: bool, msg: str) -> None: + """Operator-visible status print. Lives on stdout (not stderr) + so ``--dry-run`` output is grep-able from a pipeline.""" + if not quiet: + print(msg) + + +def main(argv: Sequence[str] | None = None) -> int: + if argv is None: + argv = sys.argv[1:] + try: + ns = _parse_args(argv) + except SystemExit: + return EXIT_USER + + quiet = ns.quiet + manifest_path = Path(ns.manifest) + if not manifest_path.is_file(): + _err(quiet, f"error: manifest not found: {manifest_path}") + return EXIT_USER + + try: + manifest_text = manifest_path.read_text(encoding="utf-8") + manifest = yaml.safe_load(manifest_text) + except (yaml.YAMLError, UnicodeDecodeError) as exc: + _err(quiet, f"error: manifest parse failed: {exc}") + return EXIT_USER + if not isinstance(manifest, dict): + _err(quiet, "error: manifest YAML must be a mapping") + return EXIT_USER + + try: + validate_manifest(manifest) + except ManifestValidationError as exc: + _err(quiet, f"error: manifest invalid: {exc}") + return EXIT_USER + + name = manifest["name"] + version = manifest["version"] + # API hub's versionId field rejects dots; translate semver + # "0.1.0" -> "0-1-0" for the resource path. Spec id stays in + # dotted form for human readability. + version_id = version.replace(".", "-") + spec_id = f"manifest-{version_id}" + + try: + creds, _ = _credentials() + except Exception as exc: + _err(quiet, f"error: ADC credentials unavailable: {exc}") + return EXIT_USER + + base = _API_HUB_BASE.format( + project=ns.project, location=ns.location + ) + headers = { + "Authorization": f"Bearer {creds.token}", + "Content-Type": "application/json", + } + + # ---- 1. API resource ------------------------------------------------ + # API hub's admission-control layer returns 403 ("Read access to + # project denied") on GET of nonexistent SUBresources even when + # the caller can write them. Treat 403 + 404 + 200 as the only + # non-fatal statuses; assume nonexistence when 403 or 404. + api_url = f"{base}/apis/{name}" + api_resp = _api_get(api_url, headers) + api_exists = api_resp.status_code == 200 + if not api_exists and api_resp.status_code not in (200, 403, 404): + _err(quiet, f"error: GET API failed ({api_resp.status_code})") + return _classify_http_error(api_resp.status_code) + + desired_attrs = _attributes_from_manifest( + manifest, project=ns.project, location=ns.location + ) + + if not api_exists: + _say(quiet, f"would create API: {name}" + if ns.dry_run else f"creating API: {name}") + if not ns.dry_run: + # Create the API resource *without* attributes; we PATCH + # them separately below. Splitting create-vs-attribute- + # set keeps the idempotency story clean: the bare + # create is "did the resource exist", the attribute + # PATCH is "do the attribute values match" — two + # independent decisions instead of one tangled "what + # did the API look like at creation time" history bit. + r = _api_post( + f"{base}/apis", + headers, + { + "displayName": name, + "description": manifest.get("description", ""), + }, + params={"apiId": name}, + ) + if r.status_code >= 400: + _err(quiet, f"error: POST api failed ({r.status_code})") + return _classify_http_error(r.status_code) + # We just created the API; treat its attribute state as + # empty so the PATCH below will fire exactly once. + current_attrs = {} + else: + current_attrs = api_resp.json().get("attributes", {}) + + # ---- 2. Version ----------------------------------------------------- + ver_url = f"{base}/apis/{name}/versions/{version_id}" + ver_resp = _api_get(ver_url, headers) + ver_exists = ver_resp.status_code == 200 + if not ver_exists and ver_resp.status_code not in (200, 403, 404): + _err(quiet, f"error: GET Version failed ({ver_resp.status_code})") + return _classify_http_error(ver_resp.status_code) + + if not ver_exists: + _say(quiet, f"would create Version: {version}" + if ns.dry_run else f"creating Version: {version}") + if not ns.dry_run: + r = _api_post( + f"{base}/apis/{name}/versions", + headers, + {"displayName": version}, + params={"versionId": version_id}, + ) + if r.status_code >= 400: + body_snippet = (r.text or "")[:300] + _err( + quiet, + f"error: POST version failed ({r.status_code}): " + f"{body_snippet}", + ) + return _classify_http_error(r.status_code) + + # ---- 3. Spec -------------------------------------------------------- + spec_contents = manifest_text.encode("utf-8") + spec_url = ( + f"{base}/apis/{name}/versions/{version_id}" + f"/specs/{spec_id}:contents" + ) + spec_resp = _api_get(spec_url, headers) + spec_exists = spec_resp.status_code == 200 + needs_spec_write = True + if spec_exists: + existing_b64 = spec_resp.json().get("contents", "") + try: + existing = base64.b64decode(existing_b64) + except (ValueError, TypeError): + existing = b"" + if existing == spec_contents: + needs_spec_write = False + else: + _say(quiet, "spec content differs; will rewrite") + if needs_spec_write: + _say(quiet, f"would create/update Spec: {spec_id}" + if ns.dry_run else f"writing Spec: {spec_id}") + if not ns.dry_run: + # API hub Spec schema requires: + # - contents is a NESTED object: {contents:, mimeType:...} + # - specType is a REQUIRED enum reference to the + # system-spec-type attribute. We use the built-in + # "skill-spec" enum value (purpose-built for this). + # Idempotency: POST on first-create, PATCH on update. + # The spec_exists flag above tells us which path to take. + spec_type_attr = ( + f"projects/{ns.project}/locations/{ns.location}" + f"/attributes/system-spec-type" + ) + spec_body = { + "displayName": spec_id, + "contents": { + "contents": base64.b64encode(spec_contents) + .decode("ascii"), + "mimeType": "application/yaml", + }, + "specType": { + "attribute": spec_type_attr, + "enumValues": { + "values": [ + { + "id": "skill-spec", + "displayName": "Skill Spec", + } + ] + }, + }, + } + if spec_exists: + # PATCH the existing Spec. API hub enforces that + # when `contents` is in updateMask, `spec_type` + # must also be present (otherwise the spec_type + # would silently revert to inferred). Include both. + spec_resource_url = ( + f"{base}/apis/{name}/versions/{version_id}" + f"/specs/{spec_id}" + ) + r = _api_patch( + spec_resource_url, + headers, + spec_body, + params={"updateMask": "contents,spec_type"}, + ) + else: + r = _api_post( + f"{base}/apis/{name}/versions/{version_id}/specs", + headers, + spec_body, + params={"specId": spec_id}, + ) + if r.status_code >= 400: + body_snippet = (r.text or "")[:300] + verb = "PATCH" if spec_exists else "POST" + _err( + quiet, + f"error: {verb} spec failed ({r.status_code}): " + f"{body_snippet}", + ) + return _classify_http_error(r.status_code) + + # ---- 4. Attributes (PATCH only on diff) ----------------------------- + if current_attrs != desired_attrs: + _say(quiet, "would patch attributes" if ns.dry_run + else "patching attributes") + if not ns.dry_run: + # API hub PATCH (standard Google AIP-134) requires an + # update_mask query param naming the field to update. + r = _api_patch( + api_url, + headers, + {"attributes": desired_attrs}, + params={"updateMask": "attributes"}, + ) + if r.status_code >= 400: + body_snippet = (r.text or "")[:300] + _err( + quiet, + f"error: PATCH api failed ({r.status_code}): " + f"{body_snippet}", + ) + return _classify_http_error(r.status_code) + + if ns.dry_run: + _say(quiet, "dry-run: no mutations performed") + else: + _say(quiet, f"registered: {name} {version}") + return EXIT_OK + + +if __name__ == "__main__": # pragma: no cover + raise SystemExit(main()) diff --git a/references/apigee-skills-serving/scripts/sign_skill.py b/references/apigee-skills-serving/scripts/sign_skill.py new file mode 100644 index 000000000..b5e625dc4 --- /dev/null +++ b/references/apigee-skills-serving/scripts/sign_skill.py @@ -0,0 +1,226 @@ +# Copyright 2026 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Sign a skill manifest with an ed25519 private key. + +CLI grammar: + + sign-skill.py --manifest + --zip + --priv-key + [--in-place | --out ] + [--quiet] + +Pipeline: + + 1. Load the manifest YAML. + 2. Compute sha256(zip-bytes), set ``zip_sha256``. + 3. Load the raw 32-byte ed25519 private key, derive the public + key, compute ``signing_key_id = sha256:``. + 4. Set ``signing_key_id`` and DROP any pre-existing ``signature`` + (so re-signing is byte-identical to a fresh sign). + 5. Canonicalise the manifest with ``scripts.common.canonical`` + and sign those bytes with ed25519. Base64-encode and set + ``signature``. + 6. Validate the resulting dict with ``validate_manifest`` + (refuse to write an invalid signed manifest). + 7. Write the YAML to ``--out`` or ``--in-place``. + +Idempotency: ed25519 is deterministic (RFC 8032), so re-running +with identical inputs produces byte-identical output. We rely on +``yaml.safe_dump(..., sort_keys=True)`` + the validator to keep +the on-disk form stable too. + +Exit codes: + 0 success + 1 user error (bad CLI args, missing file, bad YAML) + 2 system error (FS write failure) + 3 cryptographic error (priv key unreadable, invalid) +""" +from __future__ import annotations + +import argparse +import base64 +import hashlib +import sys +from pathlib import Path +from typing import Sequence + +import yaml +from cryptography.exceptions import InvalidKey +from cryptography.hazmat.primitives import serialization +from cryptography.hazmat.primitives.asymmetric.ed25519 import ( + Ed25519PrivateKey, +) + +from scripts.common.canonical import canonicalize +from scripts.common.manifest_schema import ( + ManifestValidationError, + validate_manifest, +) + +EXIT_OK = 0 +EXIT_USER = 1 +EXIT_SYSTEM = 2 +EXIT_CRYPTO = 3 + + +def _err(quiet: bool, msg: str) -> None: + """Write an error line to stderr unless ``--quiet``. We never + swallow the message in non-quiet mode; the CLI is the only + user-visible surface.""" + if not quiet: + print(msg, file=sys.stderr) + + +def _parse_args(argv: Sequence[str]) -> argparse.Namespace: + p = argparse.ArgumentParser( + prog="sign-skill.py", + description="Sign a skill manifest with ed25519.", + ) + p.add_argument("--manifest", required=True) + p.add_argument("--zip", required=True, dest="zip_path") + p.add_argument("--priv-key", required=True, dest="priv_key") + p.add_argument("--in-place", action="store_true") + p.add_argument("--out", default=None) + p.add_argument("--quiet", action="store_true") + return p.parse_args(list(argv)) + + +def _load_priv_key(path: Path) -> Ed25519PrivateKey: + """Load the raw 32-byte ed25519 private key. We use Raw + encoding + PrivateFormat.Raw + NoEncryption -- no PEM, no + PKCS8.""" + raw = path.read_bytes() + # ``from_private_bytes`` enforces the 32-byte length itself + # and raises ValueError (or InvalidKey on some versions) on + # any other length. We catch both so the caller's exit code + # is deterministic. + return Ed25519PrivateKey.from_private_bytes(raw) + + +def _signing_key_id(priv: Ed25519PrivateKey) -> str: + """sha256: fingerprint of the raw 32-byte public key. + + Matches the ``signing_key_id`` regex in the schema and is + what the consumer cross-checks against the trusted pubkey + at verify time.""" + pub_raw = priv.public_key().public_bytes( + encoding=serialization.Encoding.Raw, + format=serialization.PublicFormat.Raw, + ) + return "sha256:" + hashlib.sha256(pub_raw).hexdigest() + + +def main(argv: Sequence[str] | None = None) -> int: + """Entry point. Returns an int exit code; never raises on the + success path. Designed to be call-able from tests via + ``main(argv_list)`` without subprocesses.""" + if argv is None: + argv = sys.argv[1:] + + try: + ns = _parse_args(argv) + except SystemExit: + # argparse already wrote the usage to stderr. + return EXIT_USER + + quiet = ns.quiet + + # --in-place and --out are mutually exclusive. argparse can + # express this via a mutually-exclusive group, but using the + # group also blocks the "neither was given" check below, so + # we enforce both invariants by hand. + if ns.in_place and ns.out: + _err(quiet, "error: --in-place and --out are mutually exclusive") + return EXIT_USER + if not ns.in_place and not ns.out: + _err(quiet, "error: exactly one of --in-place or --out is required") + return EXIT_USER + + manifest_path = Path(ns.manifest) + zip_path = Path(ns.zip_path) + priv_path = Path(ns.priv_key) + + if not manifest_path.is_file(): + _err(quiet, f"error: manifest not found: {manifest_path}") + return EXIT_USER + if not zip_path.is_file(): + _err(quiet, f"error: zip not found: {zip_path}") + return EXIT_USER + + # Load the manifest. YAML parse errors are user errors. + try: + manifest_text = manifest_path.read_text(encoding="utf-8") + manifest = yaml.safe_load(manifest_text) + except (yaml.YAMLError, UnicodeDecodeError) as exc: + _err(quiet, f"error: manifest parse failed: {exc}") + return EXIT_USER + if not isinstance(manifest, dict): + _err(quiet, "error: manifest YAML must be a mapping") + return EXIT_USER + + # Load the priv key. A missing file or any decode failure maps + # to the cryptographic-error class. + if not priv_path.is_file(): + _err(quiet, f"error: priv-key not found: {priv_path}") + return EXIT_CRYPTO + try: + priv = _load_priv_key(priv_path) + except (ValueError, InvalidKey, OSError) as exc: + _err(quiet, f"error: priv-key load failed: {exc}") + return EXIT_CRYPTO + + # Compute the integrity fields. We blank out any pre-existing + # ``signature`` so re-signing is byte-identical (idempotency). + zip_bytes = zip_path.read_bytes() + manifest["zip_sha256"] = hashlib.sha256(zip_bytes).hexdigest() + manifest["signing_key_id"] = _signing_key_id(priv) + manifest.pop("signature", None) + + # Canonicalise + sign. canonicalize() strips ``signature`` + # itself; we drop it above so the in-memory dict is the + # canonical input either way. + canonical = canonicalize(manifest) + sig_bytes = priv.sign(canonical) + manifest["signature"] = base64.b64encode(sig_bytes).decode("ascii") + + # Validate the signed manifest before writing. A schema failure + # here would mean we just produced an unloadable artifact. + try: + validate_manifest(manifest) + except ManifestValidationError as exc: + _err(quiet, f"error: signed manifest is invalid: {exc}") + return EXIT_USER + + # Write. ``sort_keys=True`` keeps the on-disk YAML stable + # across runs (idempotency property surfaces on disk, not + # just in memory). + out_path = manifest_path if ns.in_place else Path(ns.out) + try: + out_path.write_text( + yaml.safe_dump(manifest, sort_keys=True), + encoding="utf-8", + ) + except OSError as exc: + _err(quiet, f"error: failed to write {out_path}: {exc}") + return EXIT_SYSTEM + + if not quiet: + print(f"signed: {out_path}") + return EXIT_OK + + +if __name__ == "__main__": # pragma: no cover - thin wrapper + raise SystemExit(main()) diff --git a/references/apigee-skills-serving/scripts/update_taxonomy.py b/references/apigee-skills-serving/scripts/update_taxonomy.py new file mode 100644 index 000000000..be5744f18 --- /dev/null +++ b/references/apigee-skills-serving/scripts/update_taxonomy.py @@ -0,0 +1,214 @@ +# Copyright 2026 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Bootstrap the API hub attribute-definition taxonomy. + +CLI grammar: + + update-taxonomy.py --project + --location + [--quiet] + +Creates the four required custom attribute definitions +(``agentic_skill``, ``keywords``, ``gs_uri``, ``signing_key_id``) +referenced by every registered skill manifest. Run once per API +hub instance. Idempotent: re-running discovers the existing defs +via GET and skips the POST. + +The four definitions are minimally typed (all string-valued) so +the existing call sites in ``register_skill.py`` don't need to +know about enum allocation. A future skill catalog with a typed +enum (``skill_class: builtin | partner | user``) would extend +this script. + +Exit codes: + 0 success + 1 user error (bad CLI args) + 2 system error + 3 IAM / 403 — fails loudly so the operator notices. +""" +from __future__ import annotations + +import argparse +import sys +from typing import Sequence + +import requests + +EXIT_OK = 0 +EXIT_USER = 1 +EXIT_SYSTEM = 2 +EXIT_IAM = 3 + +_API_HUB_BASE = ( + "https://apihub.googleapis.com/v1/" + "projects/{project}/locations/{location}" +) + +# (attribute_id, display_name, description) tuples. The four are +# the union of what register_skill writes and what the consumer +# reads. The order is the create order; tests assert the *set* of +# created IDs, not the order, but a stable order keeps logs +# deterministic for human reviewers. +_ATTR_DEFS: tuple[tuple[str, str, str], ...] = ( + ( + "agentic_skill", + "Agentic skill", + "True for skills consumable by the agent runtime.", + ), + ( + "keywords", + "Keywords", + "Discovery keywords mirrored from the manifest.", + ), + ( + "gs_uri", + "GCS URI", + "Location of the signed .skill zip in GCS.", + ), + ( + "signing_key_id", + "Signing key ID", + "sha256: fingerprint of the signing public key.", + ), +) + + +def _err(quiet: bool, msg: str) -> None: + if not quiet: + print(msg, file=sys.stderr) + + +def _say(quiet: bool, msg: str) -> None: + if not quiet: + print(msg) + + +def _credentials(): + """Cloud-platform scoped ADC (uniform across all helpers).""" + import google.auth + import google.auth.transport.requests + + creds, project = google.auth.default( + scopes=["https://www.googleapis.com/auth/cloud-platform"] + ) + creds.refresh(google.auth.transport.requests.Request()) + return creds, project + + +def _parse_args(argv: Sequence[str]) -> argparse.Namespace: + p = argparse.ArgumentParser( + prog="update-taxonomy.py", + description="Create API hub attribute defs for skills.", + ) + p.add_argument("--project", required=True) + p.add_argument("--location", required=True) + p.add_argument("--quiet", action="store_true") + return p.parse_args(list(argv)) + + +def _classify(status: int) -> int: + if status == 403: + return EXIT_IAM + return EXIT_SYSTEM + + +def main(argv: Sequence[str] | None = None) -> int: + if argv is None: + argv = sys.argv[1:] + try: + ns = _parse_args(argv) + except SystemExit: + return EXIT_USER + + quiet = ns.quiet + + try: + creds, _ = _credentials() + except Exception as exc: + _err(quiet, f"error: ADC credentials unavailable: {exc}") + return EXIT_USER + + base = _API_HUB_BASE.format( + project=ns.project, location=ns.location + ) + headers = { + "Authorization": f"Bearer {creds.token}", + "Content-Type": "application/json", + } + + created = 0 + for attr_id, display_name, description in _ATTR_DEFS: + get_url = f"{base}/attributes/{attr_id}" + try: + r = requests.request( + "GET", get_url, headers=headers, timeout=30, + ) + except requests.RequestException as exc: + _err(quiet, f"error: GET attribute network failure: {exc}") + return EXIT_SYSTEM + if r.status_code == 200: + _say(quiet, f"attribute exists: {attr_id}") + continue + if r.status_code == 403: + _err(quiet, f"error: GET attribute denied (403): {attr_id}") + return EXIT_IAM + if r.status_code not in (404,): + _err(quiet, f"error: GET attribute failed ({r.status_code})") + return _classify(r.status_code) + + # Not present: create. + post_url = f"{base}/attributes" + body = { + "displayName": display_name, + "description": description, + "scope": "API", + "dataType": "STRING", + # API hub uses `cardinality` (max number of values). + # `keywords` needs to hold the full keyword list per skill + # (up to ~20 tokens); the scalar attributes are 1 each. + "cardinality": 20 if attr_id == "keywords" else 1, + } + try: + r = requests.request( + "POST", + post_url, + headers=headers, + params={"attributeId": attr_id}, + json=body, + timeout=60, + ) + except requests.RequestException as exc: + _err(quiet, f"error: POST attribute network failure: {exc}") + return EXIT_SYSTEM + if r.status_code >= 400: + body_snippet = (r.text or "")[:500] + _err( + quiet, + f"error: POST attribute failed ({r.status_code}): " + f"{body_snippet}", + ) + return _classify(r.status_code) + _say(quiet, f"created attribute: {attr_id}") + created += 1 + + if created == 0: + _say(quiet, "taxonomy already up to date") + else: + _say(quiet, f"taxonomy updated: {created} attribute(s) created") + return EXIT_OK + + +if __name__ == "__main__": # pragma: no cover + raise SystemExit(main()) diff --git a/references/apigee-skills-serving/scripts/upload_skill.py b/references/apigee-skills-serving/scripts/upload_skill.py new file mode 100644 index 000000000..12647cc1c --- /dev/null +++ b/references/apigee-skills-serving/scripts/upload_skill.py @@ -0,0 +1,165 @@ +# Copyright 2026 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Upload a signed .skill zip to GCS. + +CLI grammar: + + upload-skill.py --zip + --bucket + [--object-name ] + [--quiet] + +We deliberately do NOT depend on ``google-cloud-storage`` (we +keep ``requirements.txt`` to four runtime packages). Instead we +issue a single POST against the GCS JSON upload API with an ADC +bearer token from ``google.auth.default()``. The endpoint shape +is: + + POST https://storage.googleapis.com/upload/storage/v1/b/ + /o?uploadType=media&name= + +with the zip bytes as the request body. The response is a JSON +object whose ``name`` field echoes back the object name; we don't +introspect it beyond status-code handling. + +Exit codes: + 0 success + 1 user error (bad CLI args, file not found) + 2 system error (GCS API error or 404) + 3 IAM error (403 — insufficient permissions) +""" +from __future__ import annotations + +import argparse +import sys +from pathlib import Path +from typing import Sequence + +import requests + +EXIT_OK = 0 +EXIT_USER = 1 +EXIT_SYSTEM = 2 +EXIT_IAM = 3 + +_UPLOAD_BASE = ( + "https://storage.googleapis.com/upload/storage/v1/b/{bucket}/o" +) + + +def _err(quiet: bool, msg: str) -> None: + if not quiet: + print(msg, file=sys.stderr) + + +def _credentials(): + """Centralised ADC fetch. Wrapped so tests can monkeypatch + this module attribute rather than ``google.auth.default`` + globally, which keeps the test surface narrow. + + Scope matches the other call sites in this repo. The GCS + upload endpoint accepts the cloud-platform scope; using a + narrower devstorage scope here would diverge from the other + Google call sites and add a scope-mismatch failure mode for + no benefit.""" + import google.auth + import google.auth.transport.requests + + creds, project = google.auth.default( + scopes=["https://www.googleapis.com/auth/cloud-platform"] + ) + creds.refresh(google.auth.transport.requests.Request()) + return creds, project + + +def _parse_args(argv: Sequence[str]) -> argparse.Namespace: + p = argparse.ArgumentParser( + prog="upload-skill.py", + description="Upload a .skill zip to GCS via the JSON API.", + ) + p.add_argument("--zip", required=True, dest="zip_path") + p.add_argument("--bucket", required=True) + p.add_argument("--object-name", default=None, dest="object_name") + p.add_argument("--quiet", action="store_true") + return p.parse_args(list(argv)) + + +def main(argv: Sequence[str] | None = None) -> int: + if argv is None: + argv = sys.argv[1:] + try: + ns = _parse_args(argv) + except SystemExit: + return EXIT_USER + + quiet = ns.quiet + zip_path = Path(ns.zip_path) + if not zip_path.is_file(): + _err(quiet, f"error: zip not found: {zip_path}") + return EXIT_USER + + object_name = ns.object_name or zip_path.name + + # Bearer token. Any failure here is a user-environment issue + # (no ADC, bad credentials file) — we surface it but classify + # as user error so the caller knows to fix their environment. + try: + creds, _ = _credentials() + except Exception as exc: # pragma: no cover - covered via mock + _err(quiet, f"error: ADC credentials unavailable: {exc}") + return EXIT_USER + + url = _UPLOAD_BASE.format(bucket=ns.bucket) + headers = { + "Authorization": f"Bearer {creds.token}", + "Content-Type": "application/zip", + } + params = {"uploadType": "media", "name": object_name} + + try: + resp = requests.post( + url, + headers=headers, + params=params, + data=zip_path.read_bytes(), + timeout=60, + ) + except requests.RequestException as exc: + _err(quiet, f"error: GCS upload network failure: {exc}") + return EXIT_SYSTEM + + if resp.status_code == 403: + _err(quiet, "error: GCS upload denied (403): " + "check bucket IAM permissions") + return EXIT_IAM + if resp.status_code == 404: + _err(quiet, f"error: bucket not found (404): {ns.bucket}") + return EXIT_SYSTEM + if resp.status_code >= 400: + _err( + quiet, + f"error: GCS upload failed ({resp.status_code}): " + f"{getattr(resp, 'text', '')}", + ) + return EXIT_SYSTEM + + uri = f"gs://{ns.bucket}/{object_name}" + if not quiet: + print(uri) + return EXIT_OK + + +if __name__ == "__main__": # pragma: no cover + raise SystemExit(main()) diff --git a/references/apigee-skills-serving/skills/apigee-policy-top10/SKILL.md b/references/apigee-skills-serving/skills/apigee-policy-top10/SKILL.md new file mode 100644 index 000000000..98ba6692a --- /dev/null +++ b/references/apigee-skills-serving/skills/apigee-policy-top10/SKILL.md @@ -0,0 +1,63 @@ +--- +name: apigee-policy-top10 +description: Reports the top 10 Apigee policy types currently in use + across deployed proxy revisions in the caller's Apigee org. Reads + the Apigee Management API only; modifies nothing. Authenticates + with Application Default Credentials. +license: Apache-2.0 +compatibility: opencode +metadata: + runtime_iam: + - apigee.proxies.list + - apigee.deployments.list + - apigee.proxyrevisions.get +--- + +# apigee-policy-top10 + +Use this skill when the user asks "what are the top N policies in +use in my Apigee org" or any close paraphrase. + +## Runtime dispatch + +This skill ships ONE behavioural contract that runs on two +runtimes. Choose the invocation path that matches the runtime you +are running in. The downstream behaviour and output contract are +identical. + +| Runtime | How you invoke `top10.py` | +|:--------|:--------------------------| +| **OpenCode** | Use the `!`bash`` injection below. OpenCode auto-executes the bash on SKILL.md load. | +| Gemini CLI / Claude Code / other agents | (install location varies; consult the agent's documentation) | +| **Any other runtime** | Invoke `top10.py` via whatever bash mechanism the runtime provides, substituting `${SKILL_DIR}` with the install path and `${APIGEE_ORG}` with the value from the shell environment. | + +## Steps + +1. Resolve the target organization from the environment variable + `APIGEE_ORG`. If unset, ask the user once for the org id and + then proceed. + +2. Invoke the bundled enumerator using the runtime path from the + table above. The exact command is: + + **Command** (this is what OpenCode runs automatically; in + other agent runtimes you run it yourself via the bash tool): + + !`python3 ${SKILL_DIR}/scripts/top10.py --org "${APIGEE_ORG}"` + +3. The enumerator's FIRST line of stdout is a customer-facing + announcement that begins `Querying your Apigee org now —`. + Surface this line verbatim to the user as it appears, before + anything else. Do not paraphrase it. (The script is the source + of truth for the announcement string; the SKILL.md does NOT + contain a verbatim copy that the agent must remember.) + +4. After the announcement, the script prints a markdown table with + two columns (`policy_type`, `count`) sorted descending. + Reproduce the table verbatim as the answer. Add a brief preface + ("Here are the top 10 policy types in use…") and stop. + +5. Lines beginning `[apigee-policy-top10]` are log lines for + operator debugging; do NOT surface them in the answer unless + they say `FAILED`. On any `FAILED` line, surface the verbatim + error to the user; do not retry. diff --git a/references/apigee-skills-serving/skills/apigee-policy-top10/manifest.yaml b/references/apigee-skills-serving/skills/apigee-policy-top10/manifest.yaml new file mode 100644 index 000000000..7fce47c02 --- /dev/null +++ b/references/apigee-skills-serving/skills/apigee-policy-top10/manifest.yaml @@ -0,0 +1,32 @@ +# Copyright 2026 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +# Demo manifest. Run scripts/sign_skill.py and scripts/upload_skill.py to populate gs_uri, signature, signing_key_id, zip_sha256. +author: apigee-devrel +capabilities: [] +description: Reports the top 10 Apigee policy types currently in use across deployed + proxy revisions in the caller's Apigee org. +keywords: +- apigee +- policy +- top10 +- proxies +license: Apache-2.0 +manifest_schema_version: '1' +name: apigee-policy-top10 +runtime_iam: +- apigee.proxies.list +- apigee.deployments.list +- apigee.proxyrevisions.get +version: 0.1.0 diff --git a/references/apigee-skills-serving/skills/apigee-policy-top10/scripts/top10.py b/references/apigee-skills-serving/skills/apigee-policy-top10/scripts/top10.py new file mode 100644 index 000000000..e931541a1 --- /dev/null +++ b/references/apigee-skills-serving/skills/apigee-policy-top10/scripts/top10.py @@ -0,0 +1,237 @@ +# Copyright 2026 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +#!/usr/bin/env python3 +"""apigee-policy-top10 enumerator. + +Strategy: full bundle download per deployed proxy revision, XML +root-element parsing, frequency aggregation. The script is the +source of truth for the customer-facing ANNOUNCEMENT string -- +the SKILL.md does NOT contain a verbatim copy that the agent +must remember; instead it instructs the agent to surface this +script's FIRST stdout line verbatim. + +Output contract: + +- The FIRST line of stdout is ``ANNOUNCEMENT`` (unprefixed, + customer-facing). Printed BEFORE any network call. +- Operator log lines carry the ``[apigee-policy-top10] `` prefix + and MAY change wording across versions. SKILL.md suppresses them + from the answer EXCEPT lines containing ``FAILED``. +- The final markdown table (unprefixed) is the answer the agent + reproduces verbatim. +""" +from __future__ import annotations + +import argparse +import io +import sys +import zipfile +from collections import Counter +from pathlib import Path +from xml.etree import ElementTree as ET + +import google.auth +import google.auth.transport.requests +import requests + +# Dual import: production .skill zip layout has no `scripts/` +# parent package; bare `common.*` resolves via the sys.path.insert +# below. Dev/test layout uses `scripts.common.*` via +# tests/conftest.py. +sys.path.insert(0, str(Path(__file__).resolve().parent)) +try: + from common import config # production .skill zip layout +except ImportError: + from scripts.common import config # dev/test layout + +APIGEE_BASE = "https://apigee.googleapis.com/v1" + +# Announcement string is enforced at runtime by being printed +# directly by this script (rather than relying on the agent to +# recall it from SKILL.md). The static test in +# tests/test_apigee_top10.py imports this constant and asserts +# byte-equality against tests/fixtures/announcement.txt. +# +# Printed unprefixed (no "[apigee-policy-top10] ") because this is +# a customer-facing string, not a log line. +ANNOUNCEMENT = ( + "Querying your Apigee org now \u2014 this enumerates every deployed " + "proxy revision and may take 20-60 seconds for orgs with 50+ " + "proxies. No data is modified." +) + + +def _say(line: str) -> None: + """Emit an operator log line (prefixed for log discipline).""" + print(f"[apigee-policy-top10] {line}", flush=True) + + +def _die(line: str, code: int = 1) -> None: + """Emit an operator log line and exit with ``code``.""" + _say(line) + sys.exit(code) + + +def _auth() -> tuple[str, str]: + """Resolve ADC token and project id.""" + creds, project = google.auth.default( + scopes=["https://www.googleapis.com/auth/cloud-platform"] + ) + creds.refresh(google.auth.transport.requests.Request()) + return creds.token, project + + +def _list_proxies(token: str, org: str) -> list[str]: + """List API proxy short names for the org.""" + url = f"{APIGEE_BASE}/organizations/{org}/apis" + r = requests.get( + url, + headers={"Authorization": f"Bearer {token}"}, + timeout=30, + ) + if r.status_code != 200: + _die( + f"list proxies: FAILED \u2014 HTTP {r.status_code} " + f"{r.reason}", + code=2, + ) + return [ + p["name"].rsplit("/", 1)[-1] + for p in r.json().get("proxies", []) + ] + + +def _deployed_revisions( + token: str, org: str, api: str +) -> set[str]: + """Return the set of revisions of ``api`` deployed to any env.""" + url = ( + f"{APIGEE_BASE}/organizations/{org}/apis/{api}/deployments" + ) + r = requests.get( + url, + headers={"Authorization": f"Bearer {token}"}, + timeout=30, + ) + if r.status_code != 200: + return set() + return { + d["revision"] for d in r.json().get("deployments", []) + } + + +def _download_bundle( + token: str, org: str, api: str, rev: str +) -> bytes: + """Download the proxy bundle zip for ``(api, rev)``.""" + url = ( + f"{APIGEE_BASE}/organizations/{org}/apis/{api}/" + f"revisions/{rev}?format=bundle" + ) + r = requests.get( + url, + headers={ + "Authorization": f"Bearer {token}", + "Accept": "application/zip", + }, + timeout=60, + ) + if r.status_code != 200: + _die( + f"bundle download {api}@{rev}: FAILED \u2014 " + f"HTTP {r.status_code} {r.reason}", + code=2, + ) + return r.content + + +def _policy_types_in_bundle(zip_bytes: bytes) -> list[str]: + """Parse ``apiproxy/policies/*.xml``; return root element names.""" + types: list[str] = [] + with zipfile.ZipFile(io.BytesIO(zip_bytes)) as zf: + for name in zf.namelist(): + if not name.startswith("apiproxy/policies/"): + continue + if not name.endswith(".xml"): + continue + try: + tree = ET.fromstring(zf.read(name)) + types.append(tree.tag) + except ET.ParseError: + _say(f"warning: unparseable policy file {name}") + return types + + +def main() -> None: + ap = argparse.ArgumentParser() + # --org defaults to (1) $APIGEE_ORG, (2) the APIGEE_ORG line + # in ~/.config/apigee-skills-demo/config.env (written by + # ./bin/demo-setup.sh). The flag is no longer required=True + # because the resolver handles the env-or-file lookup; we + # validate emptiness AFTER the announcement so the LOCKED + # first-line invariant still holds even when the config is + # missing. + ap.add_argument( + "--org", + default=config.get("APIGEE_ORG"), + help=( + "Apigee organization id (defaults to $APIGEE_ORG or " + "the APIGEE_ORG line in " + "~/.config/apigee-skills-demo/config.env)." + ), + ) + ap.add_argument("--top", type=int, default=10) + args = ap.parse_args() + + # Announcement is the FIRST line on stdout, before any network + # call. Locked Hyrum's Law surface -- change requires + # manifest_schema_version bump. + print(ANNOUNCEMENT, flush=True) + + # Now safe to enforce required-ness on --org. We emit a + # [apigee-policy-top10] FAILED line (prefixed log line) so + # SKILL.md surfaces it verbatim. + if not (args.org or "").strip(): + print( + "[apigee-policy-top10] config: FAILED -- --org is " + "empty. Set $APIGEE_ORG OR add `APIGEE_ORG=...` to " + "~/.config/apigee-skills-demo/config.env (run " + "`./bin/demo-setup.sh` to write the config file).", + flush=True, + ) + sys.exit(2) + + token, _ = _auth() + proxies = _list_proxies(token, args.org) + counter: Counter[str] = Counter() + for api in proxies: + for rev in _deployed_revisions(token, args.org, api): + bundle = _download_bundle(token, args.org, api, rev) + counter.update(_policy_types_in_bundle(bundle)) + if not counter: + _die( + f"No deployed policies found in org {args.org}. " + "Either no proxies are deployed, or IAM denies bundle " + "download.", + code=1, + ) + print("| policy_type | count |") + print("|:------------|------:|") + for policy_type, count in counter.most_common(args.top): + print(f"| {policy_type} | {count} |") + + +if __name__ == "__main__": + main() diff --git a/references/apigee-skills-serving/skills/currency-converter/SKILL.md b/references/apigee-skills-serving/skills/currency-converter/SKILL.md new file mode 100644 index 000000000..9308dccdd --- /dev/null +++ b/references/apigee-skills-serving/skills/currency-converter/SKILL.md @@ -0,0 +1,31 @@ +--- +name: currency-converter +description: Converts monetary amounts between fiat currencies using + a stub exchange-rate table. Demo-only catalog filler. +license: Apache-2.0 +compatibility: opencode +metadata: + runtime_iam: [] +--- + +# currency-converter + +Use this skill when the user asks to convert a monetary amount +from one fiat currency to another (for example, "convert 100 USD +to EUR"). + +This is a demonstration skill. It does not consult any live +exchange-rate API and ships no helper script; the agent surfaces +a canned mid-market rate and the converted amount inline, so the +demo catalog has a second skill to rank against +`apigee-policy-top10` without consuming external quota. + +When invoked, do this: + +1. Surface a stub response to the user in the form + `currency-converter: stub conversion -- -> ` + using a plausible canned mid-market rate (e.g. 1 USD ≈ 0.92 EUR). +2. Explicitly tell the user this is a demonstration skill that does + NOT consult a live exchange-rate provider; production use would + require wiring a real exchange-rate API and adding the + corresponding `runtime_iam` permissions. diff --git a/references/apigee-skills-serving/skills/currency-converter/manifest.yaml b/references/apigee-skills-serving/skills/currency-converter/manifest.yaml new file mode 100644 index 000000000..a0ea7c942 --- /dev/null +++ b/references/apigee-skills-serving/skills/currency-converter/manifest.yaml @@ -0,0 +1,29 @@ +# Copyright 2026 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +# Demo manifest. Run scripts/sign_skill.py and scripts/upload_skill.py to populate gs_uri, signature, signing_key_id, zip_sha256. +author: apigee-devrel +capabilities: [] +description: Converts monetary amounts between fiat currencies using a static exchange-rate + table. +keywords: +- currency +- exchange +- fiat +- money +license: Apache-2.0 +manifest_schema_version: '1' +name: currency-converter +runtime_iam: [] +version: 0.1.0 diff --git a/references/apigee-skills-serving/skills/weather-lookup/SKILL.md b/references/apigee-skills-serving/skills/weather-lookup/SKILL.md new file mode 100644 index 000000000..9a63e09c6 --- /dev/null +++ b/references/apigee-skills-serving/skills/weather-lookup/SKILL.md @@ -0,0 +1,32 @@ +--- +name: weather-lookup +description: Returns a stub weather forecast for a given location. + Demo-only catalog filler. +license: Apache-2.0 +compatibility: opencode +metadata: + runtime_iam: [] +--- + +# weather-lookup + +Use this skill when the user asks for the weather, temperature, +or forecast for a named city or region (for example, "what's +the weather in Tokyo?"). + +This is a demonstration skill. It does not consult any live +meteorology API and ships no helper script; the agent surfaces +a canned forecast inline so the demo catalog has a second skill +to rank against `apigee-policy-top10` without consuming external +quota. + +When invoked, do this: + +1. Surface a stub response to the user in the form + `weather-lookup: stub forecast -- : , ` + using a plausible canned reading (e.g. `Tokyo: 21C, partly + cloudy`). +2. Explicitly tell the user this is a demonstration skill that does + NOT consult a live meteorology provider; production use would + require wiring a real weather API and adding the corresponding + `runtime_iam` permissions. diff --git a/references/apigee-skills-serving/skills/weather-lookup/manifest.yaml b/references/apigee-skills-serving/skills/weather-lookup/manifest.yaml new file mode 100644 index 000000000..726d91b30 --- /dev/null +++ b/references/apigee-skills-serving/skills/weather-lookup/manifest.yaml @@ -0,0 +1,28 @@ +# Copyright 2026 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +# Demo manifest. Run scripts/sign_skill.py and scripts/upload_skill.py to populate gs_uri, signature, signing_key_id, zip_sha256. +author: apigee-devrel +capabilities: [] +description: Returns a canned weather forecast for a named city. +keywords: +- weather +- forecast +- location +- temperature +license: Apache-2.0 +manifest_schema_version: '1' +name: weather-lookup +runtime_iam: [] +version: 0.1.0 diff --git a/references/apigee-skills-serving/tests/conftest.py b/references/apigee-skills-serving/tests/conftest.py new file mode 100644 index 000000000..73387f6fd --- /dev/null +++ b/references/apigee-skills-serving/tests/conftest.py @@ -0,0 +1,15 @@ +"""Shared pytest configuration. + +Adds the repository root to ``sys.path`` so test files can do +``from scripts.common.canonical import canonicalize`` regardless +of how pytest is invoked. The source tree is rooted at +``scripts/`` and tests live in ``tests/`` at the same level. +""" +from __future__ import annotations + +import sys +from pathlib import Path + +_REPO_ROOT = Path(__file__).resolve().parent.parent +if str(_REPO_ROOT) not in sys.path: + sys.path.insert(0, str(_REPO_ROOT)) diff --git a/references/apigee-skills-serving/tests/fixtures/announcement.txt b/references/apigee-skills-serving/tests/fixtures/announcement.txt new file mode 100644 index 000000000..8f3dba6fe --- /dev/null +++ b/references/apigee-skills-serving/tests/fixtures/announcement.txt @@ -0,0 +1 @@ +Querying your Apigee org now — this enumerates every deployed proxy revision and may take 20-60 seconds for orgs with 50+ proxies. No data is modified. \ No newline at end of file diff --git a/references/apigee-skills-serving/tests/test_apigee_top10.py b/references/apigee-skills-serving/tests/test_apigee_top10.py new file mode 100644 index 000000000..8ddf49352 --- /dev/null +++ b/references/apigee-skills-serving/tests/test_apigee_top10.py @@ -0,0 +1,630 @@ +"""Tests for ``skills/apigee-policy-top10/scripts/top10.py``. + +Covers the acceptance criteria for the apigee-policy-top10 skill: + +- ``ANNOUNCEMENT`` constant exists, importable, and is printed as + the FIRST stdout line by ``main()`` BEFORE any network call + (runtime enforcement). +- ``ANNOUNCEMENT`` is byte-identical to + ``tests/fixtures/announcement.txt`` (static enforcement; CI + catches drift on either side). +- Ranked output: N+ policy types -> exactly ``--top`` rows in + descending count order; fewer than ``--top`` -> all rows. +- Malformed XML in a bundle is skipped with an + ``[apigee-policy-top10] warning:`` line, not fatal. +- Zero proxies -> exit code 1 with a FAILED-style line. +- ``runtime_iam`` list in ``SKILL.md`` frontmatter matches the + expected dot-form strings verbatim. +- No real network call ever escapes; the announcement still prints + even when the network is mocked to raise immediately. +""" +from __future__ import annotations + +import importlib.util +import io +import re +import sys +import zipfile +from collections import Counter +from pathlib import Path +from unittest import mock + +import pytest +import yaml + + +# --------------------------------------------------------------------------- +# Module loading +# +# ``top10.py`` lives under ``skills/apigee-policy-top10/scripts/`` and is +# bundled with the skill, not imported as part of a package. Load it by +# absolute path so the tests are stable regardless of how pytest is invoked. +# --------------------------------------------------------------------------- +_REPO_ROOT = Path(__file__).resolve().parent.parent +_TOP10_PATH = ( + _REPO_ROOT + / "skills" + / "apigee-policy-top10" + / "scripts" + / "top10.py" +) +_SKILL_MD_PATH = ( + _REPO_ROOT / "skills" / "apigee-policy-top10" / "SKILL.md" +) +_FIXTURE_PATH = ( + _REPO_ROOT / "tests" / "fixtures" / "announcement.txt" +) + + +def _load_top10(): + """Import ``top10`` as a standalone module from its file path.""" + spec = importlib.util.spec_from_file_location( + "top10", str(_TOP10_PATH) + ) + assert spec is not None and spec.loader is not None + module = importlib.util.module_from_spec(spec) + # Cache under the canonical name so subsequent calls return the + # same object; the contract is that + # ``from top10 import ANNOUNCEMENT`` is the import surface tests + # rely on. + sys.modules["top10"] = module + spec.loader.exec_module(module) + return module + + +@pytest.fixture +def top10_module(): + return _load_top10() + + +# --------------------------------------------------------------------------- +# Helpers to build fake Apigee API responses +# --------------------------------------------------------------------------- +def _make_bundle_zip(policy_files: dict[str, str]) -> bytes: + """Build a proxy-bundle zip with the given ``policies/*.xml`` map. + + ``policy_files`` keys are policy filenames (without the + ``apiproxy/policies/`` prefix). Values are the raw XML body. + """ + buf = io.BytesIO() + with zipfile.ZipFile(buf, "w") as zf: + for fname, body in policy_files.items(): + zf.writestr(f"apiproxy/policies/{fname}", body) + return buf.getvalue() + + +class _FakeResponse: + """Minimal stand-in for ``requests.Response``.""" + + def __init__( + self, + status_code: int = 200, + json_data: object | None = None, + content: bytes = b"", + reason: str = "OK", + ) -> None: + self.status_code = status_code + self._json = json_data + self.content = content + self.reason = reason + + def json(self): + if self._json is None: + raise ValueError("no json body") + return self._json + + +def _patch_auth(monkeypatch, top10_module) -> None: + """Bypass real ADC; tests never hit ``google.auth``.""" + monkeypatch.setattr( + top10_module, "_auth", lambda: ("fake-token", "fake-project") + ) + + +# --------------------------------------------------------------------------- +# Announcement contract +# --------------------------------------------------------------------------- +def test_announcement_constant_importable(top10_module): + """``ANNOUNCEMENT`` must be a non-empty ``str`` constant + importable from the ``top10`` module.""" + assert hasattr(top10_module, "ANNOUNCEMENT") + assert isinstance(top10_module.ANNOUNCEMENT, str) + assert top10_module.ANNOUNCEMENT.strip() != "" + + +def test_announcement_matches_design(top10_module): + """Byte-equality between ``ANNOUNCEMENT`` (UTF-8) and the + pinned fixture file. CI catches drift on either side.""" + assert _FIXTURE_PATH.is_file(), ( + f"missing fixture: {_FIXTURE_PATH}" + ) + fixture_bytes = _FIXTURE_PATH.read_bytes() + assert ( + top10_module.ANNOUNCEMENT.encode("utf-8") == fixture_bytes + ) + + +def test_announcement_is_unprefixed(top10_module): + """The announcement is customer-facing and MUST NOT carry the + ``[apigee-policy-top10]`` log prefix.""" + assert not top10_module.ANNOUNCEMENT.startswith( + "[apigee-policy-top10]" + ) + + +def test_announcement_is_first_line( + top10_module, monkeypatch, capsys +): + """End-to-end mocked run: ``stdout.splitlines()[0]`` must equal + ``ANNOUNCEMENT``. Verified against a tiny fake org with one + deployed revision so ``main()`` reaches the table render path.""" + _patch_auth(monkeypatch, top10_module) + + bundle = _make_bundle_zip({ + "p1.xml": '', + "p2.xml": '', + }) + + def fake_get(url, headers=None, timeout=None): + if url.endswith("/apis"): + return _FakeResponse(json_data={ + "proxies": [{"name": "organizations/org/apis/proxyA"}] + }) + if url.endswith("/apis/proxyA/deployments"): + return _FakeResponse(json_data={ + "deployments": [{"revision": "1"}] + }) + if "revisions/1?format=bundle" in url: + return _FakeResponse(content=bundle) + raise AssertionError(f"unexpected URL: {url}") + + monkeypatch.setattr( + top10_module.requests, "get", fake_get + ) + monkeypatch.setattr( + sys, "argv", ["top10.py", "--org", "fake-org"] + ) + + top10_module.main() + out = capsys.readouterr().out + lines = out.splitlines() + assert lines, "no stdout produced" + assert lines[0] == top10_module.ANNOUNCEMENT + + +def test_no_real_network(top10_module, monkeypatch, capsys): + """Even when the very first network call raises, the + announcement is already on stdout. Confirms the print + happens BEFORE ``_auth()`` / any network I/O.""" + + def boom(): + raise RuntimeError( + "auth should not run before announcement" + ) + + # _auth is the first thing main() does after the print; rig it + # to raise. We also rig requests.get to raise so a regression + # that moves the print after _auth still cannot silently hit + # the network. + def fake_get(*a, **kw): + raise RuntimeError("network must not be touched") + + monkeypatch.setattr(top10_module, "_auth", boom) + monkeypatch.setattr(top10_module.requests, "get", fake_get) + monkeypatch.setattr( + sys, "argv", ["top10.py", "--org", "fake-org"] + ) + + with pytest.raises(RuntimeError): + top10_module.main() + + out = capsys.readouterr().out + lines = out.splitlines() + assert lines, "announcement was not printed before _auth()" + assert lines[0] == top10_module.ANNOUNCEMENT + + +# --------------------------------------------------------------------------- +# Ranked-output contract +# --------------------------------------------------------------------------- +def _run_main_with_bundles( + top10_module, + monkeypatch, + bundles_by_rev: dict[str, bytes], + deployments: list[str], + top: int | None = None, + proxies: list[str] | None = None, +) -> None: + """Drive ``main()`` end-to-end with mocked HTTP. + + Caller pulls stdout via the ``capsys`` fixture they own; this + helper has no return value because mixing ``capsys`` ownership + across helper/caller boundaries is brittle. + """ + _patch_auth(monkeypatch, top10_module) + proxies = proxies if proxies is not None else ["proxyA"] + + def fake_get(url, headers=None, timeout=None): + if url.endswith("/apis"): + return _FakeResponse(json_data={ + "proxies": [ + {"name": f"organizations/org/apis/{p}"} + for p in proxies + ], + }) + m = re.search(r"/apis/([^/]+)/deployments$", url) + if m: + return _FakeResponse(json_data={ + "deployments": [ + {"revision": rev} for rev in deployments + ], + }) + m = re.search(r"/apis/([^/]+)/revisions/([^/?]+)\?format=bundle", url) + if m: + rev = m.group(2) + assert rev in bundles_by_rev, ( + f"unexpected rev download: {rev}" + ) + return _FakeResponse(content=bundles_by_rev[rev]) + raise AssertionError(f"unexpected URL: {url}") + + monkeypatch.setattr(top10_module.requests, "get", fake_get) + + argv = ["top10.py", "--org", "fake-org"] + if top is not None: + argv.extend(["--top", str(top)]) + monkeypatch.setattr(sys, "argv", argv) + + top10_module.main() + return None # caller pulls stdout via capsys + + +def test_ranked_output_exact_N( + top10_module, monkeypatch, capsys +): + """N+ policy types -> exactly ``--top`` rows, sorted by count + descending. Use ``--top 3`` against 5 distinct types.""" + bundle = _make_bundle_zip({ + "a1.xml": '', + "a2.xml": '', + "a3.xml": '', + "b1.xml": '', + "b2.xml": '', + "c1.xml": '', + "d1.xml": '', + "e1.xml": '', + }) + _run_main_with_bundles( + top10_module, + monkeypatch, + bundles_by_rev={"1": bundle}, + deployments=["1"], + top=3, + ) + out = capsys.readouterr().out + lines = out.splitlines() + # Drop announcement and table header lines. + data_rows = [ + ln for ln in lines + if ln.startswith("| ") and "policy_type" not in ln + and not ln.startswith("|:") + ] + assert len(data_rows) == 3 + # Counts must be in non-increasing order. + counts: list[int] = [] + for row in data_rows: + # ``| Type | N |`` + parts = [c.strip() for c in row.strip("|").split("|")] + counts.append(int(parts[1])) + assert counts == sorted(counts, reverse=True) + # Top row is the SpikeArrest with count 3. + assert "SpikeArrest" in data_rows[0] + assert data_rows[0].rstrip().endswith("| 3 |") + + +def test_smaller_than_N(top10_module, monkeypatch, capsys): + """Fewer than ``--top`` types -> all rows returned, no + padding.""" + bundle = _make_bundle_zip({ + "a.xml": '', + "b.xml": '', + }) + _run_main_with_bundles( + top10_module, + monkeypatch, + bundles_by_rev={"1": bundle}, + deployments=["1"], + top=10, + ) + out = capsys.readouterr().out + lines = out.splitlines() + data_rows = [ + ln for ln in lines + if ln.startswith("| ") and "policy_type" not in ln + and not ln.startswith("|:") + ] + assert len(data_rows) == 2 + + +# --------------------------------------------------------------------------- +# Robustness: malformed XML, zero proxies +# --------------------------------------------------------------------------- +def test_corrupt_xml_skipped(top10_module, monkeypatch, capsys): + """Malformed XML in a bundle is skipped with a warning, not + fatal. Good policies in the same bundle still count.""" + bundle = _make_bundle_zip({ + "good.xml": '', + "broken.xml": '<< exit code 1 with a FAILED-style + operator line. The announcement still printed first.""" + _patch_auth(monkeypatch, top10_module) + + def fake_get(url, headers=None, timeout=None): + if url.endswith("/apis"): + return _FakeResponse(json_data={"proxies": []}) + raise AssertionError(f"unexpected URL: {url}") + + monkeypatch.setattr(top10_module.requests, "get", fake_get) + monkeypatch.setattr( + sys, "argv", ["top10.py", "--org", "fake-org"] + ) + + with pytest.raises(SystemExit) as excinfo: + top10_module.main() + assert excinfo.value.code == 1 + + out = capsys.readouterr().out + lines = out.splitlines() + assert lines[0] == top10_module.ANNOUNCEMENT + # The contract says operator log lines (including the no- + # proxies failure) carry the bracket prefix. + assert any( + ln.startswith("[apigee-policy-top10]") for ln in lines + ) + + +# --------------------------------------------------------------------------- +# SKILL.md frontmatter contract +# --------------------------------------------------------------------------- +def test_skill_md_exists(): + assert _SKILL_MD_PATH.is_file(), ( + f"missing SKILL.md: {_SKILL_MD_PATH}" + ) + + +def _parse_frontmatter(skill_md_path: Path) -> dict: + """Parse the leading ``---``-delimited YAML block.""" + text = skill_md_path.read_text(encoding="utf-8") + assert text.startswith("---\n"), ( + "SKILL.md must start with a YAML frontmatter block" + ) + end = text.find("\n---\n", 4) + assert end != -1, "frontmatter has no closing ``---``" + yaml_text = text[4:end] + return yaml.safe_load(yaml_text) + + +def test_runtime_iam_in_skill_md(): + """``metadata.runtime_iam`` lists the three dot-form + permissions in order.""" + fm = _parse_frontmatter(_SKILL_MD_PATH) + expected = [ + "apigee.proxies.list", + "apigee.deployments.list", + "apigee.proxyrevisions.get", + ] + metadata = fm.get("metadata") or {} + runtime_iam = metadata.get("runtime_iam") + assert runtime_iam == expected + + +def test_runtime_iam_is_dot_form(): + """Each entry MUST match the dot-form regex used by + ``scripts/common/manifest_schema.py`` (rejects the + service-host-prefix form).""" + fm = _parse_frontmatter(_SKILL_MD_PATH) + runtime_iam = (fm.get("metadata") or {}).get("runtime_iam") or [] + dot_form = re.compile(r"^[a-z]+\.[a-z0-9.]+$") + for perm in runtime_iam: + assert dot_form.match(perm), ( + f"permission {perm!r} is not dot-form" + ) + assert "/" not in perm + assert "googleapis.com" not in perm + + +# --------------------------------------------------------------------------- +# --org config resolution +# +# top10.py used to require --org as an argparse flag. After the +# demo-resilience refactor, --org defaults to resolution via +# scripts/common/config: env var APIGEE_ORG > the APIGEE_ORG +# line in ~/.config/apigee-skills-demo/config.env > empty. +# Emptiness is enforced AFTER the ANNOUNCEMENT print so the +# first-line invariant still holds even in the failure case. +# --------------------------------------------------------------------------- + + +@pytest.fixture +def _isolate_config(tmp_path, monkeypatch): + """Point the config helper at a nonexistent file so the + host's ~/.config/apigee-skills-demo/config.env doesn't leak + in, and clear the cache between tests.""" + monkeypatch.setenv( + "APIGEE_SKILLS_CONFIG_FILE", + str(tmp_path / "nope.env"), + ) + for k in ("APIGEE_ORG",): + monkeypatch.delenv(k, raising=False) + # The config helper is imported by top10 lazily; reach in + # via the test-loaded module to clear its cache. Use a + # sys.path-aware import that matches top10's dual-import. + _REPO_ROOT_LOCAL = Path(__file__).resolve().parent.parent + sys.path.insert(0, str(_REPO_ROOT_LOCAL)) + from scripts.common import config as _config + _config.clear_cache() + yield tmp_path + _config.clear_cache() + + +def test_org_falls_back_to_env_var( + top10_module, monkeypatch, capsys, _isolate_config +): + """When --org isn't passed, APIGEE_ORG from the env wins.""" + monkeypatch.setenv("APIGEE_ORG", "from-env-org") + + captured = {} + + def fake_auth(): + return "tok", "p" + + def fake_list_proxies(token, org): + captured["org"] = org + return [] # empty list -> exits 1 before we hit XML + + monkeypatch.setattr(top10_module, "_auth", fake_auth) + monkeypatch.setattr(top10_module, "_list_proxies", fake_list_proxies) + monkeypatch.setattr(sys, "argv", ["top10.py"]) + + with pytest.raises(SystemExit) as exc: + top10_module.main() + # zero proxies -> exit 1 per the existing contract + assert exc.value.code == 1 + # The org that reached _list_proxies came from the env var. + assert captured["org"] == "from-env-org" + + +def test_org_falls_back_to_config_file( + top10_module, monkeypatch, capsys, _isolate_config +): + """When --org isn't passed AND env is unset, the value in + the config file wins. This is the agent-launched-without-env + fix path.""" + cfg = _isolate_config / "config.env" + cfg.write_text("APIGEE_ORG=from-file-org\n") + # Point the helper at our test file. _isolate_config already + # set APIGEE_SKILLS_CONFIG_FILE to a non-existent path; swap + # it now. + monkeypatch.setenv("APIGEE_SKILLS_CONFIG_FILE", str(cfg)) + # Re-clear the cache so the new file is read. + from scripts.common import config as _config + _config.clear_cache() + + # Re-load top10 so argparse re-evaluates its default. The + # default is computed at parse_args time but argparse + # captures the default at `add_argument` time -- which means + # we need a fresh module to pick up the new config value. + captured = {} + + def fake_auth(): + return "tok", "p" + + def fake_list_proxies(token, org): + captured["org"] = org + return [] + + # Force the module to re-evaluate config.get() for the + # default. argparse stores the default value when + # add_argument runs, which is at main() time -- so a fresh + # call to main() does re-read. + monkeypatch.setattr(top10_module, "_auth", fake_auth) + monkeypatch.setattr(top10_module, "_list_proxies", fake_list_proxies) + monkeypatch.setattr(sys, "argv", ["top10.py"]) + + with pytest.raises(SystemExit) as exc: + top10_module.main() + assert exc.value.code == 1 + assert captured["org"] == "from-file-org" + + +def test_org_explicit_flag_beats_config( + top10_module, monkeypatch, capsys, _isolate_config +): + """--org on the command line beats both env and config + file. Standard precedence.""" + cfg = _isolate_config / "config.env" + cfg.write_text("APIGEE_ORG=from-file-org\n") + monkeypatch.setenv("APIGEE_SKILLS_CONFIG_FILE", str(cfg)) + monkeypatch.setenv("APIGEE_ORG", "from-env-org") + from scripts.common import config as _config + _config.clear_cache() + + captured = {} + + def fake_auth(): + return "tok", "p" + + def fake_list_proxies(token, org): + captured["org"] = org + return [] + + monkeypatch.setattr(top10_module, "_auth", fake_auth) + monkeypatch.setattr(top10_module, "_list_proxies", fake_list_proxies) + monkeypatch.setattr( + sys, "argv", ["top10.py", "--org", "from-flag-org"] + ) + + with pytest.raises(SystemExit) as exc: + top10_module.main() + assert exc.value.code == 1 + assert captured["org"] == "from-flag-org" + + +def test_org_missing_emits_failed_line_and_exits_2( + top10_module, monkeypatch, capsys, _isolate_config +): + """When --org cannot be resolved from any source, top10 + prints the ANNOUNCEMENT (first-line invariant holds) then a + [apigee-policy-top10] FAILED line and exits 2.""" + # No env, no config file (the autouse fixture pointed at a + # non-existent file). Also rig _auth and the network so any + # accidental fall-through is loud rather than silent. + monkeypatch.setattr( + top10_module, "_auth", + lambda: (_ for _ in ()).throw( + RuntimeError("must not reach _auth when --org missing") + ), + ) + monkeypatch.setattr(sys, "argv", ["top10.py"]) + + with pytest.raises(SystemExit) as exc: + top10_module.main() + assert exc.value.code == 2 + + out = capsys.readouterr().out + lines = out.splitlines() + # Announcement is STILL the first line, even on config + # failure. This is the invariant the production ordering + # preserves. + assert lines and lines[0] == top10_module.ANNOUNCEMENT + # The FAILED line is the second non-empty line. + failed_lines = [ + line for line in lines + if "[apigee-policy-top10] config: FAILED" in line + ] + assert failed_lines, ( + f"expected a [apigee-policy-top10] config: FAILED line, " + f"got: {out!r}" + ) + fl = failed_lines[0] + # The message must reference BOTH the env var and the config + # file path so the operator can fix either route. + assert "APIGEE_ORG" in fl + assert "config.env" in fl + assert "demo-setup.sh" in fl diff --git a/references/apigee-skills-serving/tests/test_canonical.py b/references/apigee-skills-serving/tests/test_canonical.py new file mode 100644 index 000000000..93e1f0521 --- /dev/null +++ b/references/apigee-skills-serving/tests/test_canonical.py @@ -0,0 +1,138 @@ +"""Tests for ``scripts/common/canonical.py``. + +The canonical transform is: + + 1. Read manifest YAML into Python dict. + 2. Remove the ``signature`` field (it is what we sign). + 3. ``json.dumps(d, sort_keys=True, separators=(",", ":"), + ensure_ascii=False).encode("utf-8")`` + +The sign side and verify side MUST produce byte-identical output +for the same input dict. This file covers the acceptance +("sign-side and verify-side produce byte-identical output for the +same dict; signature field removal; key ordering determinism; +non-ASCII escape correctness") plus three edge cases (empty +dict, nested dict ordering, return type). +""" +from __future__ import annotations + +import pytest + +from scripts.common.canonical import canonicalize + + +@pytest.fixture +def signed_manifest() -> dict: + """A manifest dict shaped like a post-sign payload, with a + ``signature`` field that the canonicalizer must strip.""" + return { + "name": "demo-skill", + "manifest_schema_version": "1", + "gs_uri": "gs://demo-bucket/demo-skill-1.skill", + "zip_sha256": "0" * 64, + "signing_key_id": "sha256:" + "a" * 64, + "signature": "base64-encoded-bytes-go-here==", + "runtime_iam": ["apigee.proxies.list"], + } + + +def test_returns_bytes_not_str(signed_manifest: dict) -> None: + result = canonicalize(signed_manifest) + assert isinstance(result, bytes), ( + "canonicalize must return bytes so the signer can pass it " + "directly to ed25519 sign; str would force the caller to " + "guess an encoding." + ) + + +def test_signature_field_removed(signed_manifest: dict) -> None: + """The signature is the output of signing the canonical bytes; + if it were included in those bytes we would have a chicken-and-egg + problem. Verify it is stripped before serialization.""" + result = canonicalize(signed_manifest) + assert b"signature" not in result + assert b"base64-encoded-bytes-go-here" not in result + + +def test_signature_field_removal_does_not_mutate_input( + signed_manifest: dict, +) -> None: + """The caller may need the signed manifest for other purposes + after canonicalization (e.g., the signer writes it back to disk). + Stripping ``signature`` in place would be a surprising + side-effect; the function must leave the input dict unchanged.""" + before = dict(signed_manifest) + canonicalize(signed_manifest) + assert signed_manifest == before + + +def test_byte_identical_for_same_dict(signed_manifest: dict) -> None: + """The whole point of canonicalization: two calls on the same + input must yield the same bytes. If this fails, signatures + will randomly mismatch between sign and verify.""" + a = canonicalize(signed_manifest) + b = canonicalize(signed_manifest) + assert a == b + + +def test_key_ordering_is_deterministic() -> None: + """Dicts constructed in different key orders must canonicalize + identically. Python preserves insertion order; ``sort_keys=True`` + is what makes the output stable.""" + d1 = {"alpha": 1, "beta": 2, "gamma": 3} + d2 = {"gamma": 3, "alpha": 1, "beta": 2} + assert canonicalize(d1) == canonicalize(d2) + + +def test_nested_dict_keys_also_sorted() -> None: + """``sort_keys=True`` sorts at every nesting level, not just + the root. A nested dict with permuted keys must canonicalize + the same as the same dict with sorted keys.""" + d1 = {"outer": {"a": 1, "b": 2}} + d2 = {"outer": {"b": 2, "a": 1}} + assert canonicalize(d1) == canonicalize(d2) + + +def test_separators_are_compact() -> None: + """Per §2.2 ``separators=(",", ":")``. The default + ``json.dumps`` adds whitespace (``", "`` and ``": "``); the + canonical form must not, because whitespace is a hidden + variation between Python versions and serialization libraries.""" + d = {"a": 1, "b": 2} + result = canonicalize(d) + assert b", " not in result + assert b": " not in result + # Exact-shape sanity check. + assert result == b'{"a":1,"b":2}' + + +def test_non_ascii_preserved_not_escaped() -> None: + """Per §2.2 ``ensure_ascii=False``. The default ``json.dumps`` + escapes non-ASCII as ``\\u00e9``; that would make the canonical + output dependent on the source-code encoding (since YAML loaders + may return either form). Keeping ``ensure_ascii=False`` yields + raw UTF-8 bytes, which are stable.""" + d = {"description": "café"} + result = canonicalize(d) + assert "café".encode("utf-8") in result + assert b"\\u00e9" not in result + + +def test_empty_dict() -> None: + """Edge case: ``{}`` → ``b"{}"``. Not an error; an empty + manifest is a degenerate but valid input.""" + assert canonicalize({}) == b"{}" + + +def test_signature_field_only_stripped_at_top_level() -> None: + """Defensive: the strip happens at the top level. A nested + ``signature`` key under, say, ``metadata.signature`` is part of + the schema and must NOT be removed.""" + d = { + "name": "x", + "signature": "TOP_LEVEL_GONE", + "metadata": {"signature": "NESTED_KEPT"}, + } + result = canonicalize(d) + assert b"TOP_LEVEL_GONE" not in result + assert b"NESTED_KEPT" in result diff --git a/references/apigee-skills-serving/tests/test_check_demo_prerequisites.py b/references/apigee-skills-serving/tests/test_check_demo_prerequisites.py new file mode 100644 index 000000000..2d81037e7 --- /dev/null +++ b/references/apigee-skills-serving/tests/test_check_demo_prerequisites.py @@ -0,0 +1,327 @@ +"""Pre-flight env-var checker tests. + +``bin/check-prerequisites.sh`` is the operator's last-mile gate +before a live demo. It iterates the required environment variables +and emits one ``[prereq]`` log line per variable so the operator +can see exactly what passed, what failed, and what was advisory. + +Required behaviour (all locked by these tests): + +* If every operator-controlled required var is set AND ADC tokens + are retrievable, exit 0 and final line says ``PASS``. +* If any one of {``APIHUB_PROJECT``, ``APIHUB_LOCATION``, + ``APIGEE_ORG``} is unset or empty, that produces a + ``[prereq] FAILED`` line and exit 1. +* If ``APIGEE_SKILLS_MIN_KEYWORD_OVERLAP`` is set to a non-positive- + int string, the script emits a ``[prereq] WARNING`` advisory + line but does NOT fail; the runtime falls back to default 1 + silently. +* If ``APIGEE_SKILLS_MIN_KEYWORD_OVERLAP`` is unset, the script + emits no warning for that variable. +* If ADC tokens cannot be retrieved (mocked via fake ``gcloud`` + shim that exits non-zero), the script emits the ADC FAILED + line and exits 1 even if everything else is fine. +* Watcher overrides (``OPENCODE_EXPERIMENTAL_FILEWATCHER`` and + ``OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER``) and framework- + provided vars (``ARGUMENTS``, ``SKILL_DIR``, ``OPENCODE_AGENT``) + are advisory only -- their absence never fails the script. +""" +from __future__ import annotations + +import os +import stat +import subprocess +from pathlib import Path + +import pytest + +_REPO_ROOT = Path(__file__).resolve().parent.parent +_SCRIPT = _REPO_ROOT / "bin" / "check-prerequisites.sh" + +# Minimal env that the script's pre-flight considers "valid". +# Includes the three operator-required vars + a path-injectable +# stub gcloud shim. We intentionally do NOT set the watcher, +# framework, or optional vars: those are advisory by spec. +_BASE_OK_ENV = { + "APIHUB_PROJECT": "demo-project-123", + "APIHUB_LOCATION": "us-central1", + "APIGEE_ORG": "demo-apigee-org", + # Required for the script itself to find sh, awk, etc.: + "HOME": str(Path.home()), + "LANG": "C.UTF-8", +} + + +def _write_fake_gcloud( + tmp_path: Path, exit_code: int +) -> Path: + """Create an executable shim at ``tmp_path/bin/gcloud`` that + swallows all args and exits with *exit_code*. The directory + returned is what the test prepends to ``PATH`` for the + subprocess invocation -- the real ``gcloud`` (if any) never + runs. + """ + bindir = tmp_path / "bin" + bindir.mkdir(parents=True, exist_ok=True) + shim = bindir / "gcloud" + shim.write_text(f"#!/bin/sh\nexit {exit_code}\n") + shim.chmod( + shim.stat().st_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH + ) + return bindir + + +def _run(env: dict[str, str]) -> subprocess.CompletedProcess[str]: + """Invoke the prereq script with a hermetic env -- nothing + leaks in from the test runner's own environment.""" + assert _SCRIPT.is_file(), ( + f"missing pre-flight script {_SCRIPT}" + ) + # Belt-and-braces: the script may rely on a few non-listed + # PATH entries (/bin, /usr/bin) for awk/cut. The caller + # prepends a fake-gcloud dir to whatever PATH we pass. + return subprocess.run( + ["bash", str(_SCRIPT)], + env=env, + capture_output=True, + text=True, + check=False, + ) + + +# --------------------------------------------------------------- # +# Happy path +# --------------------------------------------------------------- # + +def test_all_required_set_and_adc_ok_exits_zero( + tmp_path: Path, +) -> None: + fake_bin = _write_fake_gcloud(tmp_path, 0) + env = dict(_BASE_OK_ENV) + env["PATH"] = f"{fake_bin}:/usr/bin:/bin" + result = _run(env) + assert result.returncode == 0, ( + f"expected exit 0 with all prereqs OK; " + f"got {result.returncode}\nstdout:\n{result.stdout}" + f"\nstderr:\n{result.stderr}" + ) + # Every required var should produce its own line. + for var in ("APIHUB_PROJECT", "APIHUB_LOCATION", "APIGEE_ORG"): + assert var in result.stdout, ( + f"missing [prereq] line for {var}" + ) + assert "[prereq] OK" in result.stdout, ( + "expected at least one explicit OK marker" + ) + assert "[prereq] PASS" in result.stdout, ( + "expected final PASS summary line" + ) + + +# --------------------------------------------------------------- # +# Required vars: unset / empty -> FAILED + exit 1 +# --------------------------------------------------------------- # + +@pytest.mark.parametrize( + "missing_var", + ["APIHUB_PROJECT", "APIHUB_LOCATION", "APIGEE_ORG"], +) +def test_required_var_unset_fails( + tmp_path: Path, missing_var: str +) -> None: + fake_bin = _write_fake_gcloud(tmp_path, 0) + env = dict(_BASE_OK_ENV) + env["PATH"] = f"{fake_bin}:/usr/bin:/bin" + env.pop(missing_var, None) + result = _run(env) + assert result.returncode == 1, ( + f"expected exit 1 when {missing_var} unset; " + f"got {result.returncode}\nstdout:\n{result.stdout}" + ) + assert "FAILED" in result.stdout, ( + f"expected a FAILED line when {missing_var} unset" + ) + assert missing_var in result.stdout, ( + f"FAILED line must name {missing_var} verbatim" + ) + + +@pytest.mark.parametrize( + "empty_var", + ["APIHUB_PROJECT", "APIHUB_LOCATION", "APIGEE_ORG"], +) +def test_required_var_empty_string_fails( + tmp_path: Path, empty_var: str +) -> None: + """An empty string is just as bad as unset: §2.9 contract + rejects both with the same FAILED line.""" + fake_bin = _write_fake_gcloud(tmp_path, 0) + env = dict(_BASE_OK_ENV) + env["PATH"] = f"{fake_bin}:/usr/bin:/bin" + env[empty_var] = "" + result = _run(env) + assert result.returncode == 1 + assert "FAILED" in result.stdout + assert empty_var in result.stdout + + +# --------------------------------------------------------------- # +# APIGEE_SKILLS_MIN_KEYWORD_OVERLAP (advisory) +# --------------------------------------------------------------- # + +def test_keyword_overlap_unset_no_warning(tmp_path: Path) -> None: + """Unset is fine -- runtime defaults to 1 silently. No + advisory line should mention it.""" + fake_bin = _write_fake_gcloud(tmp_path, 0) + env = dict(_BASE_OK_ENV) + env["PATH"] = f"{fake_bin}:/usr/bin:/bin" + env.pop("APIGEE_SKILLS_MIN_KEYWORD_OVERLAP", None) + result = _run(env) + assert result.returncode == 0 + assert "WARNING" not in result.stdout or ( + "APIGEE_SKILLS_MIN_KEYWORD_OVERLAP" not in + " ".join( + line for line in result.stdout.splitlines() + if "WARNING" in line + ) + ), ( + "unset APIGEE_SKILLS_MIN_KEYWORD_OVERLAP must not produce " + "a WARNING line" + ) + + +def test_keyword_overlap_malformed_warns_but_does_not_fail( + tmp_path: Path, +) -> None: + """Bad value -> advisory WARNING line, but exit still 0 + because the fallback is silent at runtime.""" + fake_bin = _write_fake_gcloud(tmp_path, 0) + env = dict(_BASE_OK_ENV) + env["PATH"] = f"{fake_bin}:/usr/bin:/bin" + env["APIGEE_SKILLS_MIN_KEYWORD_OVERLAP"] = "not-an-int" + result = _run(env) + assert result.returncode == 0, ( + f"malformed APIGEE_SKILLS_MIN_KEYWORD_OVERLAP must not " + f"fail the script; got exit {result.returncode}\n" + f"stdout:\n{result.stdout}" + ) + assert "WARNING" in result.stdout + assert "APIGEE_SKILLS_MIN_KEYWORD_OVERLAP" in result.stdout + assert "not-an-int" in result.stdout, ( + "WARNING line should echo the raw bad value" + ) + + +def test_keyword_overlap_zero_is_malformed(tmp_path: Path) -> None: + """0 is not a positive int: §2.9 says positive.""" + fake_bin = _write_fake_gcloud(tmp_path, 0) + env = dict(_BASE_OK_ENV) + env["PATH"] = f"{fake_bin}:/usr/bin:/bin" + env["APIGEE_SKILLS_MIN_KEYWORD_OVERLAP"] = "0" + result = _run(env) + assert result.returncode == 0 + assert "WARNING" in result.stdout + assert "APIGEE_SKILLS_MIN_KEYWORD_OVERLAP" in result.stdout + + +def test_keyword_overlap_positive_int_ok(tmp_path: Path) -> None: + fake_bin = _write_fake_gcloud(tmp_path, 0) + env = dict(_BASE_OK_ENV) + env["PATH"] = f"{fake_bin}:/usr/bin:/bin" + env["APIGEE_SKILLS_MIN_KEYWORD_OVERLAP"] = "2" + result = _run(env) + assert result.returncode == 0 + # Either no warning at all about this var, or only OK/INFO: + bad_lines = [ + line for line in result.stdout.splitlines() + if "WARNING" in line + and "APIGEE_SKILLS_MIN_KEYWORD_OVERLAP" in line + ] + assert not bad_lines, ( + f"positive-int value must not warn; got: {bad_lines}" + ) + + +# --------------------------------------------------------------- # +# ADC +# --------------------------------------------------------------- # + +def test_adc_unavailable_fails(tmp_path: Path) -> None: + """Fake gcloud exits 1 -> ADC token unobtainable -> FAILED.""" + fake_bin = _write_fake_gcloud(tmp_path, 1) + env = dict(_BASE_OK_ENV) + env["PATH"] = f"{fake_bin}:/usr/bin:/bin" + result = _run(env) + assert result.returncode == 1, ( + f"expected exit 1 when ADC unavailable; " + f"got {result.returncode}\nstdout:\n{result.stdout}" + ) + assert "ADC" in result.stdout + assert "FAILED" in result.stdout + # Operator-facing remediation hint should appear: + assert "gcloud auth application-default" in result.stdout.lower() \ + or "gcloud auth application-default" in result.stdout, ( + "ADC FAILED line should hint at the remediation command" + ) + + +# --------------------------------------------------------------- # +# Watcher + framework advisory vars +# --------------------------------------------------------------- # + +def test_watcher_overrides_absent_never_fail(tmp_path: Path) -> None: + """The two watcher override vars are optional; their absence + must never affect the exit code.""" + fake_bin = _write_fake_gcloud(tmp_path, 0) + env = dict(_BASE_OK_ENV) + env["PATH"] = f"{fake_bin}:/usr/bin:/bin" + env.pop("OPENCODE_EXPERIMENTAL_FILEWATCHER", None) + env.pop("OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER", None) + result = _run(env) + assert result.returncode == 0 + + +def test_framework_vars_absent_emit_info(tmp_path: Path) -> None: + """``ARGUMENTS`` and ``SKILL_DIR`` are set by OpenCode at + injection time. Pre-flight cannot verify them but should + report them so the operator knows they're known-deferred.""" + fake_bin = _write_fake_gcloud(tmp_path, 0) + env = dict(_BASE_OK_ENV) + env["PATH"] = f"{fake_bin}:/usr/bin:/bin" + env.pop("ARGUMENTS", None) + env.pop("SKILL_DIR", None) + result = _run(env) + assert result.returncode == 0 + assert "ARGUMENTS" in result.stdout + assert "SKILL_DIR" in result.stdout + # We don't lock the exact verb (INFO / NOTE / DEFERRED) -- the + # key invariant is "mentioned and didn't fail": + info_lines = [ + line for line in result.stdout.splitlines() + if "ARGUMENTS" in line or "SKILL_DIR" in line + ] + assert info_lines, "framework vars must produce visible lines" + assert not any("FAILED" in l for l in info_lines), ( + f"framework vars must not produce FAILED lines: {info_lines}" + ) + + +# --------------------------------------------------------------- # +# Idempotency / stable output +# --------------------------------------------------------------- # + +def test_output_is_stable_across_invocations( + tmp_path: Path, +) -> None: + """The script MUST be idempotent and produce stable output. + Two back-to-back invocations with identical env produce + identical stdout.""" + fake_bin = _write_fake_gcloud(tmp_path, 0) + env = dict(_BASE_OK_ENV) + env["PATH"] = f"{fake_bin}:/usr/bin:/bin" + a = _run(env).stdout + b = _run(env).stdout + assert a == b, ( + "pre-flight output must be stable across invocations; " + f"first:\n{a}\nsecond:\n{b}" + ) diff --git a/references/apigee-skills-serving/tests/test_config.py b/references/apigee-skills-serving/tests/test_config.py new file mode 100644 index 000000000..616970bd5 --- /dev/null +++ b/references/apigee-skills-serving/tests/test_config.py @@ -0,0 +1,403 @@ +"""Unit tests for scripts/common/config.py. + +The helper has 3 concerns we want to nail down: + 1. Resolution order (env beats file beats default). + 2. Parser tolerance (comments, blank lines, `export` prefix, + quoted values, unknown keys). + 3. `get_or_die` contract (calls die_fn with a message that + references BOTH env var AND config file path). + +Each test isolates the config-file path via the +APIGEE_SKILLS_CONFIG_FILE env override and clears the cache so +back-to-back tests don't poison each other. +""" +from __future__ import annotations + +import sys +from pathlib import Path + +import pytest + +# Make scripts/common importable as a package. +_REPO_ROOT = Path(__file__).resolve().parent.parent +sys.path.insert(0, str(_REPO_ROOT)) + +from scripts.common import config # noqa: E402 + + +@pytest.fixture(autouse=True) +def _clear_cache_and_env(monkeypatch): + """Every test starts with a clean cache + scrubbed env so + we test what the test sets, not what the host shell leaks.""" + config.clear_cache() + for k in ( + "APIHUB_PROJECT", + "APIHUB_LOCATION", + "APIGEE_ORG", + "APIGEE_SKILLS_MIN_KEYWORD_OVERLAP", + "APIGEE_SKILLS_CONFIG_FILE", + ): + monkeypatch.delenv(k, raising=False) + yield + config.clear_cache() + + +def _point_to_config(tmp_path: Path, contents: str, monkeypatch) -> Path: + """Write `contents` to tmp_path/config.env and point the + helper at it via APIGEE_SKILLS_CONFIG_FILE.""" + path = tmp_path / "config.env" + path.write_text(contents) + monkeypatch.setenv("APIGEE_SKILLS_CONFIG_FILE", str(path)) + config.clear_cache() + return path + + +# -------------------------------------------------------------- +# 1. Resolution order +# -------------------------------------------------------------- + + +class TestResolutionOrder: + + def test_env_beats_file(self, tmp_path, monkeypatch): + _point_to_config( + tmp_path, + "APIHUB_PROJECT=from-file\n", + monkeypatch, + ) + monkeypatch.setenv("APIHUB_PROJECT", "from-env") + assert config.get("APIHUB_PROJECT") == "from-env" + assert config.source("APIHUB_PROJECT") == ( + "from-env", "env" + ) + + def test_file_used_when_env_unset( + self, tmp_path, monkeypatch + ): + _point_to_config( + tmp_path, + "APIHUB_PROJECT=from-file\n", + monkeypatch, + ) + assert config.get("APIHUB_PROJECT") == "from-file" + assert config.source("APIHUB_PROJECT") == ( + "from-file", "config-file" + ) + + def test_file_used_when_env_is_whitespace( + self, tmp_path, monkeypatch + ): + """An env var set to a whitespace string is functionally + unset for our purposes. We strip and treat empty as + missing.""" + _point_to_config( + tmp_path, + "APIHUB_PROJECT=from-file\n", + monkeypatch, + ) + monkeypatch.setenv("APIHUB_PROJECT", " ") + assert config.get("APIHUB_PROJECT") == "from-file" + + def test_default_returned_when_nothing_set( + self, tmp_path, monkeypatch + ): + # Point at a non-existent file so the file path also + # contributes nothing. + nonexistent = tmp_path / "nope.env" + monkeypatch.setenv( + "APIGEE_SKILLS_CONFIG_FILE", str(nonexistent) + ) + config.clear_cache() + assert config.get("APIHUB_PROJECT", "fallback") == "fallback" + assert config.source("APIHUB_PROJECT") == ("", "missing") + + def test_empty_string_default_is_returned( + self, tmp_path, monkeypatch + ): + nonexistent = tmp_path / "nope.env" + monkeypatch.setenv( + "APIGEE_SKILLS_CONFIG_FILE", str(nonexistent) + ) + config.clear_cache() + assert config.get("APIHUB_PROJECT") == "" + + +# -------------------------------------------------------------- +# 2. Parser tolerance +# -------------------------------------------------------------- + + +class TestParser: + + def test_blank_lines_and_comments_ignored( + self, tmp_path, monkeypatch + ): + _point_to_config( + tmp_path, + ( + "# This is a comment line.\n" + "\n" + " # indented comment\n" + "APIHUB_PROJECT=p\n" + "\n" + "APIHUB_LOCATION=l\n" + ), + monkeypatch, + ) + assert config.get("APIHUB_PROJECT") == "p" + assert config.get("APIHUB_LOCATION") == "l" + + def test_export_prefix_tolerated(self, tmp_path, monkeypatch): + """Operators may copy-paste the demo-setup `export` + block straight into the file. That should work without + making them edit out the `export` keyword.""" + _point_to_config( + tmp_path, + ( + "export APIHUB_PROJECT=p\n" + " export APIHUB_LOCATION=l\n" + ), + monkeypatch, + ) + assert config.get("APIHUB_PROJECT") == "p" + assert config.get("APIHUB_LOCATION") == "l" + + def test_double_quoted_value_stripped( + self, tmp_path, monkeypatch + ): + _point_to_config( + tmp_path, + 'APIHUB_PROJECT="quoted-value"\n', + monkeypatch, + ) + assert config.get("APIHUB_PROJECT") == "quoted-value" + + def test_single_quoted_value_stripped( + self, tmp_path, monkeypatch + ): + _point_to_config( + tmp_path, + "APIHUB_PROJECT='quoted-value'\n", + monkeypatch, + ) + assert config.get("APIHUB_PROJECT") == "quoted-value" + + def test_unknown_keys_dropped_silently( + self, tmp_path, monkeypatch + ): + _point_to_config( + tmp_path, + ( + "RANDOM_GARBAGE=ignored\n" + "APIHUB_PROJECT=p\n" + "ALSO_GARBAGE=ignored\n" + ), + monkeypatch, + ) + assert config.get("APIHUB_PROJECT") == "p" + # Unknown keys are not exposed by any helper. + + def test_malformed_lines_skipped( + self, tmp_path, monkeypatch + ): + _point_to_config( + tmp_path, + ( + "this is not a valid line\n" + "APIHUB_PROJECT=p\n" + "=value-with-no-key\n" + ), + monkeypatch, + ) + assert config.get("APIHUB_PROJECT") == "p" + + def test_missing_file_yields_empty( + self, tmp_path, monkeypatch + ): + nonexistent = tmp_path / "nope.env" + monkeypatch.setenv( + "APIGEE_SKILLS_CONFIG_FILE", str(nonexistent) + ) + config.clear_cache() + assert config.get("APIHUB_PROJECT") == "" + + def test_unreadable_file_yields_empty( + self, tmp_path, monkeypatch + ): + path = tmp_path / "config.env" + path.write_text("APIHUB_PROJECT=p\n") + path.chmod(0o000) + monkeypatch.setenv( + "APIGEE_SKILLS_CONFIG_FILE", str(path) + ) + config.clear_cache() + try: + # Either the read fails (returns "") or, on some + # filesystems, root-equivalent access is allowed. + # Both are acceptable as long as nothing raises. + v = config.get("APIHUB_PROJECT", "fallback") + assert v in ("p", "fallback", "") + finally: + # Restore so pytest can clean up. + path.chmod(0o600) + + +# -------------------------------------------------------------- +# 3. get_or_die contract +# -------------------------------------------------------------- + + +class TestGetOrDie: + + def test_returns_value_when_present( + self, tmp_path, monkeypatch + ): + _point_to_config( + tmp_path, + "APIHUB_PROJECT=p\n", + monkeypatch, + ) + called: list[str] = [] + + def fake_die(msg): + called.append(msg) + raise SystemExit(2) + + v = config.get_or_die("APIHUB_PROJECT", die_fn=fake_die) + assert v == "p" + assert called == [] # die_fn was not invoked + + def test_calls_die_with_helpful_message( + self, tmp_path, monkeypatch + ): + nonexistent = tmp_path / "nope.env" + monkeypatch.setenv( + "APIGEE_SKILLS_CONFIG_FILE", str(nonexistent) + ) + config.clear_cache() + captured: list[str] = [] + + def fake_die(msg): + captured.append(msg) + raise SystemExit(2) + + with pytest.raises(SystemExit) as exc: + config.get_or_die("APIHUB_PROJECT", die_fn=fake_die) + assert exc.value.code == 2 + assert len(captured) == 1 + msg = captured[0] + # The message MUST mention both the env var and the + # config file path so the operator can fix either route. + assert "APIHUB_PROJECT" in msg + assert "export" in msg + assert str(nonexistent) in msg + assert "demo-setup.sh" in msg + assert msg.startswith("config: FAILED") + + +# -------------------------------------------------------------- +# 4. Convenience getters +# -------------------------------------------------------------- + + +class TestConvenienceGetters: + + def test_apihub_project_reads_env(self, monkeypatch): + monkeypatch.setenv("APIHUB_PROJECT", "p") + assert config.apihub_project() == "p" + + def test_keyword_overlap_default(self, tmp_path, monkeypatch): + nonexistent = tmp_path / "nope.env" + monkeypatch.setenv( + "APIGEE_SKILLS_CONFIG_FILE", str(nonexistent) + ) + config.clear_cache() + assert config.keyword_overlap_threshold() == 1 + + def test_keyword_overlap_explicit_via_env( + self, monkeypatch + ): + monkeypatch.setenv( + "APIGEE_SKILLS_MIN_KEYWORD_OVERLAP", "3" + ) + assert config.keyword_overlap_threshold() == 3 + + def test_keyword_overlap_explicit_via_file( + self, tmp_path, monkeypatch + ): + _point_to_config( + tmp_path, + "APIGEE_SKILLS_MIN_KEYWORD_OVERLAP=2\n", + monkeypatch, + ) + assert config.keyword_overlap_threshold() == 2 + + def test_keyword_overlap_malformed_falls_back( + self, monkeypatch + ): + monkeypatch.setenv( + "APIGEE_SKILLS_MIN_KEYWORD_OVERLAP", "not-a-number" + ) + assert config.keyword_overlap_threshold(default=5) == 5 + + def test_keyword_overlap_negative_falls_back( + self, monkeypatch + ): + monkeypatch.setenv( + "APIGEE_SKILLS_MIN_KEYWORD_OVERLAP", "-1" + ) + assert config.keyword_overlap_threshold() == 1 + + +# -------------------------------------------------------------- +# 5. Cache behavior +# -------------------------------------------------------------- + + +class TestCacheBehavior: + + def test_repeated_reads_hit_cache( + self, tmp_path, monkeypatch + ): + path = _point_to_config( + tmp_path, + "APIHUB_PROJECT=p\n", + monkeypatch, + ) + assert config.get("APIHUB_PROJECT") == "p" + # Mutate file; the cache should hold. + path.write_text("APIHUB_PROJECT=changed\n") + assert config.get("APIHUB_PROJECT") == "p" + + def test_clear_cache_picks_up_changes( + self, tmp_path, monkeypatch + ): + path = _point_to_config( + tmp_path, + "APIHUB_PROJECT=p\n", + monkeypatch, + ) + assert config.get("APIHUB_PROJECT") == "p" + path.write_text("APIHUB_PROJECT=changed\n") + config.clear_cache() + assert config.get("APIHUB_PROJECT") == "changed" + + def test_override_swap_invalidates_cache( + self, tmp_path, monkeypatch + ): + """Pointing APIGEE_SKILLS_CONFIG_FILE at a different + file should give that file's values, not the cached + prior file's.""" + path1 = tmp_path / "a.env" + path1.write_text("APIHUB_PROJECT=from-a\n") + path2 = tmp_path / "b.env" + path2.write_text("APIHUB_PROJECT=from-b\n") + + monkeypatch.setenv("APIGEE_SKILLS_CONFIG_FILE", str(path1)) + config.clear_cache() + assert config.get("APIHUB_PROJECT") == "from-a" + + # Swap to b; should pick up b without manual clear + # because the cache key includes the path. + monkeypatch.setenv("APIGEE_SKILLS_CONFIG_FILE", str(path2)) + assert config.get("APIHUB_PROJECT") == "from-b" diff --git a/references/apigee-skills-serving/tests/test_http_retry.py b/references/apigee-skills-serving/tests/test_http_retry.py new file mode 100644 index 000000000..45b77058c --- /dev/null +++ b/references/apigee-skills-serving/tests/test_http_retry.py @@ -0,0 +1,395 @@ +"""Tests for ``scripts/common/http_retry.py``. + +The helper: + +* Retries exactly once on 5xx with a jittered 200-400 ms backoff. +* Invokes an optional ``on_retry`` callback with + ``(status_code, sleep_seconds)`` BEFORE the retry attempt so the + caller can log retries however it wants. The library itself + emits NOTHING to stdout -- the transient-failure contract line + is owned by the caller. +* Does NOT retry on 4xx -- 4xx responses raise immediately. +* On 2xx, returns the ``requests.Response`` directly. + +The POST variant sits alongside the original GET; both share the +backoff/retry/jitter logic and differ only in HTTP verb. + +All ``requests`` and ``time.sleep`` calls are stubbed; no test +makes a real HTTP call. +""" +from __future__ import annotations + +from typing import Any +from unittest import mock + +import pytest +import requests + +from scripts.common import http_retry + + +# ----- Test helpers ----- + + +class _FakeResponse: + """Minimal ``requests.Response`` stand-in. + + Real ``requests.Response`` is heavy to construct; the helper + only needs ``status_code``, ``reason``, ``raise_for_status``, + and (for the IAM caller) ``json()``. ``raise_for_status`` + must raise ``requests.HTTPError`` on 4xx/5xx, matching the + real library's contract. + """ + + def __init__( + self, + status_code: int, + json_data: Any = None, + reason: str = "", + ) -> None: + self.status_code = status_code + self.reason = reason + self._json_data = json_data + + def raise_for_status(self) -> None: + if 400 <= self.status_code < 600: + err = requests.HTTPError( + f"{self.status_code} {self.reason}" + ) + err.response = self # type: ignore[attr-defined] + raise err + + def json(self) -> Any: + return self._json_data + + +@pytest.fixture +def captured_sleeps(monkeypatch: pytest.MonkeyPatch) -> list[float]: + """Replace ``time.sleep`` in the module under test with a + capture list so tests can assert on jitter bounds without + actually sleeping.""" + calls: list[float] = [] + monkeypatch.setattr( + http_retry.time, "sleep", lambda s: calls.append(s) + ) + return calls + + +# ----- GET: success on first try ----- + + +def test_get_success_first_try_returns_response( + captured_sleeps: list[float], + capsys: pytest.CaptureFixture[str], +) -> None: + """First-try success: response returned, no sleep, no stdout, + and on_retry callback is NOT invoked (no retry happened).""" + ok = _FakeResponse(200, json_data={"x": 1}) + retry_calls: list[tuple[int, float]] = [] + with mock.patch.object( + http_retry.requests, "get", return_value=ok + ) as mocked_get: + resp = http_retry.http_get_retry( + "https://example/foo", + on_retry=lambda code, sleep: retry_calls.append( + (code, sleep) + ), + ) + assert resp is ok + mocked_get.assert_called_once_with("https://example/foo") + assert captured_sleeps == [] # No backoff on success. + assert capsys.readouterr().out == "" # Library is silent. + assert retry_calls == [] # Callback not invoked on success. + + +# ----- GET: 5xx then 2xx ----- + + +def test_get_retries_once_on_5xx_then_succeeds( + captured_sleeps: list[float], + capsys: pytest.CaptureFixture[str], +) -> None: + """503 then 200: response returned from second call, one + sleep recorded, on_retry callback invoked exactly once with + (503, sleep_seconds) where 0.2 <= sleep_seconds <= 0.4.""" + bad = _FakeResponse(503, reason="Service Unavailable") + ok = _FakeResponse(200, json_data={"ok": True}) + retry_calls: list[tuple[int, float]] = [] + with mock.patch.object( + http_retry.requests, "get", side_effect=[bad, ok] + ) as mocked_get: + resp = http_retry.http_get_retry( + "https://example/foo", + on_retry=lambda code, sleep: retry_calls.append( + (code, sleep) + ), + ) + assert resp is ok + assert mocked_get.call_count == 2 + assert len(captured_sleeps) == 1 + assert 0.2 <= captured_sleeps[0] <= 0.4 + # Library MUST NOT print anything; the caller owns the §2.8 + # transient-failure contract line. + assert capsys.readouterr().out == "" + # on_retry invoked exactly once with the 5xx status and the + # same sleep value that was passed to time.sleep. + assert len(retry_calls) == 1 + assert retry_calls[0][0] == 503 + assert retry_calls[0][1] == captured_sleeps[0] + assert 0.2 <= retry_calls[0][1] <= 0.4 + + +def test_get_retry_callback_not_invoked_when_none( + captured_sleeps: list[float], + capsys: pytest.CaptureFixture[str], +) -> None: + """on_retry defaults to None; retry still happens silently + and the library still produces no stdout.""" + bad = _FakeResponse(503, reason="Service Unavailable") + ok = _FakeResponse(200, json_data={"ok": True}) + with mock.patch.object( + http_retry.requests, "get", side_effect=[bad, ok] + ) as mocked_get: + resp = http_retry.http_get_retry("https://example/foo") + assert resp is ok + assert mocked_get.call_count == 2 + assert capsys.readouterr().out == "" + + +# ----- GET: 5xx then 5xx -> raise after one retry ----- + + +def test_get_5xx_then_5xx_raises_after_single_retry( + captured_sleeps: list[float], + capsys: pytest.CaptureFixture[str], +) -> None: + """Two consecutive 5xx: HTTPError raised after one retry; + on_retry invoked exactly once for the FIRST 5xx (the retry + boundary), not again for the terminal 5xx.""" + bad1 = _FakeResponse(500, reason="Internal Server Error") + bad2 = _FakeResponse(502, reason="Bad Gateway") + retry_calls: list[tuple[int, float]] = [] + with mock.patch.object( + http_retry.requests, "get", side_effect=[bad1, bad2] + ) as mocked_get: + with pytest.raises(requests.HTTPError): + http_retry.http_get_retry( + "https://example/foo", + on_retry=lambda code, sleep: retry_calls.append( + (code, sleep) + ), + ) + # Exactly two attempts: original + one retry. + assert mocked_get.call_count == 2 + assert len(captured_sleeps) == 1 + assert capsys.readouterr().out == "" + # Callback fired once for the first 5xx (status=500). + assert len(retry_calls) == 1 + assert retry_calls[0][0] == 500 + + +# ----- GET: 4xx -> raise immediately, no retry, no callback ----- + + +def test_get_4xx_raises_immediately_no_retry( + captured_sleeps: list[float], + capsys: pytest.CaptureFixture[str], +) -> None: + """4xx is terminal: no retry, no sleep, no callback, no + stdout. The caller's responsibility to surface the error.""" + bad = _FakeResponse(404, reason="Not Found") + retry_calls: list[tuple[int, float]] = [] + with mock.patch.object( + http_retry.requests, "get", side_effect=[bad] + ) as mocked_get: + with pytest.raises(requests.HTTPError): + http_retry.http_get_retry( + "https://example/foo", + on_retry=lambda code, sleep: retry_calls.append( + (code, sleep) + ), + ) + mocked_get.assert_called_once() + assert captured_sleeps == [] + assert capsys.readouterr().out == "" + assert retry_calls == [] # No retry happened, no callback. + + +# ----- POST: success on first try ----- + + +def test_post_success_first_try_returns_response( + captured_sleeps: list[float], + capsys: pytest.CaptureFixture[str], +) -> None: + ok = _FakeResponse(200, json_data={"y": 2}) + retry_calls: list[tuple[int, float]] = [] + with mock.patch.object( + http_retry.requests, "post", return_value=ok + ) as mocked_post: + resp = http_retry.http_post_retry( + "https://example/bar", + json={"a": 1}, + on_retry=lambda code, sleep: retry_calls.append( + (code, sleep) + ), + ) + assert resp is ok + mocked_post.assert_called_once_with( + "https://example/bar", json={"a": 1} + ) + assert captured_sleeps == [] + assert capsys.readouterr().out == "" + assert retry_calls == [] + + +# ----- POST: 5xx then 2xx ----- + + +def test_post_retries_once_on_5xx_then_succeeds( + captured_sleeps: list[float], + capsys: pytest.CaptureFixture[str], +) -> None: + bad = _FakeResponse(503, reason="Service Unavailable") + ok = _FakeResponse(200, json_data={"ok": True}) + retry_calls: list[tuple[int, float]] = [] + with mock.patch.object( + http_retry.requests, "post", side_effect=[bad, ok] + ) as mocked_post: + resp = http_retry.http_post_retry( + "https://example/bar", + json={"k": "v"}, + on_retry=lambda code, sleep: retry_calls.append( + (code, sleep) + ), + ) + assert resp is ok + assert mocked_post.call_count == 2 + assert len(captured_sleeps) == 1 + assert 0.2 <= captured_sleeps[0] <= 0.4 + assert capsys.readouterr().out == "" + assert len(retry_calls) == 1 + assert retry_calls[0][0] == 503 + assert retry_calls[0][1] == captured_sleeps[0] + + +# ----- POST: 5xx then 5xx -> raise ----- + + +def test_post_5xx_then_5xx_raises_after_single_retry( + captured_sleeps: list[float], + capsys: pytest.CaptureFixture[str], +) -> None: + bad1 = _FakeResponse(500, reason="Internal Server Error") + bad2 = _FakeResponse(504, reason="Gateway Timeout") + retry_calls: list[tuple[int, float]] = [] + with mock.patch.object( + http_retry.requests, "post", side_effect=[bad1, bad2] + ) as mocked_post: + with pytest.raises(requests.HTTPError): + http_retry.http_post_retry( + "https://example/bar", + json={}, + on_retry=lambda code, sleep: retry_calls.append( + (code, sleep) + ), + ) + assert mocked_post.call_count == 2 + assert len(captured_sleeps) == 1 + assert len(retry_calls) == 1 + assert retry_calls[0][0] == 500 + + +# ----- POST: 4xx -> raise immediately, no callback ----- + + +def test_post_4xx_raises_immediately_no_retry( + captured_sleeps: list[float], + capsys: pytest.CaptureFixture[str], +) -> None: + bad = _FakeResponse(403, reason="Forbidden") + retry_calls: list[tuple[int, float]] = [] + with mock.patch.object( + http_retry.requests, "post", side_effect=[bad] + ) as mocked_post: + with pytest.raises(requests.HTTPError): + http_retry.http_post_retry( + "https://example/bar", + json={}, + on_retry=lambda code, sleep: retry_calls.append( + (code, sleep) + ), + ) + mocked_post.assert_called_once() + assert captured_sleeps == [] + assert capsys.readouterr().out == "" + assert retry_calls == [] + + +# ----- Jitter: many runs all land in [0.2, 0.4] ----- + + +def test_jitter_always_within_200_400_ms( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Sample many retries; every sleep must land in + ``[0.2, 0.4]`` seconds. Asserts the jitter bound, not any + specific value (the implementation uses + ``random.uniform(0.2, 0.4)`` which is closed on both ends).""" + sleeps: list[float] = [] + monkeypatch.setattr( + http_retry.time, "sleep", lambda s: sleeps.append(s) + ) + for _ in range(50): + bad = _FakeResponse(503, reason="Service Unavailable") + ok = _FakeResponse(200) + with mock.patch.object( + http_retry.requests, "get", side_effect=[bad, ok] + ): + http_retry.http_get_retry("https://example/x") + assert len(sleeps) == 50 + for s in sleeps: + assert 0.2 <= s <= 0.4, f"sleep {s} outside [0.2, 0.4]" + + +# ----- Header / kwarg pass-through (used by IAM caller) ----- + + +def test_get_passes_kwargs_through( + captured_sleeps: list[float], +) -> None: + ok = _FakeResponse(200) + with mock.patch.object( + http_retry.requests, "get", return_value=ok + ) as mocked_get: + http_retry.http_get_retry( + "https://example/foo", + headers={"Authorization": "Bearer x"}, + timeout=5, + ) + mocked_get.assert_called_once_with( + "https://example/foo", + headers={"Authorization": "Bearer x"}, + timeout=5, + ) + + +def test_post_passes_kwargs_through( + captured_sleeps: list[float], +) -> None: + ok = _FakeResponse(200) + with mock.patch.object( + http_retry.requests, "post", return_value=ok + ) as mocked_post: + http_retry.http_post_retry( + "https://example/bar", + json={"q": 1}, + headers={"Authorization": "Bearer x"}, + timeout=5, + ) + mocked_post.assert_called_once_with( + "https://example/bar", + json={"q": 1}, + headers={"Authorization": "Bearer x"}, + timeout=5, + ) diff --git a/references/apigee-skills-serving/tests/test_iam_preflight.py b/references/apigee-skills-serving/tests/test_iam_preflight.py new file mode 100644 index 000000000..da93e8a7a --- /dev/null +++ b/references/apigee-skills-serving/tests/test_iam_preflight.py @@ -0,0 +1,482 @@ +"""Tests for ``scripts/common/iam_preflight.py``. + +The pre-flight library: + +* Posts the dot-form ``runtime_iam`` permissions verbatim to + Apigee/Cloud ``testIamPermissions`` (no slash-form translation). +* Reads the returned ``permissions[]`` list -- any input + permission not echoed back is considered NOT granted. +* On empty input, skips the call entirely. +* Hardening: routes the POST through ``http_post_retry`` (1 + retry on 5xx); uses a centralized ``_creds()`` helper with + uniform ``cloud-platform`` scope; calls ``raise_for_status()`` + and catches ``json.JSONDecodeError`` BEFORE extracting + ``permissions[]``. + +This module is a PURE API: it emits NOTHING to stdout. The +contract lines (``[apigee-skills] IAM pre-flight: ...``) are +owned by the loader, which reads the returned +``IamPreflightResult`` and formats the contract line. Tests +therefore assert on the dataclass fields, not on captured stdout. + +All ``requests``, ``google.auth.default``, and ``time.sleep`` +calls are stubbed; no test hits the network. +""" +from __future__ import annotations + +import json +from typing import Any +from unittest import mock + +import pytest +import requests + +from scripts.common import http_retry, iam_preflight + + +# ----- Fakes ----- + + +class _FakeResponse: + """Minimal stand-in for ``requests.Response``. + + Mirrors the helper used in ``test_http_retry.py`` so the two + test files exercise the same interface. ``raise_for_status`` + raises ``requests.HTTPError`` on 4xx/5xx; ``json()`` returns + the configured payload or raises ``json.JSONDecodeError`` if + constructed with ``json_decode_error=True``. + """ + + def __init__( + self, + status_code: int, + json_data: Any = None, + reason: str = "", + json_decode_error: bool = False, + ) -> None: + self.status_code = status_code + self.reason = reason + self._json_data = json_data + self._json_decode_error = json_decode_error + + def raise_for_status(self) -> None: + if 400 <= self.status_code < 600: + err = requests.HTTPError( + f"{self.status_code} {self.reason}" + ) + err.response = self # type: ignore[attr-defined] + raise err + + def json(self) -> Any: + if self._json_decode_error: + raise json.JSONDecodeError("expecting value", "", 0) + return self._json_data + + +class _FakeCreds: + """Stand-in for ``google.auth.credentials.Credentials``. + + The pre-flight library never reads token values in tests -- + only that the object exposes a ``token`` attribute the + Authorization header can be built from. ``refresh`` is a + no-op so the centralized ``_creds()`` helper can call it + without exploding. + """ + + def __init__(self, token: str = "fake-token") -> None: + self.token = token + + def refresh(self, _request: Any) -> None: # pragma: no cover + return None + + +@pytest.fixture +def patched_auth(monkeypatch: pytest.MonkeyPatch) -> mock.MagicMock: + """Replace ``google.auth.default`` in ``iam_preflight`` with a + mock that returns ``(_FakeCreds, "test-project")``. Tests + that need to assert on the scopes passed to it can read + ``call_args`` from the returned mock.""" + fake = mock.MagicMock( + return_value=(_FakeCreds(), "test-project") + ) + monkeypatch.setattr(iam_preflight.google_auth, "default", fake) + return fake + + +@pytest.fixture +def no_sleep(monkeypatch: pytest.MonkeyPatch) -> None: + """Stub ``time.sleep`` inside the shared retry helper so the + retry tests in this file never actually pause.""" + monkeypatch.setattr(http_retry.time, "sleep", lambda _s: None) + + +# ----- Empty runtime_iam -> skip path ----- + + +def test_empty_runtime_iam_returns_skipped_result( + patched_auth: mock.MagicMock, + capsys: pytest.CaptureFixture[str], +) -> None: + """Empty input: status=SKIPPED, no network call, no auth + lookup, and -- because this is a pure API -- no stdout.""" + with mock.patch.object( + http_retry.requests, "post" + ) as mocked_post: + result = iam_preflight.iam_preflight( + project="proj-1", runtime_iam=[] + ) + assert result.status == "SKIPPED" + assert result.requested == () + assert result.granted == () + assert result.missing == () + # Skip path MUST NOT call testIamPermissions or even ask for + # credentials -- there is nothing to validate. + mocked_post.assert_not_called() + patched_auth.assert_not_called() + # Pure API: no stdout, ever. + assert capsys.readouterr().out == "" + + +# ----- All perms granted -> GRANTED ----- + + +def test_all_perms_granted_returns_granted_status( + patched_auth: mock.MagicMock, + no_sleep: None, + capsys: pytest.CaptureFixture[str], +) -> None: + """All permissions echoed back: status=GRANTED, granted is + the full input in INPUT ORDER, missing is empty. + + Input order is preserved in the dataclass; if the caller + wants a sorted contract line it can sort the tuple itself. + """ + perms = [ + "apigee.proxies.list", + "apigee.deployments.list", + "apigee.proxyrevisions.get", + ] + granted = list(perms) # All granted. + ok = _FakeResponse(200, json_data={"permissions": granted}) + with mock.patch.object( + http_retry.requests, "post", return_value=ok + ) as mocked_post: + result = iam_preflight.iam_preflight( + project="proj-1", runtime_iam=perms + ) + assert result.status == "GRANTED" + assert result.requested == tuple(perms) + assert result.granted == tuple(perms) # Input order preserved. + assert result.missing == () + assert result.http_status is None + assert result.http_reason is None + assert result.error_class is None + # Exactly one POST -- no retry, no extra call. + assert mocked_post.call_count == 1 + # Pure API. + assert capsys.readouterr().out == "" + + +# ----- Some perms missing -> DENIED, input-order missing list ----- + + +def test_partial_grant_returns_denied_with_input_order_missing( + patched_auth: mock.MagicMock, + no_sleep: None, +) -> None: + """One of three granted: status=DENIED. ``granted`` lists + the subset in input order; ``missing`` is the input-order + set difference.""" + requested = [ + "apigee.proxies.list", + "apigee.deployments.list", + "apigee.proxyrevisions.get", + ] + granted_back = ["apigee.proxies.list"] # The other two missing. + ok = _FakeResponse(200, json_data={"permissions": granted_back}) + with mock.patch.object( + http_retry.requests, "post", return_value=ok + ): + result = iam_preflight.iam_preflight( + project="proj-1", runtime_iam=requested + ) + assert result.status == "DENIED" + assert result.requested == tuple(requested) + assert result.granted == ("apigee.proxies.list",) + # Missing list MUST preserve input order so the caller can + # surface it directly to the operator. + assert result.missing == ( + "apigee.deployments.list", + "apigee.proxyrevisions.get", + ) + + +def test_zero_perms_granted_reports_all_as_missing_in_input_order( + patched_auth: mock.MagicMock, + no_sleep: None, +) -> None: + """testIamPermissions returning an empty list (or omitting + the key entirely) means every input permission is missing. + Input order is preserved verbatim.""" + requested = ["b.read", "a.write", "c.list"] + ok = _FakeResponse(200, json_data={"permissions": []}) + with mock.patch.object( + http_retry.requests, "post", return_value=ok + ): + result = iam_preflight.iam_preflight( + project="proj-1", runtime_iam=requested + ) + assert result.status == "DENIED" + assert result.requested == tuple(requested) + assert result.granted == () + assert result.missing == tuple(requested) # Input order. + + +def test_403_response_treated_as_denied_all_missing( + patched_auth: mock.MagicMock, + no_sleep: None, +) -> None: + """testIamPermissions returning 403 means the caller has no + relevant perms (lacks the broader role to even call + testIamPermissions). Map to status=DENIED with the full + input list reported missing -- the user's situation is the + same as if they had been granted zero perms.""" + requested = ["apigee.proxies.list", "apigee.deployments.list"] + forbidden = _FakeResponse(403, reason="Forbidden", json_data={}) + with mock.patch.object( + http_retry.requests, "post", return_value=forbidden + ): + result = iam_preflight.iam_preflight( + project="proj-1", runtime_iam=requested + ) + assert result.status == "DENIED" + assert result.requested == tuple(requested) + assert result.granted == () + assert result.missing == tuple(requested) + + +def test_404_response_treated_as_denied_all_missing( + patched_auth: mock.MagicMock, + no_sleep: None, +) -> None: + """Same semantics as 403: 404 from testIamPermissions maps + to DENIED with the full input list reported missing.""" + requested = ["apigee.proxies.list"] + notfound = _FakeResponse(404, reason="Not Found", json_data={}) + with mock.patch.object( + http_retry.requests, "post", return_value=notfound + ): + result = iam_preflight.iam_preflight( + project="proj-1", runtime_iam=requested + ) + assert result.status == "DENIED" + assert result.requested == tuple(requested) + assert result.granted == () + assert result.missing == tuple(requested) + + +# ----- Dot-form pass-through (no slash-form translation) ----- + + +def test_dot_form_permissions_posted_verbatim( + patched_auth: mock.MagicMock, + no_sleep: None, +) -> None: + """The POST body MUST contain the EXACT dot-form strings, + not slash-form translations. Verified by inspecting the + ``json=`` kwarg passed to ``requests.post``.""" + requested = [ + "apigee.proxies.list", + "apigee.deployments.list", + "secretmanager.versions.access", + ] + ok = _FakeResponse(200, json_data={"permissions": requested}) + with mock.patch.object( + http_retry.requests, "post", return_value=ok + ) as mocked_post: + iam_preflight.iam_preflight( + project="proj-1", runtime_iam=requested + ) + # Single call; inspect the json= kwarg. + assert mocked_post.call_count == 1 + _, kwargs = mocked_post.call_args + body = kwargs.get("json") + assert body is not None, "POST body must be JSON-encoded" + posted_perms = body.get("permissions") + assert posted_perms == requested # Verbatim, in order. + # Defensive: no slash-form leaked anywhere in the POSTed body. + for p in posted_perms: + assert "/" not in p + assert "googleapis.com" not in p + + +# ----- 5xx retry via http_post_retry ----- + + +def test_5xx_triggers_retry_via_http_post_retry( + patched_auth: mock.MagicMock, + no_sleep: None, + capsys: pytest.CaptureFixture[str], +) -> None: + """503 then 200: exactly two POST calls (original + 1 retry). + Result is GRANTED. Library still emits nothing to stdout -- + the transient line belongs to the caller.""" + requested = ["apigee.proxies.list"] + bad = _FakeResponse(503, reason="Service Unavailable") + ok = _FakeResponse(200, json_data={"permissions": requested}) + with mock.patch.object( + http_retry.requests, "post", side_effect=[bad, ok] + ) as mocked_post: + result = iam_preflight.iam_preflight( + project="proj-1", runtime_iam=requested + ) + assert result.status == "GRANTED" + assert mocked_post.call_count == 2 + # Pure API: no stdout from the library on retry either. + assert capsys.readouterr().out == "" + + +def test_persistent_5xx_returns_http_error_status( + patched_auth: mock.MagicMock, + no_sleep: None, +) -> None: + """Two consecutive 5xx: result.status == 'HTTP_ERROR' with + http_status/http_reason populated. The library DOES NOT + raise -- the caller reads the result and prints the + 'HTTP ' contract line.""" + requested = ["apigee.proxies.list"] + bad1 = _FakeResponse(500, reason="Internal Server Error") + bad2 = _FakeResponse(502, reason="Bad Gateway") + with mock.patch.object( + http_retry.requests, "post", side_effect=[bad1, bad2] + ) as mocked_post: + result = iam_preflight.iam_preflight( + project="proj-1", runtime_iam=requested + ) + assert mocked_post.call_count == 2 + assert result.status == "HTTP_ERROR" + assert result.requested == tuple(requested) + assert result.granted == () + assert result.missing == () + # The terminal status is what raise_for_status raised on: + # the SECOND response (502). That matches the contract -- + # 'HTTP ' refers to the response actually received. + assert result.http_status == 502 + assert result.http_reason == "Bad Gateway" + assert result.error_class is None + + +def test_500_then_503_returns_http_error_with_terminal_status( + patched_auth: mock.MagicMock, + no_sleep: None, +) -> None: + """Locks the property that the HTTP_ERROR result reports the + TERMINAL HTTP status (the one raise_for_status raised on), + not the initial one. Defensive sibling to the previous test.""" + requested = ["x.y"] + bad = _FakeResponse( + 500, reason="Internal Server Error", json_data={"x": 1} + ) + bad2 = _FakeResponse( + 503, reason="Service Unavailable", json_data={"x": 2} + ) + with mock.patch.object( + http_retry.requests, "post", side_effect=[bad, bad2] + ): + result = iam_preflight.iam_preflight( + project="proj-1", runtime_iam=requested + ) + assert result.status == "HTTP_ERROR" + assert result.http_status == 503 + assert result.http_reason == "Service Unavailable" + + +# ----- JSON decode failure -> NON_JSON status ----- + + +def test_non_json_body_returns_non_json_status( + patched_auth: mock.MagicMock, + no_sleep: None, +) -> None: + """An HTML proxy error page returned with a 200 status maps + to status=NON_JSON with error_class='JSONDecodeError'. The + library DOES NOT raise -- the caller reads the result and + prints the 'non-JSON body' contract line.""" + requested = ["apigee.proxies.list"] + html_resp = _FakeResponse( + 200, reason="OK", json_decode_error=True + ) + with mock.patch.object( + http_retry.requests, "post", return_value=html_resp + ): + result = iam_preflight.iam_preflight( + project="proj-1", runtime_iam=requested + ) + assert result.status == "NON_JSON" + assert result.requested == tuple(requested) + assert result.granted == () + assert result.missing == () + assert result.error_class == "JSONDecodeError" + + +# ----- Uniform cloud-platform scope ----- + + +def test_creds_helper_requests_cloud_platform_scope( + patched_auth: mock.MagicMock, + no_sleep: None, +) -> None: + """The centralized ``_creds()`` helper MUST call + ``google.auth.default(scopes=[...cloud-platform])`` so every + HTTP path uses the same auth scope.""" + requested = ["apigee.proxies.list"] + ok = _FakeResponse(200, json_data={"permissions": requested}) + with mock.patch.object( + http_retry.requests, "post", return_value=ok + ): + iam_preflight.iam_preflight( + project="proj-1", runtime_iam=requested + ) + patched_auth.assert_called_once() + _, kwargs = patched_auth.call_args + assert kwargs.get("scopes") == [ + "https://www.googleapis.com/auth/cloud-platform" + ] + + +def test_creds_helper_is_a_separate_function( + patched_auth: mock.MagicMock, + no_sleep: None, +) -> None: + """The helper is named ``_creds`` so other callers can + import and reuse it without duplicating the scope literal.""" + assert hasattr(iam_preflight, "_creds") + assert callable(iam_preflight._creds) + creds, project = iam_preflight._creds() + assert creds is not None + assert project == "test-project" + + +# ----- Public API surface: dataclass + iam_preflight function ----- + + +def test_public_api_exports_iam_preflight_result_dataclass() -> None: + """The library exports ``IamPreflightResult`` as the + consumer-facing data type so any caller can import it for + type hints without duplicating the field layout.""" + assert hasattr(iam_preflight, "IamPreflightResult") + cls = iam_preflight.IamPreflightResult + # Frozen dataclass -- field assignment must raise. + instance = cls(status="SKIPPED") + with pytest.raises(Exception): + instance.status = "GRANTED" # type: ignore[misc] + + +def test_public_function_name_has_no_leading_underscore() -> None: + """The function was previously exposed as ``_iam_preflight`` + as if it were module-private, but it is the cross-module + public API. Callers import ``iam_preflight`` (no underscore). + Lock the surface so a future rename does not silently break + callers.""" + assert hasattr(iam_preflight, "iam_preflight") + assert callable(iam_preflight.iam_preflight) diff --git a/references/apigee-skills-serving/tests/test_manifest_schema.py b/references/apigee-skills-serving/tests/test_manifest_schema.py new file mode 100644 index 000000000..e30a198a7 --- /dev/null +++ b/references/apigee-skills-serving/tests/test_manifest_schema.py @@ -0,0 +1,334 @@ +"""Tests for ``scripts/common/manifest_schema.py``. + +The manifest schema is LOCKED. Every required field is enforced; +every regex has a positive and a negative case. ``runtime_iam`` +must use the GCP IAM dot-form (e.g., ``apigee.proxies.list``), +NOT the service-host prefix form +(``apigee.googleapis.com/proxies``) -- so the negative regex case +for that field is the bug we are explicitly defending against. + +Acceptance: "every required field rejected when absent; every +regex enforced on a positive and a negative (including dot-form +``runtime_iam``); unknown top-level keys accepted; schema version +``\"1\"``". +""" +from __future__ import annotations + +from typing import Any + +import pytest + +from scripts.common.manifest_schema import ( + ManifestValidationError, + validate_manifest, +) + + +@pytest.fixture +def valid_manifest() -> dict[str, Any]: + """A minimal-but-complete manifest that MUST validate cleanly. + Tests mutate copies to exercise edge cases; the fixture itself + is the baseline 'this should pass' shape.""" + return { + "manifest_schema_version": "1", + "name": "demo-skill", + "version": "1.0.0", + "description": "Demonstration skill for integration tests.", + "keywords": ["demo", "test"], + "author": "demo-test-suite", + "license": "Apache-2.0", + "gs_uri": "gs://demo-bucket/demo-skill-1.0.0.skill", + "zip_sha256": "0" * 64, + "signature": "AAAA", # base64 sentinel; sig length not + # gated by the schema + "signing_key_id": "sha256:" + "a" * 64, + "runtime_iam": ["apigee.proxies.list"], + } + + +def test_valid_minimal_manifest_passes( + valid_manifest: dict[str, Any], +) -> None: + """The fixture itself MUST validate; this is the canary that + catches accidental over-strict regexes in the implementation.""" + validate_manifest(valid_manifest) # no exception + + +REQUIRED_FIELDS = ( + "manifest_schema_version", + "name", + "version", + "description", + "keywords", + "author", + "license", + "gs_uri", + "zip_sha256", + "signature", + "signing_key_id", +) + + +@pytest.mark.parametrize("field", REQUIRED_FIELDS) +def test_required_field_rejected_when_absent( + valid_manifest: dict[str, Any], field: str +) -> None: + """Each of the 11 required fields, removed in turn, must + cause validation to raise. Parameterized so a future schema + addition only needs a new entry in REQUIRED_FIELDS, not a new + test function.""" + del valid_manifest[field] + with pytest.raises(ManifestValidationError): + validate_manifest(valid_manifest) + + +def test_manifest_schema_version_must_be_string_one( + valid_manifest: dict[str, Any], +) -> None: + """Per §2.6 the field is the *string* ``"1"``, not the int + ``1`` and not ``"2"`` (which would advertise a future + incompatible schema).""" + for bad in ["2", "1.0", 1, None]: + valid_manifest["manifest_schema_version"] = bad + with pytest.raises(ManifestValidationError): + validate_manifest(valid_manifest) + + +@pytest.mark.parametrize( + "name", ["a", "demo-skill", "1-2-3", "abc-def-ghi", "a" * 64] +) +def test_name_regex_positive( + valid_manifest: dict[str, Any], name: str +) -> None: + valid_manifest["name"] = name + validate_manifest(valid_manifest) + + +@pytest.mark.parametrize( + "name", + [ + "", + "My-Skill", # uppercase + "my_skill", # underscore + "my skill", # space + "-leading-dash", + "trailing-dash-", + "a" * 65, # too long + "double--dash", # consecutive dashes not in regex + ], +) +def test_name_regex_negative( + valid_manifest: dict[str, Any], name: str +) -> None: + valid_manifest["name"] = name + with pytest.raises(ManifestValidationError): + validate_manifest(valid_manifest) + + +@pytest.mark.parametrize( + "version", ["1.0.0", "0.0.0", "10.20.30", "999.999.999"] +) +def test_version_semver_positive( + valid_manifest: dict[str, Any], version: str +) -> None: + valid_manifest["version"] = version + validate_manifest(valid_manifest) + + +@pytest.mark.parametrize( + "version", ["1.0", "v1.0.0", "1.0.0-beta", "1.0.0.0", ""] +) +def test_version_semver_negative( + valid_manifest: dict[str, Any], version: str +) -> None: + valid_manifest["version"] = version + with pytest.raises(ManifestValidationError): + validate_manifest(valid_manifest) + + +def test_description_length_bounds( + valid_manifest: dict[str, Any], +) -> None: + """Per §2.6 length 1-1024. Boundary checks both ends.""" + valid_manifest["description"] = "a" # 1 ok + validate_manifest(valid_manifest) + valid_manifest["description"] = "a" * 1024 # 1024 ok + validate_manifest(valid_manifest) + valid_manifest["description"] = "" # 0 bad + with pytest.raises(ManifestValidationError): + validate_manifest(valid_manifest) + valid_manifest["description"] = "a" * 1025 # 1025 bad + with pytest.raises(ManifestValidationError): + validate_manifest(valid_manifest) + + +def test_keywords_count_bounds( + valid_manifest: dict[str, Any], +) -> None: + """Per §2.6 1-20 items.""" + valid_manifest["keywords"] = ["a"] + validate_manifest(valid_manifest) + valid_manifest["keywords"] = [f"k{i}" for i in range(20)] + validate_manifest(valid_manifest) + valid_manifest["keywords"] = [] + with pytest.raises(ManifestValidationError): + validate_manifest(valid_manifest) + valid_manifest["keywords"] = [f"k{i}" for i in range(21)] + with pytest.raises(ManifestValidationError): + validate_manifest(valid_manifest) + + +@pytest.mark.parametrize( + "keyword", ["abc", "abc-def", "123", "abc-123"] +) +def test_keywords_regex_positive( + valid_manifest: dict[str, Any], keyword: str +) -> None: + valid_manifest["keywords"] = [keyword] + validate_manifest(valid_manifest) + + +@pytest.mark.parametrize( + "keyword", ["ABC", "abc_def", "abc def", "abc.def", ""] +) +def test_keywords_regex_negative( + valid_manifest: dict[str, Any], keyword: str +) -> None: + valid_manifest["keywords"] = [keyword] + with pytest.raises(ManifestValidationError): + validate_manifest(valid_manifest) + + +@pytest.mark.parametrize( + "uri", + [ + "gs://my-bucket/my-skill.skill", + "gs://b/a.skill", + "gs://a.b.c/deep/path/to.skill", + ], +) +def test_gs_uri_regex_positive( + valid_manifest: dict[str, Any], uri: str +) -> None: + valid_manifest["gs_uri"] = uri + validate_manifest(valid_manifest) + + +@pytest.mark.parametrize( + "uri", + [ + "https://example.com/x.skill", + "gs://Bucket/x.skill", # uppercase + "gs://bucket/x.zip", # wrong extension + "gs://bucket/", # empty object + "", + ], +) +def test_gs_uri_regex_negative( + valid_manifest: dict[str, Any], uri: str +) -> None: + valid_manifest["gs_uri"] = uri + with pytest.raises(ManifestValidationError): + validate_manifest(valid_manifest) + + +def test_zip_sha256_regex(valid_manifest: dict[str, Any]) -> None: + """Per §2.6 exactly 64 lowercase hex chars.""" + valid_manifest["zip_sha256"] = "a" * 64 + validate_manifest(valid_manifest) + valid_manifest["zip_sha256"] = "0123456789abcdef" * 4 + validate_manifest(valid_manifest) + valid_manifest["zip_sha256"] = "a" * 63 # too short + with pytest.raises(ManifestValidationError): + validate_manifest(valid_manifest) + valid_manifest["zip_sha256"] = "A" * 64 # uppercase + with pytest.raises(ManifestValidationError): + validate_manifest(valid_manifest) + valid_manifest["zip_sha256"] = "g" * 64 # non-hex + with pytest.raises(ManifestValidationError): + validate_manifest(valid_manifest) + + +def test_signing_key_id_regex(valid_manifest: dict[str, Any]) -> None: + valid_manifest["signing_key_id"] = "sha256:" + "f" * 64 + validate_manifest(valid_manifest) + # Wrong prefix. + valid_manifest["signing_key_id"] = "sha1:" + "f" * 40 + with pytest.raises(ManifestValidationError): + validate_manifest(valid_manifest) + # Missing colon. + valid_manifest["signing_key_id"] = "sha256" + "f" * 64 + with pytest.raises(ManifestValidationError): + validate_manifest(valid_manifest) + + +def test_runtime_iam_is_optional(valid_manifest: dict[str, Any]) -> None: + """Per §2.6 runtime_iam is the only optional field besides + capabilities. Absent and empty-list MUST both validate.""" + del valid_manifest["runtime_iam"] + validate_manifest(valid_manifest) + valid_manifest["runtime_iam"] = [] + validate_manifest(valid_manifest) + + +@pytest.mark.parametrize( + "perm", + [ + "apigee.proxies.list", + "apigee.deployments.list", + "apigee.proxyrevisions.get", + "apigee.organizations.apis.list", + "iam.serviceaccounts.actas", + ], +) +def test_runtime_iam_dot_form_positive( + valid_manifest: dict[str, Any], perm: str +) -> None: + """The §2.4 IAM list MUST validate; these are exactly the + permissions the apigee-policy-top10 skill declares.""" + valid_manifest["runtime_iam"] = [perm] + validate_manifest(valid_manifest) + + +@pytest.mark.parametrize( + "bad_perm", + [ + # The §10.1 explicit-rejection case: service-host-prefixed + # form. testIamPermissions rejects this. + "apigee.googleapis.com/proxies.list", + # Slash form (REST-style). + "apigee/proxies/list", + # Uppercase. + "Apigee.Proxies.List", + # Empty string. + "", + # Single segment (no dots). + "apigee", + ], +) +def test_runtime_iam_dot_form_negative( + valid_manifest: dict[str, Any], bad_perm: str +) -> None: + valid_manifest["runtime_iam"] = [bad_perm] + with pytest.raises(ManifestValidationError): + validate_manifest(valid_manifest) + + +def test_unknown_top_level_keys_accepted( + valid_manifest: dict[str, Any], +) -> None: + """Per §2.6: unknown top-level keys accepted. Forward + compatibility -- a future schema field can be added by a + newer signer without breaking older verifiers.""" + valid_manifest["future_field"] = "ignored" + valid_manifest["another_one"] = {"nested": True} + validate_manifest(valid_manifest) + + +def test_capabilities_is_optional(valid_manifest: dict[str, Any]) -> None: + """Per §2.6 capabilities is documented as optional and + free-form (not enforced).""" + valid_manifest["capabilities"] = ["a", "b", "c"] + validate_manifest(valid_manifest) + valid_manifest["capabilities"] = [] + validate_manifest(valid_manifest) diff --git a/references/apigee-skills-serving/tests/test_permission_resolver.py b/references/apigee-skills-serving/tests/test_permission_resolver.py new file mode 100644 index 000000000..152cb377a --- /dev/null +++ b/references/apigee-skills-serving/tests/test_permission_resolver.py @@ -0,0 +1,263 @@ +"""Tests for ``scripts/common/permission_resolver.py``. + +The resolver reads OpenCode's documented permission chain: global +``~/.config/opencode/opencode.json``, project ``./opencode.json``, +per-agent ``permission.skill.*`` overrides, and the absolute-deny +escape hatch ``agent..tools.skill: false``. The acceptance +criteria are: "default allow, tools.skill=false override, pattern +precedence, agent override". + +The implementation in IMPL_DETAILS resolves +``GLOBAL_OPENCODE_JSON`` and ``PROJECT_OPENCODE_JSON`` at import +time, which makes tests that need to redirect them tricky. We +test the public behavior by monkeypatching the module-level +constants in each test that needs an isolated config tree. +""" +from __future__ import annotations + +import json +from pathlib import Path + +import pytest + +from scripts.common import permission_resolver as pr +from scripts.common.permission_resolver import ( + Resolution, + Verdict, + detect_active_agent, + resolve_skill_permission, +) + + +@pytest.fixture +def isolated_config_paths( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> tuple[Path, Path]: + """Redirect ``GLOBAL_OPENCODE_JSON`` and + ``PROJECT_OPENCODE_JSON`` into ``tmp_path`` for the duration + of the test. Returns ``(global_path, project_path)`` so the + test can write config files at known locations without + touching the user's real ``~/.config``.""" + global_path = tmp_path / "global" / "opencode.json" + project_path = tmp_path / "project" / "opencode.json" + global_path.parent.mkdir(parents=True, exist_ok=True) + project_path.parent.mkdir(parents=True, exist_ok=True) + monkeypatch.setattr(pr, "GLOBAL_OPENCODE_JSON", global_path) + monkeypatch.setattr(pr, "PROJECT_OPENCODE_JSON", project_path) + return global_path, project_path + + +def _write(path: Path, cfg: dict) -> None: + path.write_text(json.dumps(cfg)) + + +# ----- Default + empty-config behaviour ----- + + +def test_default_allow_when_no_config_files( + isolated_config_paths: tuple[Path, Path], +) -> None: + """Both opencode.json files absent: OpenCode's documented + default for the build agent is ALLOW. Source is 'default' so + operators can grep for unconfigured installs.""" + r = resolve_skill_permission("apigee-policy-top10") + assert r == Resolution( + verdict=Verdict.ALLOW, matched_pattern=None, source="default" + ) + + +def test_default_allow_when_configs_are_empty_dicts( + isolated_config_paths: tuple[Path, Path], +) -> None: + """Empty ``{}`` files MUST behave identically to absent + files. Otherwise an operator who creates the file to make a + single tweak and then deletes the tweak gets a different + verdict than before they ever touched it.""" + global_path, project_path = isolated_config_paths + _write(global_path, {}) + _write(project_path, {}) + r = resolve_skill_permission("any-skill") + assert r.verdict == Verdict.ALLOW + assert r.source == "default" + + +# ----- Global vs project precedence ----- + + +def test_global_pattern_allow_matches( + isolated_config_paths: tuple[Path, Path], +) -> None: + global_path, _ = isolated_config_paths + _write(global_path, {"permission": {"skill": {"*": "allow"}}}) + r = resolve_skill_permission("anything") + assert r.verdict == Verdict.ALLOW + assert r.matched_pattern == "*" + assert r.source == "pattern:*" + + +def test_global_pattern_deny_matches( + isolated_config_paths: tuple[Path, Path], +) -> None: + global_path, _ = isolated_config_paths + _write(global_path, {"permission": {"skill": {"*": "deny"}}}) + r = resolve_skill_permission("anything") + assert r.verdict == Verdict.DENY + assert r.matched_pattern == "*" + + +def test_project_pattern_overrides_global( + isolated_config_paths: tuple[Path, Path], +) -> None: + """Per §2.3 project config overlays onto global. A + project-level ``apigee-*: allow`` MUST beat a global ``*: + deny`` for skills matching ``apigee-*``. This is the canonical + 'team unlocks a specific skill family' workflow.""" + global_path, project_path = isolated_config_paths + _write(global_path, {"permission": {"skill": {"*": "deny"}}}) + _write( + project_path, + {"permission": {"skill": {"apigee-*": "allow"}}}, + ) + r = resolve_skill_permission("apigee-policy-top10") + assert r.verdict == Verdict.ALLOW + assert r.matched_pattern == "apigee-*" + + +def test_first_pattern_in_dict_order_wins( + isolated_config_paths: tuple[Path, Path], +) -> None: + """Python 3.7+ preserves dict insertion order. The resolver + documents 'first match wins iterating in dict insertion + order' so operators can reason about precedence by reading + the JSON top-to-bottom.""" + global_path, _ = isolated_config_paths + _write( + global_path, + { + "permission": { + "skill": {"apigee-*": "allow", "*": "deny"} + } + }, + ) + r = resolve_skill_permission("apigee-x") + assert r.verdict == Verdict.ALLOW + # And a skill that doesn't match apigee-* falls to the + # second pattern. + r2 = resolve_skill_permission("other-skill") + assert r2.verdict == Verdict.DENY + + +# ----- Per-agent overrides ----- + + +def test_agent_override_overrides_global_skill( + isolated_config_paths: tuple[Path, Path], +) -> None: + """A global ``permission.skill.*: allow`` can be tightened + by an agent-specific ``agent.plan.permission.skill.*: deny``. + Lets a careful operator opt the 'plan' agent out of skills + that are fine for 'build'.""" + global_path, _ = isolated_config_paths + _write( + global_path, + { + "permission": {"skill": {"*": "allow"}}, + "agent": { + "plan": { + "permission": {"skill": {"*": "deny"}} + } + }, + }, + ) + # Default agent ('build') still sees ALLOW. + assert ( + resolve_skill_permission("anything").verdict == Verdict.ALLOW + ) + # 'plan' agent sees DENY. + assert ( + resolve_skill_permission( + "anything", active_agent="plan" + ).verdict + == Verdict.DENY + ) + + +def test_tools_skill_false_overrides_everything( + isolated_config_paths: tuple[Path, Path], +) -> None: + """The absolute-deny escape hatch. Even with a sweeping + ``permission.skill.*: allow``, ``tools.skill: false`` for an + agent removes the skill tool entirely. Source MUST be + 'tools.skill=false' so the failure message is unambiguous.""" + global_path, _ = isolated_config_paths + _write( + global_path, + { + "permission": {"skill": {"*": "allow"}}, + "agent": {"plan": {"tools": {"skill": False}}}, + }, + ) + r = resolve_skill_permission( + "apigee-policy-top10", active_agent="plan" + ) + assert r.verdict == Verdict.DENY + assert r.source == "tools.skill=false" + assert r.matched_pattern is None + + +def test_ask_verdict_is_supported( + isolated_config_paths: tuple[Path, Path], +) -> None: + """OpenCode permission actions include 'ask'. The resolver + must not collapse it to allow or deny silently.""" + global_path, _ = isolated_config_paths + _write( + global_path, + {"permission": {"skill": {"experimental-*": "ask"}}}, + ) + r = resolve_skill_permission("experimental-foo") + assert r.verdict == Verdict.ASK + + +# ----- Error paths ----- + + +def test_unknown_action_raises_runtimeerror( + isolated_config_paths: tuple[Path, Path], +) -> None: + global_path, _ = isolated_config_paths + _write( + global_path, + {"permission": {"skill": {"*": "maybe"}}}, + ) + with pytest.raises(RuntimeError, match="Unknown permission action"): + resolve_skill_permission("x") + + +def test_corrupt_json_raises_runtimeerror( + isolated_config_paths: tuple[Path, Path], +) -> None: + global_path, _ = isolated_config_paths + global_path.write_text("{not: valid json,}") + with pytest.raises(RuntimeError, match="is not valid JSON"): + resolve_skill_permission("x") + + +# ----- detect_active_agent helper ----- + + +def test_detect_active_agent_from_env( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setenv("OPENCODE_AGENT", "plan") + assert detect_active_agent() == "plan" + + +def test_detect_active_agent_default_build( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Per §2.3 the default is 'build' when unset. This matches + OpenCode's default agent identity so detection failure + degrades safely.""" + monkeypatch.delenv("OPENCODE_AGENT", raising=False) + assert detect_active_agent() == "build" diff --git a/references/apigee-skills-serving/tests/test_register_fetch_integration.py b/references/apigee-skills-serving/tests/test_register_fetch_integration.py new file mode 100644 index 000000000..464533a4d --- /dev/null +++ b/references/apigee-skills-serving/tests/test_register_fetch_integration.py @@ -0,0 +1,296 @@ +"""Register + fetch round-trip integration test. + +Real ``register_skill.main`` against a mocked API hub HTTP layer; +asserts that what register_skill writes as the Spec body is the +exact byte sequence the consumer's ``_fetch_spec()`` would read +back. This is the seam that guarantees the install side sees the +same manifest the author signed. + +The consumer's loader is not part of this repo, so we mirror +``_fetch_spec``'s contract inline here: GET +``.../apis/{name}/versions/{ver}/specs/{spec_id}:contents``, +decode the base64 ``contents`` field. This stand-in is a faithful +substitute even though the production helper lives elsewhere. + +Byte-equality is the key assertion: if register_skill mangled +the manifest YAML (e.g., re-serialised it through PyYAML and lost +key ordering), this test would catch it. +""" +from __future__ import annotations + +import base64 +import json +from pathlib import Path +from typing import Any +from unittest.mock import MagicMock + +import pytest +import yaml + + +def _import_register_main(): + from scripts.register_skill import main as _main + return _main + + +# --------------------------------------------------------------------------- +# Local fake API hub (state + HTTP shim) +# --------------------------------------------------------------------------- + +class _FakeHub: + def __init__(self) -> None: + # Track only what we need: spec bodies keyed by + # (api_id, ver_id, spec_id). All other resources are + # acknowledged but their contents are uninteresting. + self.apis: set[str] = set() + self.versions: set[tuple[str, str]] = set() + self.specs: dict[tuple[str, str, str], bytes] = {} + self.attrs: dict[str, dict] = {} + + def __call__(self, method: str, url: str, **kwargs: Any): + suffix = self._normalize(url) + params = kwargs.get("params") or {} + body = kwargs.get("json") or {} + parts = suffix.strip("/").split("/") + + if method == "POST" and suffix == "/apis": + api_id = params.get("apiId") + self.apis.add(api_id) + return _resp(200, {}) + + if method == "GET" and suffix.startswith("/apis/") \ + and len(parts) == 2: + api_id = parts[1] + if api_id not in self.apis: + return _resp(404, {}) + return _resp(200, {"attributes": self.attrs.get(api_id, {})}) + + if method == "GET" and len(parts) == 4 \ + and parts[2] == "versions": + api_id, ver_id = parts[1], parts[3] + if (api_id, ver_id) not in self.versions: + return _resp(404, {}) + return _resp(200, {}) + + if method == "POST" and suffix.endswith("/versions"): + api_id = parts[1] + ver_id = params.get("versionId") + self.versions.add((api_id, ver_id)) + return _resp(200, {}) + + if method == "GET" and suffix.endswith(":contents"): + api_id, ver_id, spec_id = parts[1], parts[3], parts[5] + spec_id = spec_id.split(":")[0] + key = (api_id, ver_id, spec_id) + if key not in self.specs: + return _resp(404, {}) + return _resp(200, { + "contents": base64.b64encode( + self.specs[key] + ).decode("ascii"), + }) + + if method == "POST" and suffix.endswith("/specs"): + api_id, ver_id = parts[1], parts[3] + spec_id = params.get("specId") + # API hub's Spec body wraps the base64-encoded payload + # inside a NESTED `contents` object alongside `mimeType` + # and `specType`. Unwrap to find the actual b64 string. + raw_contents = body.get("contents", "") + if isinstance(raw_contents, dict): + b64_str = raw_contents.get("contents", "") + else: + b64_str = raw_contents + raw = base64.b64decode(b64_str) + self.specs[(api_id, ver_id, spec_id)] = raw + return _resp(200, {}) + + if method == "PATCH" and suffix.startswith("/apis/") \ + and len(parts) == 2: + api_id = parts[1] + self.attrs[api_id] = body.get("attributes", {}) + return _resp(200, {}) + + return _resp(404, {"error": f"unmocked {method} {suffix}"}) + + @staticmethod + def _normalize(url: str) -> str: + marker = "/locations/" + idx = url.find(marker) + if idx < 0: + return url + tail = url[idx + len(marker):] + return "/" + tail.split("/", 1)[1] if "/" in tail else "/" + + +def _resp(status: int, body: dict): + class _R: + status_code = status + text = json.dumps(body) + + def json(self) -> dict: + return body + + def raise_for_status(self) -> None: + if status >= 400: + import requests + raise requests.HTTPError( + f"HTTP {status}", + response=self, # type: ignore[arg-type] + ) + return _R() + + +# --------------------------------------------------------------------------- +# The local _fetch_spec stand-in mirroring the consumer's contract. +# --------------------------------------------------------------------------- + +def _fetch_spec( + hub: _FakeHub, project: str, location: str, name: str, + version: str, spec_id: str, +) -> bytes: + """Mirror of the helper the consumer's loader will expose as + ``_fetch_spec``. Pulled out here so the test is a real + round-trip: same URL the production fetcher uses, same + base64-decode, same byte output.""" + url = ( + f"https://apihub.googleapis.com/v1/projects/{project}" + f"/locations/{location}/apis/{name}/versions/{version}" + f"/specs/{spec_id}:contents" + ) + r = hub("GET", url) + if r.status_code != 200: + raise RuntimeError( + f"fetch_spec failed: {r.status_code} {r.text}" + ) + return base64.b64decode(r.json()["contents"]) + + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + +@pytest.fixture +def signed_manifest_file(tmp_path: Path) -> Path: + m = { + "manifest_schema_version": "1", + "name": "demo-skill", + "version": "2.0.0", + "description": "Integration test manifest.", + "keywords": ["demo"], + "author": "tests", + "license": "Apache-2.0", + "gs_uri": "gs://demo-bucket/demo-skill-2.0.0.skill", + "zip_sha256": "0" * 64, + "signature": "AAAA", + "signing_key_id": "sha256:" + "a" * 64, + "runtime_iam": ["apigee.proxies.list"], + } + p = tmp_path / "manifest.signed.yaml" + p.write_text(yaml.safe_dump(m, sort_keys=True)) + return p + + +@pytest.fixture +def hub_and_register(monkeypatch: pytest.MonkeyPatch) -> _FakeHub: + import scripts.register_skill as rs + hub = _FakeHub() + creds = MagicMock() + creds.token = "tok" + creds.refresh = MagicMock(return_value=None) + monkeypatch.setattr(rs, "_credentials", lambda: (creds, "demo")) + monkeypatch.setattr( + rs.requests, "request", + lambda method, url, **kw: hub(method, url, **kw), + ) + return hub + + +# --------------------------------------------------------------------------- +# Test +# --------------------------------------------------------------------------- + +def test_register_then_fetch_byte_identical( + hub_and_register: _FakeHub, + signed_manifest_file: Path, +) -> None: + """Register the manifest and immediately fetch the Spec back. + The fetched bytes MUST equal the on-disk manifest bytes — + no YAML re-serialisation, no encoding swap, no BOM + introduction. If they differ, ed25519 verify in the consumer + would fail in production. + + Note: API hub's versionId field rejects dots (see + `register_skill.py` lines 203-207), so the on-the-wire ids are + the hyphen-translated form `2-0-0` / `manifest-2-0-0`, not the + semver `2.0.0` / `manifest-2.0.0`. + """ + main = _import_register_main() + rc = main([ + "--manifest", str(signed_manifest_file), + "--project", "demo-project", + "--location", "us-central1", + "--quiet", + ]) + assert rc == 0 + fetched = _fetch_spec( + hub_and_register, "demo-project", "us-central1", + "demo-skill", "2-0-0", "manifest-2-0-0", + ) + assert fetched == signed_manifest_file.read_bytes() + + +def test_register_then_fetch_then_parse_roundtrip( + hub_and_register: _FakeHub, + signed_manifest_file: Path, +) -> None: + """Even tighter: fetch the Spec, parse it as YAML, and + confirm every field matches the source manifest. Catches + encoding bugs that byte-equality might miss when the test + fixture happens to be ASCII-pure.""" + main = _import_register_main() + assert main([ + "--manifest", str(signed_manifest_file), + "--project", "demo-project", + "--location", "us-central1", + "--quiet", + ]) == 0 + fetched = _fetch_spec( + hub_and_register, "demo-project", "us-central1", + "demo-skill", "2-0-0", "manifest-2-0-0", + ) + src = yaml.safe_load(signed_manifest_file.read_text()) + dst = yaml.safe_load(fetched.decode("utf-8")) + assert src == dst + + +def test_second_register_does_not_disturb_fetch( + hub_and_register: _FakeHub, + signed_manifest_file: Path, +) -> None: + """Idempotency at the fetch seam: re-running register_skill + must not cause the consumer to download a different byte + sequence on the second install. Equivalent to 'two installs + in a row succeed identically'.""" + main = _import_register_main() + assert main([ + "--manifest", str(signed_manifest_file), + "--project", "demo-project", + "--location", "us-central1", + "--quiet", + ]) == 0 + fetched_first = _fetch_spec( + hub_and_register, "demo-project", "us-central1", + "demo-skill", "2-0-0", "manifest-2-0-0", + ) + assert main([ + "--manifest", str(signed_manifest_file), + "--project", "demo-project", + "--location", "us-central1", + "--quiet", + ]) == 0 + fetched_second = _fetch_spec( + hub_and_register, "demo-project", "us-central1", + "demo-skill", "2-0-0", "manifest-2-0-0", + ) + assert fetched_first == fetched_second diff --git a/references/apigee-skills-serving/tests/test_register_skill.py b/references/apigee-skills-serving/tests/test_register_skill.py new file mode 100644 index 000000000..32358a797 --- /dev/null +++ b/references/apigee-skills-serving/tests/test_register_skill.py @@ -0,0 +1,425 @@ +"""Tests for ``scripts/register_skill.py``. + +Coverage targets: + + * API hub mocked: create-API on first run, no-op on second run. + * PATCH attributes on attribute mismatch. + * Dry-run prints intent, mutates nothing. + +Per-component criterion: + + * Creates exactly one API + one Version + one Spec + four + attribute values on a fresh project; is a no-op on second run + with identical manifest. + +API hub REST surface used: + + 1. POST .../apis create API + 2. POST .../apis/{api}/versions create Version + 3. POST .../apis/{api}/versions/{v}/specs create Spec + 4. PATCH .../apis/{api} set attributes + 5. GET .../apis/{api}/versions/{v}/specs/{s}:contents + fetch existing Spec + +We mock ``requests`` and ``google.auth.default`` at the module +boundary. The fixture below builds a *stateful* mock that emulates +the API hub's resource model just well enough that the idempotency +test can drive a real second-run-is-no-op path. +""" +from __future__ import annotations + +import base64 +import json +from pathlib import Path +from typing import Any +from unittest.mock import MagicMock + +import pytest +import yaml + + +def _import_register_main(): + from scripts.register_skill import main as _main + return _main + + +# --------------------------------------------------------------------------- +# Fake API hub +# --------------------------------------------------------------------------- + +class FakeApiHub: + """In-memory model of the slice of API hub the registration + script touches. Drives both the create-then-noop test and + the PATCH-on-mismatch test from a single stateful mock so + the assertions about call counts remain meaningful. + + Resource model (one project + location implied by the fake): + + apis[name] -> { + "name": , + "attributes": {: , ...}, + "versions": {ver_id: {"specs": {spec_id: {"contents": }}}} + } + """ + + def __init__(self) -> None: + self.apis: dict[str, dict[str, Any]] = {} + # Call ledger: each entry is (METHOD, path-suffix). Lets + # the test assert exact counts per endpoint. + self.calls: list[tuple[str, str]] = [] + + # ------------------------------------------------------------------ + # The ``requests`` HTTP verb stubs. + # ------------------------------------------------------------------ + + def request(self, method: str, url: str, **kwargs: Any): + # Strip the host + version + project + location prefix. + prefix_marker = "/locations/" + idx = url.find(prefix_marker) + suffix = url[idx + len(prefix_marker):] if idx >= 0 else url + # Drop the location segment itself, keep the rest. + if "/" in suffix: + suffix = "/" + suffix.split("/", 1)[1] + self.calls.append((method, suffix)) + body = kwargs.get("json") + params = kwargs.get("params", {}) or {} + + if method == "POST" and suffix == "/apis": + api_id = params.get("apiId") or (body or {}).get("name") + if api_id in self.apis: + return _resp(409, {"error": "already exists"}) + self.apis[api_id] = { + "name": f"projects/p/locations/l/apis/{api_id}", + "attributes": (body or {}).get("attributes", {}), + "versions": {}, + } + return _resp(200, self.apis[api_id]) + + if method == "GET" and suffix.startswith("/apis/"): + parts = suffix.strip("/").split("/") + # /apis/{id}/versions/{v}/specs/{s}:contents (6 parts) + if suffix.endswith(":contents") and len(parts) == 6: + api_id, ver_id, spec_id = parts[1], parts[3], parts[5] + spec_id = spec_id.split(":")[0] + api = self.apis.get(api_id) + if not api: + return _resp(404, {}) + spec = ( + api["versions"] + .get(ver_id, {}) + .get("specs", {}) + .get(spec_id) + ) + if not spec: + return _resp(404, {}) + return _resp(200, { + "contents": spec["contents_b64"], + "mimeType": "application/yaml", + }) + # /apis/{id}/versions/{v} (4 parts) + if len(parts) == 4 and parts[2] == "versions": + api_id, ver_id = parts[1], parts[3] + api = self.apis.get(api_id) + if not api or ver_id not in api["versions"]: + return _resp(404, {}) + return _resp(200, { + "name": f"{api['name']}/versions/{ver_id}", + }) + # Bare GET .../apis/{id} (2 parts) + if len(parts) == 2: + api_id = parts[1] + api = self.apis.get(api_id) + if not api: + return _resp(404, {}) + return _resp(200, api) + return _resp(404, {"error": f"unmocked GET {suffix}"}) + + if method == "POST" and "/versions" in suffix \ + and not suffix.endswith("/specs"): + api_id = suffix.strip("/").split("/")[1] + ver_id = params.get("versionId") or (body or {}).get("name") + api = self.apis[api_id] + if ver_id in api["versions"]: + return _resp(409, {"error": "already exists"}) + api["versions"][ver_id] = {"specs": {}} + return _resp(200, {"name": f"{api['name']}/versions/{ver_id}"}) + + if method == "POST" and suffix.endswith("/specs"): + parts = suffix.strip("/").split("/") + api_id, ver_id = parts[1], parts[3] + spec_id = params.get("specId") or (body or {}).get("name") + api = self.apis[api_id] + ver = api["versions"][ver_id] + # API hub's Spec body wraps the base64-encoded payload + # inside a NESTED `contents` object alongside `mimeType` + # and `specType`: + # {contents: {contents: , mimeType: "..."}, + # specType: {...}} + # The old fake assumed a flat `contents: ` field + # (the v0 surface before specType was made required). + # Extract the inner b64 string so the second-run-noop + # path (which GETs and byte-compares the stored spec) + # finds an equal value instead of a serialized dict. + raw_contents = (body or {}).get("contents", "") + if isinstance(raw_contents, dict): + raw_b64 = raw_contents.get("contents", "") + else: + # Backstop for any caller still using the flat shape. + raw_b64 = raw_contents + ver["specs"][spec_id] = {"contents_b64": raw_b64} + return _resp(200, {"name": f"spec/{spec_id}"}) + + if method == "PATCH" and suffix.startswith("/apis/"): + api_id = suffix.strip("/").split("/")[1] + api = self.apis.get(api_id) + if not api: + return _resp(404, {}) + # Replace attributes wholesale (the test cares about + # the final state, not the merge semantics). + api["attributes"] = (body or {}).get("attributes", {}) + return _resp(200, api) + + return _resp(404, {"error": f"unmocked {method} {suffix}"}) + + +def _resp(status: int, body: dict): + """Build a minimal Response-shaped object for the fake.""" + class _R: + status_code = status + text = json.dumps(body) + + def json(self) -> dict: + return body + + def raise_for_status(self) -> None: + if status >= 400: + import requests + raise requests.HTTPError( + f"HTTP {status}", response=self, # type: ignore[arg-type] + ) + return _R() + + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + +@pytest.fixture +def fake_hub(monkeypatch: pytest.MonkeyPatch) -> FakeApiHub: + import scripts.register_skill as rs + + hub = FakeApiHub() + creds = MagicMock() + creds.token = "fake-bearer" + creds.refresh = MagicMock(return_value=None) + monkeypatch.setattr(rs, "_credentials", lambda: (creds, "demo-project")) + + def request_router(method: str, url: str, **kwargs: Any): + return hub.request(method, url, **kwargs) + + # We route all verbs through a single entry point so the fake + # remains the single source of truth. + monkeypatch.setattr( + rs.requests, "request", + lambda method, url, **kw: request_router(method, url, **kw), + ) + return hub + + +@pytest.fixture +def signed_manifest_file(tmp_path: Path) -> Path: + """A signed manifest YAML on disk. The signature itself is a + sentinel string — the registration script does NOT verify + signatures (that is the consumer's job at install time); + register_skill only validates the schema and ships the + manifest as the Spec body. So a sentinel sig is fine.""" + m = { + "manifest_schema_version": "1", + "name": "demo-skill", + "version": "1.0.0", + "description": "Demo.", + "keywords": ["demo"], + "author": "tests", + "license": "Apache-2.0", + "gs_uri": "gs://demo-bucket/demo-skill-1.0.0.skill", + "zip_sha256": "0" * 64, + "signature": "AAAA", + "signing_key_id": "sha256:" + "a" * 64, + "runtime_iam": ["apigee.proxies.list"], + } + p = tmp_path / "manifest.signed.yaml" + p.write_text(yaml.safe_dump(m, sort_keys=True)) + return p + + +# --------------------------------------------------------------------------- +# Happy path: create on first run, no-op on second +# --------------------------------------------------------------------------- + +def test_first_run_creates_api_version_spec_and_attrs( + fake_hub: FakeApiHub, + signed_manifest_file: Path, +) -> None: + """Exactly one API + one Version + one Spec + four attribute + values.""" + main = _import_register_main() + rc = main([ + "--manifest", str(signed_manifest_file), + "--project", "demo-project", + "--location", "us-central1", + "--quiet", + ]) + assert rc == 0 + # The fake's call ledger tells us what was attempted. + posts = [c for c in fake_hub.calls if c[0] == "POST"] + patches = [c for c in fake_hub.calls if c[0] == "PATCH"] + # Exactly one POST per resource type: api, version, spec. + assert sum(1 for m, s in posts if s == "/apis") == 1 + assert sum( + 1 for m, s in posts if s.endswith("/versions") + ) == 1 + assert sum( + 1 for m, s in posts if s.endswith("/specs") + ) == 1 + # Exactly one PATCH for the attributes. + assert len(patches) == 1 + # The four attribute values land on the API resource. API hub + # keys the AttributeValues map by FULLY-QUALIFIED attribute + # resource name (`projects/

/locations//attributes/`), + # not by bare id. The script emits the FQ form when project + + # location are passed (see `register_skill._attributes_from_manifest` + # lines 100-127). + attrs = fake_hub.apis["demo-skill"]["attributes"] + expected_attr_keys = { + f"projects/demo-project/locations/us-central1/attributes/{a}" + for a in ( + "agentic_skill", "keywords", "gs_uri", "signing_key_id" + ) + } + assert set(attrs.keys()) >= expected_attr_keys + + +def test_second_run_is_noop( + fake_hub: FakeApiHub, + signed_manifest_file: Path, +) -> None: + """Second run with identical manifest is a no-op (compares + Spec contents byte-for-byte; only updates on mismatch). The + exact noop-detection mechanism is left to the implementation, + but the observable contract is: no additional POSTs to /apis, + /versions, /specs and no PATCH on the API.""" + main = _import_register_main() + main([ + "--manifest", str(signed_manifest_file), + "--project", "demo-project", + "--location", "us-central1", + "--quiet", + ]) + fake_hub.calls.clear() + rc = main([ + "--manifest", str(signed_manifest_file), + "--project", "demo-project", + "--location", "us-central1", + "--quiet", + ]) + assert rc == 0 + posts = [c for c in fake_hub.calls if c[0] == "POST"] + patches = [c for c in fake_hub.calls if c[0] == "PATCH"] + # Zero writes on the second run. + assert posts == [] + assert patches == [] + + +def test_attribute_mismatch_triggers_patch( + fake_hub: FakeApiHub, + signed_manifest_file: Path, + tmp_path: Path, +) -> None: + """If the second-run manifest changes an attribute (here, + keywords), the script must PATCH the API resource. Spec + content is unchanged so no Spec POST; only the attribute + delta is written.""" + main = _import_register_main() + main([ + "--manifest", str(signed_manifest_file), + "--project", "demo-project", + "--location", "us-central1", + "--quiet", + ]) + fake_hub.calls.clear() + + # Mutate the manifest in a way that ONLY changes the attribute + # set (different keywords) but leaves the Spec body byte-for- + # byte equal once attributes are stripped. The simplest way is + # to load, mutate, and write back. + signed = yaml.safe_load(signed_manifest_file.read_text()) + signed["keywords"] = ["demo", "new-keyword"] + signed_manifest_file.write_text(yaml.safe_dump(signed, sort_keys=True)) + + rc = main([ + "--manifest", str(signed_manifest_file), + "--project", "demo-project", + "--location", "us-central1", + "--quiet", + ]) + assert rc == 0 + patches = [c for c in fake_hub.calls if c[0] == "PATCH"] + assert len(patches) >= 1 + + +def test_dry_run_mutates_nothing( + fake_hub: FakeApiHub, + signed_manifest_file: Path, + capsys: pytest.CaptureFixture[str], +) -> None: + """``--dry-run``: prints intent (so the operator can audit), + issues only GETs (to discover state), and makes zero writes.""" + main = _import_register_main() + rc = main([ + "--manifest", str(signed_manifest_file), + "--project", "demo-project", + "--location", "us-central1", + "--dry-run", + ]) + assert rc == 0 + out = capsys.readouterr().out + assert "dry-run" in out.lower() or "would" in out.lower() + # No POSTs and no PATCHes. + writes = [c for c in fake_hub.calls if c[0] in ("POST", "PATCH")] + assert writes == [] + + +# --------------------------------------------------------------------------- +# Validation paths +# --------------------------------------------------------------------------- + +def test_invalid_manifest_exits_1( + fake_hub: FakeApiHub, + tmp_path: Path, +) -> None: + """The script must validate before talking to API hub. A + schema-invalid manifest is user error.""" + bad = tmp_path / "bad.yaml" + bad.write_text("manifest_schema_version: '2'\nname: ohno\n") + main = _import_register_main() + rc = main([ + "--manifest", str(bad), + "--project", "demo-project", + "--location", "us-central1", + "--quiet", + ]) + assert rc == 1 + + +def test_missing_manifest_exits_1( + fake_hub: FakeApiHub, + tmp_path: Path, +) -> None: + main = _import_register_main() + rc = main([ + "--manifest", str(tmp_path / "missing.yaml"), + "--project", "demo-project", + "--location", "us-central1", + "--quiet", + ]) + assert rc == 1 diff --git a/references/apigee-skills-serving/tests/test_sign_skill.py b/references/apigee-skills-serving/tests/test_sign_skill.py new file mode 100644 index 000000000..f9b65ab3d --- /dev/null +++ b/references/apigee-skills-serving/tests/test_sign_skill.py @@ -0,0 +1,358 @@ +"""Tests for ``scripts/sign_skill.py``. + +Coverage targets: + + * Idempotency: re-signing produces byte-identical output. + * Exit codes: 0 success, 1 user error, 2 system error, 3 crypto + error. + * In-place vs out-of-place writes. + * Missing priv key → exit 3 (crypto error). + * Unparseable manifest → exit 1 (user error). + +Per-component criterion: + + * sign-skill.py writes a manifest that round-trips through + validate_manifest(). + * Idempotency: diff between two sequential runs is empty. + +Tests drive the script via ``scripts.sign_skill.main(argv)`` with +an argv list so that we don't need to fork subprocesses (faster +and lets us assert exit codes via SystemExit). Real ed25519 +keys are generated in the fixture; canonicalize() is the real +module function. No network, no fakes. +""" +from __future__ import annotations + +import hashlib +from pathlib import Path + +import pytest +import yaml +from cryptography.hazmat.primitives import serialization +from cryptography.hazmat.primitives.asymmetric.ed25519 import ( + Ed25519PrivateKey, +) + +from scripts.common.canonical import canonicalize +from scripts.common.manifest_schema import validate_manifest + +# Defer module import: importing the script at module collection +# time would crash if it ever grows side-effects, so we import via +# helper inside each test. Cheap, and keeps failure messages local. + + +def _import_sign_main(): + from scripts.sign_skill import main as _main + return _main + + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + +@pytest.fixture +def priv_key_path(tmp_path: Path) -> Path: + """Generate a real ed25519 private key on disk in raw 32-byte + format. We use the real cryptography library, not a mock; + ed25519 keygen is fast (sub-millisecond) and using the real + codepath avoids a class of mock-disagrees-with-reality bugs.""" + key = Ed25519PrivateKey.generate() + raw = key.private_bytes( + encoding=serialization.Encoding.Raw, + format=serialization.PrivateFormat.Raw, + encryption_algorithm=serialization.NoEncryption(), + ) + p = tmp_path / "priv.key" + p.write_bytes(raw) + return p + + +@pytest.fixture +def skill_zip(tmp_path: Path) -> Path: + """A stand-in for a real ``.skill`` zip — the signer only needs + the bytes to sha256, so any bytes will do. We avoid making a + real zip file here to keep the test focused on the sign path.""" + p = tmp_path / "demo-skill-1.0.0.skill" + p.write_bytes(b"PK\x03\x04this-is-a-fake-zip-blob") + return p + + +@pytest.fixture +def manifest_path(tmp_path: Path) -> Path: + """An unsigned manifest YAML missing ``signature``, ``zip_sha256``, + and ``signing_key_id`` — those are computed by the signer.""" + m = { + "manifest_schema_version": "1", + "name": "demo-skill", + "version": "1.0.0", + "description": "Demonstration skill.", + "keywords": ["demo"], + "author": "demo-test-suite", + "license": "Apache-2.0", + "gs_uri": "gs://demo-bucket/demo-skill-1.0.0.skill", + "runtime_iam": ["apigee.proxies.list"], + } + p = tmp_path / "manifest.yaml" + p.write_text(yaml.safe_dump(m, sort_keys=False)) + return p + + +# --------------------------------------------------------------------------- +# Happy path +# --------------------------------------------------------------------------- + +def test_success_writes_signed_manifest( + manifest_path: Path, + skill_zip: Path, + priv_key_path: Path, + tmp_path: Path, +) -> None: + """End-to-end: argv → exit 0 → signed manifest on disk that + passes validate_manifest().""" + out = tmp_path / "manifest.signed.yaml" + main = _import_sign_main() + rc = main([ + "--manifest", str(manifest_path), + "--zip", str(skill_zip), + "--priv-key", str(priv_key_path), + "--out", str(out), + "--quiet", + ]) + assert rc == 0 + assert out.exists() + signed = yaml.safe_load(out.read_text()) + validate_manifest(signed) + # zip_sha256 must equal the actual zip sha256 (not a placeholder). + assert signed["zip_sha256"] == hashlib.sha256( + skill_zip.read_bytes() + ).hexdigest() + # signing_key_id is sha256:. + priv = Ed25519PrivateKey.from_private_bytes( + priv_key_path.read_bytes() + ) + pub_raw = priv.public_key().public_bytes( + encoding=serialization.Encoding.Raw, + format=serialization.PublicFormat.Raw, + ) + expected_kid = "sha256:" + hashlib.sha256(pub_raw).hexdigest() + assert signed["signing_key_id"] == expected_kid + + +def test_idempotent_byte_identical_on_resign( + manifest_path: Path, + skill_zip: Path, + priv_key_path: Path, + tmp_path: Path, +) -> None: + """The diff between two sequential runs is empty. We sign + twice (same priv key, same inputs) and assert the output + bytes are identical. Ed25519 is deterministic (RFC 8032), so + this is a hard requirement, not a soft one.""" + out_a = tmp_path / "a.yaml" + out_b = tmp_path / "b.yaml" + main = _import_sign_main() + assert main([ + "--manifest", str(manifest_path), + "--zip", str(skill_zip), + "--priv-key", str(priv_key_path), + "--out", str(out_a), + "--quiet", + ]) == 0 + assert main([ + "--manifest", str(manifest_path), + "--zip", str(skill_zip), + "--priv-key", str(priv_key_path), + "--out", str(out_b), + "--quiet", + ]) == 0 + assert out_a.read_bytes() == out_b.read_bytes() + + +def test_in_place_write_mutates_manifest_file( + manifest_path: Path, + skill_zip: Path, + priv_key_path: Path, +) -> None: + """``--in-place`` overwrites the manifest path. Verifies the + flag does what the §3.1 grammar promises.""" + before = manifest_path.read_text() + main = _import_sign_main() + rc = main([ + "--manifest", str(manifest_path), + "--zip", str(skill_zip), + "--priv-key", str(priv_key_path), + "--in-place", + "--quiet", + ]) + assert rc == 0 + after = manifest_path.read_text() + assert before != after + signed = yaml.safe_load(after) + validate_manifest(signed) + + +def test_signature_verifies_with_pubkey( + manifest_path: Path, + skill_zip: Path, + priv_key_path: Path, + tmp_path: Path, +) -> None: + """Cross-check: extract the signature, recompute canonical + bytes the verify side would compute, and confirm the real + ed25519 public key accepts the signature. This is the unit + half of the §8.2 sign+verify integration.""" + import base64 + + from cryptography.hazmat.primitives.asymmetric.ed25519 import ( + Ed25519PublicKey, + ) + + out = tmp_path / "m.signed.yaml" + main = _import_sign_main() + assert main([ + "--manifest", str(manifest_path), + "--zip", str(skill_zip), + "--priv-key", str(priv_key_path), + "--out", str(out), + "--quiet", + ]) == 0 + signed = yaml.safe_load(out.read_text()) + priv = Ed25519PrivateKey.from_private_bytes( + priv_key_path.read_bytes() + ) + pub = priv.public_key() + sig = base64.b64decode(signed["signature"]) + pub.verify(sig, canonicalize(signed)) # raises on mismatch + + +# --------------------------------------------------------------------------- +# Error paths and exit codes +# --------------------------------------------------------------------------- + +def test_missing_priv_key_exits_3( + manifest_path: Path, + skill_zip: Path, + tmp_path: Path, +) -> None: + """A non-existent priv-key path is a cryptographic-prerequisite + error; §3.1 reserves exit 3 for this class.""" + main = _import_sign_main() + rc = main([ + "--manifest", str(manifest_path), + "--zip", str(skill_zip), + "--priv-key", str(tmp_path / "does-not-exist.key"), + "--out", str(tmp_path / "out.yaml"), + "--quiet", + ]) + assert rc == 3 + + +def test_invalid_priv_key_content_exits_3( + manifest_path: Path, + skill_zip: Path, + tmp_path: Path, +) -> None: + """The file exists but doesn't decode as a 32-byte raw ed25519 + private key. §3.1 says 'priv key unreadable, invalid' → 3.""" + bad = tmp_path / "bad.key" + bad.write_bytes(b"this is not a valid ed25519 raw private key") + main = _import_sign_main() + rc = main([ + "--manifest", str(manifest_path), + "--zip", str(skill_zip), + "--priv-key", str(bad), + "--out", str(tmp_path / "out.yaml"), + "--quiet", + ]) + assert rc == 3 + + +def test_unparseable_manifest_exits_1( + skill_zip: Path, + priv_key_path: Path, + tmp_path: Path, +) -> None: + """A YAML parse failure is user error per §3.1 (bad inputs).""" + bad = tmp_path / "bad.yaml" + bad.write_text("this: is: invalid: yaml: ::: [\n") + main = _import_sign_main() + rc = main([ + "--manifest", str(bad), + "--zip", str(skill_zip), + "--priv-key", str(priv_key_path), + "--out", str(tmp_path / "out.yaml"), + "--quiet", + ]) + assert rc == 1 + + +def test_missing_manifest_file_exits_1( + skill_zip: Path, + priv_key_path: Path, + tmp_path: Path, +) -> None: + """`--manifest` points at a nonexistent file → user error.""" + main = _import_sign_main() + rc = main([ + "--manifest", str(tmp_path / "nope.yaml"), + "--zip", str(skill_zip), + "--priv-key", str(priv_key_path), + "--out", str(tmp_path / "out.yaml"), + "--quiet", + ]) + assert rc == 1 + + +def test_missing_zip_file_exits_1( + manifest_path: Path, + priv_key_path: Path, + tmp_path: Path, +) -> None: + """`--zip` points at a nonexistent file → user error.""" + main = _import_sign_main() + rc = main([ + "--manifest", str(manifest_path), + "--zip", str(tmp_path / "nope.skill"), + "--priv-key", str(priv_key_path), + "--out", str(tmp_path / "out.yaml"), + "--quiet", + ]) + assert rc == 1 + + +def test_both_in_place_and_out_is_error( + manifest_path: Path, + skill_zip: Path, + priv_key_path: Path, + tmp_path: Path, +) -> None: + """The §3.1 grammar marks `--in-place` and `--out` as mutually + exclusive; supplying both is user error.""" + main = _import_sign_main() + rc = main([ + "--manifest", str(manifest_path), + "--zip", str(skill_zip), + "--priv-key", str(priv_key_path), + "--in-place", + "--out", str(tmp_path / "out.yaml"), + "--quiet", + ]) + assert rc == 1 + + +def test_neither_in_place_nor_out_is_error( + manifest_path: Path, + skill_zip: Path, + priv_key_path: Path, +) -> None: + """If the caller specifies neither, we cannot know where to + write — refuse rather than guess (silent in-place would be + surprising). User error.""" + main = _import_sign_main() + rc = main([ + "--manifest", str(manifest_path), + "--zip", str(skill_zip), + "--priv-key", str(priv_key_path), + "--quiet", + ]) + assert rc == 1 diff --git a/references/apigee-skills-serving/tests/test_sign_verify_integration.py b/references/apigee-skills-serving/tests/test_sign_verify_integration.py new file mode 100644 index 000000000..8a5d39a43 --- /dev/null +++ b/references/apigee-skills-serving/tests/test_sign_verify_integration.py @@ -0,0 +1,181 @@ +"""Sign + verify round-trip integration test. + +Real ed25519 keypair, real ``canonicalize()``, real +``sign_skill.main``. No mocks, no network. This is the integration +half of the byte-exactness contract: the bytes the signer feeds +to ``Ed25519PrivateKey.sign`` MUST equal the bytes the verifier +feeds to ``Ed25519PublicKey.verify``. If they drift the +signature mismatches in production. + +The test deliberately reaches outside the script boundary — it +does what the consumer will do at install time, in miniature: + + 1. Generate a real ed25519 keypair on disk. + 2. Run ``sign_skill.main(...)`` against a real manifest + + ``.skill`` zip. + 3. Re-load the signed manifest. + 4. Recompute ``canonicalize(signed)`` and verify with the real + public key. + 5. As a bonus, mutate one byte in the manifest and confirm + verification FAILS — the test is only meaningful if the + signature is actually distinguishing real vs tampered. +""" +from __future__ import annotations + +import base64 +from pathlib import Path + +import pytest +import yaml +from cryptography.exceptions import InvalidSignature +from cryptography.hazmat.primitives import serialization +from cryptography.hazmat.primitives.asymmetric.ed25519 import ( + Ed25519PrivateKey, +) + +from scripts.common.canonical import canonicalize +from scripts.common.manifest_schema import validate_manifest +from scripts.sign_skill import main as sign_main + + +def _make_keypair(tmp_path: Path) -> Path: + key = Ed25519PrivateKey.generate() + raw = key.private_bytes( + encoding=serialization.Encoding.Raw, + format=serialization.PrivateFormat.Raw, + encryption_algorithm=serialization.NoEncryption(), + ) + p = tmp_path / "priv.key" + p.write_bytes(raw) + return p + + +def _make_manifest(tmp_path: Path) -> Path: + m = { + "manifest_schema_version": "1", + "name": "demo-skill", + "version": "1.2.3", + "description": "Round-trip integration test.", + "keywords": ["demo", "integration"], + "author": "demo-test-suite", + "license": "Apache-2.0", + "gs_uri": "gs://demo-bucket/demo-skill-1.2.3.skill", + "runtime_iam": ["apigee.proxies.list"], + } + p = tmp_path / "manifest.yaml" + p.write_text(yaml.safe_dump(m, sort_keys=False)) + return p + + +def _make_zip(tmp_path: Path) -> Path: + p = tmp_path / "demo-skill-1.2.3.skill" + p.write_bytes(b"PK\x03\x04integration-test-zip-payload") + return p + + +@pytest.fixture +def signed_manifest(tmp_path: Path) -> tuple[Path, Path]: + priv = _make_keypair(tmp_path) + manifest = _make_manifest(tmp_path) + skill = _make_zip(tmp_path) + out = tmp_path / "manifest.signed.yaml" + rc = sign_main([ + "--manifest", str(manifest), + "--zip", str(skill), + "--priv-key", str(priv), + "--out", str(out), + "--quiet", + ]) + assert rc == 0 + return out, priv + + +# --------------------------------------------------------------------------- +# Positive: sign + verify is byte-identical end-to-end +# --------------------------------------------------------------------------- + +def test_signed_manifest_validates( + signed_manifest: tuple[Path, Path] +) -> None: + out, _ = signed_manifest + signed = yaml.safe_load(out.read_text()) + validate_manifest(signed) + + +def test_signed_manifest_verifies_with_real_pubkey( + signed_manifest: tuple[Path, Path], +) -> None: + """The whole point: real verify-side bytes accepted by real + ed25519 public key. Drift in canonicalize() between sign and + verify would surface as InvalidSignature here.""" + out, priv_path = signed_manifest + signed = yaml.safe_load(out.read_text()) + priv = Ed25519PrivateKey.from_private_bytes( + priv_path.read_bytes() + ) + pub = priv.public_key() + sig = base64.b64decode(signed["signature"]) + pub.verify(sig, canonicalize(signed)) # raises on mismatch + + +def test_resign_is_byte_identical( + signed_manifest: tuple[Path, Path], + tmp_path: Path, +) -> None: + """Cross-check: re-signing the *original* unsigned manifest + with the same priv-key produces an identical YAML file on + disk. RFC 8032 ed25519 is deterministic, so this is a + correctness — not a flakiness — assertion.""" + out_a, priv_path = signed_manifest + out_b = tmp_path / "manifest.b.yaml" + redo_dir = tmp_path / "redo" + redo_dir.mkdir() + manifest_path = _make_manifest(redo_dir) + skill = _make_zip(redo_dir) + rc = sign_main([ + "--manifest", str(manifest_path), + "--zip", str(skill), + "--priv-key", str(priv_path), + "--out", str(out_b), + "--quiet", + ]) + assert rc == 0 + assert out_a.read_bytes() == out_b.read_bytes() + + +# --------------------------------------------------------------------------- +# Negative: tampered manifest fails verification +# --------------------------------------------------------------------------- + +def test_tampered_manifest_fails_verify( + signed_manifest: tuple[Path, Path], +) -> None: + """Mutate one field (description) after signing and confirm + the signature no longer verifies. Without this assertion the + other tests could pass with a no-op signer.""" + out, priv_path = signed_manifest + signed = yaml.safe_load(out.read_text()) + signed["description"] = "tampered after signing" + priv = Ed25519PrivateKey.from_private_bytes( + priv_path.read_bytes() + ) + pub = priv.public_key() + sig = base64.b64decode(signed["signature"]) + with pytest.raises(InvalidSignature): + pub.verify(sig, canonicalize(signed)) + + +def test_wrong_pubkey_fails_verify( + signed_manifest: tuple[Path, Path], + tmp_path: Path, +) -> None: + """A different ed25519 keypair MUST NOT validate the signature. + Defends against the 'we accidentally accept any signature' + failure mode.""" + out, _ = signed_manifest + signed = yaml.safe_load(out.read_text()) + other_priv = Ed25519PrivateKey.generate() + other_pub = other_priv.public_key() + sig = base64.b64decode(signed["signature"]) + with pytest.raises(InvalidSignature): + other_pub.verify(sig, canonicalize(signed)) diff --git a/references/apigee-skills-serving/tests/test_update_taxonomy.py b/references/apigee-skills-serving/tests/test_update_taxonomy.py new file mode 100644 index 000000000..6c9a16196 --- /dev/null +++ b/references/apigee-skills-serving/tests/test_update_taxonomy.py @@ -0,0 +1,216 @@ +"""Tests for ``scripts/update_taxonomy.py``. + +Coverage targets: + + * All four attributes created on fresh instance. + * Idempotent on second run. + * Permission-denied error path. + +Per-component criterion: creates exactly four attribute +definitions on a fresh project; no-op on second run. The four +attribute keys are fixed: + + agentic_skill, keywords, gs_uri, signing_key_id + +These mirror the four ``_ATTR_KEYS`` consumed by +``register_skill.py``; the constants are duplicated rather than +shared so a future change to one script forces the test author +to think about the other. +""" +from __future__ import annotations + +import json +from typing import Any +from unittest.mock import MagicMock + +import pytest + + +def _import_taxonomy_main(): + from scripts.update_taxonomy import main as _main + return _main + + +class FakeAttrSurface: + """Tiny stateful fake for the attribute-definition CRUD + endpoints. Tracks created attribute IDs so the idempotency + test can assert second-run is a pure-GET pass.""" + + def __init__(self) -> None: + self.attrs: set[str] = set() + self.calls: list[tuple[str, str]] = [] + + def request(self, method: str, url: str, **kwargs: Any): + prefix = "/locations/" + idx = url.find(prefix) + suffix = url[idx + len(prefix):] if idx >= 0 else url + if "/" in suffix: + suffix = "/" + suffix.split("/", 1)[1] + self.calls.append((method, suffix)) + params = kwargs.get("params", {}) or {} + body = kwargs.get("json") or {} + + if method == "GET" and suffix.startswith("/attributes/"): + attr_id = suffix.strip("/").split("/")[1] + if attr_id in self.attrs: + return _resp(200, {"name": attr_id}) + return _resp(404, {}) + + if method == "POST" and suffix == "/attributes": + attr_id = params.get("attributeId") or body.get("name") + if attr_id in self.attrs: + return _resp(409, {"error": "exists"}) + self.attrs.add(attr_id) + return _resp(200, {"name": attr_id}) + + return _resp(404, {"error": f"unmocked {method} {suffix}"}) + + +def _resp(status: int, body: dict): + class _R: + status_code = status + text = json.dumps(body) + + def json(self) -> dict: + return body + + def raise_for_status(self) -> None: + if status >= 400: + import requests + raise requests.HTTPError( + f"HTTP {status}", + response=self, # type: ignore[arg-type] + ) + return _R() + + +@pytest.fixture +def fake_surface(monkeypatch: pytest.MonkeyPatch) -> FakeAttrSurface: + import scripts.update_taxonomy as ut + + surface = FakeAttrSurface() + creds = MagicMock() + creds.token = "fake" + creds.refresh = MagicMock(return_value=None) + monkeypatch.setattr(ut, "_credentials", lambda: (creds, "demo")) + monkeypatch.setattr( + ut.requests, "request", + lambda method, url, **kw: surface.request(method, url, **kw), + ) + return surface + + +# --------------------------------------------------------------------------- +# Happy path +# --------------------------------------------------------------------------- + +def test_fresh_project_creates_four_attributes( + fake_surface: FakeAttrSurface, +) -> None: + """Exactly four attribute defs on a fresh instance.""" + main = _import_taxonomy_main() + rc = main([ + "--project", "demo-project", + "--location", "us-central1", + "--quiet", + ]) + assert rc == 0 + posts = [c for c in fake_surface.calls if c[0] == "POST"] + assert len(posts) == 4 + assert fake_surface.attrs == { + "agentic_skill", "keywords", "gs_uri", "signing_key_id" + } + + +def test_second_run_is_noop( + fake_surface: FakeAttrSurface, +) -> None: + """Second run is no-op (no POSTs).""" + main = _import_taxonomy_main() + main([ + "--project", "demo-project", + "--location", "us-central1", + "--quiet", + ]) + fake_surface.calls.clear() + rc = main([ + "--project", "demo-project", + "--location", "us-central1", + "--quiet", + ]) + assert rc == 0 + posts = [c for c in fake_surface.calls if c[0] == "POST"] + assert posts == [] + + +def test_partial_existing_creates_only_missing( + fake_surface: FakeAttrSurface, +) -> None: + """If some attributes already exist (e.g. created by a + previous partial run that crashed), the script must create + only the missing ones — not re-POST existing ones.""" + fake_surface.attrs.add("agentic_skill") + fake_surface.attrs.add("keywords") + main = _import_taxonomy_main() + rc = main([ + "--project", "demo-project", + "--location", "us-central1", + "--quiet", + ]) + assert rc == 0 + posts = [c for c in fake_surface.calls if c[0] == "POST"] + assert len(posts) == 2 + assert fake_surface.attrs == { + "agentic_skill", "keywords", "gs_uri", "signing_key_id" + } + + +# --------------------------------------------------------------------------- +# Error paths +# --------------------------------------------------------------------------- + +def test_permission_denied_exits_3( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """403 → exit 3 (IAM error class). §3.4 spec says 'fails + loudly on permission denied (exit 3)'.""" + import scripts.update_taxonomy as ut + + creds = MagicMock() + creds.token = "fake" + creds.refresh = MagicMock(return_value=None) + monkeypatch.setattr(ut, "_credentials", lambda: (creds, "demo")) + monkeypatch.setattr( + ut.requests, "request", + lambda method, url, **kw: _resp(403, {"error": "denied"}), + ) + main = _import_taxonomy_main() + rc = main([ + "--project", "demo-project", + "--location", "us-central1", + "--quiet", + ]) + assert rc == 3 + + +def test_5xx_exits_2( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Generic server-side failures classify as system error.""" + import scripts.update_taxonomy as ut + + creds = MagicMock() + creds.token = "fake" + creds.refresh = MagicMock(return_value=None) + monkeypatch.setattr(ut, "_credentials", lambda: (creds, "demo")) + monkeypatch.setattr( + ut.requests, "request", + lambda method, url, **kw: _resp(500, {"error": "boom"}), + ) + main = _import_taxonomy_main() + rc = main([ + "--project", "demo-project", + "--location", "us-central1", + "--quiet", + ]) + assert rc == 2 diff --git a/references/apigee-skills-serving/tests/test_upload_skill.py b/references/apigee-skills-serving/tests/test_upload_skill.py new file mode 100644 index 000000000..22d55ccd0 --- /dev/null +++ b/references/apigee-skills-serving/tests/test_upload_skill.py @@ -0,0 +1,252 @@ +"""Tests for ``scripts/upload_skill.py``. + +Coverage targets: + + * GCS mocked: success path returns URI on stdout. + * Bucket-not-found error path. + * Permission-denied path. + +The implementation uses ``requests`` against the GCS JSON upload +endpoint (no ``google-cloud-storage`` dependency). ADC provides +the bearer token via ``google.auth.default()``. Both boundaries +are mocked at module-attribute level (``monkeypatch.setattr``) so +the tests never hit the network. +""" +from __future__ import annotations + +import io +from pathlib import Path +from typing import Any +from unittest.mock import MagicMock + +import pytest + + +def _import_upload_main(): + from scripts.upload_skill import main as _main + return _main + + +# --------------------------------------------------------------------------- +# Boundary mocks +# --------------------------------------------------------------------------- + +class FakeResponse: + """Minimal ``requests.Response`` stand-in. Carries a status + and a json body; ``raise_for_status`` mirrors the real + behaviour. Used in place of the network layer.""" + + def __init__(self, status: int, body: dict | None = None, + text: str = "") -> None: + self.status_code = status + self._body = body or {} + self.text = text + + def json(self) -> dict: + return self._body + + def raise_for_status(self) -> None: + if self.status_code >= 400: + import requests + raise requests.HTTPError( + f"HTTP {self.status_code}: {self.text}", + response=self, # type: ignore[arg-type] + ) + + +@pytest.fixture +def fake_creds(monkeypatch: pytest.MonkeyPatch) -> MagicMock: + """Replace ``google.auth.default`` with a stub that returns a + pre-built credentials object whose ``refresh`` is a no-op and + whose ``token`` is a sentinel string. The upload script uses + the token only as a bearer header; the string contents are + not interpreted by the script.""" + import scripts.upload_skill as us + + creds = MagicMock() + creds.token = "fake-bearer-token" + creds.refresh = MagicMock(return_value=None) + monkeypatch.setattr( + us, "_credentials", lambda: (creds, "demo-project") + ) + return creds + + +@pytest.fixture +def skill_zip(tmp_path: Path) -> Path: + p = tmp_path / "demo-skill-1.0.0.skill" + p.write_bytes(b"PK\x03\x04fake-zip-payload") + return p + + +# --------------------------------------------------------------------------- +# Happy path +# --------------------------------------------------------------------------- + +def test_success_uploads_and_prints_uri( + fake_creds: MagicMock, + skill_zip: Path, + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +) -> None: + """Success path: 200 from GCS → exit 0 → final stdout line is + the gs:// URI.""" + import scripts.upload_skill as us + + captured: dict[str, Any] = {} + + def fake_post(url: str, **kwargs: Any) -> FakeResponse: + captured["url"] = url + captured["headers"] = kwargs.get("headers", {}) + captured["data"] = kwargs.get("data") + captured["params"] = kwargs.get("params", {}) + return FakeResponse(200, {"name": "demo-skill-1.0.0.skill"}) + + monkeypatch.setattr(us.requests, "post", fake_post) + + main = _import_upload_main() + rc = main([ + "--zip", str(skill_zip), + "--bucket", "demo-bucket", + ]) + assert rc == 0 + out = capsys.readouterr().out + assert "gs://demo-bucket/demo-skill-1.0.0.skill" in out + # Authorization header threaded through from the fake creds. + assert captured["headers"].get("Authorization") == ( + "Bearer fake-bearer-token" + ) + + +def test_object_name_override( + fake_creds: MagicMock, + skill_zip: Path, + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +) -> None: + """``--object-name`` replaces the default basename in both + the request and the printed URI.""" + import scripts.upload_skill as us + + captured: dict[str, Any] = {} + + def fake_post(url: str, **kwargs: Any) -> FakeResponse: + captured["params"] = kwargs.get("params", {}) + return FakeResponse(200, {}) + + monkeypatch.setattr(us.requests, "post", fake_post) + + main = _import_upload_main() + rc = main([ + "--zip", str(skill_zip), + "--bucket", "demo-bucket", + "--object-name", "custom/path/my.skill", + ]) + assert rc == 0 + out = capsys.readouterr().out + assert "gs://demo-bucket/custom/path/my.skill" in out + assert captured["params"].get("name") == "custom/path/my.skill" + + +# --------------------------------------------------------------------------- +# Error paths +# --------------------------------------------------------------------------- + +def test_bucket_not_found_exits_2( + fake_creds: MagicMock, + skill_zip: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """A 404 from GCS → system error (exit 2).""" + import scripts.upload_skill as us + monkeypatch.setattr( + us.requests, "post", + lambda url, **kw: FakeResponse(404, text="bucket not found"), + ) + main = _import_upload_main() + rc = main([ + "--zip", str(skill_zip), + "--bucket", "missing-bucket", + "--quiet", + ]) + assert rc == 2 + + +def test_permission_denied_exits_3( + fake_creds: MagicMock, + skill_zip: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """A 403 from GCS is IAM, not transport — §3.2 reserves exit 3.""" + import scripts.upload_skill as us + monkeypatch.setattr( + us.requests, "post", + lambda url, **kw: FakeResponse(403, text="forbidden"), + ) + main = _import_upload_main() + rc = main([ + "--zip", str(skill_zip), + "--bucket", "denied-bucket", + "--quiet", + ]) + assert rc == 3 + + +def test_other_5xx_exits_2( + fake_creds: MagicMock, + skill_zip: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Server error → system error. (The retry policy lives in + the consumer's install pipeline; upload is one-shot.)""" + import scripts.upload_skill as us + monkeypatch.setattr( + us.requests, "post", + lambda url, **kw: FakeResponse(500, text="boom"), + ) + main = _import_upload_main() + rc = main([ + "--zip", str(skill_zip), + "--bucket", "demo-bucket", + "--quiet", + ]) + assert rc == 2 + + +def test_missing_zip_exits_1( + fake_creds: MagicMock, + tmp_path: Path, +) -> None: + """`--zip` points at nothing → user error per §3.2.""" + main = _import_upload_main() + rc = main([ + "--zip", str(tmp_path / "missing.skill"), + "--bucket", "demo-bucket", + "--quiet", + ]) + assert rc == 1 + + +def test_quiet_suppresses_stdout( + fake_creds: MagicMock, + skill_zip: Path, + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +) -> None: + """Even on success, ``--quiet`` keeps stdout empty. The §3.2 + contract says the URI is the only stdout line — quiet kills + everything except it; we drop it entirely under --quiet so the + script is safe in shell pipelines that capture stdout.""" + import scripts.upload_skill as us + monkeypatch.setattr( + us.requests, "post", + lambda url, **kw: FakeResponse(200, {}), + ) + main = _import_upload_main() + rc = main([ + "--zip", str(skill_zip), + "--bucket", "demo-bucket", + "--quiet", + ]) + assert rc == 0 + assert capsys.readouterr().out == "" diff --git a/references/apigee-skills-serving/tests/test_watcher_probe.py b/references/apigee-skills-serving/tests/test_watcher_probe.py new file mode 100644 index 000000000..a107d8079 --- /dev/null +++ b/references/apigee-skills-serving/tests/test_watcher_probe.py @@ -0,0 +1,202 @@ +"""Tests for ``scripts/common/watcher_probe.py``. + +The three-state probe returns: + + WATCHER_DISABLED -- env unset or explicitly disabled + WATCHER_ENABLED -- env enabled AND probe directory survived + the settle window (degenerate: the probe + trivially survives because we created it + ourselves) + WATCHER_UNDETECTABLE -- env enabled but mkdir / iterdir raised + OSError (e.g., read-only skills dir) + +Acceptance: "env-disabled returns DISABLED; env-unset returns +DISABLED; env-enabled + writable dir returns ENABLED; env-enabled + +read-only dir returns UNDETECTABLE; probe cleanup on all paths". + +All tests pass ``settle_seconds=0.0`` so the suite stays fast -- +the settle window's actual value is irrelevant for the +three-state outcome. +""" +from __future__ import annotations + +from pathlib import Path + +import pytest + +from scripts.common.watcher_probe import ( + WatcherState, + detect_watcher, +) + + +# ----- DISABLED paths ----- + + +def test_env_disabled_returns_DISABLED( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + """The explicit disable variable beats every other signal.""" + monkeypatch.setenv( + "OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER", "1" + ) + monkeypatch.setenv("OPENCODE_EXPERIMENTAL_FILEWATCHER", "1") + state = detect_watcher( + skills_dir=tmp_path, settle_seconds=0.0 + ) + assert state == WatcherState.WATCHER_DISABLED + + +def test_env_unset_returns_DISABLED( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + """The watcher is opt-in. Absence of the enable variable is + equivalent to DISABLED -- and explicitly so, not + UNDETECTABLE.""" + monkeypatch.delenv( + "OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER", raising=False + ) + monkeypatch.delenv( + "OPENCODE_EXPERIMENTAL_FILEWATCHER", raising=False + ) + state = detect_watcher( + skills_dir=tmp_path, settle_seconds=0.0 + ) + assert state == WatcherState.WATCHER_DISABLED + + +def test_disable_var_takes_precedence_over_enable_var( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + """The disable variable is the operator's escape hatch and + beats the enable variable. Order in the function matters: + ``_env_disabled()`` is checked first.""" + monkeypatch.setenv("OPENCODE_EXPERIMENTAL_FILEWATCHER", "1") + monkeypatch.setenv( + "OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER", "1" + ) + state = detect_watcher( + skills_dir=tmp_path, settle_seconds=0.0 + ) + assert state == WatcherState.WATCHER_DISABLED + + +def test_disabled_path_does_not_write_probe( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + """When DISABLED the probe MUST short-circuit without touching + the skills directory. Otherwise we would write a useless + sentinel on every install on every machine.""" + monkeypatch.delenv( + "OPENCODE_EXPERIMENTAL_FILEWATCHER", raising=False + ) + detect_watcher(skills_dir=tmp_path, settle_seconds=0.0) + assert list(tmp_path.iterdir()) == [], ( + "DISABLED path wrote to skills_dir; should have skipped" + ) + + +# ----- ENABLED + UNDETECTABLE paths ----- + + +def test_env_enabled_writable_dir_returns_ENABLED( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + """Happy path: env opt-in is set, skills_dir is writable. + The probe writes a ``.probe-`` directory, settles, and + confirms it appears in the listing. (We know this check is + degenerate -- the probe trivially survives because we created + it ourselves -- but ENABLED is the documented return value + for this config.)""" + monkeypatch.setenv("OPENCODE_EXPERIMENTAL_FILEWATCHER", "1") + monkeypatch.delenv( + "OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER", raising=False + ) + state = detect_watcher( + skills_dir=tmp_path, settle_seconds=0.0 + ) + assert state == WatcherState.WATCHER_ENABLED + + +def test_env_enabled_readonly_dir_returns_UNDETECTABLE( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + """When the skills directory is unwritable, the probe cannot + write its sentinel. OSError is caught and converted to + UNDETECTABLE so the caller can fall back to the + /reload-skills path -- not crash.""" + monkeypatch.setenv("OPENCODE_EXPERIMENTAL_FILEWATCHER", "1") + monkeypatch.delenv( + "OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER", raising=False + ) + # Remove write permission to force mkdir to raise. + tmp_path.chmod(0o555) + try: + state = detect_watcher( + skills_dir=tmp_path, settle_seconds=0.0 + ) + assert state == WatcherState.WATCHER_UNDETECTABLE + finally: + # Restore so tmp_path cleanup (pytest fixture teardown) + # can remove the dir. + tmp_path.chmod(0o755) + + +# ----- Probe cleanup ----- + + +def test_probe_dir_cleaned_up_after_ENABLED( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + """The probe writes ``.probe-/SKILL.md``. After return, + no probe directory should remain -- otherwise repeated + invocations accumulate stale probe dirs in ~/.config/.../skills.""" + monkeypatch.setenv("OPENCODE_EXPERIMENTAL_FILEWATCHER", "1") + monkeypatch.delenv( + "OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER", raising=False + ) + detect_watcher(skills_dir=tmp_path, settle_seconds=0.0) + probe_dirs = [p for p in tmp_path.iterdir() if p.name.startswith(".probe-")] + assert probe_dirs == [], ( + f"probe dirs leaked after detect_watcher: {probe_dirs}" + ) + + +def test_probe_dir_cleaned_up_after_UNDETECTABLE( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + """Even on the error path, cleanup must run. The try/finally + in detect_watcher ensures shutil.rmtree(probe_dir, + ignore_errors=True) fires whether the probe succeeded or + raised OSError mid-stream.""" + monkeypatch.setenv("OPENCODE_EXPERIMENTAL_FILEWATCHER", "1") + monkeypatch.delenv( + "OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER", raising=False + ) + tmp_path.chmod(0o555) + try: + detect_watcher(skills_dir=tmp_path, settle_seconds=0.0) + # mkdir failed, so no probe dir was created; either way + # the directory listing should not contain a `.probe-*`. + tmp_path.chmod(0o755) + probe_dirs = [ + p for p in tmp_path.iterdir() if p.name.startswith(".probe-") + ] + assert probe_dirs == [] + finally: + tmp_path.chmod(0o755) + + +# ----- WatcherState enum sanity ----- + + +def test_watcher_state_string_values() -> None: + """The enum's str values are observable via .value -- callers + log them. Lock the wire format so a future rename doesn't + silently break log greps.""" + assert WatcherState.WATCHER_ENABLED.value == "watcher_enabled" + assert WatcherState.WATCHER_DISABLED.value == "watcher_disabled" + assert ( + WatcherState.WATCHER_UNDETECTABLE.value + == "watcher_undetectable" + ) From eda2aa74273d186e4d1c85db08c72d5bfcbfb1eb Mon Sep 17 00:00:00 2001 From: Geir Sjurseth Date: Thu, 25 Jun 2026 15:45:39 +0000 Subject: [PATCH 2/4] fix(apigee-skills-serving): satisfy isort + add license headers to tests Two CI fixes from PR #889 first run: 1. PYTHON_ISORT (Lint Codebase job): 12 Python files had import groupings that violate isort defaults (multi-line imports that fit on one line; trailing blank line after imports). Auto-fixed with `isort scripts/ tests/`. 2. License Headers job: 16 files in tests/ were missing the Apache 2.0 header. (My earlier bulk-header script targeted scripts/ and skills/ only; tests/ was overlooked.) All 16 now have the standard header. Verified locally: - isort --check-only scripts/ tests/ (clean) - pytest -q tests/ (220/220 pass) --- .../scripts/common/permission_resolver.py | 1 - .../scripts/common/watcher_probe.py | 1 - .../scripts/register_skill.py | 6 ++--- .../scripts/sign_skill.py | 10 +++----- .../apigee-skills-serving/tests/conftest.py | 14 +++++++++++ .../tests/test_apigee_top10.py | 15 +++++++++++- .../tests/test_canonical.py | 14 +++++++++++ .../tests/test_check_demo_prerequisites.py | 14 +++++++++++ .../tests/test_config.py | 14 +++++++++++ .../tests/test_http_retry.py | 15 +++++++++++- .../tests/test_iam_preflight.py | 15 +++++++++++- .../tests/test_manifest_schema.py | 20 ++++++++++++---- .../tests/test_permission_resolver.py | 23 ++++++++++++++----- .../tests/test_register_fetch_integration.py | 14 +++++++++++ .../tests/test_register_skill.py | 14 +++++++++++ .../tests/test_sign_skill.py | 23 ++++++++++++++----- .../tests/test_sign_verify_integration.py | 18 ++++++++++++--- .../tests/test_update_taxonomy.py | 14 +++++++++++ .../tests/test_upload_skill.py | 14 +++++++++++ .../tests/test_watcher_probe.py | 20 ++++++++++++---- 20 files changed, 239 insertions(+), 40 deletions(-) diff --git a/references/apigee-skills-serving/scripts/common/permission_resolver.py b/references/apigee-skills-serving/scripts/common/permission_resolver.py index 38e15d668..5e23952ce 100644 --- a/references/apigee-skills-serving/scripts/common/permission_resolver.py +++ b/references/apigee-skills-serving/scripts/common/permission_resolver.py @@ -50,7 +50,6 @@ from pathlib import Path from typing import Any - GLOBAL_OPENCODE_JSON = ( Path.home() / ".config" / "opencode" / "opencode.json" ) diff --git a/references/apigee-skills-serving/scripts/common/watcher_probe.py b/references/apigee-skills-serving/scripts/common/watcher_probe.py index 23fcebc8e..ee60873cb 100644 --- a/references/apigee-skills-serving/scripts/common/watcher_probe.py +++ b/references/apigee-skills-serving/scripts/common/watcher_probe.py @@ -41,7 +41,6 @@ from enum import Enum from pathlib import Path - SKILLS_DIR = Path.home() / ".config" / "opencode" / "skills" PROBE_SETTLE_SECONDS = 2.0 diff --git a/references/apigee-skills-serving/scripts/register_skill.py b/references/apigee-skills-serving/scripts/register_skill.py index f2dc85c23..4ed40c6f3 100644 --- a/references/apigee-skills-serving/scripts/register_skill.py +++ b/references/apigee-skills-serving/scripts/register_skill.py @@ -54,10 +54,8 @@ import requests import yaml -from scripts.common.manifest_schema import ( - ManifestValidationError, - validate_manifest, -) +from scripts.common.manifest_schema import (ManifestValidationError, + validate_manifest) EXIT_OK = 0 EXIT_USER = 1 diff --git a/references/apigee-skills-serving/scripts/sign_skill.py b/references/apigee-skills-serving/scripts/sign_skill.py index b5e625dc4..eae85cb7d 100644 --- a/references/apigee-skills-serving/scripts/sign_skill.py +++ b/references/apigee-skills-serving/scripts/sign_skill.py @@ -60,15 +60,11 @@ import yaml from cryptography.exceptions import InvalidKey from cryptography.hazmat.primitives import serialization -from cryptography.hazmat.primitives.asymmetric.ed25519 import ( - Ed25519PrivateKey, -) +from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey from scripts.common.canonical import canonicalize -from scripts.common.manifest_schema import ( - ManifestValidationError, - validate_manifest, -) +from scripts.common.manifest_schema import (ManifestValidationError, + validate_manifest) EXIT_OK = 0 EXIT_USER = 1 diff --git a/references/apigee-skills-serving/tests/conftest.py b/references/apigee-skills-serving/tests/conftest.py index 73387f6fd..ee945fd6d 100644 --- a/references/apigee-skills-serving/tests/conftest.py +++ b/references/apigee-skills-serving/tests/conftest.py @@ -1,3 +1,17 @@ +# Copyright 2026 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + """Shared pytest configuration. Adds the repository root to ``sys.path`` so test files can do diff --git a/references/apigee-skills-serving/tests/test_apigee_top10.py b/references/apigee-skills-serving/tests/test_apigee_top10.py index 8ddf49352..fd61b825d 100644 --- a/references/apigee-skills-serving/tests/test_apigee_top10.py +++ b/references/apigee-skills-serving/tests/test_apigee_top10.py @@ -1,3 +1,17 @@ +# Copyright 2026 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + """Tests for ``skills/apigee-policy-top10/scripts/top10.py``. Covers the acceptance criteria for the apigee-policy-top10 skill: @@ -32,7 +46,6 @@ import pytest import yaml - # --------------------------------------------------------------------------- # Module loading # diff --git a/references/apigee-skills-serving/tests/test_canonical.py b/references/apigee-skills-serving/tests/test_canonical.py index 93e1f0521..73a6ea714 100644 --- a/references/apigee-skills-serving/tests/test_canonical.py +++ b/references/apigee-skills-serving/tests/test_canonical.py @@ -1,3 +1,17 @@ +# Copyright 2026 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + """Tests for ``scripts/common/canonical.py``. The canonical transform is: diff --git a/references/apigee-skills-serving/tests/test_check_demo_prerequisites.py b/references/apigee-skills-serving/tests/test_check_demo_prerequisites.py index 2d81037e7..4a1d0a938 100644 --- a/references/apigee-skills-serving/tests/test_check_demo_prerequisites.py +++ b/references/apigee-skills-serving/tests/test_check_demo_prerequisites.py @@ -1,3 +1,17 @@ +# Copyright 2026 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + """Pre-flight env-var checker tests. ``bin/check-prerequisites.sh`` is the operator's last-mile gate diff --git a/references/apigee-skills-serving/tests/test_config.py b/references/apigee-skills-serving/tests/test_config.py index 616970bd5..c546fa59c 100644 --- a/references/apigee-skills-serving/tests/test_config.py +++ b/references/apigee-skills-serving/tests/test_config.py @@ -1,3 +1,17 @@ +# Copyright 2026 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + """Unit tests for scripts/common/config.py. The helper has 3 concerns we want to nail down: diff --git a/references/apigee-skills-serving/tests/test_http_retry.py b/references/apigee-skills-serving/tests/test_http_retry.py index 45b77058c..41b71a2ca 100644 --- a/references/apigee-skills-serving/tests/test_http_retry.py +++ b/references/apigee-skills-serving/tests/test_http_retry.py @@ -1,3 +1,17 @@ +# Copyright 2026 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + """Tests for ``scripts/common/http_retry.py``. The helper: @@ -27,7 +41,6 @@ from scripts.common import http_retry - # ----- Test helpers ----- diff --git a/references/apigee-skills-serving/tests/test_iam_preflight.py b/references/apigee-skills-serving/tests/test_iam_preflight.py index da93e8a7a..838b31647 100644 --- a/references/apigee-skills-serving/tests/test_iam_preflight.py +++ b/references/apigee-skills-serving/tests/test_iam_preflight.py @@ -1,3 +1,17 @@ +# Copyright 2026 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + """Tests for ``scripts/common/iam_preflight.py``. The pre-flight library: @@ -33,7 +47,6 @@ from scripts.common import http_retry, iam_preflight - # ----- Fakes ----- diff --git a/references/apigee-skills-serving/tests/test_manifest_schema.py b/references/apigee-skills-serving/tests/test_manifest_schema.py index e30a198a7..fd231caf2 100644 --- a/references/apigee-skills-serving/tests/test_manifest_schema.py +++ b/references/apigee-skills-serving/tests/test_manifest_schema.py @@ -1,3 +1,17 @@ +# Copyright 2026 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + """Tests for ``scripts/common/manifest_schema.py``. The manifest schema is LOCKED. Every required field is enforced; @@ -18,10 +32,8 @@ import pytest -from scripts.common.manifest_schema import ( - ManifestValidationError, - validate_manifest, -) +from scripts.common.manifest_schema import (ManifestValidationError, + validate_manifest) @pytest.fixture diff --git a/references/apigee-skills-serving/tests/test_permission_resolver.py b/references/apigee-skills-serving/tests/test_permission_resolver.py index 152cb377a..e840b82ef 100644 --- a/references/apigee-skills-serving/tests/test_permission_resolver.py +++ b/references/apigee-skills-serving/tests/test_permission_resolver.py @@ -1,3 +1,17 @@ +# Copyright 2026 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + """Tests for ``scripts/common/permission_resolver.py``. The resolver reads OpenCode's documented permission chain: global @@ -21,12 +35,9 @@ import pytest from scripts.common import permission_resolver as pr -from scripts.common.permission_resolver import ( - Resolution, - Verdict, - detect_active_agent, - resolve_skill_permission, -) +from scripts.common.permission_resolver import (Resolution, Verdict, + detect_active_agent, + resolve_skill_permission) @pytest.fixture diff --git a/references/apigee-skills-serving/tests/test_register_fetch_integration.py b/references/apigee-skills-serving/tests/test_register_fetch_integration.py index 464533a4d..e00e3ad97 100644 --- a/references/apigee-skills-serving/tests/test_register_fetch_integration.py +++ b/references/apigee-skills-serving/tests/test_register_fetch_integration.py @@ -1,3 +1,17 @@ +# Copyright 2026 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + """Register + fetch round-trip integration test. Real ``register_skill.main`` against a mocked API hub HTTP layer; diff --git a/references/apigee-skills-serving/tests/test_register_skill.py b/references/apigee-skills-serving/tests/test_register_skill.py index 32358a797..f404295d0 100644 --- a/references/apigee-skills-serving/tests/test_register_skill.py +++ b/references/apigee-skills-serving/tests/test_register_skill.py @@ -1,3 +1,17 @@ +# Copyright 2026 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + """Tests for ``scripts/register_skill.py``. Coverage targets: diff --git a/references/apigee-skills-serving/tests/test_sign_skill.py b/references/apigee-skills-serving/tests/test_sign_skill.py index f9b65ab3d..50b04fe27 100644 --- a/references/apigee-skills-serving/tests/test_sign_skill.py +++ b/references/apigee-skills-serving/tests/test_sign_skill.py @@ -1,3 +1,17 @@ +# Copyright 2026 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + """Tests for ``scripts/sign_skill.py``. Coverage targets: @@ -29,9 +43,7 @@ import pytest import yaml from cryptography.hazmat.primitives import serialization -from cryptography.hazmat.primitives.asymmetric.ed25519 import ( - Ed25519PrivateKey, -) +from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey from scripts.common.canonical import canonicalize from scripts.common.manifest_schema import validate_manifest @@ -203,9 +215,8 @@ def test_signature_verifies_with_pubkey( half of the §8.2 sign+verify integration.""" import base64 - from cryptography.hazmat.primitives.asymmetric.ed25519 import ( - Ed25519PublicKey, - ) + from cryptography.hazmat.primitives.asymmetric.ed25519 import \ + Ed25519PublicKey out = tmp_path / "m.signed.yaml" main = _import_sign_main() diff --git a/references/apigee-skills-serving/tests/test_sign_verify_integration.py b/references/apigee-skills-serving/tests/test_sign_verify_integration.py index 8a5d39a43..848645195 100644 --- a/references/apigee-skills-serving/tests/test_sign_verify_integration.py +++ b/references/apigee-skills-serving/tests/test_sign_verify_integration.py @@ -1,3 +1,17 @@ +# Copyright 2026 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + """Sign + verify round-trip integration test. Real ed25519 keypair, real ``canonicalize()``, real @@ -29,9 +43,7 @@ import yaml from cryptography.exceptions import InvalidSignature from cryptography.hazmat.primitives import serialization -from cryptography.hazmat.primitives.asymmetric.ed25519 import ( - Ed25519PrivateKey, -) +from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey from scripts.common.canonical import canonicalize from scripts.common.manifest_schema import validate_manifest diff --git a/references/apigee-skills-serving/tests/test_update_taxonomy.py b/references/apigee-skills-serving/tests/test_update_taxonomy.py index 6c9a16196..6d4942984 100644 --- a/references/apigee-skills-serving/tests/test_update_taxonomy.py +++ b/references/apigee-skills-serving/tests/test_update_taxonomy.py @@ -1,3 +1,17 @@ +# Copyright 2026 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + """Tests for ``scripts/update_taxonomy.py``. Coverage targets: diff --git a/references/apigee-skills-serving/tests/test_upload_skill.py b/references/apigee-skills-serving/tests/test_upload_skill.py index 22d55ccd0..837911c2b 100644 --- a/references/apigee-skills-serving/tests/test_upload_skill.py +++ b/references/apigee-skills-serving/tests/test_upload_skill.py @@ -1,3 +1,17 @@ +# Copyright 2026 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + """Tests for ``scripts/upload_skill.py``. Coverage targets: diff --git a/references/apigee-skills-serving/tests/test_watcher_probe.py b/references/apigee-skills-serving/tests/test_watcher_probe.py index a107d8079..62694ace6 100644 --- a/references/apigee-skills-serving/tests/test_watcher_probe.py +++ b/references/apigee-skills-serving/tests/test_watcher_probe.py @@ -1,3 +1,17 @@ +# Copyright 2026 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + """Tests for ``scripts/common/watcher_probe.py``. The three-state probe returns: @@ -24,11 +38,7 @@ import pytest -from scripts.common.watcher_probe import ( - WatcherState, - detect_watcher, -) - +from scripts.common.watcher_probe import WatcherState, detect_watcher # ----- DISABLED paths ----- From 76fdc1cb356c65ecc575b22df1c6e35e3cd49235 Mon Sep 17 00:00:00 2001 From: Geir Sjurseth Date: Thu, 25 Jun 2026 16:50:48 +0000 Subject: [PATCH 3/4] fix(apigee-skills-serving): satisfy flake8, bandit, yamllint from CI Fixes remaining CI lint failures on PR #889 across four linters: flake8 (W391, F401, E265, E741): - Removed trailing blank line from 2 __init__.py files - Removed 7 unused imports across 5 test files - Renamed ambiguous variable `l` to `line` in a comprehension - Moved shebang to line 1 in top10.py (was below license header) bandit (B311, B405, B314): - Annotated random.randint() for retry jitter with `# nosec B311` (random is not used in a cryptographic context) - Annotated xml.etree.ElementTree import and ET.fromstring() with `# nosec B405` / `# nosec B314` (XML is fetched from authenticated Apigee API, not untrusted user input) yamllint (indentation): - Re-indented block sequences in 4 manifest.yaml files from 0-indent to 2-indent per yamllint default (`keywords:\n - item` instead of `keywords:\n- item`) Verified locally with CI-matching versions: - isort 5.9.3 --check-only: clean - flake8 7.3.0: clean - bandit 1.7.0: no issues - yamllint: clean - pytest -q tests/: 220/220 pass --- .../examples/apigee-proxy-skill/manifest.yaml | 70 +++++++++---------- .../apigee-skills-serving/scripts/__init__.py | 1 - .../scripts/common/__init__.py | 1 - .../scripts/common/http_retry.py | 2 +- .../skills/apigee-policy-top10/manifest.yaml | 14 ++-- .../apigee-policy-top10/scripts/top10.py | 7 +- .../skills/currency-converter/manifest.yaml | 8 +-- .../skills/weather-lookup/manifest.yaml | 8 +-- .../tests/test_apigee_top10.py | 2 - .../tests/test_check_demo_prerequisites.py | 3 +- .../tests/test_register_skill.py | 1 - .../tests/test_sign_skill.py | 3 - .../tests/test_upload_skill.py | 1 - 13 files changed, 55 insertions(+), 66 deletions(-) diff --git a/references/apigee-skills-serving/examples/apigee-proxy-skill/manifest.yaml b/references/apigee-skills-serving/examples/apigee-proxy-skill/manifest.yaml index 0746b9e3b..b9c0b981b 100644 --- a/references/apigee-skills-serving/examples/apigee-proxy-skill/manifest.yaml +++ b/references/apigee-skills-serving/examples/apigee-proxy-skill/manifest.yaml @@ -25,43 +25,43 @@ description: 'Teaches an LLM agent to scaffold, validate, package, upload, and d cross-issuer sub collisions). 5 architectural decisions recorded as ADRs; 600 tests; 1.30:1 test:src ratio.' keywords: -- apigee -- proxy -- mcp -- scaffold -- validate -- package -- upload -- deploy -- policy -- xml -- jinja2 -- defusedxml -- workload-identity-federation -- cloud-run + - apigee + - proxy + - mcp + - scaffold + - validate + - package + - upload + - deploy + - policy + - xml + - jinja2 + - defusedxml + - workload-identity-federation + - cloud-run license: Apache-2.0 manifest_schema_version: '1' name: apigee-proxy-skill runtime_iam: -- apigee.proxies.create -- apigee.proxies.get -- apigee.proxies.list -- apigee.proxyrevisions.create -- apigee.proxyrevisions.get -- apigee.proxyrevisions.list -- apigee.deployments.create -- apigee.deployments.delete -- apigee.deployments.get -- apigee.deployments.list -- apigee.keyvaluemaps.create -- apigee.keyvaluemaps.get -- apigee.keyvaluemaps.update -- apigee.keyvaluemapentries.create -- apigee.keyvaluemapentries.update -- apigee.targetservers.create -- apigee.targetservers.get -- apigee.targetservers.update -- apigee.resourcefiles.create -- apigee.resourcefiles.get -- apigee.resourcefiles.update + - apigee.proxies.create + - apigee.proxies.get + - apigee.proxies.list + - apigee.proxyrevisions.create + - apigee.proxyrevisions.get + - apigee.proxyrevisions.list + - apigee.deployments.create + - apigee.deployments.delete + - apigee.deployments.get + - apigee.deployments.list + - apigee.keyvaluemaps.create + - apigee.keyvaluemaps.get + - apigee.keyvaluemaps.update + - apigee.keyvaluemapentries.create + - apigee.keyvaluemapentries.update + - apigee.targetservers.create + - apigee.targetservers.get + - apigee.targetservers.update + - apigee.resourcefiles.create + - apigee.resourcefiles.get + - apigee.resourcefiles.update version: 0.1.0 diff --git a/references/apigee-skills-serving/scripts/__init__.py b/references/apigee-skills-serving/scripts/__init__.py index a087f1b90..f8cb6d9d2 100644 --- a/references/apigee-skills-serving/scripts/__init__.py +++ b/references/apigee-skills-serving/scripts/__init__.py @@ -11,4 +11,3 @@ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. - diff --git a/references/apigee-skills-serving/scripts/common/__init__.py b/references/apigee-skills-serving/scripts/common/__init__.py index a087f1b90..f8cb6d9d2 100644 --- a/references/apigee-skills-serving/scripts/common/__init__.py +++ b/references/apigee-skills-serving/scripts/common/__init__.py @@ -11,4 +11,3 @@ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. - diff --git a/references/apigee-skills-serving/scripts/common/http_retry.py b/references/apigee-skills-serving/scripts/common/http_retry.py index 3a89eaee6..f758a2744 100644 --- a/references/apigee-skills-serving/scripts/common/http_retry.py +++ b/references/apigee-skills-serving/scripts/common/http_retry.py @@ -80,7 +80,7 @@ def _request_with_retry( """ resp = fn(url, **kwargs) if 500 <= resp.status_code < 600: - sleep_seconds = random.uniform(0.2, 0.4) + sleep_seconds = random.uniform(0.2, 0.4) # nosec B311 - retry jitter, not cryptographic # Hand the retry decision to the caller BEFORE sleeping # so the caller's log line carries the same backoff value # we are about to wait. Library itself stays silent -- diff --git a/references/apigee-skills-serving/skills/apigee-policy-top10/manifest.yaml b/references/apigee-skills-serving/skills/apigee-policy-top10/manifest.yaml index 7fce47c02..5e30b6597 100644 --- a/references/apigee-skills-serving/skills/apigee-policy-top10/manifest.yaml +++ b/references/apigee-skills-serving/skills/apigee-policy-top10/manifest.yaml @@ -18,15 +18,15 @@ capabilities: [] description: Reports the top 10 Apigee policy types currently in use across deployed proxy revisions in the caller's Apigee org. keywords: -- apigee -- policy -- top10 -- proxies + - apigee + - policy + - top10 + - proxies license: Apache-2.0 manifest_schema_version: '1' name: apigee-policy-top10 runtime_iam: -- apigee.proxies.list -- apigee.deployments.list -- apigee.proxyrevisions.get + - apigee.proxies.list + - apigee.deployments.list + - apigee.proxyrevisions.get version: 0.1.0 diff --git a/references/apigee-skills-serving/skills/apigee-policy-top10/scripts/top10.py b/references/apigee-skills-serving/skills/apigee-policy-top10/scripts/top10.py index e931541a1..588cc3df1 100644 --- a/references/apigee-skills-serving/skills/apigee-policy-top10/scripts/top10.py +++ b/references/apigee-skills-serving/skills/apigee-policy-top10/scripts/top10.py @@ -1,3 +1,4 @@ +#!/usr/bin/env python3 # Copyright 2026 Google LLC # # Licensed under the Apache License, Version 2.0 (the "License"); @@ -11,8 +12,6 @@ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. - -#!/usr/bin/env python3 """apigee-policy-top10 enumerator. Strategy: full bundle download per deployed proxy revision, XML @@ -40,7 +39,7 @@ import zipfile from collections import Counter from pathlib import Path -from xml.etree import ElementTree as ET +from xml.etree import ElementTree as ET # nosec B405 - trusted Apigee proxy XML import google.auth import google.auth.transport.requests @@ -167,7 +166,7 @@ def _policy_types_in_bundle(zip_bytes: bytes) -> list[str]: if not name.endswith(".xml"): continue try: - tree = ET.fromstring(zf.read(name)) + tree = ET.fromstring(zf.read(name)) # nosec B314 - trusted Apigee proxy XML types.append(tree.tag) except ET.ParseError: _say(f"warning: unparseable policy file {name}") diff --git a/references/apigee-skills-serving/skills/currency-converter/manifest.yaml b/references/apigee-skills-serving/skills/currency-converter/manifest.yaml index a0ea7c942..afec70501 100644 --- a/references/apigee-skills-serving/skills/currency-converter/manifest.yaml +++ b/references/apigee-skills-serving/skills/currency-converter/manifest.yaml @@ -18,10 +18,10 @@ capabilities: [] description: Converts monetary amounts between fiat currencies using a static exchange-rate table. keywords: -- currency -- exchange -- fiat -- money + - currency + - exchange + - fiat + - money license: Apache-2.0 manifest_schema_version: '1' name: currency-converter diff --git a/references/apigee-skills-serving/skills/weather-lookup/manifest.yaml b/references/apigee-skills-serving/skills/weather-lookup/manifest.yaml index 726d91b30..9c3b2535b 100644 --- a/references/apigee-skills-serving/skills/weather-lookup/manifest.yaml +++ b/references/apigee-skills-serving/skills/weather-lookup/manifest.yaml @@ -17,10 +17,10 @@ author: apigee-devrel capabilities: [] description: Returns a canned weather forecast for a named city. keywords: -- weather -- forecast -- location -- temperature + - weather + - forecast + - location + - temperature license: Apache-2.0 manifest_schema_version: '1' name: weather-lookup diff --git a/references/apigee-skills-serving/tests/test_apigee_top10.py b/references/apigee-skills-serving/tests/test_apigee_top10.py index fd61b825d..24653ede8 100644 --- a/references/apigee-skills-serving/tests/test_apigee_top10.py +++ b/references/apigee-skills-serving/tests/test_apigee_top10.py @@ -39,9 +39,7 @@ import re import sys import zipfile -from collections import Counter from pathlib import Path -from unittest import mock import pytest import yaml diff --git a/references/apigee-skills-serving/tests/test_check_demo_prerequisites.py b/references/apigee-skills-serving/tests/test_check_demo_prerequisites.py index 4a1d0a938..d23544742 100644 --- a/references/apigee-skills-serving/tests/test_check_demo_prerequisites.py +++ b/references/apigee-skills-serving/tests/test_check_demo_prerequisites.py @@ -42,7 +42,6 @@ """ from __future__ import annotations -import os import stat import subprocess from pathlib import Path @@ -315,7 +314,7 @@ def test_framework_vars_absent_emit_info(tmp_path: Path) -> None: if "ARGUMENTS" in line or "SKILL_DIR" in line ] assert info_lines, "framework vars must produce visible lines" - assert not any("FAILED" in l for l in info_lines), ( + assert not any("FAILED" in line for line in info_lines), ( f"framework vars must not produce FAILED lines: {info_lines}" ) diff --git a/references/apigee-skills-serving/tests/test_register_skill.py b/references/apigee-skills-serving/tests/test_register_skill.py index f404295d0..965f98ca3 100644 --- a/references/apigee-skills-serving/tests/test_register_skill.py +++ b/references/apigee-skills-serving/tests/test_register_skill.py @@ -42,7 +42,6 @@ """ from __future__ import annotations -import base64 import json from pathlib import Path from typing import Any diff --git a/references/apigee-skills-serving/tests/test_sign_skill.py b/references/apigee-skills-serving/tests/test_sign_skill.py index 50b04fe27..a6e82fc47 100644 --- a/references/apigee-skills-serving/tests/test_sign_skill.py +++ b/references/apigee-skills-serving/tests/test_sign_skill.py @@ -215,9 +215,6 @@ def test_signature_verifies_with_pubkey( half of the §8.2 sign+verify integration.""" import base64 - from cryptography.hazmat.primitives.asymmetric.ed25519 import \ - Ed25519PublicKey - out = tmp_path / "m.signed.yaml" main = _import_sign_main() assert main([ diff --git a/references/apigee-skills-serving/tests/test_upload_skill.py b/references/apigee-skills-serving/tests/test_upload_skill.py index 837911c2b..f9036b8c3 100644 --- a/references/apigee-skills-serving/tests/test_upload_skill.py +++ b/references/apigee-skills-serving/tests/test_upload_skill.py @@ -28,7 +28,6 @@ """ from __future__ import annotations -import io from pathlib import Path from typing import Any from unittest.mock import MagicMock From 11d54be837b5c57d9551cac92a1c7409d968531e Mon Sep 17 00:00:00 2001 From: Geir Sjurseth Date: Fri, 26 Jun 2026 11:27:22 +0000 Subject: [PATCH 4/4] fix(apigee-skills-serving): unwrap isort imports for CI's line-length=100 Megalinter v4 invokes isort with an effective line-length of 100 (matching flake8's --max-line-length=100). Four files had imports wrapped across two lines that actually fit on a single 100-char line. CI's isort wants the unwrapped form; my local v8 isort defaulted to 79 and wrapped them. Verified with isort 5.9.3 (CI's version) at --line-length=100: clean. Tests: 220/220 still pass. --- references/apigee-skills-serving/scripts/register_skill.py | 3 +-- references/apigee-skills-serving/scripts/sign_skill.py | 3 +-- references/apigee-skills-serving/tests/test_manifest_schema.py | 3 +-- .../apigee-skills-serving/tests/test_permission_resolver.py | 3 +-- 4 files changed, 4 insertions(+), 8 deletions(-) diff --git a/references/apigee-skills-serving/scripts/register_skill.py b/references/apigee-skills-serving/scripts/register_skill.py index 4ed40c6f3..394f149ce 100644 --- a/references/apigee-skills-serving/scripts/register_skill.py +++ b/references/apigee-skills-serving/scripts/register_skill.py @@ -54,8 +54,7 @@ import requests import yaml -from scripts.common.manifest_schema import (ManifestValidationError, - validate_manifest) +from scripts.common.manifest_schema import ManifestValidationError, validate_manifest EXIT_OK = 0 EXIT_USER = 1 diff --git a/references/apigee-skills-serving/scripts/sign_skill.py b/references/apigee-skills-serving/scripts/sign_skill.py index eae85cb7d..cb2f01f6a 100644 --- a/references/apigee-skills-serving/scripts/sign_skill.py +++ b/references/apigee-skills-serving/scripts/sign_skill.py @@ -63,8 +63,7 @@ from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey from scripts.common.canonical import canonicalize -from scripts.common.manifest_schema import (ManifestValidationError, - validate_manifest) +from scripts.common.manifest_schema import ManifestValidationError, validate_manifest EXIT_OK = 0 EXIT_USER = 1 diff --git a/references/apigee-skills-serving/tests/test_manifest_schema.py b/references/apigee-skills-serving/tests/test_manifest_schema.py index fd231caf2..6a7fdb710 100644 --- a/references/apigee-skills-serving/tests/test_manifest_schema.py +++ b/references/apigee-skills-serving/tests/test_manifest_schema.py @@ -32,8 +32,7 @@ import pytest -from scripts.common.manifest_schema import (ManifestValidationError, - validate_manifest) +from scripts.common.manifest_schema import ManifestValidationError, validate_manifest @pytest.fixture diff --git a/references/apigee-skills-serving/tests/test_permission_resolver.py b/references/apigee-skills-serving/tests/test_permission_resolver.py index e840b82ef..edc08744d 100644 --- a/references/apigee-skills-serving/tests/test_permission_resolver.py +++ b/references/apigee-skills-serving/tests/test_permission_resolver.py @@ -35,8 +35,7 @@ import pytest from scripts.common import permission_resolver as pr -from scripts.common.permission_resolver import (Resolution, Verdict, - detect_active_agent, +from scripts.common.permission_resolver import (Resolution, Verdict, detect_active_agent, resolve_skill_permission)