graph-agents-cli 0.3.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- graph_agents_cli/__init__.py +26 -0
- graph_agents_cli/_api_policy.py +2145 -0
- graph_agents_cli/_approvals.py +400 -0
- graph_agents_cli/_build.py +186 -0
- graph_agents_cli/_build_info.json +7 -0
- graph_agents_cli/_chat_client.py +462 -0
- graph_agents_cli/_click.py +157 -0
- graph_agents_cli/_defaults.py +139 -0
- graph_agents_cli/_experiments.py +64 -0
- graph_agents_cli/_http.py +192 -0
- graph_agents_cli/_output.py +83 -0
- graph_agents_cli/_project.py +462 -0
- graph_agents_cli/_remote.py +220 -0
- graph_agents_cli/_response_schema.py +264 -0
- graph_agents_cli/_runner.py +319 -0
- graph_agents_cli/_skills_check.py +274 -0
- graph_agents_cli/_tools.py +189 -0
- graph_agents_cli/_trust.py +66 -0
- graph_agents_cli/api/__init__.py +15 -0
- graph_agents_cli/api/_changes.py +506 -0
- graph_agents_cli/api/_files.py +658 -0
- graph_agents_cli/api/cmd_api.py +2480 -0
- graph_agents_cli/deploy/__init__.py +15 -0
- graph_agents_cli/deploy/_config.py +171 -0
- graph_agents_cli/deploy/_image.py +128 -0
- graph_agents_cli/deploy/_kube.py +286 -0
- graph_agents_cli/deploy/_modes.py +234 -0
- graph_agents_cli/deploy/_preflight.py +370 -0
- graph_agents_cli/deploy/_values.py +168 -0
- graph_agents_cli/deploy/cmd_deploy.py +1866 -0
- graph_agents_cli/deploy/gitops.py +562 -0
- graph_agents_cli/deploy/local_load.py +273 -0
- graph_agents_cli/dev/__init__.py +13 -0
- graph_agents_cli/dev/cmd_build.py +131 -0
- graph_agents_cli/dev/cmd_install.py +78 -0
- graph_agents_cli/dev/cmd_lint.py +119 -0
- graph_agents_cli/dev/cmd_playground.py +297 -0
- graph_agents_cli/dev/policy_check.py +1287 -0
- graph_agents_cli/eval/__init__.py +22 -0
- graph_agents_cli/eval/_client.py +670 -0
- graph_agents_cli/eval/_common.py +177 -0
- graph_agents_cli/eval/_judge.py +168 -0
- graph_agents_cli/eval/_judge_runner.py +238 -0
- graph_agents_cli/eval/_paths.py +212 -0
- graph_agents_cli/eval/checks.py +581 -0
- graph_agents_cli/eval/cmd_analyze.py +278 -0
- graph_agents_cli/eval/cmd_compare.py +284 -0
- graph_agents_cli/eval/cmd_eval_group.py +80 -0
- graph_agents_cli/eval/cmd_generate.py +558 -0
- graph_agents_cli/eval/cmd_grade.py +466 -0
- graph_agents_cli/eval/cmd_metric.py +156 -0
- graph_agents_cli/eval/cmd_run.py +370 -0
- graph_agents_cli/eval/cmd_submit.py +400 -0
- graph_agents_cli/eval/config.py +435 -0
- graph_agents_cli/eval/dataset.py +350 -0
- graph_agents_cli/eval/gate.py +420 -0
- graph_agents_cli/eval/transcript.py +192 -0
- graph_agents_cli/extension/__init__.py +13 -0
- graph_agents_cli/extension/_compat.py +86 -0
- graph_agents_cli/extension/_loader.py +293 -0
- graph_agents_cli/extension/_manifest.py +135 -0
- graph_agents_cli/extension/_overrides.py +195 -0
- graph_agents_cli/extension/_paths.py +91 -0
- graph_agents_cli/extension/_refs.py +193 -0
- graph_agents_cli/extension/_resolver.py +453 -0
- graph_agents_cli/extension/_schema.py +106 -0
- graph_agents_cli/extension/_spec.py +253 -0
- graph_agents_cli/extension/_sync.py +102 -0
- graph_agents_cli/extension/_trust.py +58 -0
- graph_agents_cli/extension/cmd_extension_add.py +259 -0
- graph_agents_cli/extension/cmd_extension_group.py +57 -0
- graph_agents_cli/extension/cmd_extension_list.py +56 -0
- graph_agents_cli/extension/cmd_extension_remove.py +61 -0
- graph_agents_cli/extension/cmd_extension_update.py +195 -0
- graph_agents_cli/info/__init__.py +13 -0
- graph_agents_cli/info/cmd_info.py +222 -0
- graph_agents_cli/infra/__init__.py +15 -0
- graph_agents_cli/infra/checks.py +1169 -0
- graph_agents_cli/infra/cmd_infra.py +103 -0
- graph_agents_cli/main.py +591 -0
- graph_agents_cli/peer/__init__.py +15 -0
- graph_agents_cli/peer/_generate.py +254 -0
- graph_agents_cli/peer/cmd_peer.py +1151 -0
- graph_agents_cli/run/__init__.py +13 -0
- graph_agents_cli/run/_local_server.py +1157 -0
- graph_agents_cli/run/_signals.py +141 -0
- graph_agents_cli/run/cmd_approvals.py +530 -0
- graph_agents_cli/run/cmd_run.py +1421 -0
- graph_agents_cli/scaffold/__init__.py +19 -0
- graph_agents_cli/scaffold/agents/README.md +24 -0
- graph_agents_cli/scaffold/agents/empty_py/.template/templateconfig.yaml +22 -0
- graph_agents_cli/scaffold/agents/langgraph/.env.example +292 -0
- graph_agents_cli/scaffold/agents/langgraph/.template/templateconfig.yaml +28 -0
- graph_agents_cli/scaffold/agents/langgraph/Dockerfile +59 -0
- graph_agents_cli/scaffold/agents/langgraph/Dockerfile.langgraph-server +59 -0
- graph_agents_cli/scaffold/agents/langgraph/README.md +571 -0
- graph_agents_cli/scaffold/agents/langgraph/api-policy.yaml +60 -0
- graph_agents_cli/scaffold/agents/langgraph/app/__init__.py +20 -0
- graph_agents_cli/scaffold/agents/langgraph/app/agent.py +174 -0
- graph_agents_cli/scaffold/agents/langgraph/app/app_utils/__init__.py +15 -0
- graph_agents_cli/scaffold/agents/langgraph/app/app_utils/a2a.py +2162 -0
- graph_agents_cli/scaffold/agents/langgraph/app/app_utils/a2a_client.py +1167 -0
- graph_agents_cli/scaffold/agents/langgraph/app/app_utils/api_client.py +4220 -0
- graph_agents_cli/scaffold/agents/langgraph/app/app_utils/approvals.py +1349 -0
- graph_agents_cli/scaffold/agents/langgraph/app/app_utils/auth.py +1986 -0
- graph_agents_cli/scaffold/agents/langgraph/app/app_utils/chat.py +2962 -0
- graph_agents_cli/scaffold/agents/langgraph/app/app_utils/checkpointer.py +432 -0
- graph_agents_cli/scaffold/agents/langgraph/app/app_utils/content.py +569 -0
- graph_agents_cli/scaffold/agents/langgraph/app/app_utils/db.py +580 -0
- graph_agents_cli/scaffold/agents/langgraph/app/app_utils/limits.py +203 -0
- graph_agents_cli/scaffold/agents/langgraph/app/app_utils/metrics.py +231 -0
- graph_agents_cli/scaffold/agents/langgraph/app/app_utils/middleware.py +361 -0
- graph_agents_cli/scaffold/agents/langgraph/app/app_utils/model.py +611 -0
- graph_agents_cli/scaffold/agents/langgraph/app/app_utils/playground.py +230 -0
- graph_agents_cli/scaffold/agents/langgraph/app/app_utils/run_locks.py +459 -0
- graph_agents_cli/scaffold/agents/langgraph/app/app_utils/structured.py +755 -0
- graph_agents_cli/scaffold/agents/langgraph/app/app_utils/telemetry.py +681 -0
- graph_agents_cli/scaffold/agents/langgraph/app/app_utils/threads.py +493 -0
- graph_agents_cli/scaffold/agents/langgraph/app/app_utils/token_exchange.py +959 -0
- graph_agents_cli/scaffold/agents/langgraph/app/fast_api_app.py +770 -0
- graph_agents_cli/scaffold/agents/langgraph/app/policies/__init__.py +55 -0
- graph_agents_cli/scaffold/agents/langgraph/app/policies/custom.py +97 -0
- graph_agents_cli/scaffold/agents/langgraph/app/tools/__init__.py +46 -0
- graph_agents_cli/scaffold/agents/langgraph/app/tools/example_api.py +92 -0
- graph_agents_cli/scaffold/agents/langgraph/app/tools/weather.py +33 -0
- graph_agents_cli/scaffold/agents/langgraph/langgraph.json +14 -0
- graph_agents_cli/scaffold/agents/langgraph/pyproject.toml +78 -0
- graph_agents_cli/scaffold/agents/langgraph/tests/conftest.py +376 -0
- graph_agents_cli/scaffold/agents/langgraph/tests/eval/datasets/basic-dataset.json +53 -0
- graph_agents_cli/scaffold/agents/langgraph/tests/eval/eval_config.yaml +32 -0
- graph_agents_cli/scaffold/agents/langgraph/tests/integration/approval_graph.py +137 -0
- graph_agents_cli/scaffold/agents/langgraph/tests/integration/fake_issuer.py +216 -0
- graph_agents_cli/scaffold/agents/langgraph/tests/integration/fake_openai.py +357 -0
- graph_agents_cli/scaffold/agents/langgraph/tests/integration/test_a2a_outcomes.py +569 -0
- graph_agents_cli/scaffold/agents/langgraph/tests/integration/test_a2a_relay.py +479 -0
- graph_agents_cli/scaffold/agents/langgraph/tests/integration/test_api_surface.py +812 -0
- graph_agents_cli/scaffold/agents/langgraph/tests/integration/test_approvals.py +1367 -0
- graph_agents_cli/scaffold/agents/langgraph/tests/integration/test_approvals_server.py +794 -0
- graph_agents_cli/scaffold/agents/langgraph/tests/integration/test_cross_actor.py +497 -0
- graph_agents_cli/scaffold/agents/langgraph/tests/integration/test_cross_actor_server.py +247 -0
- graph_agents_cli/scaffold/agents/langgraph/tests/integration/test_history_repair.py +278 -0
- graph_agents_cli/scaffold/agents/langgraph/tests/integration/test_model_apis.py +242 -0
- graph_agents_cli/scaffold/agents/langgraph/tests/integration/test_postgres.py +637 -0
- graph_agents_cli/scaffold/agents/langgraph/tests/integration/test_resilience_postgres.py +770 -0
- graph_agents_cli/scaffold/agents/langgraph/tests/integration/test_runtime_guardrails.py +854 -0
- graph_agents_cli/scaffold/agents/langgraph/tests/integration/test_server_e2e.py +340 -0
- graph_agents_cli/scaffold/agents/langgraph/tests/integration/test_server_runtime.py +989 -0
- graph_agents_cli/scaffold/agents/langgraph/tests/integration/test_structured_answers.py +584 -0
- graph_agents_cli/scaffold/agents/langgraph/tests/integration/test_structured_server.py +222 -0
- graph_agents_cli/scaffold/agents/langgraph/tests/integration/test_token_exchange_issuer.py +650 -0
- graph_agents_cli/scaffold/agents/langgraph/tests/load_test/.results/.placeholder +0 -0
- graph_agents_cli/scaffold/agents/langgraph/tests/load_test/README.md +22 -0
- graph_agents_cli/scaffold/agents/langgraph/tests/load_test/conftest.py +21 -0
- graph_agents_cli/scaffold/agents/langgraph/tests/load_test/load_test.py +81 -0
- graph_agents_cli/scaffold/agents/langgraph/tests/unit/test_a2a_client.py +824 -0
- graph_agents_cli/scaffold/agents/langgraph/tests/unit/test_a2a_scoping.py +724 -0
- graph_agents_cli/scaffold/agents/langgraph/tests/unit/test_api_client.py +1214 -0
- graph_agents_cli/scaffold/agents/langgraph/tests/unit/test_api_client_hardening.py +716 -0
- graph_agents_cli/scaffold/agents/langgraph/tests/unit/test_api_policy_rpc.py +767 -0
- graph_agents_cli/scaffold/agents/langgraph/tests/unit/test_approval_ledger.py +1536 -0
- graph_agents_cli/scaffold/agents/langgraph/tests/unit/test_fake_model.py +115 -0
- graph_agents_cli/scaffold/agents/langgraph/tests/unit/test_jwt_policy.py +991 -0
- graph_agents_cli/scaffold/agents/langgraph/tests/unit/test_limits.py +310 -0
- graph_agents_cli/scaffold/agents/langgraph/tests/unit/test_logging.py +148 -0
- graph_agents_cli/scaffold/agents/langgraph/tests/unit/test_logging_hardening.py +271 -0
- graph_agents_cli/scaffold/agents/langgraph/tests/unit/test_policy.py +378 -0
- graph_agents_cli/scaffold/agents/langgraph/tests/unit/test_resilience.py +610 -0
- graph_agents_cli/scaffold/agents/langgraph/tests/unit/test_server_auth.py +702 -0
- graph_agents_cli/scaffold/agents/langgraph/tests/unit/test_structured.py +673 -0
- graph_agents_cli/scaffold/agents/langgraph/tests/unit/test_telemetry.py +404 -0
- graph_agents_cli/scaffold/agents/langgraph/tests/unit/test_thread_listing.py +255 -0
- graph_agents_cli/scaffold/agents/langgraph/tests/unit/test_threads.py +268 -0
- graph_agents_cli/scaffold/agents/langgraph/tests/unit/test_token_exchange.py +1320 -0
- graph_agents_cli/scaffold/agents/langgraph/tests/unit/test_untrusted_content.py +393 -0
- graph_agents_cli/scaffold/agents/langgraph/uv-fastapi.lock +2084 -0
- graph_agents_cli/scaffold/agents/langgraph/uv-langgraph-server.lock +2106 -0
- graph_agents_cli/scaffold/agents/langgraph/{{cookiecutter.agent_guidance_filename}} +129 -0
- graph_agents_cli/scaffold/base_templates/_shared/graph-agents-cli-manifest.yaml +36 -0
- graph_agents_cli/scaffold/base_templates/python/.dockerignore +32 -0
- graph_agents_cli/scaffold/base_templates/python/.github/CODEOWNERS +30 -0
- graph_agents_cli/scaffold/base_templates/python/.github/agent.env +7 -0
- graph_agents_cli/scaffold/base_templates/python/.github/workflows/pr_checks.yaml +214 -0
- graph_agents_cli/scaffold/base_templates/python/.gitignore +209 -0
- graph_agents_cli/scaffold/base_templates/python/tests/unit/test_dummy.py +23 -0
- graph_agents_cli/scaffold/base_templates/python/{{cookiecutter.agent_guidance_filename}} +35 -0
- graph_agents_cli/scaffold/cmd_scaffold_group.py +49 -0
- graph_agents_cli/scaffold/commands/__init__.py +13 -0
- graph_agents_cli/scaffold/commands/create.py +1424 -0
- graph_agents_cli/scaffold/commands/enhance.py +1652 -0
- graph_agents_cli/scaffold/commands/upgrade.py +570 -0
- graph_agents_cli/scaffold/deployment_targets/kubernetes/python/.github/agent.env +12 -0
- graph_agents_cli/scaffold/deployment_targets/kubernetes/python/.github/workflows/promote-to-prod.yaml +371 -0
- graph_agents_cli/scaffold/deployment_targets/kubernetes/python/.github/workflows/staging.yaml +450 -0
- graph_agents_cli/scaffold/deployment_targets/kubernetes/python/deployment/argocd/application-dev.yaml +43 -0
- graph_agents_cli/scaffold/deployment_targets/kubernetes/python/deployment/argocd/application-prod.yaml +41 -0
- graph_agents_cli/scaffold/deployment_targets/kubernetes/python/deployment/argocd/application-staging.yaml +43 -0
- graph_agents_cli/scaffold/deployment_targets/kubernetes/python/deployment/helm/{{cookiecutter.project_name}}/.helmignore +14 -0
- graph_agents_cli/scaffold/deployment_targets/kubernetes/python/deployment/helm/{{cookiecutter.project_name}}/Chart.yaml +21 -0
- graph_agents_cli/scaffold/deployment_targets/kubernetes/python/deployment/helm/{{cookiecutter.project_name}}/examples/networkpolicy.yaml +103 -0
- graph_agents_cli/scaffold/deployment_targets/kubernetes/python/deployment/helm/{{cookiecutter.project_name}}/templates/NOTES.txt +48 -0
- graph_agents_cli/scaffold/deployment_targets/kubernetes/python/deployment/helm/{{cookiecutter.project_name}}/templates/_helpers.tpl +189 -0
- graph_agents_cli/scaffold/deployment_targets/kubernetes/python/deployment/helm/{{cookiecutter.project_name}}/templates/certificate.yaml +15 -0
- graph_agents_cli/scaffold/deployment_targets/kubernetes/python/deployment/helm/{{cookiecutter.project_name}}/templates/configmap.yaml +10 -0
- graph_agents_cli/scaffold/deployment_targets/kubernetes/python/deployment/helm/{{cookiecutter.project_name}}/templates/deployment.yaml +199 -0
- graph_agents_cli/scaffold/deployment_targets/kubernetes/python/deployment/helm/{{cookiecutter.project_name}}/templates/hpa.yaml +22 -0
- graph_agents_cli/scaffold/deployment_targets/kubernetes/python/deployment/helm/{{cookiecutter.project_name}}/templates/httproute.yaml +30 -0
- graph_agents_cli/scaffold/deployment_targets/kubernetes/python/deployment/helm/{{cookiecutter.project_name}}/templates/ingress.yaml +39 -0
- graph_agents_cli/scaffold/deployment_targets/kubernetes/python/deployment/helm/{{cookiecutter.project_name}}/templates/networkpolicy.yaml +48 -0
- graph_agents_cli/scaffold/deployment_targets/kubernetes/python/deployment/helm/{{cookiecutter.project_name}}/templates/pdb.yaml +13 -0
- graph_agents_cli/scaffold/deployment_targets/kubernetes/python/deployment/helm/{{cookiecutter.project_name}}/templates/postgresql-secret.yaml +37 -0
- graph_agents_cli/scaffold/deployment_targets/kubernetes/python/deployment/helm/{{cookiecutter.project_name}}/templates/service.yaml +15 -0
- graph_agents_cli/scaffold/deployment_targets/kubernetes/python/deployment/helm/{{cookiecutter.project_name}}/templates/serviceaccount.yaml +13 -0
- graph_agents_cli/scaffold/deployment_targets/kubernetes/python/deployment/helm/{{cookiecutter.project_name}}/templates/servicemonitor.yaml +42 -0
- graph_agents_cli/scaffold/deployment_targets/kubernetes/python/deployment/helm/{{cookiecutter.project_name}}/values-dev.yaml +22 -0
- graph_agents_cli/scaffold/deployment_targets/kubernetes/python/deployment/helm/{{cookiecutter.project_name}}/values-prod.yaml +45 -0
- graph_agents_cli/scaffold/deployment_targets/kubernetes/python/deployment/helm/{{cookiecutter.project_name}}/values-staging.yaml +29 -0
- graph_agents_cli/scaffold/deployment_targets/kubernetes/python/deployment/helm/{{cookiecutter.project_name}}/values.yaml +396 -0
- graph_agents_cli/scaffold/deployment_targets/kubernetes/python/tests/integration/test_chart.py +269 -0
- graph_agents_cli/scaffold/deployment_targets/none/README.md +5 -0
- graph_agents_cli/scaffold/deployment_targets/none/python/README.md +6 -0
- graph_agents_cli/scaffold/utils/__init__.py +13 -0
- graph_agents_cli/scaffold/utils/backup.py +212 -0
- graph_agents_cli/scaffold/utils/build_record.py +257 -0
- graph_agents_cli/scaffold/utils/cli_options.py +184 -0
- graph_agents_cli/scaffold/utils/fs.py +83 -0
- graph_agents_cli/scaffold/utils/generate_locks.py +214 -0
- graph_agents_cli/scaffold/utils/generation_metadata.py +88 -0
- graph_agents_cli/scaffold/utils/keyedit.py +768 -0
- graph_agents_cli/scaffold/utils/keymerge.py +537 -0
- graph_agents_cli/scaffold/utils/language.py +138 -0
- graph_agents_cli/scaffold/utils/lock_utils.py +94 -0
- graph_agents_cli/scaffold/utils/logging.py +77 -0
- graph_agents_cli/scaffold/utils/manifest.py +292 -0
- graph_agents_cli/scaffold/utils/merge.py +970 -0
- graph_agents_cli/scaffold/utils/merge3.py +216 -0
- graph_agents_cli/scaffold/utils/openapi_seed.py +199 -0
- graph_agents_cli/scaffold/utils/remote_template.py +376 -0
- graph_agents_cli/scaffold/utils/template.py +1352 -0
- graph_agents_cli/scaffold/utils/upgrade.py +894 -0
- graph_agents_cli/scaffold/utils/version.py +438 -0
- graph_agents_cli/secrets/__init__.py +15 -0
- graph_agents_cli/secrets/_apply.py +954 -0
- graph_agents_cli/secrets/_required.py +188 -0
- graph_agents_cli/secrets/cmd_secrets.py +211 -0
- graph_agents_cli/setup/__init__.py +13 -0
- graph_agents_cli/setup/_antigravity.py +221 -0
- graph_agents_cli/setup/cmd_auth.py +1030 -0
- graph_agents_cli/setup/cmd_dev_token.py +513 -0
- graph_agents_cli/setup/cmd_setup.py +428 -0
- graph_agents_cli/setup/cmd_update.py +140 -0
- graph_agents_cli/skills/__init__.py +13 -0
- graph_agents_cli/skills/_bundle.py +65 -0
- graph_agents_cli/skills/data/README.md +19 -0
- graph_agents_cli/skills/data/graph-agents-cli-deploy/SKILL.md +357 -0
- graph_agents_cli/skills/data/graph-agents-cli-deploy/references/github-settings.md +113 -0
- graph_agents_cli/skills/data/graph-agents-cli-deploy/references/gitops.md +137 -0
- graph_agents_cli/skills/data/graph-agents-cli-deploy/references/kubernetes.md +315 -0
- graph_agents_cli/skills/data/graph-agents-cli-deploy/references/secrets.md +160 -0
- graph_agents_cli/skills/data/graph-agents-cli-eval/SKILL.md +303 -0
- graph_agents_cli/skills/data/graph-agents-cli-eval/references/dataset_schema.md +282 -0
- graph_agents_cli/skills/data/graph-agents-cli-eval/references/metrics-guide.md +143 -0
- graph_agents_cli/skills/data/graph-agents-cli-langgraph-code/SKILL.md +659 -0
- graph_agents_cli/skills/data/graph-agents-cli-langgraph-code/references/langchain-models.md +124 -0
- graph_agents_cli/skills/data/graph-agents-cli-langgraph-code/references/langgraph.md +235 -0
- graph_agents_cli/skills/data/graph-agents-cli-langgraph-code/references/template-contract.md +477 -0
- graph_agents_cli/skills/data/graph-agents-cli-observability/SKILL.md +231 -0
- graph_agents_cli/skills/data/graph-agents-cli-observability/references/langsmith.md +46 -0
- graph_agents_cli/skills/data/graph-agents-cli-observability/references/otel.md +59 -0
- graph_agents_cli/skills/data/graph-agents-cli-scaffold/SKILL.md +414 -0
- graph_agents_cli/skills/data/graph-agents-cli-scaffold/references/flags.md +134 -0
- graph_agents_cli/skills/data/graph-agents-cli-workflow/SKILL.md +478 -0
- graph_agents_cli/skills/data/graph-agents-cli-workflow/references/brainstorming.md +118 -0
- graph_agents_cli/skills/data/graph-agents-cli-workflow/references/commands.md +419 -0
- graph_agents_cli/skills/data/graph-agents-cli-workflow/references/extension.md +156 -0
- graph_agents_cli/skills/data/graph-agents-cli-workflow/references/internals.md +67 -0
- graph_agents_cli/skills/data/graph-agents-cli-workflow/references/spec-template.md +56 -0
- graph_agents_cli/skills/data/graph-agents-cli-workflow/references/terminology.md +119 -0
- graph_agents_cli/system/__init__.py +15 -0
- graph_agents_cli/system/_apply.py +519 -0
- graph_agents_cli/system/_checks.py +1023 -0
- graph_agents_cli/system/_deploy.py +215 -0
- graph_agents_cli/system/_model.py +363 -0
- graph_agents_cli/system/_system.py +664 -0
- graph_agents_cli/system/_views.py +208 -0
- graph_agents_cli/system/cmd_system.py +423 -0
- graph_agents_cli-0.3.1.dist-info/METADATA +162 -0
- graph_agents_cli-0.3.1.dist-info/RECORD +291 -0
- graph_agents_cli-0.3.1.dist-info/WHEEL +4 -0
- graph_agents_cli-0.3.1.dist-info/entry_points.txt +2 -0
- graph_agents_cli-0.3.1.dist-info/licenses/LICENSE +201 -0
- graph_agents_cli-0.3.1.dist-info/licenses/NOTICE +19 -0
|
@@ -0,0 +1,1320 @@
|
|
|
1
|
+
# Copyright 2026 graph-agents-cli contributors
|
|
2
|
+
#
|
|
3
|
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
4
|
+
# you may not use this file except in compliance with the License.
|
|
5
|
+
# You may obtain a copy of the License at
|
|
6
|
+
#
|
|
7
|
+
# https://www.apache.org/licenses/LICENSE-2.0
|
|
8
|
+
#
|
|
9
|
+
# Unless required by applicable law or agreed to in writing, software
|
|
10
|
+
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
11
|
+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
12
|
+
# See the License for the specific language governing permissions and
|
|
13
|
+
# limitations under the License.
|
|
14
|
+
|
|
15
|
+
"""RFC 8693 token exchange (`app_utils.token_exchange`) and the `auth: exchange` calls it serves.
|
|
16
|
+
|
|
17
|
+
The issuer's token endpoint is an httpx MockTransport (`FakeTokenEndpoint`),
|
|
18
|
+
and so is the API the exchanged token is sent to; the clocks are the test's.
|
|
19
|
+
Nothing leaves the process. `tests/integration/test_token_exchange_issuer.py`
|
|
20
|
+
runs the same exchange against a real (loopback) RFC 8693 issuer.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
import asyncio
|
|
26
|
+
import base64
|
|
27
|
+
import hashlib
|
|
28
|
+
import json
|
|
29
|
+
import logging
|
|
30
|
+
from collections.abc import Iterator
|
|
31
|
+
from dataclasses import dataclass, field
|
|
32
|
+
from pathlib import Path
|
|
33
|
+
from typing import Any
|
|
34
|
+
from urllib.parse import parse_qs, unquote
|
|
35
|
+
|
|
36
|
+
import httpx
|
|
37
|
+
import pytest
|
|
38
|
+
from cryptography.hazmat.primitives import serialization
|
|
39
|
+
from cryptography.hazmat.primitives.asymmetric import rsa
|
|
40
|
+
|
|
41
|
+
from {{cookiecutter.agent_directory}}.app_utils import api_client, metrics, token_exchange
|
|
42
|
+
from {{cookiecutter.agent_directory}}.app_utils.api_client import (
|
|
43
|
+
APPROVAL_DECISION,
|
|
44
|
+
ApiCallError,
|
|
45
|
+
ApiPolicyError,
|
|
46
|
+
BoundApproval,
|
|
47
|
+
get_client,
|
|
48
|
+
reset_limits,
|
|
49
|
+
reset_policy_cache,
|
|
50
|
+
set_approval_ledger,
|
|
51
|
+
set_outbound_headers,
|
|
52
|
+
)
|
|
53
|
+
from {{cookiecutter.agent_directory}}.app_utils.limits import SettingsError
|
|
54
|
+
from {{cookiecutter.agent_directory}}.app_utils.token_exchange import (
|
|
55
|
+
ACCESS_TOKEN_TYPE,
|
|
56
|
+
GRANT_TYPE,
|
|
57
|
+
JWT_TOKEN_TYPE,
|
|
58
|
+
ExchangeSettings,
|
|
59
|
+
TokenExchangeError,
|
|
60
|
+
compatibility,
|
|
61
|
+
exchange_settings,
|
|
62
|
+
exchanger,
|
|
63
|
+
loop_problem,
|
|
64
|
+
names_no_actor,
|
|
65
|
+
reset_token_exchange,
|
|
66
|
+
startup_problems,
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
TOKEN_URL = "https://issuer.test/realms/agents/token"
|
|
70
|
+
CLIENT_ID = "concierge"
|
|
71
|
+
CLIENT_SECRET = "s3cret:with/odd chars"
|
|
72
|
+
SUBJECT = "alice-own-token.at-concierge"
|
|
73
|
+
WALL_START = 1_900_000_000.0
|
|
74
|
+
|
|
75
|
+
POLICY = """
|
|
76
|
+
apis:
|
|
77
|
+
orders_agent:
|
|
78
|
+
base_url_env: ORDERS_AGENT_URL
|
|
79
|
+
auth: exchange
|
|
80
|
+
exchange:
|
|
81
|
+
audience: orders
|
|
82
|
+
scope: "orders.read orders.cancel"
|
|
83
|
+
resource: https://orders.example.com
|
|
84
|
+
allowed_methods: [GET, POST]
|
|
85
|
+
limits: {max_calls_per_run: 3}
|
|
86
|
+
approval:
|
|
87
|
+
required_for:
|
|
88
|
+
operations:
|
|
89
|
+
- {operationId: cancelOrder, path: "/orders/{order_id}/cancel", methods: [POST]}
|
|
90
|
+
approvers: [requester]
|
|
91
|
+
billing_agent:
|
|
92
|
+
base_url_env: BILLING_AGENT_URL
|
|
93
|
+
auth: exchange
|
|
94
|
+
forward_header: X-Agent-Token
|
|
95
|
+
exchange: {audience: billing, allow_actorless: true}
|
|
96
|
+
allowed_methods: [GET]
|
|
97
|
+
weather:
|
|
98
|
+
base_url_env: WEATHER_URL
|
|
99
|
+
auth: bearer
|
|
100
|
+
token_env: WEATHER_TOKEN
|
|
101
|
+
allowed_methods: [GET]
|
|
102
|
+
"""
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _b64(data: bytes) -> str:
|
|
106
|
+
return base64.urlsafe_b64encode(data).rstrip(b"=").decode()
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _jwt(claims: Any) -> str:
|
|
110
|
+
"""A JWS-shaped token with these claims; the exchanger never verifies it (the callee does)."""
|
|
111
|
+
header = _b64(json.dumps({"alg": "RS256", "typ": "JWT"}).encode())
|
|
112
|
+
return f"{header}.{_b64(json.dumps(claims).encode())}.{_b64(b'signature')}"
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def exchanged(n: int, audience: str) -> str:
|
|
116
|
+
"""The token `FakeTokenEndpoint` issues for its `n`-th request, for `audience`: a JWT
|
|
117
|
+
naming this agent in `act`, as an issuer that follows RFC 8693 mints it."""
|
|
118
|
+
return _jwt({"sub": "alice", "aud": audience, "n": n, "act": {"sub": CLIENT_ID}})
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
class Clock:
|
|
122
|
+
"""Both clocks the exchanger reads: monotonic (`mono`) and wall (`wall`, epoch seconds)."""
|
|
123
|
+
|
|
124
|
+
def __init__(self) -> None:
|
|
125
|
+
self.now = 0.0
|
|
126
|
+
|
|
127
|
+
def mono(self) -> float:
|
|
128
|
+
return self.now
|
|
129
|
+
|
|
130
|
+
def wall(self) -> float:
|
|
131
|
+
return WALL_START + self.now
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
class FakeTokenEndpoint:
|
|
135
|
+
"""An RFC 8693 token endpoint: records every request, answers from `answers` or issues.
|
|
136
|
+
|
|
137
|
+
An issued token is `exchanged(<n>, <audience>)` (a JWT naming this agent in `act`),
|
|
138
|
+
`Bearer`, an access token, with
|
|
139
|
+
`expires_in`. `delay` holds each answer that long (asyncio time); `hang` never
|
|
140
|
+
answers.
|
|
141
|
+
"""
|
|
142
|
+
|
|
143
|
+
def __init__(self) -> None:
|
|
144
|
+
self.requests: list[dict[str, Any]] = []
|
|
145
|
+
self.answers: list[httpx.Response] = []
|
|
146
|
+
self.expires_in: int | None = 300
|
|
147
|
+
self.delay = 0.0
|
|
148
|
+
self.hang = False
|
|
149
|
+
|
|
150
|
+
@property
|
|
151
|
+
def calls(self) -> int:
|
|
152
|
+
return len(self.requests)
|
|
153
|
+
|
|
154
|
+
async def handler(self, request: httpx.Request) -> httpx.Response:
|
|
155
|
+
form = {k: v[0] for k, v in parse_qs(request.content.decode()).items()}
|
|
156
|
+
self.requests.append({"url": str(request.url), "headers": request.headers, "form": form})
|
|
157
|
+
if self.hang:
|
|
158
|
+
await asyncio.sleep(3600)
|
|
159
|
+
if self.delay:
|
|
160
|
+
await asyncio.sleep(self.delay)
|
|
161
|
+
if self.answers:
|
|
162
|
+
return self.answers.pop(0)
|
|
163
|
+
body: dict[str, Any] = {
|
|
164
|
+
"access_token": exchanged(len(self.requests), str(form.get("audience"))),
|
|
165
|
+
"issued_token_type": ACCESS_TOKEN_TYPE,
|
|
166
|
+
"token_type": "Bearer",
|
|
167
|
+
}
|
|
168
|
+
if self.expires_in is not None:
|
|
169
|
+
body["expires_in"] = self.expires_in
|
|
170
|
+
return httpx.Response(200, json=body)
|
|
171
|
+
|
|
172
|
+
@property
|
|
173
|
+
def transport(self) -> httpx.MockTransport:
|
|
174
|
+
return httpx.MockTransport(self.handler)
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
@pytest.fixture
|
|
178
|
+
def clock() -> Clock:
|
|
179
|
+
return Clock()
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
@pytest.fixture
|
|
183
|
+
def endpoint(clock: Clock, monkeypatch: pytest.MonkeyPatch) -> Iterator[FakeTokenEndpoint]:
|
|
184
|
+
server = FakeTokenEndpoint()
|
|
185
|
+
for name, value in {
|
|
186
|
+
"TOKEN_EXCHANGE_URL": TOKEN_URL,
|
|
187
|
+
"TOKEN_EXCHANGE_CLIENT_ID": CLIENT_ID,
|
|
188
|
+
"TOKEN_EXCHANGE_CLIENT_SECRET": CLIENT_SECRET,
|
|
189
|
+
}.items():
|
|
190
|
+
monkeypatch.setenv(name, value)
|
|
191
|
+
for name in (
|
|
192
|
+
"APP_ENV",
|
|
193
|
+
"A2A_NAME",
|
|
194
|
+
"AUTH_JWT_AUDIENCE",
|
|
195
|
+
"TOKEN_EXCHANGE_CLIENT_AUTH",
|
|
196
|
+
"TOKEN_EXCHANGE_SUBJECT_TOKEN_TYPE",
|
|
197
|
+
"TOKEN_EXCHANGE_TIMEOUT_MS",
|
|
198
|
+
"TOKEN_EXCHANGE_MAX_TTL_S",
|
|
199
|
+
"TOKEN_EXCHANGE_FAILURE_TTL_S",
|
|
200
|
+
"TOKEN_EXCHANGE_CACHE_MAX",
|
|
201
|
+
"TOKEN_EXCHANGE_ALLOW_HTTP",
|
|
202
|
+
):
|
|
203
|
+
monkeypatch.delenv(name, raising=False)
|
|
204
|
+
reset_token_exchange(clock=clock.mono, wall=clock.wall, transport=server.transport)
|
|
205
|
+
yield server
|
|
206
|
+
reset_token_exchange()
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def _count(api: str, outcome: str) -> float:
|
|
210
|
+
value = metrics.REGISTRY.get_sample_value(
|
|
211
|
+
"agent_token_exchanges_total", {"api": api, "outcome": outcome}
|
|
212
|
+
)
|
|
213
|
+
return value or 0.0
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
async def _token(api: str = "orders_agent", subject: str = SUBJECT, **kwargs: Any) -> str:
|
|
217
|
+
kwargs.setdefault("audience", "orders")
|
|
218
|
+
return await exchanger().token(api, subject, **kwargs)
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
# ---------------------------------------------------------------------------
|
|
222
|
+
# The request (RFC 8693 section 2.1, RFC 6749 section 2.3.1)
|
|
223
|
+
# ---------------------------------------------------------------------------
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
async def test_the_request_is_an_rfc_8693_exchange_with_basic_client_auth(
|
|
227
|
+
endpoint: FakeTokenEndpoint,
|
|
228
|
+
) -> None:
|
|
229
|
+
token = await _token(scope="orders.read", resource="https://orders.example.com")
|
|
230
|
+
assert token == exchanged(1, "orders")
|
|
231
|
+
[sent] = endpoint.requests
|
|
232
|
+
assert sent["url"] == TOKEN_URL
|
|
233
|
+
assert sent["form"] == {
|
|
234
|
+
"grant_type": GRANT_TYPE,
|
|
235
|
+
"subject_token": SUBJECT,
|
|
236
|
+
"subject_token_type": ACCESS_TOKEN_TYPE,
|
|
237
|
+
"requested_token_type": ACCESS_TOKEN_TYPE,
|
|
238
|
+
"audience": "orders",
|
|
239
|
+
"scope": "orders.read",
|
|
240
|
+
"resource": "https://orders.example.com",
|
|
241
|
+
}
|
|
242
|
+
scheme, _, encoded = sent["headers"]["authorization"].partition(" ")
|
|
243
|
+
client, _, secret = base64.b64decode(encoded).decode().partition(":")
|
|
244
|
+
assert scheme == "Basic"
|
|
245
|
+
# Each part form-encoded before base64 (RFC 6749 section 2.3.1).
|
|
246
|
+
assert (unquote(client), unquote(secret)) == (CLIENT_ID, CLIENT_SECRET)
|
|
247
|
+
assert secret != CLIENT_SECRET
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
async def test_client_secret_post_and_the_jwt_subject_token_type(
|
|
251
|
+
endpoint: FakeTokenEndpoint, monkeypatch: pytest.MonkeyPatch
|
|
252
|
+
) -> None:
|
|
253
|
+
monkeypatch.setenv("TOKEN_EXCHANGE_CLIENT_AUTH", "client_secret_post")
|
|
254
|
+
monkeypatch.setenv("TOKEN_EXCHANGE_SUBJECT_TOKEN_TYPE", JWT_TOKEN_TYPE)
|
|
255
|
+
await _token()
|
|
256
|
+
[sent] = endpoint.requests
|
|
257
|
+
assert "authorization" not in sent["headers"]
|
|
258
|
+
assert sent["form"]["client_id"] == CLIENT_ID
|
|
259
|
+
assert sent["form"]["client_secret"] == CLIENT_SECRET
|
|
260
|
+
assert sent["form"]["subject_token_type"] == JWT_TOKEN_TYPE
|
|
261
|
+
assert "scope" not in sent["form"] and "resource" not in sent["form"]
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
async def test_redirects_are_not_followed(endpoint: FakeTokenEndpoint) -> None:
|
|
265
|
+
endpoint.answers.append(httpx.Response(302, headers={"Location": "https://evil.test/token"}))
|
|
266
|
+
with pytest.raises(TokenExchangeError, match=r"token issuer unavailable \(HTTP 302\)"):
|
|
267
|
+
await _token()
|
|
268
|
+
assert endpoint.calls == 1
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
# ---------------------------------------------------------------------------
|
|
272
|
+
# The cache
|
|
273
|
+
# ---------------------------------------------------------------------------
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
async def test_single_flight_shares_one_exchange(endpoint: FakeTokenEndpoint) -> None:
|
|
277
|
+
endpoint.delay = 0.05
|
|
278
|
+
before = _count("sf_api", "issued"), _count("sf_api", "cached")
|
|
279
|
+
tokens = await asyncio.gather(*(_token("sf_api") for _ in range(100)))
|
|
280
|
+
assert endpoint.calls == 1
|
|
281
|
+
assert set(tokens) == {exchanged(1, "orders")}
|
|
282
|
+
assert _count("sf_api", "issued") - before[0] == 1
|
|
283
|
+
assert _count("sf_api", "cached") - before[1] == 99
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
async def test_cache_is_per_subject_token(endpoint: FakeTokenEndpoint) -> None:
|
|
287
|
+
"""T6: a token exchanged for one user's token is never handed to another's call."""
|
|
288
|
+
first = await _token(subject="alice-token")
|
|
289
|
+
second = await _token(subject="bob-token")
|
|
290
|
+
assert first != second and endpoint.calls == 2
|
|
291
|
+
assert await _token(subject="alice-token") == first
|
|
292
|
+
assert await _token(subject="bob-token") == second
|
|
293
|
+
assert endpoint.calls == 2
|
|
294
|
+
assert [r["form"]["subject_token"] for r in endpoint.requests] == ["alice-token", "bob-token"]
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
async def test_audience_pinned(endpoint: FakeTokenEndpoint) -> None:
|
|
298
|
+
"""T5: each API gets a token minted for its own audience (and scope and resource)."""
|
|
299
|
+
orders = await _token(audience="orders")
|
|
300
|
+
inventory = await _token("inventory_agent", audience="inventory")
|
|
301
|
+
narrower = await _token(audience="orders", scope="orders.read")
|
|
302
|
+
assert len({orders, inventory, narrower}) == 3
|
|
303
|
+
assert [r["form"]["audience"] for r in endpoint.requests] == ["orders", "inventory", "orders"]
|
|
304
|
+
assert await _token(audience="orders") == orders
|
|
305
|
+
assert endpoint.calls == 3
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
@pytest.mark.parametrize(
|
|
309
|
+
("expires_in", "subject_left", "kept_for"),
|
|
310
|
+
[
|
|
311
|
+
(600, None, 270), # the TOKEN_EXCHANGE_MAX_TTL_S cap (300) less the 30 s margin
|
|
312
|
+
(100, None, 70), # the issuer's expires_in less the margin
|
|
313
|
+
(None, None, 30), # expires_in absent reads as 60
|
|
314
|
+
(300, 90, 60), # never past the subject token's own expiry
|
|
315
|
+
(300, 34, 0), # kept under 5 s: used once, not kept
|
|
316
|
+
(20, None, 0),
|
|
317
|
+
],
|
|
318
|
+
)
|
|
319
|
+
async def test_ttl_caps(
|
|
320
|
+
endpoint: FakeTokenEndpoint,
|
|
321
|
+
clock: Clock,
|
|
322
|
+
expires_in: int | None,
|
|
323
|
+
subject_left: int | None,
|
|
324
|
+
kept_for: int,
|
|
325
|
+
) -> None:
|
|
326
|
+
endpoint.expires_in = expires_in
|
|
327
|
+
exp = clock.wall() + subject_left if subject_left is not None else None
|
|
328
|
+
first = await _token(subject_expires_at=exp)
|
|
329
|
+
if kept_for:
|
|
330
|
+
clock.now += kept_for - 1
|
|
331
|
+
assert await _token(subject_expires_at=exp) == first
|
|
332
|
+
assert endpoint.calls == 1
|
|
333
|
+
clock.now += 2
|
|
334
|
+
await _token(subject_expires_at=exp)
|
|
335
|
+
assert endpoint.calls == 2
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
async def test_a_lower_ttl_cap_setting(
|
|
339
|
+
endpoint: FakeTokenEndpoint, clock: Clock, monkeypatch
|
|
340
|
+
) -> None:
|
|
341
|
+
monkeypatch.setenv("TOKEN_EXCHANGE_MAX_TTL_S", "60")
|
|
342
|
+
first = await _token()
|
|
343
|
+
clock.now += 29
|
|
344
|
+
assert await _token() == first
|
|
345
|
+
clock.now += 2
|
|
346
|
+
await _token()
|
|
347
|
+
assert endpoint.calls == 2
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
async def test_the_cache_is_bounded(endpoint: FakeTokenEndpoint, monkeypatch) -> None:
|
|
351
|
+
monkeypatch.setenv("TOKEN_EXCHANGE_CACHE_MAX", "2")
|
|
352
|
+
for user in ("a", "b", "c"):
|
|
353
|
+
await _token(subject=user)
|
|
354
|
+
assert exchanger().cached() == 2
|
|
355
|
+
await _token(subject="c") # the most recent ones are kept
|
|
356
|
+
await _token(subject="a") # the oldest went
|
|
357
|
+
assert endpoint.calls == 4
|
|
358
|
+
|
|
359
|
+
|
|
360
|
+
async def test_a_subject_token_about_to_expire_is_not_exchanged(
|
|
361
|
+
endpoint: FakeTokenEndpoint, clock: Clock
|
|
362
|
+
) -> None:
|
|
363
|
+
with pytest.raises(
|
|
364
|
+
TokenExchangeError, match="the caller's token has expired; nothing was sent"
|
|
365
|
+
):
|
|
366
|
+
await _token(subject_expires_at=clock.wall() + 10)
|
|
367
|
+
assert endpoint.calls == 0
|
|
368
|
+
await _token(subject_expires_at=clock.wall() + 11)
|
|
369
|
+
assert endpoint.calls == 1
|
|
370
|
+
|
|
371
|
+
|
|
372
|
+
# ---------------------------------------------------------------------------
|
|
373
|
+
# Failures: refusals, the breaker, timeouts, unusable answers
|
|
374
|
+
# ---------------------------------------------------------------------------
|
|
375
|
+
|
|
376
|
+
|
|
377
|
+
async def test_a_refusal_is_remembered_for_the_failure_ttl(
|
|
378
|
+
endpoint: FakeTokenEndpoint, clock: Clock
|
|
379
|
+
) -> None:
|
|
380
|
+
endpoint.answers.append(
|
|
381
|
+
httpx.Response(400, json={"error": "invalid_target", "error_description": SUBJECT})
|
|
382
|
+
)
|
|
383
|
+
before = _count("refused_api", "refused")
|
|
384
|
+
with pytest.raises(TokenExchangeError) as exc:
|
|
385
|
+
await _token("refused_api")
|
|
386
|
+
assert str(exc.value) == (
|
|
387
|
+
"token exchange for API 'refused_api' was refused (invalid_target); nothing was sent."
|
|
388
|
+
)
|
|
389
|
+
assert SUBJECT not in str(exc.value)
|
|
390
|
+
clock.now += 9
|
|
391
|
+
with pytest.raises(TokenExchangeError, match="invalid_target"):
|
|
392
|
+
await _token("refused_api")
|
|
393
|
+
assert endpoint.calls == 1 # remembered, not asked again
|
|
394
|
+
assert _count("refused_api", "refused") - before == 2
|
|
395
|
+
await _token("other_user_api", subject="bob") # another key is not refused
|
|
396
|
+
clock.now += 2
|
|
397
|
+
assert await _token("refused_api") == exchanged(3, "orders")
|
|
398
|
+
|
|
399
|
+
|
|
400
|
+
async def test_a_refusal_without_an_error_code_names_the_status(
|
|
401
|
+
endpoint: FakeTokenEndpoint,
|
|
402
|
+
) -> None:
|
|
403
|
+
endpoint.answers.append(httpx.Response(403, text="<html>no " + SUBJECT + "</html>"))
|
|
404
|
+
with pytest.raises(TokenExchangeError, match=r"was refused \(HTTP 403\)"):
|
|
405
|
+
await _token()
|
|
406
|
+
|
|
407
|
+
|
|
408
|
+
async def test_breaker_fails_fast(endpoint: FakeTokenEndpoint, clock: Clock) -> None:
|
|
409
|
+
"""T7: three failures in a row open the breaker; calls then fail at once."""
|
|
410
|
+
endpoint.answers.extend(httpx.Response(503) for _ in range(3))
|
|
411
|
+
for user in ("a", "b", "c"):
|
|
412
|
+
with pytest.raises(TokenExchangeError, match=r"token issuer unavailable \(HTTP 503\)"):
|
|
413
|
+
await _token("breaker_api", subject=user)
|
|
414
|
+
before = _count("breaker_api", "circuit_open")
|
|
415
|
+
with pytest.raises(TokenExchangeError) as exc:
|
|
416
|
+
await _token("breaker_api", subject="d")
|
|
417
|
+
assert str(exc.value) == (
|
|
418
|
+
"token issuer unavailable (retry in 10 s); nothing was sent to API 'breaker_api'."
|
|
419
|
+
)
|
|
420
|
+
assert exc.value.outcome == "circuit_open"
|
|
421
|
+
assert endpoint.calls == 3 # the issuer was left alone
|
|
422
|
+
assert _count("breaker_api", "circuit_open") - before == 1
|
|
423
|
+
clock.now += 10 # half open: one call probes, and closes it
|
|
424
|
+
assert await _token("breaker_api", subject="d") == exchanged(4, "orders")
|
|
425
|
+
await _token("breaker_api", subject="e")
|
|
426
|
+
assert endpoint.calls == 5
|
|
427
|
+
|
|
428
|
+
|
|
429
|
+
async def test_a_failed_probe_reopens_the_breaker(
|
|
430
|
+
endpoint: FakeTokenEndpoint, clock: Clock
|
|
431
|
+
) -> None:
|
|
432
|
+
endpoint.answers.extend(httpx.Response(500) for _ in range(4))
|
|
433
|
+
for user in ("a", "b", "c"):
|
|
434
|
+
with pytest.raises(TokenExchangeError):
|
|
435
|
+
await _token(subject=user)
|
|
436
|
+
clock.now += 10
|
|
437
|
+
with pytest.raises(TokenExchangeError, match=r"HTTP 500"):
|
|
438
|
+
await _token(subject="probe") # the probe fails
|
|
439
|
+
with pytest.raises(TokenExchangeError, match=r"retry in 10 s"):
|
|
440
|
+
await _token(subject="next") # open again, at once
|
|
441
|
+
assert endpoint.calls == 4
|
|
442
|
+
|
|
443
|
+
|
|
444
|
+
async def test_while_the_probe_runs_other_calls_fail_fast(
|
|
445
|
+
endpoint: FakeTokenEndpoint, clock: Clock
|
|
446
|
+
) -> None:
|
|
447
|
+
endpoint.answers.extend(httpx.Response(502) for _ in range(3))
|
|
448
|
+
for user in ("a", "b", "c"):
|
|
449
|
+
with pytest.raises(TokenExchangeError):
|
|
450
|
+
await _token(subject=user)
|
|
451
|
+
clock.now += 10
|
|
452
|
+
endpoint.delay = 0.05
|
|
453
|
+
probe = asyncio.create_task(_token(subject="probe"))
|
|
454
|
+
await asyncio.sleep(0.01)
|
|
455
|
+
with pytest.raises(TokenExchangeError, match=r"retry in \d+ s"):
|
|
456
|
+
await _token(subject="other")
|
|
457
|
+
assert await probe == exchanged(4, "orders")
|
|
458
|
+
assert endpoint.calls == 4
|
|
459
|
+
|
|
460
|
+
|
|
461
|
+
async def test_a_refusal_does_not_count_against_the_breaker(endpoint: FakeTokenEndpoint) -> None:
|
|
462
|
+
endpoint.answers.extend(
|
|
463
|
+
[httpx.Response(503), httpx.Response(503), httpx.Response(400, json={"error": "x"})]
|
|
464
|
+
)
|
|
465
|
+
endpoint.answers.append(httpx.Response(503))
|
|
466
|
+
for user in ("a", "b", "c", "d"):
|
|
467
|
+
with pytest.raises(TokenExchangeError):
|
|
468
|
+
await _token(subject=user)
|
|
469
|
+
await _token(subject="e") # the issuer answered in between: not three failures in a row
|
|
470
|
+
assert endpoint.calls == 5
|
|
471
|
+
|
|
472
|
+
|
|
473
|
+
async def test_timeout(endpoint: FakeTokenEndpoint, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
474
|
+
monkeypatch.setenv("TOKEN_EXCHANGE_TIMEOUT_MS", "100")
|
|
475
|
+
endpoint.hang = True
|
|
476
|
+
loop = asyncio.get_running_loop()
|
|
477
|
+
started = loop.time()
|
|
478
|
+
with pytest.raises(TokenExchangeError, match=r"token issuer unavailable \(timed out\)"):
|
|
479
|
+
await _token()
|
|
480
|
+
assert loop.time() - started < 1.0
|
|
481
|
+
|
|
482
|
+
|
|
483
|
+
@pytest.mark.parametrize(
|
|
484
|
+
"answer",
|
|
485
|
+
[
|
|
486
|
+
httpx.Response(200, text="not json"),
|
|
487
|
+
httpx.Response(200, json=["a list"]),
|
|
488
|
+
httpx.Response(200, json={"token_type": "Bearer", "expires_in": 60}),
|
|
489
|
+
httpx.Response(200, json={"access_token": "t", "token_type": "N_A"}),
|
|
490
|
+
httpx.Response(200, json={"access_token": "t", "token_type": "DPoP"}),
|
|
491
|
+
httpx.Response(200, json={"access_token": "t", "token_type": "Bearer", "expires_in": "60"}),
|
|
492
|
+
httpx.Response(200, json={"access_token": "t", "token_type": "Bearer", "expires_in": 0}),
|
|
493
|
+
httpx.Response(200, json={"access_token": "t", "token_type": "Bearer", "expires_in": True}),
|
|
494
|
+
httpx.Response(
|
|
495
|
+
200,
|
|
496
|
+
json={"access_token": "t", "token_type": "Bearer", "issued_token_type": JWT_TOKEN_TYPE},
|
|
497
|
+
),
|
|
498
|
+
httpx.Response(200, json={"access_token": "t" * 16_385, "token_type": "Bearer"}),
|
|
499
|
+
httpx.Response(200, content=b"{" + b" " * 70_000 + b"}"),
|
|
500
|
+
],
|
|
501
|
+
)
|
|
502
|
+
async def test_an_unusable_answer_is_not_used(
|
|
503
|
+
endpoint: FakeTokenEndpoint, answer: httpx.Response
|
|
504
|
+
) -> None:
|
|
505
|
+
endpoint.answers.append(answer)
|
|
506
|
+
with pytest.raises(TokenExchangeError, match=r"unusable issuer response"):
|
|
507
|
+
await _token()
|
|
508
|
+
|
|
509
|
+
|
|
510
|
+
async def test_the_token_type_is_case_insensitive(endpoint: FakeTokenEndpoint) -> None:
|
|
511
|
+
token = exchanged(1, "orders")
|
|
512
|
+
endpoint.answers.append(
|
|
513
|
+
httpx.Response(200, json={"access_token": token, "token_type": "bearer", "expires_in": 60})
|
|
514
|
+
)
|
|
515
|
+
assert await _token() == token
|
|
516
|
+
|
|
517
|
+
|
|
518
|
+
async def test_not_configured_sends_nothing(
|
|
519
|
+
endpoint: FakeTokenEndpoint, monkeypatch: pytest.MonkeyPatch
|
|
520
|
+
) -> None:
|
|
521
|
+
monkeypatch.delenv("TOKEN_EXCHANGE_CLIENT_SECRET")
|
|
522
|
+
monkeypatch.delenv("TOKEN_EXCHANGE_URL")
|
|
523
|
+
with pytest.raises(TokenExchangeError) as exc:
|
|
524
|
+
await _token()
|
|
525
|
+
assert str(exc.value) == (
|
|
526
|
+
"API 'orders_agent' uses auth: exchange, but TOKEN_EXCHANGE_URL, "
|
|
527
|
+
"TOKEN_EXCHANGE_CLIENT_SECRET are not set; nothing was sent."
|
|
528
|
+
)
|
|
529
|
+
assert endpoint.calls == 0
|
|
530
|
+
|
|
531
|
+
|
|
532
|
+
# ---------------------------------------------------------------------------
|
|
533
|
+
# Logs and metrics carry no token material
|
|
534
|
+
# ---------------------------------------------------------------------------
|
|
535
|
+
|
|
536
|
+
|
|
537
|
+
async def test_no_token_in_logs(
|
|
538
|
+
endpoint: FakeTokenEndpoint, clock: Clock, caplog: pytest.LogCaptureFixture
|
|
539
|
+
) -> None:
|
|
540
|
+
caplog.set_level(logging.DEBUG)
|
|
541
|
+
issued = await _token(subject=SUBJECT)
|
|
542
|
+
endpoint.answers.append(
|
|
543
|
+
httpx.Response(400, json={"error": "invalid_grant", "error_description": SUBJECT})
|
|
544
|
+
)
|
|
545
|
+
with pytest.raises(TokenExchangeError):
|
|
546
|
+
await _token(subject=SUBJECT + "-2")
|
|
547
|
+
endpoint.answers.extend(httpx.Response(500, text=SUBJECT) for _ in range(3))
|
|
548
|
+
for user in ("x", "y", "z"):
|
|
549
|
+
with pytest.raises(TokenExchangeError):
|
|
550
|
+
await _token(subject=SUBJECT + user)
|
|
551
|
+
text = caplog.text + "".join(str(r.__dict__) for r in caplog.records)
|
|
552
|
+
assert "token exchange for orders_agent (audience orders): issued" in caplog.text
|
|
553
|
+
assert "refused (invalid_grant)" in caplog.text
|
|
554
|
+
assert "the issuer failed 3 times in a row" in caplog.text
|
|
555
|
+
for secret in (
|
|
556
|
+
SUBJECT,
|
|
557
|
+
issued,
|
|
558
|
+
CLIENT_SECRET,
|
|
559
|
+
hashlib.sha256(SUBJECT.encode()).hexdigest(),
|
|
560
|
+
hashlib.sha256(SUBJECT.encode()).hexdigest()[:16],
|
|
561
|
+
):
|
|
562
|
+
assert secret not in text
|
|
563
|
+
rendered = metrics.render()[0].decode()
|
|
564
|
+
assert SUBJECT not in rendered and issued not in rendered
|
|
565
|
+
|
|
566
|
+
|
|
567
|
+
def _issue(endpoint: FakeTokenEndpoint, token: str) -> None:
|
|
568
|
+
endpoint.answers.append(
|
|
569
|
+
httpx.Response(200, json={"access_token": token, "token_type": "Bearer", "expires_in": 300})
|
|
570
|
+
)
|
|
571
|
+
|
|
572
|
+
|
|
573
|
+
NO_ACTOR = "names no actor"
|
|
574
|
+
|
|
575
|
+
# Tokens that name no actor as far as the calling agent can tell (`names_no_actor`).
|
|
576
|
+
ACTORLESS_TOKENS = [
|
|
577
|
+
_jwt({"sub": "alice", "aud": "orders", "azp": CLIENT_ID}), # no act: Keycloak's shape
|
|
578
|
+
"opaque-token", # not a JWT
|
|
579
|
+
"h.p.s.i.t", # encrypted (JWE)
|
|
580
|
+
"h..s",
|
|
581
|
+
"h.***.s", # not base64url
|
|
582
|
+
f"h.{_b64(b'not json')}.s",
|
|
583
|
+
f"h.{_b64(bytes([0x80, 0x81]))}.s", # not UTF-8
|
|
584
|
+
f"h.{_b64(b'[1, 2]')}.s", # not an object
|
|
585
|
+
f"h.{_b64(b'[' * 12_000)}.s", # nested too deep to read
|
|
586
|
+
]
|
|
587
|
+
ACTORLESS_IDS = [
|
|
588
|
+
"no-act",
|
|
589
|
+
"opaque",
|
|
590
|
+
"jwe",
|
|
591
|
+
"empty-payload",
|
|
592
|
+
"not-base64url",
|
|
593
|
+
"not-json",
|
|
594
|
+
"not-utf8",
|
|
595
|
+
"not-an-object",
|
|
596
|
+
"too-deep",
|
|
597
|
+
]
|
|
598
|
+
|
|
599
|
+
|
|
600
|
+
def _no_actor_warnings(caplog: pytest.LogCaptureFixture) -> list[logging.LogRecord]:
|
|
601
|
+
return [
|
|
602
|
+
r for r in caplog.records if r.levelno >= logging.WARNING and NO_ACTOR in r.getMessage()
|
|
603
|
+
]
|
|
604
|
+
|
|
605
|
+
|
|
606
|
+
@pytest.mark.parametrize("token", ACTORLESS_TOKENS, ids=ACTORLESS_IDS)
|
|
607
|
+
async def test_an_issued_token_that_names_no_actor_is_refused(
|
|
608
|
+
endpoint: FakeTokenEndpoint, caplog: pytest.LogCaptureFixture, token: str
|
|
609
|
+
) -> None:
|
|
610
|
+
"""An issuer whose exchanged tokens carry no `act` (Keycloak's standard token exchange), or
|
|
611
|
+
that mints tokens this agent cannot read: the called agent would take them for the user's
|
|
612
|
+
own, and could let this agent decide the user's approvals. Refused (fail closed); the
|
|
613
|
+
refusal is remembered like the issuer's own, and the token is never logged."""
|
|
614
|
+
caplog.set_level(logging.DEBUG)
|
|
615
|
+
before = _count("orders_agent", "no_actor"), _count("orders_agent", "issued")
|
|
616
|
+
_issue(endpoint, token)
|
|
617
|
+
with pytest.raises(TokenExchangeError) as exc:
|
|
618
|
+
await _token(subject="alice")
|
|
619
|
+
assert exc.value.outcome == "no_actor"
|
|
620
|
+
message = str(exc.value)
|
|
621
|
+
assert "the issuer's token names no actor (no act claim, or not a readable JWT)" in message
|
|
622
|
+
assert "nothing was sent" in message and "exchange.allow_actorless: true" in message
|
|
623
|
+
assert "AUTH_JWT_DIRECT_CLIENTS" in message
|
|
624
|
+
assert token not in message
|
|
625
|
+
assert _count("orders_agent", "no_actor") - before[0] == 1
|
|
626
|
+
assert _count("orders_agent", "issued") - before[1] == 0
|
|
627
|
+
assert exchanger().cached() == 0
|
|
628
|
+
# Remembered for the failure TTL: the issuer is not asked again, and the breaker is shut.
|
|
629
|
+
with pytest.raises(TokenExchangeError) as again:
|
|
630
|
+
await _token(subject="alice")
|
|
631
|
+
assert again.value.outcome == "no_actor"
|
|
632
|
+
assert endpoint.calls == 1
|
|
633
|
+
assert _count("orders_agent", "no_actor") - before[0] == 2
|
|
634
|
+
refused = (
|
|
635
|
+
"token exchange for orders_agent (audience orders): refused (the token names no actor)"
|
|
636
|
+
)
|
|
637
|
+
assert refused in caplog.text
|
|
638
|
+
logged = caplog.text + "".join(str(r.__dict__) for r in caplog.records)
|
|
639
|
+
assert token not in logged
|
|
640
|
+
if token.count(".") == 2 and len(token.split(".")[1]) > 3:
|
|
641
|
+
assert token.split(".")[1] not in logged
|
|
642
|
+
assert _no_actor_warnings(caplog) == [] # a refusal, not a warning
|
|
643
|
+
|
|
644
|
+
|
|
645
|
+
async def test_the_refusal_of_a_token_naming_no_actor_is_forgotten_after_the_ttl(
|
|
646
|
+
endpoint: FakeTokenEndpoint, clock: Clock
|
|
647
|
+
) -> None:
|
|
648
|
+
_issue(endpoint, _jwt({"sub": "alice", "aud": "orders"}))
|
|
649
|
+
with pytest.raises(TokenExchangeError):
|
|
650
|
+
await _token(subject="alice")
|
|
651
|
+
clock.now += token_exchange.DEFAULT_FAILURE_TTL_S + 1
|
|
652
|
+
# The issuer now names the agent (say, after its admin fixed the mapper).
|
|
653
|
+
assert await _token(subject="alice") == exchanged(2, "orders")
|
|
654
|
+
assert endpoint.calls == 2
|
|
655
|
+
|
|
656
|
+
|
|
657
|
+
@pytest.mark.parametrize("token", ACTORLESS_TOKENS, ids=ACTORLESS_IDS)
|
|
658
|
+
async def test_an_api_that_allows_it_takes_a_token_naming_no_actor_and_warns_once(
|
|
659
|
+
endpoint: FakeTokenEndpoint, caplog: pytest.LogCaptureFixture, token: str
|
|
660
|
+
) -> None:
|
|
661
|
+
"""`exchange.allow_actorless: true`: the token is used; one warning per process says what
|
|
662
|
+
the called agent must set, naming this agent's client; the token is never logged."""
|
|
663
|
+
caplog.set_level(logging.DEBUG)
|
|
664
|
+
_issue(endpoint, token)
|
|
665
|
+
assert await _token(subject="alice", allow_actorless=True) == token
|
|
666
|
+
[record] = _no_actor_warnings(caplog)
|
|
667
|
+
assert record.levelno == logging.WARNING
|
|
668
|
+
message = record.getMessage()
|
|
669
|
+
assert "the token the issuer minted for orders_agent (audience orders)" in message
|
|
670
|
+
assert "(no act claim)" in message and "exchange.allow_actorless lets it through" in message
|
|
671
|
+
assert "AUTH_JWT_DIRECT_CLIENTS" in message and f"client:{CLIENT_ID}" in message
|
|
672
|
+
logged = caplog.text + "".join(str(r.__dict__) for r in caplog.records)
|
|
673
|
+
assert token not in logged
|
|
674
|
+
# Once per process: more such tokens, for other users and APIs, add no warning.
|
|
675
|
+
_issue(endpoint, _jwt({"sub": "bob", "aud": "orders", "azp": CLIENT_ID}))
|
|
676
|
+
_issue(endpoint, _jwt({"sub": "bob", "aud": "billing"}))
|
|
677
|
+
await _token(subject="bob", allow_actorless=True)
|
|
678
|
+
await _token("billing_agent", subject="bob", audience="billing", allow_actorless=True)
|
|
679
|
+
assert endpoint.calls == 3
|
|
680
|
+
assert _no_actor_warnings(caplog) == [record]
|
|
681
|
+
|
|
682
|
+
|
|
683
|
+
async def test_a_token_kept_for_an_api_that_allows_it_never_reaches_one_that_does_not(
|
|
684
|
+
endpoint: FakeTokenEndpoint,
|
|
685
|
+
) -> None:
|
|
686
|
+
"""Two APIs with the same audience, scope and resource share nothing when only one opts in:
|
|
687
|
+
the token kept for the one that allows a token naming no actor is not the other's."""
|
|
688
|
+
actless = _jwt({"sub": "alice", "aud": "orders"})
|
|
689
|
+
_issue(endpoint, actless)
|
|
690
|
+
assert await _token("lenient_api", subject="alice", allow_actorless=True) == actless
|
|
691
|
+
assert await _token("lenient_api", subject="alice", allow_actorless=True) == actless
|
|
692
|
+
assert endpoint.calls == 1 # kept for the lenient API
|
|
693
|
+
_issue(endpoint, actless)
|
|
694
|
+
with pytest.raises(TokenExchangeError) as exc:
|
|
695
|
+
await _token("strict_api", subject="alice") # its own exchange, refused
|
|
696
|
+
assert exc.value.outcome == "no_actor"
|
|
697
|
+
assert endpoint.calls == 2
|
|
698
|
+
# ... and the strict API's refusal does not reach the lenient one.
|
|
699
|
+
assert await _token("lenient_api", subject="alice", allow_actorless=True) == actless
|
|
700
|
+
assert endpoint.calls == 2
|
|
701
|
+
|
|
702
|
+
|
|
703
|
+
async def test_calls_sharing_an_exchange_all_get_the_refusal(endpoint: FakeTokenEndpoint) -> None:
|
|
704
|
+
endpoint.delay = 0.05
|
|
705
|
+
_issue(endpoint, _jwt({"sub": "alice", "aud": "orders"}))
|
|
706
|
+
before = _count("sf_actorless", "no_actor")
|
|
707
|
+
results = await asyncio.gather(
|
|
708
|
+
*(_token("sf_actorless", subject="alice") for _ in range(20)), return_exceptions=True
|
|
709
|
+
)
|
|
710
|
+
assert endpoint.calls == 1
|
|
711
|
+
assert all(isinstance(r, TokenExchangeError) and r.outcome == "no_actor" for r in results)
|
|
712
|
+
assert _count("sf_actorless", "no_actor") - before == 20
|
|
713
|
+
|
|
714
|
+
|
|
715
|
+
@pytest.mark.parametrize(
|
|
716
|
+
("token", "claim"),
|
|
717
|
+
[
|
|
718
|
+
(_jwt({"sub": "alice", "act": {"sub": CLIENT_ID}}), None), # the issuer names the agent
|
|
719
|
+
(_jwt({"sub": "alice", "act": None}), None), # present: the callee refuses it (401)
|
|
720
|
+
(_jwt({"sub": "alice", "ext": {"actor": {"sub": CLIENT_ID}}}), "ext.actor"),
|
|
721
|
+
(_jwt({"sub": "alice", "ext.actor": {"sub": CLIENT_ID}}), "ext.actor"),
|
|
722
|
+
],
|
|
723
|
+
)
|
|
724
|
+
async def test_a_token_that_names_its_actor_is_used_without_a_warning(
|
|
725
|
+
endpoint: FakeTokenEndpoint,
|
|
726
|
+
caplog: pytest.LogCaptureFixture,
|
|
727
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
728
|
+
token: str,
|
|
729
|
+
claim: str | None,
|
|
730
|
+
) -> None:
|
|
731
|
+
if claim is not None:
|
|
732
|
+
monkeypatch.setenv("AUTH_JWT_ACTOR_CLAIM", claim)
|
|
733
|
+
else:
|
|
734
|
+
monkeypatch.delenv("AUTH_JWT_ACTOR_CLAIM", raising=False)
|
|
735
|
+
caplog.set_level(logging.DEBUG)
|
|
736
|
+
_issue(endpoint, token)
|
|
737
|
+
assert await _token() == token
|
|
738
|
+
assert _no_actor_warnings(caplog) == []
|
|
739
|
+
|
|
740
|
+
|
|
741
|
+
async def test_a_failure_while_looking_at_the_token_refuses_it(
|
|
742
|
+
endpoint: FakeTokenEndpoint, monkeypatch: pytest.MonkeyPatch
|
|
743
|
+
) -> None:
|
|
744
|
+
def broken(token: str, claim: str = "act") -> bool:
|
|
745
|
+
raise RuntimeError("a bug while reading the claims")
|
|
746
|
+
|
|
747
|
+
monkeypatch.setattr(token_exchange, "names_no_actor", broken)
|
|
748
|
+
_issue(endpoint, _jwt({"sub": "alice", "act": {"sub": CLIENT_ID}}))
|
|
749
|
+
with pytest.raises(TokenExchangeError) as exc:
|
|
750
|
+
await _token()
|
|
751
|
+
assert exc.value.outcome == "no_actor" # fail closed
|
|
752
|
+
|
|
753
|
+
|
|
754
|
+
def test_names_no_actor() -> None:
|
|
755
|
+
assert names_no_actor(_jwt({"sub": "alice", "azp": "concierge"})) is True
|
|
756
|
+
assert names_no_actor(_jwt({"sub": "alice", "act": {"sub": "concierge"}})) is False
|
|
757
|
+
assert names_no_actor(_jwt({"sub": "alice", "act": None})) is False # jwt refuses it (401)
|
|
758
|
+
# AUTH_JWT_ACTOR_CLAIM's claim, a dotted path, read as jwt reads it.
|
|
759
|
+
assert names_no_actor(_jwt({"act": {"sub": "concierge"}}), "ext.actor") is True
|
|
760
|
+
assert names_no_actor(_jwt({"ext": {"actor": {"sub": "c"}}}), "ext.actor") is False
|
|
761
|
+
assert names_no_actor(_jwt({"ext.actor": {"sub": "c"}}), "ext.actor") is False
|
|
762
|
+
assert names_no_actor(_jwt({"ext": "flat"}), "ext.actor") is True
|
|
763
|
+
# A token this agent cannot read names no actor as far as it can tell.
|
|
764
|
+
for unreadable in ACTORLESS_TOKENS[1:]:
|
|
765
|
+
assert names_no_actor(unreadable) is True
|
|
766
|
+
|
|
767
|
+
|
|
768
|
+
def test_the_actor_claim_is_the_jwt_policys(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
769
|
+
monkeypatch.delenv("AUTH_JWT_ACTOR_CLAIM", raising=False)
|
|
770
|
+
assert token_exchange.actor_claim() == "act"
|
|
771
|
+
monkeypatch.setenv("AUTH_JWT_ACTOR_CLAIM", "") # jwt's 0.2 reading: the caller still asks
|
|
772
|
+
assert token_exchange.actor_claim() == "act"
|
|
773
|
+
monkeypatch.setenv("AUTH_JWT_ACTOR_CLAIM", " ext.actor ")
|
|
774
|
+
assert token_exchange.actor_claim() == "ext.actor"
|
|
775
|
+
|
|
776
|
+
|
|
777
|
+
async def test_the_no_actor_check_reads_the_configured_actor_claim(
|
|
778
|
+
endpoint: FakeTokenEndpoint, caplog: pytest.LogCaptureFixture, monkeypatch: pytest.MonkeyPatch
|
|
779
|
+
) -> None:
|
|
780
|
+
monkeypatch.setenv("AUTH_JWT_ACTOR_CLAIM", "ext.actor")
|
|
781
|
+
_issue(endpoint, _jwt({"sub": "alice", "act": {"sub": CLIENT_ID}}))
|
|
782
|
+
with pytest.raises(TokenExchangeError) as exc:
|
|
783
|
+
await _token()
|
|
784
|
+
assert "(no ext.actor claim, or not a readable JWT)" in str(exc.value)
|
|
785
|
+
caplog.set_level(logging.WARNING)
|
|
786
|
+
_issue(endpoint, _jwt({"sub": "alice", "act": {"sub": CLIENT_ID}}))
|
|
787
|
+
await _token("lenient_api", allow_actorless=True)
|
|
788
|
+
[record] = _no_actor_warnings(caplog)
|
|
789
|
+
assert "(no ext.actor claim)" in record.getMessage()
|
|
790
|
+
|
|
791
|
+
|
|
792
|
+
def test_settings_repr_hides_the_secret(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
793
|
+
monkeypatch.setenv("TOKEN_EXCHANGE_CLIENT_SECRET", CLIENT_SECRET)
|
|
794
|
+
assert CLIENT_SECRET not in repr(exchange_settings())
|
|
795
|
+
|
|
796
|
+
|
|
797
|
+
# ---------------------------------------------------------------------------
|
|
798
|
+
# Settings
|
|
799
|
+
# ---------------------------------------------------------------------------
|
|
800
|
+
|
|
801
|
+
|
|
802
|
+
@pytest.mark.parametrize(
|
|
803
|
+
("env", "allowed"),
|
|
804
|
+
[
|
|
805
|
+
({"TOKEN_EXCHANGE_URL": "https://issuer.example.com/token"}, True),
|
|
806
|
+
({"TOKEN_EXCHANGE_URL": "http://issuer.example.com/token"}, False),
|
|
807
|
+
({"TOKEN_EXCHANGE_URL": "http://issuer.example.com/token", "APP_ENV": "dev"}, True),
|
|
808
|
+
({"TOKEN_EXCHANGE_URL": "http://127.0.0.1:8080/token"}, True),
|
|
809
|
+
({"TOKEN_EXCHANGE_URL": "http://localhost/token"}, True),
|
|
810
|
+
({"TOKEN_EXCHANGE_URL": "http://[::1]:9/token"}, True),
|
|
811
|
+
(
|
|
812
|
+
{
|
|
813
|
+
"TOKEN_EXCHANGE_URL": "http://keycloak.auth.svc/token",
|
|
814
|
+
"TOKEN_EXCHANGE_ALLOW_HTTP": "true",
|
|
815
|
+
},
|
|
816
|
+
True,
|
|
817
|
+
),
|
|
818
|
+
({"TOKEN_EXCHANGE_URL": "ftp://issuer.example.com/token"}, False),
|
|
819
|
+
({"TOKEN_EXCHANGE_URL": "https://user:pw@issuer.example.com/token"}, False),
|
|
820
|
+
({"TOKEN_EXCHANGE_URL": "https://issuer.example.com/token#x"}, False),
|
|
821
|
+
],
|
|
822
|
+
)
|
|
823
|
+
def test_the_https_rule(env: dict[str, str], allowed: bool) -> None:
|
|
824
|
+
if allowed:
|
|
825
|
+
assert exchange_settings(env).url == env["TOKEN_EXCHANGE_URL"]
|
|
826
|
+
else:
|
|
827
|
+
with pytest.raises(SettingsError, match="TOKEN_EXCHANGE_URL"):
|
|
828
|
+
exchange_settings(env)
|
|
829
|
+
|
|
830
|
+
|
|
831
|
+
@pytest.mark.parametrize(
|
|
832
|
+
"env",
|
|
833
|
+
[
|
|
834
|
+
{"TOKEN_EXCHANGE_CLIENT_AUTH": "private_key_jwt"},
|
|
835
|
+
{"TOKEN_EXCHANGE_SUBJECT_TOKEN_TYPE": "urn:ietf:params:oauth:token-type:id_token"},
|
|
836
|
+
{"TOKEN_EXCHANGE_TIMEOUT_MS": "abc"},
|
|
837
|
+
{"TOKEN_EXCHANGE_TIMEOUT_MS": "10"},
|
|
838
|
+
{"TOKEN_EXCHANGE_MAX_TTL_S": "301"},
|
|
839
|
+
{"TOKEN_EXCHANGE_MAX_TTL_S": "0"},
|
|
840
|
+
{"TOKEN_EXCHANGE_FAILURE_TTL_S": "-1"},
|
|
841
|
+
{"TOKEN_EXCHANGE_CACHE_MAX": "0"},
|
|
842
|
+
],
|
|
843
|
+
)
|
|
844
|
+
def test_a_bad_setting_is_a_settings_error(env: dict[str, str]) -> None:
|
|
845
|
+
with pytest.raises(SettingsError, match=next(iter(env))):
|
|
846
|
+
exchange_settings(env)
|
|
847
|
+
|
|
848
|
+
|
|
849
|
+
def test_the_defaults() -> None:
|
|
850
|
+
settings = exchange_settings({})
|
|
851
|
+
assert settings == ExchangeSettings()
|
|
852
|
+
assert (settings.timeout_s, settings.max_ttl_s, settings.failure_ttl_s) == (2.0, 300, 10)
|
|
853
|
+
assert settings.cache_max == 10_000 and settings.client_auth == "client_secret_basic"
|
|
854
|
+
assert settings.missing() == [
|
|
855
|
+
"TOKEN_EXCHANGE_URL",
|
|
856
|
+
"TOKEN_EXCHANGE_CLIENT_ID",
|
|
857
|
+
"TOKEN_EXCHANGE_CLIENT_SECRET",
|
|
858
|
+
]
|
|
859
|
+
|
|
860
|
+
|
|
861
|
+
# ---------------------------------------------------------------------------
|
|
862
|
+
# The compatibility matrix and the startup checks
|
|
863
|
+
# ---------------------------------------------------------------------------
|
|
864
|
+
|
|
865
|
+
# A public key for the jwt policy of a startup check (no token is verified with it).
|
|
866
|
+
JWT_PUBLIC_KEY = (
|
|
867
|
+
rsa.generate_private_key(public_exponent=65537, key_size=2048)
|
|
868
|
+
.public_key()
|
|
869
|
+
.public_bytes(serialization.Encoding.PEM, serialization.PublicFormat.SubjectPublicKeyInfo)
|
|
870
|
+
.decode()
|
|
871
|
+
)
|
|
872
|
+
EXCHANGE_API = {"auth": "exchange", "exchange": {"audience": "orders"}}
|
|
873
|
+
FORWARD_API = {"auth": "forward"}
|
|
874
|
+
AIMED_FORWARD_API = {"auth": "forward", "forward_audience": "orders"}
|
|
875
|
+
|
|
876
|
+
|
|
877
|
+
@pytest.mark.parametrize(
|
|
878
|
+
("apis", "auth_policy", "runtime", "problem"),
|
|
879
|
+
[
|
|
880
|
+
({"p": EXCHANGE_API}, "jwt", "fastapi", None),
|
|
881
|
+
({"p": EXCHANGE_API}, "custom", "fastapi", None),
|
|
882
|
+
({"p": EXCHANGE_API}, "shared-bearer", "fastapi", "shared-bearer has no user token"),
|
|
883
|
+
({"p": EXCHANGE_API}, "jwt", "langgraph-server", "persists the run context"),
|
|
884
|
+
({"p": FORWARD_API}, "custom", "fastapi", None),
|
|
885
|
+
({"p": AIMED_FORWARD_API}, "jwt", "fastapi", None),
|
|
886
|
+
# The forward rows refuse at startup too (the owner's decision of 2026-09-28).
|
|
887
|
+
({"p": FORWARD_API}, "jwt", "fastapi", "without forward_audience"),
|
|
888
|
+
({"p": FORWARD_API}, "shared-bearer", "fastapi", "no user credential to forward"),
|
|
889
|
+
({"p": FORWARD_API}, "custom", "langgraph-server", "carries no credentials"),
|
|
890
|
+
(
|
|
891
|
+
{"p": {"auth": "bearer"}, "q": {"auth": "none"}},
|
|
892
|
+
"shared-bearer",
|
|
893
|
+
"langgraph-server",
|
|
894
|
+
None,
|
|
895
|
+
),
|
|
896
|
+
],
|
|
897
|
+
)
|
|
898
|
+
def test_the_compatibility_matrix(
|
|
899
|
+
apis: dict[str, Any], auth_policy: str, runtime: str, problem: str | None
|
|
900
|
+
) -> None:
|
|
901
|
+
problems = compatibility(auth_policy, runtime, apis)
|
|
902
|
+
assert [problem in p for p in problems] == ([True] if problem else [])
|
|
903
|
+
|
|
904
|
+
|
|
905
|
+
@pytest.fixture
|
|
906
|
+
def policy_env(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path:
|
|
907
|
+
path = tmp_path / "api-policy.yaml"
|
|
908
|
+
path.write_text(POLICY, encoding="utf-8")
|
|
909
|
+
monkeypatch.setenv("API_POLICY_PATH", str(path))
|
|
910
|
+
for name in ("RUNTIME", "LANGGRAPH_SERVER", "LANGSERVE_GRAPHS", "APP_ENV"):
|
|
911
|
+
monkeypatch.delenv(name, raising=False)
|
|
912
|
+
reset_policy_cache()
|
|
913
|
+
yield path
|
|
914
|
+
reset_policy_cache()
|
|
915
|
+
|
|
916
|
+
|
|
917
|
+
def test_startup_problems_name_the_missing_settings(
|
|
918
|
+
policy_env: Path, monkeypatch: pytest.MonkeyPatch
|
|
919
|
+
) -> None:
|
|
920
|
+
for name in ("TOKEN_EXCHANGE_URL", "TOKEN_EXCHANGE_CLIENT_ID", "TOKEN_EXCHANGE_CLIENT_SECRET"):
|
|
921
|
+
monkeypatch.delenv(name, raising=False)
|
|
922
|
+
[problem] = startup_problems("jwt")
|
|
923
|
+
assert problem.startswith("auth: exchange (apis: orders_agent, billing_agent) needs ")
|
|
924
|
+
assert "TOKEN_EXCHANGE_URL, TOKEN_EXCHANGE_CLIENT_ID, TOKEN_EXCHANGE_CLIENT_SECRET" in problem
|
|
925
|
+
monkeypatch.setenv("TOKEN_EXCHANGE_URL", TOKEN_URL)
|
|
926
|
+
monkeypatch.setenv("TOKEN_EXCHANGE_CLIENT_ID", CLIENT_ID)
|
|
927
|
+
monkeypatch.setenv("TOKEN_EXCHANGE_CLIENT_SECRET", CLIENT_SECRET)
|
|
928
|
+
assert startup_problems("jwt") == []
|
|
929
|
+
assert len(startup_problems("shared-bearer")) == 1
|
|
930
|
+
monkeypatch.setenv("RUNTIME", "langgraph-server")
|
|
931
|
+
assert "persists the run context" in startup_problems("jwt")[0]
|
|
932
|
+
|
|
933
|
+
|
|
934
|
+
def test_check_startup_refuses_outside_dev_and_logs_under_dev(
|
|
935
|
+
policy_env: Path, monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture
|
|
936
|
+
) -> None:
|
|
937
|
+
from {{cookiecutter.agent_directory}}.app_utils import auth
|
|
938
|
+
|
|
939
|
+
monkeypatch.setenv("AUTH_POLICY", "shared-bearer")
|
|
940
|
+
monkeypatch.setenv("API_KEY", "k")
|
|
941
|
+
for name in ("TOKEN_EXCHANGE_URL", "TOKEN_EXCHANGE_CLIENT_ID", "TOKEN_EXCHANGE_CLIENT_SECRET"):
|
|
942
|
+
monkeypatch.delenv(name, raising=False)
|
|
943
|
+
auth.reset_policy_cache()
|
|
944
|
+
try:
|
|
945
|
+
with pytest.raises(RuntimeError) as exc:
|
|
946
|
+
auth.check_startup()
|
|
947
|
+
assert str(exc.value).startswith(
|
|
948
|
+
"api-policy.yaml cannot work with this configuration, refusing to start: "
|
|
949
|
+
)
|
|
950
|
+
assert "shared-bearer has no user token to exchange" in str(exc.value)
|
|
951
|
+
assert "needs TOKEN_EXCHANGE_URL" in str(exc.value)
|
|
952
|
+
monkeypatch.setenv("APP_ENV", "dev")
|
|
953
|
+
with caplog.at_level(logging.ERROR):
|
|
954
|
+
auth.check_startup()
|
|
955
|
+
assert "APP_ENV=dev: starting anyway" in caplog.text
|
|
956
|
+
finally:
|
|
957
|
+
auth.reset_policy_cache()
|
|
958
|
+
|
|
959
|
+
|
|
960
|
+
@pytest.mark.parametrize(
|
|
961
|
+
("api", "auth_policy", "runtime", "problem"),
|
|
962
|
+
[
|
|
963
|
+
("auth: forward", "shared-bearer", "fastapi", "no user credential to forward"),
|
|
964
|
+
("auth: forward", "jwt", "fastapi", "without forward_audience"),
|
|
965
|
+
("auth: forward", "custom", "langgraph-server", "the server's run context carries no"),
|
|
966
|
+
],
|
|
967
|
+
)
|
|
968
|
+
def test_a_forward_api_that_cannot_work_refuses_startup_outside_dev(
|
|
969
|
+
tmp_path: Path,
|
|
970
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
971
|
+
caplog: pytest.LogCaptureFixture,
|
|
972
|
+
api: str,
|
|
973
|
+
auth_policy: str,
|
|
974
|
+
runtime: str,
|
|
975
|
+
problem: str,
|
|
976
|
+
) -> None:
|
|
977
|
+
"""The owner's decision of 2026-09-28: such an API's every call fails ('no credential'),
|
|
978
|
+
so outside APP_ENV=dev the app does not start with it (lint and api add already refuse
|
|
979
|
+
it); under dev it starts and says why."""
|
|
980
|
+
from {{cookiecutter.agent_directory}}.app_utils import auth
|
|
981
|
+
|
|
982
|
+
path = tmp_path / "api-policy.yaml"
|
|
983
|
+
path.write_text(
|
|
984
|
+
f"apis:\n me:\n base_url_env: ME_URL\n {api}\n allowed_methods: [GET]\n"
|
|
985
|
+
)
|
|
986
|
+
monkeypatch.setenv("API_POLICY_PATH", str(path))
|
|
987
|
+
monkeypatch.setenv("AUTH_POLICY", auth_policy)
|
|
988
|
+
monkeypatch.setenv("RUNTIME", runtime)
|
|
989
|
+
monkeypatch.delenv("APP_ENV", raising=False)
|
|
990
|
+
policies = {
|
|
991
|
+
"shared-bearer": {"API_KEY": "k"},
|
|
992
|
+
"jwt": {
|
|
993
|
+
"AUTH_JWT_PUBLIC_KEY": JWT_PUBLIC_KEY,
|
|
994
|
+
"AUTH_JWT_ISSUER": "https://issuer.test",
|
|
995
|
+
"AUTH_JWT_AUDIENCE": "me",
|
|
996
|
+
},
|
|
997
|
+
"custom": {},
|
|
998
|
+
}
|
|
999
|
+
for name, value in policies[auth_policy].items():
|
|
1000
|
+
monkeypatch.setenv(name, value)
|
|
1001
|
+
reset_policy_cache()
|
|
1002
|
+
auth.reset_policy_cache()
|
|
1003
|
+
try:
|
|
1004
|
+
probe = getattr(auth.get_policy(), "startup_problems", None)
|
|
1005
|
+
if callable(probe) and probe():
|
|
1006
|
+
pytest.skip(f"the {auth_policy} policy itself is not configured here: {probe()}")
|
|
1007
|
+
with pytest.raises(RuntimeError) as exc:
|
|
1008
|
+
auth.check_startup()
|
|
1009
|
+
assert str(exc.value).startswith(
|
|
1010
|
+
"api-policy.yaml cannot work with this configuration, refusing to start: "
|
|
1011
|
+
)
|
|
1012
|
+
assert problem in str(exc.value)
|
|
1013
|
+
monkeypatch.setenv("APP_ENV", "dev")
|
|
1014
|
+
with caplog.at_level(logging.WARNING):
|
|
1015
|
+
auth.check_startup()
|
|
1016
|
+
[record] = [r for r in caplog.records if problem in r.getMessage()]
|
|
1017
|
+
assert "APP_ENV=dev: starting anyway" in record.getMessage()
|
|
1018
|
+
finally:
|
|
1019
|
+
auth.reset_policy_cache()
|
|
1020
|
+
reset_policy_cache()
|
|
1021
|
+
|
|
1022
|
+
|
|
1023
|
+
# ---------------------------------------------------------------------------
|
|
1024
|
+
# `auth: exchange` calls (api_client)
|
|
1025
|
+
# ---------------------------------------------------------------------------
|
|
1026
|
+
|
|
1027
|
+
|
|
1028
|
+
@dataclass
|
|
1029
|
+
class _Context:
|
|
1030
|
+
principal_id: str = "alice"
|
|
1031
|
+
attributes: dict[str, Any] = field(default_factory=dict)
|
|
1032
|
+
|
|
1033
|
+
|
|
1034
|
+
def _context(subject: str | None = SUBJECT, chain: tuple[str, ...] = (), **extra: Any) -> _Context:
|
|
1035
|
+
credentials: dict[str, Any] = {}
|
|
1036
|
+
if subject is not None:
|
|
1037
|
+
credentials = {
|
|
1038
|
+
"@subject_token": subject,
|
|
1039
|
+
"@subject_aud": ("concierge",),
|
|
1040
|
+
"@subject_exp": WALL_START + 3600,
|
|
1041
|
+
}
|
|
1042
|
+
attributes: dict[str, Any] = {"credentials": credentials, **extra}
|
|
1043
|
+
if chain:
|
|
1044
|
+
attributes["@actor"] = {"id": chain[0], "chain": list(chain), "client": chain[0]}
|
|
1045
|
+
return _Context(attributes=attributes)
|
|
1046
|
+
|
|
1047
|
+
|
|
1048
|
+
class Api:
|
|
1049
|
+
"""The APIs the exchanged token goes to: records every request."""
|
|
1050
|
+
|
|
1051
|
+
def __init__(self) -> None:
|
|
1052
|
+
self.requests: list[httpx.Request] = []
|
|
1053
|
+
self.status = 200
|
|
1054
|
+
|
|
1055
|
+
def handler(self, request: httpx.Request) -> httpx.Response:
|
|
1056
|
+
self.requests.append(request)
|
|
1057
|
+
if self.status != 200:
|
|
1058
|
+
# An upstream that echoes the credential it was sent back in its error body.
|
|
1059
|
+
return httpx.Response(
|
|
1060
|
+
self.status, text=f"denied: {request.headers.get('authorization')}"
|
|
1061
|
+
)
|
|
1062
|
+
return httpx.Response(200, json={"ok": True})
|
|
1063
|
+
|
|
1064
|
+
@property
|
|
1065
|
+
def transport(self) -> httpx.MockTransport:
|
|
1066
|
+
return httpx.MockTransport(self.handler)
|
|
1067
|
+
|
|
1068
|
+
|
|
1069
|
+
@pytest.fixture
|
|
1070
|
+
def api(policy_env: Path, endpoint: FakeTokenEndpoint, monkeypatch: pytest.MonkeyPatch) -> Api:
|
|
1071
|
+
monkeypatch.setenv("ORDERS_AGENT_URL", "https://orders.test")
|
|
1072
|
+
monkeypatch.setenv("BILLING_AGENT_URL", "https://billing.test")
|
|
1073
|
+
monkeypatch.setenv("WEATHER_URL", "https://weather.test")
|
|
1074
|
+
monkeypatch.setenv("WEATHER_TOKEN", "weather-key")
|
|
1075
|
+
reset_limits()
|
|
1076
|
+
set_outbound_headers(None)
|
|
1077
|
+
return Api()
|
|
1078
|
+
|
|
1079
|
+
|
|
1080
|
+
async def test_an_exchange_api_gets_a_token_minted_for_its_audience(
|
|
1081
|
+
api: Api, endpoint: FakeTokenEndpoint
|
|
1082
|
+
) -> None:
|
|
1083
|
+
orders = get_client("orders_agent", context=_context(), transport=api.transport)
|
|
1084
|
+
assert await orders.get("/orders", operation_id="listOrders") == {"ok": True}
|
|
1085
|
+
[sent] = api.requests
|
|
1086
|
+
assert sent.headers["authorization"] == f"Bearer {exchanged(1, 'orders')}"
|
|
1087
|
+
[asked] = endpoint.requests
|
|
1088
|
+
assert asked["form"]["audience"] == "orders"
|
|
1089
|
+
assert asked["form"]["scope"] == "orders.read orders.cancel"
|
|
1090
|
+
assert asked["form"]["resource"] == "https://orders.example.com"
|
|
1091
|
+
assert asked["form"]["subject_token"] == SUBJECT
|
|
1092
|
+
billing = get_client("billing_agent", context=_context(), transport=api.transport)
|
|
1093
|
+
await billing.get("/balance")
|
|
1094
|
+
assert api.requests[1].headers["x-agent-token"] == f"Bearer {exchanged(2, 'billing')}"
|
|
1095
|
+
assert "authorization" not in api.requests[1].headers
|
|
1096
|
+
await orders.get("/orders") # kept: no new exchange
|
|
1097
|
+
assert endpoint.calls == 2 and len(api.requests) == 3
|
|
1098
|
+
|
|
1099
|
+
|
|
1100
|
+
async def test_the_tool_cannot_set_the_credential_header(api: Api) -> None:
|
|
1101
|
+
orders = get_client("orders_agent", context=_context(), transport=api.transport)
|
|
1102
|
+
prepared = orders._prepare(
|
|
1103
|
+
"GET", "/orders", None, None, None, None, {"Authorization": "Bearer forged", "X-Tool": "1"}
|
|
1104
|
+
)
|
|
1105
|
+
assert prepared.tool_headers == [("x-tool", "1")]
|
|
1106
|
+
assert "authorization" not in prepared.headers
|
|
1107
|
+
await orders.get("/orders", headers={"Authorization": "Bearer forged"})
|
|
1108
|
+
assert api.requests[0].headers["authorization"] == f"Bearer {exchanged(1, 'orders')}"
|
|
1109
|
+
|
|
1110
|
+
|
|
1111
|
+
async def test_no_user_token_sends_nothing(api: Api, endpoint: FakeTokenEndpoint) -> None:
|
|
1112
|
+
orders = get_client("orders_agent", context=_context(subject=None), transport=api.transport)
|
|
1113
|
+
with pytest.raises(ApiCallError) as exc:
|
|
1114
|
+
await orders.get("/orders")
|
|
1115
|
+
assert str(exc.value) == (
|
|
1116
|
+
"API 'orders_agent' uses auth: exchange, but this run has no user token to exchange "
|
|
1117
|
+
"(shared-bearer, or a run resumed by another principal); nothing was sent."
|
|
1118
|
+
)
|
|
1119
|
+
assert endpoint.calls == 0 and api.requests == []
|
|
1120
|
+
|
|
1121
|
+
|
|
1122
|
+
async def test_no_exchange_on_a_refused_call(api: Api, endpoint: FakeTokenEndpoint) -> None:
|
|
1123
|
+
orders = get_client("orders_agent", context=_context(), transport=api.transport)
|
|
1124
|
+
with pytest.raises(ApiPolicyError, match="not in allowed_methods"):
|
|
1125
|
+
await orders.delete("/orders/7")
|
|
1126
|
+
with pytest.raises(ApiPolicyError, match=r"only inside an agent run"):
|
|
1127
|
+
# Gated, and outside a run nothing can pause it: refused before any exchange.
|
|
1128
|
+
await orders.post(
|
|
1129
|
+
"/orders/{order_id}/cancel", operation_id="cancelOrder", path_params={"order_id": "7"}
|
|
1130
|
+
)
|
|
1131
|
+
assert endpoint.calls == 0
|
|
1132
|
+
for _ in range(3):
|
|
1133
|
+
await orders.get("/orders")
|
|
1134
|
+
with pytest.raises(ApiPolicyError, match="max_calls_per_run"):
|
|
1135
|
+
await orders.get("/orders")
|
|
1136
|
+
assert endpoint.calls == 1 and len(api.requests) == 3
|
|
1137
|
+
|
|
1138
|
+
|
|
1139
|
+
async def test_no_exchange_while_paused_and_one_after_the_approval(
|
|
1140
|
+
api: Api, endpoint: FakeTokenEndpoint
|
|
1141
|
+
) -> None:
|
|
1142
|
+
"""The exchange runs after the gate: none while a person decides, one once they approve."""
|
|
1143
|
+
from typing import TypedDict
|
|
1144
|
+
|
|
1145
|
+
from langgraph.checkpoint.memory import InMemorySaver
|
|
1146
|
+
from langgraph.graph import END, START, StateGraph
|
|
1147
|
+
from langgraph.types import Command
|
|
1148
|
+
|
|
1149
|
+
class Ledger:
|
|
1150
|
+
def __init__(self) -> None:
|
|
1151
|
+
self.used: list[str] = []
|
|
1152
|
+
|
|
1153
|
+
async def consume(self, approval_id: str, call_hash: str, thread_id: Any = None) -> None:
|
|
1154
|
+
self.used.append(approval_id)
|
|
1155
|
+
|
|
1156
|
+
async def bound_approvals(self, **kwargs: Any) -> list[BoundApproval]:
|
|
1157
|
+
return []
|
|
1158
|
+
|
|
1159
|
+
class State(TypedDict):
|
|
1160
|
+
result: str
|
|
1161
|
+
|
|
1162
|
+
async def node(state: State) -> dict[str, str]:
|
|
1163
|
+
client = get_client("orders_agent", context=_context(), transport=api.transport)
|
|
1164
|
+
data = await client.post(
|
|
1165
|
+
"/orders/{order_id}/cancel", operation_id="cancelOrder", path_params={"order_id": "7"}
|
|
1166
|
+
)
|
|
1167
|
+
return {"result": str(data)}
|
|
1168
|
+
|
|
1169
|
+
builder = StateGraph(State)
|
|
1170
|
+
builder.add_node("cancel", node)
|
|
1171
|
+
builder.add_edge(START, "cancel")
|
|
1172
|
+
builder.add_edge("cancel", END)
|
|
1173
|
+
graph = builder.compile(checkpointer=InMemorySaver())
|
|
1174
|
+
config = {"configurable": {"thread_id": "t1"}}
|
|
1175
|
+
ledger = Ledger()
|
|
1176
|
+
set_approval_ledger(ledger)
|
|
1177
|
+
try:
|
|
1178
|
+
await graph.ainvoke({"result": ""}, config=config)
|
|
1179
|
+
[interrupt] = (await graph.aget_state(config)).interrupts
|
|
1180
|
+
assert interrupt.value["path"] == "/orders/7/cancel"
|
|
1181
|
+
assert endpoint.calls == 0 and api.requests == [] # paused: nothing exchanged
|
|
1182
|
+
decision = {
|
|
1183
|
+
"type": APPROVAL_DECISION,
|
|
1184
|
+
"decision": "approve",
|
|
1185
|
+
"approval_id": "a1",
|
|
1186
|
+
"api": "orders_agent",
|
|
1187
|
+
"method": "POST",
|
|
1188
|
+
"path": "/orders/7/cancel",
|
|
1189
|
+
"call_hash": interrupt.value["call_hash"],
|
|
1190
|
+
"approvers": interrupt.value["approvers"],
|
|
1191
|
+
"decide_with": "direct",
|
|
1192
|
+
"relayers": [],
|
|
1193
|
+
}
|
|
1194
|
+
done = await graph.ainvoke(Command(resume={interrupt.id: decision}), config=config)
|
|
1195
|
+
assert done["result"] == "{'ok': True}"
|
|
1196
|
+
assert endpoint.calls == 1 and ledger.used == ["a1"]
|
|
1197
|
+
[sent] = api.requests
|
|
1198
|
+
assert sent.headers["authorization"] == f"Bearer {exchanged(1, 'orders')}"
|
|
1199
|
+
finally:
|
|
1200
|
+
set_approval_ledger(None)
|
|
1201
|
+
|
|
1202
|
+
|
|
1203
|
+
async def test_the_exchanged_token_is_redacted_in_error_bodies(api: Api) -> None:
|
|
1204
|
+
api.status = 403
|
|
1205
|
+
orders = get_client("orders_agent", context=_context(), transport=api.transport)
|
|
1206
|
+
with pytest.raises(ApiCallError) as exc:
|
|
1207
|
+
await orders.get("/orders")
|
|
1208
|
+
assert exc.value.status_code == 403
|
|
1209
|
+
assert exc.value.body == "denied: <redacted>"
|
|
1210
|
+
assert exchanged(1, "orders") not in str(exc.value)
|
|
1211
|
+
|
|
1212
|
+
|
|
1213
|
+
async def test_an_issuer_refusal_sends_nothing(
|
|
1214
|
+
api: Api, endpoint: FakeTokenEndpoint, caplog: pytest.LogCaptureFixture
|
|
1215
|
+
) -> None:
|
|
1216
|
+
endpoint.answers.append(httpx.Response(400, json={"error": "invalid_target"}))
|
|
1217
|
+
orders = get_client("orders_agent", context=_context(), transport=api.transport)
|
|
1218
|
+
with caplog.at_level(logging.WARNING), pytest.raises(ApiCallError) as exc:
|
|
1219
|
+
await orders.get("/orders")
|
|
1220
|
+
assert str(exc.value) == (
|
|
1221
|
+
"token exchange for API 'orders_agent' was refused (invalid_target); nothing was sent."
|
|
1222
|
+
)
|
|
1223
|
+
assert "api call not sent: orders_agent GET <concrete path>: token exchange refused" in (
|
|
1224
|
+
caplog.text
|
|
1225
|
+
)
|
|
1226
|
+
assert api.requests == []
|
|
1227
|
+
|
|
1228
|
+
|
|
1229
|
+
async def test_a_token_naming_no_actor_sends_nothing(
|
|
1230
|
+
api: Api, endpoint: FakeTokenEndpoint, caplog: pytest.LogCaptureFixture
|
|
1231
|
+
) -> None:
|
|
1232
|
+
_issue(endpoint, _jwt({"sub": "alice", "aud": "orders", "azp": CLIENT_ID}))
|
|
1233
|
+
orders = get_client("orders_agent", context=_context(), transport=api.transport)
|
|
1234
|
+
with caplog.at_level(logging.WARNING), pytest.raises(ApiCallError) as exc:
|
|
1235
|
+
await orders.get("/orders")
|
|
1236
|
+
assert str(exc.value).startswith(
|
|
1237
|
+
"token exchange for API 'orders_agent': the issuer's token names no actor"
|
|
1238
|
+
)
|
|
1239
|
+
not_sent = "api call not sent: orders_agent GET <concrete path>: exchanged token names no actor"
|
|
1240
|
+
assert not_sent in caplog.text
|
|
1241
|
+
assert api.requests == []
|
|
1242
|
+
|
|
1243
|
+
|
|
1244
|
+
async def test_an_api_that_allows_it_is_sent_a_token_naming_no_actor(
|
|
1245
|
+
api: Api, endpoint: FakeTokenEndpoint
|
|
1246
|
+
) -> None:
|
|
1247
|
+
"""billing_agent sets `exchange.allow_actorless: true` (its agent sets AUTH_JWT_DIRECT_CLIENTS)."""
|
|
1248
|
+
actless = _jwt({"sub": "alice", "aud": "billing", "azp": CLIENT_ID})
|
|
1249
|
+
_issue(endpoint, actless)
|
|
1250
|
+
billing = get_client("billing_agent", context=_context(), transport=api.transport)
|
|
1251
|
+
assert await billing.get("/balance") == {"ok": True}
|
|
1252
|
+
assert api.requests[0].headers["x-agent-token"] == f"Bearer {actless}"
|
|
1253
|
+
|
|
1254
|
+
|
|
1255
|
+
async def test_trace_headers_go_to_exchange_apis(api: Api) -> None:
|
|
1256
|
+
"""The owner's decision (2026-09-28): `auth: exchange` APIs receive them, `bearer` ones not."""
|
|
1257
|
+
set_outbound_headers(
|
|
1258
|
+
lambda: {"X-Request-ID": "req-7", "traceparent": "00-" + "1" * 32 + "-" + "2" * 16 + "-01"}
|
|
1259
|
+
)
|
|
1260
|
+
try:
|
|
1261
|
+
orders = get_client("orders_agent", context=_context(), transport=api.transport)
|
|
1262
|
+
await orders.get("/orders")
|
|
1263
|
+
weather = get_client("weather", transport=api.transport)
|
|
1264
|
+
await weather.get("/today")
|
|
1265
|
+
finally:
|
|
1266
|
+
set_outbound_headers(None)
|
|
1267
|
+
to_orders, to_weather = api.requests
|
|
1268
|
+
assert to_orders.headers["x-request-id"] == "req-7" and "traceparent" in to_orders.headers
|
|
1269
|
+
assert "x-request-id" not in to_weather.headers and "traceparent" not in to_weather.headers
|
|
1270
|
+
|
|
1271
|
+
|
|
1272
|
+
# ---------------------------------------------------------------------------
|
|
1273
|
+
# Loops
|
|
1274
|
+
# ---------------------------------------------------------------------------
|
|
1275
|
+
|
|
1276
|
+
|
|
1277
|
+
def test_loop_problem(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
1278
|
+
monkeypatch.setenv("A2A_NAME", "billing")
|
|
1279
|
+
monkeypatch.setenv("AUTH_JWT_AUDIENCE", "billing-api, billing.example.com")
|
|
1280
|
+
assert loop_problem("orders", ("concierge",)) is None
|
|
1281
|
+
assert loop_problem("billing", ()) == "calling billing would call this agent itself (billing)"
|
|
1282
|
+
assert loop_problem("billing.example.com", ()) is not None
|
|
1283
|
+
assert loop_problem("concierge", ("concierge",)) == (
|
|
1284
|
+
"calling concierge would loop back through the delegation chain "
|
|
1285
|
+
"(concierge -> billing -> concierge)"
|
|
1286
|
+
)
|
|
1287
|
+
assert loop_problem("concierge", ("client:concierge",)) is not None # an issuer without act
|
|
1288
|
+
assert loop_problem("gateway", ("concierge", "gateway")) == (
|
|
1289
|
+
"calling gateway would loop back through the delegation chain "
|
|
1290
|
+
"(gateway -> concierge -> billing -> gateway)"
|
|
1291
|
+
)
|
|
1292
|
+
|
|
1293
|
+
|
|
1294
|
+
async def test_a_call_that_would_loop_is_refused_before_anything(
|
|
1295
|
+
api: Api, endpoint: FakeTokenEndpoint, monkeypatch: pytest.MonkeyPatch
|
|
1296
|
+
) -> None:
|
|
1297
|
+
looping = get_client(
|
|
1298
|
+
"orders_agent", context=_context(chain=("orders",)), transport=api.transport
|
|
1299
|
+
)
|
|
1300
|
+
with pytest.raises(ApiPolicyError) as exc:
|
|
1301
|
+
await looping.post(
|
|
1302
|
+
"/orders/{order_id}/cancel", operation_id="cancelOrder", path_params={"order_id": "7"}
|
|
1303
|
+
)
|
|
1304
|
+
assert "would loop back through the delegation chain" in str(exc.value)
|
|
1305
|
+
monkeypatch.setenv("A2A_NAME", "orders")
|
|
1306
|
+
itself = get_client("orders_agent", context=_context(), transport=api.transport)
|
|
1307
|
+
with pytest.raises(ApiPolicyError, match="would call this agent itself"):
|
|
1308
|
+
await itself.get("/orders")
|
|
1309
|
+
assert endpoint.calls == 0 and api.requests == []
|
|
1310
|
+
monkeypatch.delenv("A2A_NAME")
|
|
1311
|
+
other = get_client(
|
|
1312
|
+
"orders_agent", context=_context(chain=("billing",)), transport=api.transport
|
|
1313
|
+
)
|
|
1314
|
+
await other.get("/orders")
|
|
1315
|
+
assert endpoint.calls == 1
|
|
1316
|
+
|
|
1317
|
+
|
|
1318
|
+
def test_the_exchange_module_is_the_one_the_client_uses() -> None:
|
|
1319
|
+
assert api_client.get_client.__module__ == api_client.__name__
|
|
1320
|
+
assert token_exchange.exchanger() is exchanger()
|