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,959 @@
|
|
|
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 for `auth: exchange` APIs (another agent, a user-scoped service).
|
|
16
|
+
|
|
17
|
+
An `auth: exchange` API is called with a token the issuer mints for it in
|
|
18
|
+
exchange for the caller's own (the verified bearer the `jwt` policy keeps in
|
|
19
|
+
`credentials["@subject_token"]`, or a custom policy's `keep_subject_token`):
|
|
20
|
+
the API's `exchange.audience`, and its optional `scope` and `resource`, pin
|
|
21
|
+
what the new token is good for, and the issuer names this agent in its `act`
|
|
22
|
+
claim. `api_client` asks for it just before sending, after the policy check,
|
|
23
|
+
the approval gate and the limits, so a refused call, or one paused for a
|
|
24
|
+
person's approval, never exchanges; nothing here runs while a request is
|
|
25
|
+
authenticated.
|
|
26
|
+
|
|
27
|
+
Settings (environment; `TOKEN_EXCHANGE_CLIENT_SECRET` is a secret):
|
|
28
|
+
|
|
29
|
+
* `TOKEN_EXCHANGE_URL`: the issuer's token endpoint. https outside
|
|
30
|
+
`APP_ENV=dev`, unless the host is loopback or `TOKEN_EXCHANGE_ALLOW_HTTP=true`
|
|
31
|
+
(a trusted in-cluster issuer), as for `AUTH_JWT_JWKS_URL`.
|
|
32
|
+
* `TOKEN_EXCHANGE_CLIENT_ID`, `TOKEN_EXCHANGE_CLIENT_SECRET`: this agent's
|
|
33
|
+
client, sent as `TOKEN_EXCHANGE_CLIENT_AUTH` says (`client_secret_basic`,
|
|
34
|
+
the default, or `client_secret_post`).
|
|
35
|
+
* `TOKEN_EXCHANGE_SUBJECT_TOKEN_TYPE`: the `subject_token_type` sent
|
|
36
|
+
(`urn:ietf:params:oauth:token-type:access_token`, the default, or `...:jwt`).
|
|
37
|
+
* `TOKEN_EXCHANGE_TIMEOUT_MS` (2000): the whole exchange's deadline (connecting
|
|
38
|
+
takes at most 1 s of it).
|
|
39
|
+
* `TOKEN_EXCHANGE_MAX_TTL_S` (300, at most 300): how long an exchanged token is
|
|
40
|
+
reused.
|
|
41
|
+
* `TOKEN_EXCHANGE_FAILURE_TTL_S` (10): how long a refusal is remembered, and
|
|
42
|
+
how long the issuer is left alone after it fails.
|
|
43
|
+
* `TOKEN_EXCHANGE_CACHE_MAX` (10000): exchanged tokens kept per process.
|
|
44
|
+
|
|
45
|
+
Behaviour:
|
|
46
|
+
|
|
47
|
+
* Cached in process memory only (never persisted, traced or logged), per
|
|
48
|
+
subject token (its SHA-256), token URL, client, audience, scope and resource,
|
|
49
|
+
for min(`expires_in`, `TOKEN_EXCHANGE_MAX_TTL_S`, the subject token's
|
|
50
|
+
remaining lifetime) less 30 s; a token that would be kept under 5 s is used
|
|
51
|
+
once and not kept. Concurrent calls for one key share one exchange.
|
|
52
|
+
* A subject token with 10 s or less left is not exchanged.
|
|
53
|
+
* The issuer's refusal (a 4xx: `invalid_grant`, `invalid_target`, ...) is
|
|
54
|
+
remembered for that key for `TOKEN_EXCHANGE_FAILURE_TTL_S`.
|
|
55
|
+
* A timeout, a connection error, a 5xx (or 408/429) or an unusable answer
|
|
56
|
+
counts against the token URL's circuit breaker: 3 in a row open it for
|
|
57
|
+
`TOKEN_EXCHANGE_FAILURE_TTL_S`, while calls fail at once; then one call
|
|
58
|
+
probes the issuer, and its outcome closes or reopens it. During an issuer
|
|
59
|
+
outage a process waits at most one deadline per window, on calls to
|
|
60
|
+
exchange APIs only.
|
|
61
|
+
* An issued token that names no actor is refused, and nothing is sent: a JWT
|
|
62
|
+
without the `act` claim (or the claim `AUTH_JWT_ACTOR_CLAIM` names), and
|
|
63
|
+
any token that is not a readable signed JWT (opaque, encrypted). The agent
|
|
64
|
+
behind the API would read it as the user's own unless it sets
|
|
65
|
+
`AUTH_JWT_DIRECT_CLIENTS`, so it could let this agent decide the user's
|
|
66
|
+
approvals there. The refusal is remembered for that key like the issuer's
|
|
67
|
+
own. An API opts in with `exchange.allow_actorless: true` (the agent behind
|
|
68
|
+
it must then set `AUTH_JWT_DIRECT_CLIENTS` and list this agent as
|
|
69
|
+
`client:<its client id>` in `AUTH_ALLOWED_ACTORS`); the first such token
|
|
70
|
+
then logs one warning saying so. The claims are read unverified, for this
|
|
71
|
+
check only (the called agent verifies the token).
|
|
72
|
+
* Metrics: `agent_token_exchanges_total{api, outcome}` (`issued`, `cached`,
|
|
73
|
+
`refused`, `no_actor`, `unavailable`, `circuit_open`) and
|
|
74
|
+
`agent_token_exchange_duration_seconds{api}`; one log line per exchange
|
|
75
|
+
sent, one warning when the breaker opens. Neither carries token material or
|
|
76
|
+
the subject's hash; only the issuer's RFC 6749 `error` code and HTTP status.
|
|
77
|
+
"""
|
|
78
|
+
|
|
79
|
+
from __future__ import annotations
|
|
80
|
+
|
|
81
|
+
import asyncio
|
|
82
|
+
import base64
|
|
83
|
+
import hashlib
|
|
84
|
+
import ipaddress
|
|
85
|
+
import json
|
|
86
|
+
import logging
|
|
87
|
+
import os
|
|
88
|
+
import re
|
|
89
|
+
import threading
|
|
90
|
+
import time
|
|
91
|
+
from collections import OrderedDict
|
|
92
|
+
from collections.abc import Callable, Mapping
|
|
93
|
+
from dataclasses import dataclass, field
|
|
94
|
+
from typing import Any
|
|
95
|
+
from urllib.parse import quote, urlsplit
|
|
96
|
+
|
|
97
|
+
import httpx
|
|
98
|
+
|
|
99
|
+
from {{cookiecutter.agent_directory}}.app_utils.limits import SettingsError
|
|
100
|
+
|
|
101
|
+
logger = logging.getLogger(__name__)
|
|
102
|
+
|
|
103
|
+
GRANT_TYPE = "urn:ietf:params:oauth:grant-type:token-exchange"
|
|
104
|
+
ACCESS_TOKEN_TYPE = "urn:ietf:params:oauth:token-type:access_token"
|
|
105
|
+
JWT_TOKEN_TYPE = "urn:ietf:params:oauth:token-type:jwt"
|
|
106
|
+
SUBJECT_TOKEN_TYPES = (ACCESS_TOKEN_TYPE, JWT_TOKEN_TYPE)
|
|
107
|
+
CLIENT_SECRET_BASIC = "client_secret_basic"
|
|
108
|
+
CLIENT_SECRET_POST = "client_secret_post"
|
|
109
|
+
CLIENT_AUTH_METHODS = (CLIENT_SECRET_BASIC, CLIENT_SECRET_POST)
|
|
110
|
+
|
|
111
|
+
DEFAULT_TIMEOUT_MS = 2000
|
|
112
|
+
MIN_TIMEOUT_MS = 100
|
|
113
|
+
MAX_TIMEOUT_MS = 60_000
|
|
114
|
+
MAX_CONNECT_S = 1.0
|
|
115
|
+
DEFAULT_MAX_TTL_S = 300
|
|
116
|
+
MAX_TTL_CAP_S = 300
|
|
117
|
+
DEFAULT_FAILURE_TTL_S = 10
|
|
118
|
+
MAX_FAILURE_TTL_S = 300
|
|
119
|
+
DEFAULT_CACHE_MAX = 10_000
|
|
120
|
+
MAX_CACHE_MAX = 1_000_000
|
|
121
|
+
# `expires_in` when the issuer leaves it out (RFC 6749 section 5.1 makes it optional).
|
|
122
|
+
DEFAULT_EXPIRES_IN_S = 60
|
|
123
|
+
# A kept token is dropped this long before it would expire; one that would be kept for
|
|
124
|
+
# less than MIN_CACHE_TTL_S is used for the call that asked and not kept.
|
|
125
|
+
EXPIRY_MARGIN_S = 30
|
|
126
|
+
MIN_CACHE_TTL_S = 5
|
|
127
|
+
# A subject token with this long or less to live is not exchanged.
|
|
128
|
+
MIN_SUBJECT_LIFETIME_S = 10
|
|
129
|
+
RESPONSE_MAX_BYTES = 65_536
|
|
130
|
+
ACCESS_TOKEN_MAX_CHARS = 16_384
|
|
131
|
+
# Consecutive issuer failures that open a token URL's circuit breaker.
|
|
132
|
+
BREAKER_THRESHOLD = 3
|
|
133
|
+
# The RFC 8693 claim naming the agent that presents a token, unless
|
|
134
|
+
# `AUTH_JWT_ACTOR_CLAIM` names another (`auth.DEFAULT_JWT_ACTOR_CLAIM`).
|
|
135
|
+
ACTOR_CLAIM = "act"
|
|
136
|
+
|
|
137
|
+
ISSUED = "issued"
|
|
138
|
+
CACHED = "cached"
|
|
139
|
+
REFUSED = "refused"
|
|
140
|
+
NO_ACTOR = "no_actor"
|
|
141
|
+
UNAVAILABLE = "unavailable"
|
|
142
|
+
CIRCUIT_OPEN = "circuit_open"
|
|
143
|
+
OUTCOMES = (ISSUED, CACHED, REFUSED, NO_ACTOR, UNAVAILABLE, CIRCUIT_OPEN)
|
|
144
|
+
# Kept as a key's refusal when the issued token names no actor: never an RFC 6749 error code
|
|
145
|
+
# (those match `_ERROR_CODE_RE`, which allows no space).
|
|
146
|
+
_NO_ACTOR_CODE = "names no actor"
|
|
147
|
+
|
|
148
|
+
_TRUE = ("1", "true", "yes", "on")
|
|
149
|
+
_LOOPBACK_HOSTS = ("localhost",)
|
|
150
|
+
# The issuer's RFC 6749 error code, when it is one (never other response text).
|
|
151
|
+
_ERROR_CODE_RE = re.compile(r"[A-Za-z0-9_.-]{1,64}")
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
class TokenExchangeError(Exception):
|
|
155
|
+
"""No token for the call: the message is what the tool (and the model) reads.
|
|
156
|
+
|
|
157
|
+
`outcome` is the metric's (`refused`, `no_actor`, `unavailable`,
|
|
158
|
+
`circuit_open`), or None
|
|
159
|
+
when no exchange was attempted (not configured, no or an expiring subject
|
|
160
|
+
token); `reason` is a short fixed phrase for the call's log line.
|
|
161
|
+
"""
|
|
162
|
+
|
|
163
|
+
def __init__(self, message: str, *, outcome: str | None, reason: str) -> None:
|
|
164
|
+
super().__init__(message)
|
|
165
|
+
self.outcome = outcome
|
|
166
|
+
self.reason = reason
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
# ---------------------------------------------------------------------------
|
|
170
|
+
# Settings
|
|
171
|
+
# ---------------------------------------------------------------------------
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def _dev(env: Mapping[str, str]) -> bool:
|
|
175
|
+
return env.get("APP_ENV") == "dev" # exactly, as auth.dev_mode()
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def _int(env: Mapping[str, str], name: str, default: int, low: int, high: int) -> int:
|
|
179
|
+
raw = (env.get(name) or "").strip()
|
|
180
|
+
if not raw:
|
|
181
|
+
return default
|
|
182
|
+
try:
|
|
183
|
+
value = int(raw)
|
|
184
|
+
except ValueError:
|
|
185
|
+
raise SettingsError(f"{name}={raw!r} is not a whole number.") from None
|
|
186
|
+
if not low <= value <= high:
|
|
187
|
+
raise SettingsError(f"{name}={value} must be from {low} to {high}.")
|
|
188
|
+
return value
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def _loopback(host: str) -> bool:
|
|
192
|
+
host = host.strip("[]").lower()
|
|
193
|
+
if host in _LOOPBACK_HOSTS:
|
|
194
|
+
return True
|
|
195
|
+
try:
|
|
196
|
+
return ipaddress.ip_address(host).is_loopback
|
|
197
|
+
except ValueError:
|
|
198
|
+
return False
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
@dataclass(frozen=True)
|
|
202
|
+
class ExchangeSettings:
|
|
203
|
+
"""The `TOKEN_EXCHANGE_*` settings, validated (`exchange_settings`)."""
|
|
204
|
+
|
|
205
|
+
url: str | None = None
|
|
206
|
+
client_id: str | None = None
|
|
207
|
+
# Out of repr: a repr ends up in logs and tracebacks.
|
|
208
|
+
client_secret: str | None = field(default=None, repr=False)
|
|
209
|
+
client_auth: str = CLIENT_SECRET_BASIC
|
|
210
|
+
subject_token_type: str = ACCESS_TOKEN_TYPE
|
|
211
|
+
timeout_s: float = DEFAULT_TIMEOUT_MS / 1000
|
|
212
|
+
max_ttl_s: int = DEFAULT_MAX_TTL_S
|
|
213
|
+
failure_ttl_s: int = DEFAULT_FAILURE_TTL_S
|
|
214
|
+
cache_max: int = DEFAULT_CACHE_MAX
|
|
215
|
+
|
|
216
|
+
def missing(self) -> list[str]:
|
|
217
|
+
"""The settings an exchange needs that are not set."""
|
|
218
|
+
return [
|
|
219
|
+
name
|
|
220
|
+
for name, value in (
|
|
221
|
+
("TOKEN_EXCHANGE_URL", self.url),
|
|
222
|
+
("TOKEN_EXCHANGE_CLIENT_ID", self.client_id),
|
|
223
|
+
("TOKEN_EXCHANGE_CLIENT_SECRET", self.client_secret),
|
|
224
|
+
)
|
|
225
|
+
if not value
|
|
226
|
+
]
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def exchange_settings(env: Mapping[str, str] | None = None) -> ExchangeSettings:
|
|
230
|
+
"""The token exchange settings; `SettingsError` names a bad one (a startup error).
|
|
231
|
+
|
|
232
|
+
Unset settings are not errors here (`ExchangeSettings.missing`, reported at
|
|
233
|
+
startup when an `auth: exchange` API exists); a malformed value, or an http
|
|
234
|
+
`TOKEN_EXCHANGE_URL` outside `APP_ENV=dev` without a loopback host or
|
|
235
|
+
`TOKEN_EXCHANGE_ALLOW_HTTP=true`, is.
|
|
236
|
+
"""
|
|
237
|
+
env = os.environ if env is None else env
|
|
238
|
+
url = (env.get("TOKEN_EXCHANGE_URL") or "").strip() or None
|
|
239
|
+
if url is not None:
|
|
240
|
+
parts = urlsplit(url)
|
|
241
|
+
host = parts.hostname or ""
|
|
242
|
+
if parts.scheme not in ("https", "http") or not host:
|
|
243
|
+
raise SettingsError(
|
|
244
|
+
"TOKEN_EXCHANGE_URL must be an https:// URL (the issuer's token endpoint)."
|
|
245
|
+
)
|
|
246
|
+
if parts.username or parts.password or parts.fragment:
|
|
247
|
+
raise SettingsError("TOKEN_EXCHANGE_URL must not carry credentials or a fragment.")
|
|
248
|
+
allow_http = (env.get("TOKEN_EXCHANGE_ALLOW_HTTP") or "").strip().lower() in _TRUE
|
|
249
|
+
if parts.scheme == "http" and not (_dev(env) or _loopback(host) or allow_http):
|
|
250
|
+
raise SettingsError(
|
|
251
|
+
"TOKEN_EXCHANGE_URL must use https outside APP_ENV=dev (it carries the users' "
|
|
252
|
+
"tokens and this agent's client secret); set TOKEN_EXCHANGE_ALLOW_HTTP=true only "
|
|
253
|
+
"for a trusted in-cluster issuer."
|
|
254
|
+
)
|
|
255
|
+
client_auth = (env.get("TOKEN_EXCHANGE_CLIENT_AUTH") or CLIENT_SECRET_BASIC).strip().lower()
|
|
256
|
+
if client_auth not in CLIENT_AUTH_METHODS:
|
|
257
|
+
raise SettingsError(
|
|
258
|
+
f"TOKEN_EXCHANGE_CLIENT_AUTH={client_auth!r} must be one of "
|
|
259
|
+
f"{', '.join(CLIENT_AUTH_METHODS)}."
|
|
260
|
+
)
|
|
261
|
+
token_type = (env.get("TOKEN_EXCHANGE_SUBJECT_TOKEN_TYPE") or ACCESS_TOKEN_TYPE).strip()
|
|
262
|
+
if token_type not in SUBJECT_TOKEN_TYPES:
|
|
263
|
+
raise SettingsError(
|
|
264
|
+
f"TOKEN_EXCHANGE_SUBJECT_TOKEN_TYPE={token_type!r} must be one of "
|
|
265
|
+
f"{', '.join(SUBJECT_TOKEN_TYPES)}."
|
|
266
|
+
)
|
|
267
|
+
return ExchangeSettings(
|
|
268
|
+
url=url,
|
|
269
|
+
client_id=(env.get("TOKEN_EXCHANGE_CLIENT_ID") or "").strip() or None,
|
|
270
|
+
client_secret=env.get("TOKEN_EXCHANGE_CLIENT_SECRET") or None,
|
|
271
|
+
client_auth=client_auth,
|
|
272
|
+
subject_token_type=token_type,
|
|
273
|
+
timeout_s=_int(
|
|
274
|
+
env, "TOKEN_EXCHANGE_TIMEOUT_MS", DEFAULT_TIMEOUT_MS, MIN_TIMEOUT_MS, MAX_TIMEOUT_MS
|
|
275
|
+
)
|
|
276
|
+
/ 1000,
|
|
277
|
+
max_ttl_s=_int(env, "TOKEN_EXCHANGE_MAX_TTL_S", DEFAULT_MAX_TTL_S, 1, MAX_TTL_CAP_S),
|
|
278
|
+
failure_ttl_s=_int(
|
|
279
|
+
env, "TOKEN_EXCHANGE_FAILURE_TTL_S", DEFAULT_FAILURE_TTL_S, 1, MAX_FAILURE_TTL_S
|
|
280
|
+
),
|
|
281
|
+
cache_max=_int(env, "TOKEN_EXCHANGE_CACHE_MAX", DEFAULT_CACHE_MAX, 1, MAX_CACHE_MAX),
|
|
282
|
+
)
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
# ---------------------------------------------------------------------------
|
|
286
|
+
# Which APIs act for the caller, and where (the compatibility matrix)
|
|
287
|
+
# ---------------------------------------------------------------------------
|
|
288
|
+
|
|
289
|
+
SHARED_BEARER = "shared-bearer"
|
|
290
|
+
JWT = "jwt"
|
|
291
|
+
FASTAPI = "fastapi"
|
|
292
|
+
LANGGRAPH_SERVER = "langgraph-server"
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
def server_runtime(env: Mapping[str, str] | None = None) -> str:
|
|
296
|
+
"""`langgraph-server` or `fastapi`, read as `chat.detect_runtime` reads it."""
|
|
297
|
+
env = os.environ if env is None else env
|
|
298
|
+
explicit = (env.get("RUNTIME") or "").strip().lower()
|
|
299
|
+
if explicit in (FASTAPI, LANGGRAPH_SERVER):
|
|
300
|
+
return explicit
|
|
301
|
+
if (env.get("LANGGRAPH_SERVER") or "").lower() in ("1", "true", "yes"):
|
|
302
|
+
return LANGGRAPH_SERVER
|
|
303
|
+
if env.get("LANGSERVE_GRAPHS"):
|
|
304
|
+
return LANGGRAPH_SERVER
|
|
305
|
+
return FASTAPI
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
def _policy_apis() -> dict[str, dict[str, Any]]:
|
|
309
|
+
"""The loaded api-policy's APIs; empty without a readable, valid policy."""
|
|
310
|
+
try:
|
|
311
|
+
from {{cookiecutter.agent_directory}}.app_utils.api_client import load_policy
|
|
312
|
+
|
|
313
|
+
return load_policy().apis
|
|
314
|
+
except Exception:
|
|
315
|
+
return {}
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
def _names(apis: Mapping[str, Mapping[str, Any]], test: Callable[[Mapping[str, Any]], bool]) -> str:
|
|
319
|
+
return ", ".join(name for name, api in apis.items() if test(api))
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
def compatibility(
|
|
323
|
+
auth_policy: str,
|
|
324
|
+
runtime: str,
|
|
325
|
+
apis: Mapping[str, Mapping[str, Any]] | None = None,
|
|
326
|
+
) -> list[str]:
|
|
327
|
+
"""The api-policy's identity-carrying APIs against this auth policy and runtime.
|
|
328
|
+
|
|
329
|
+
The problems (the startup refuses them outside `APP_ENV=dev` and logs them
|
|
330
|
+
under dev; `lint` and `api add` report the same cells as errors): an
|
|
331
|
+
`auth: exchange` API under `shared-bearer` (no user token to exchange) or
|
|
332
|
+
under langgraph-server (the server persists the run context, so the user's
|
|
333
|
+
token would be stored), and an `auth: forward` API under `shared-bearer`,
|
|
334
|
+
under `jwt` without `forward_audience`, or under langgraph-server, whose
|
|
335
|
+
every call fails for want of a credential.
|
|
336
|
+
"""
|
|
337
|
+
apis = _policy_apis() if apis is None else apis
|
|
338
|
+
problems: list[str] = []
|
|
339
|
+
exchange = _names(apis, lambda a: a.get("auth") == "exchange")
|
|
340
|
+
forward = _names(apis, lambda a: a.get("auth") == "forward")
|
|
341
|
+
if exchange and auth_policy == SHARED_BEARER:
|
|
342
|
+
problems.append(
|
|
343
|
+
f"auth: exchange (apis: {exchange}) under AUTH_POLICY=shared-bearer: shared-bearer "
|
|
344
|
+
"has no user token to exchange; use auth: bearer with the peer's agent key"
|
|
345
|
+
)
|
|
346
|
+
if exchange and runtime == LANGGRAPH_SERVER:
|
|
347
|
+
problems.append(
|
|
348
|
+
f"auth: exchange (apis: {exchange}) is not supported with runtime langgraph-server: "
|
|
349
|
+
"LangGraph Server persists the run context, so the user's token would be stored"
|
|
350
|
+
)
|
|
351
|
+
if forward and auth_policy == SHARED_BEARER:
|
|
352
|
+
problems.append(
|
|
353
|
+
f"auth: forward (apis: {forward}) under AUTH_POLICY=shared-bearer: there is no user "
|
|
354
|
+
"credential to forward, so every call to it fails"
|
|
355
|
+
)
|
|
356
|
+
unaimed = _names(apis, lambda a: a.get("auth") == "forward" and "forward_audience" not in a)
|
|
357
|
+
if unaimed and auth_policy == JWT:
|
|
358
|
+
problems.append(
|
|
359
|
+
f"auth: forward (apis: {unaimed}) under AUTH_POLICY=jwt without forward_audience: jwt "
|
|
360
|
+
"sets no per-API credential, so every call to it fails; set forward_audience (the "
|
|
361
|
+
"caller's token must be minted for that audience too), or prefer auth: exchange"
|
|
362
|
+
)
|
|
363
|
+
if forward and runtime == LANGGRAPH_SERVER:
|
|
364
|
+
problems.append(
|
|
365
|
+
f"auth: forward (apis: {forward}) is not supported with runtime langgraph-server: "
|
|
366
|
+
"the server's run context carries no credentials, so every call to it fails"
|
|
367
|
+
)
|
|
368
|
+
return problems
|
|
369
|
+
|
|
370
|
+
|
|
371
|
+
def startup_problems(auth_policy: str, runtime: str | None = None) -> list[str]:
|
|
372
|
+
"""What stops startup outside `APP_ENV=dev` (`auth.check_startup`, which logs it
|
|
373
|
+
under dev): the refused cells of the compatibility matrix, and an
|
|
374
|
+
`auth: exchange` API without the issuer's token endpoint or this agent's
|
|
375
|
+
client."""
|
|
376
|
+
apis = _policy_apis()
|
|
377
|
+
problems = compatibility(auth_policy, runtime or server_runtime(), apis)
|
|
378
|
+
exchange = _names(apis, lambda a: a.get("auth") == "exchange")
|
|
379
|
+
if exchange:
|
|
380
|
+
try:
|
|
381
|
+
missing = exchange_settings().missing()
|
|
382
|
+
except SettingsError:
|
|
383
|
+
missing = [] # the lifespan's settings check names the bad value
|
|
384
|
+
if missing:
|
|
385
|
+
problems.append(
|
|
386
|
+
f"auth: exchange (apis: {exchange}) needs {', '.join(missing)} (the issuer's "
|
|
387
|
+
"token endpoint and this agent's client there)"
|
|
388
|
+
)
|
|
389
|
+
return problems
|
|
390
|
+
|
|
391
|
+
|
|
392
|
+
# ---------------------------------------------------------------------------
|
|
393
|
+
# Loops
|
|
394
|
+
# ---------------------------------------------------------------------------
|
|
395
|
+
|
|
396
|
+
# How `jwt` names an agent known only by its token's client (`auth.CLIENT_ACTOR_PREFIX`).
|
|
397
|
+
CLIENT_ACTOR_PREFIX = "client:"
|
|
398
|
+
|
|
399
|
+
|
|
400
|
+
def own_names(env: Mapping[str, str] | None = None) -> set[str]:
|
|
401
|
+
"""What names this agent: its A2A name, and the audiences its own tokens carry."""
|
|
402
|
+
env = os.environ if env is None else env
|
|
403
|
+
names = {env.get("A2A_NAME") or "{{cookiecutter.agent_directory}}"}
|
|
404
|
+
names.update(p.strip() for p in (env.get("AUTH_JWT_AUDIENCE") or "").split(",") if p.strip())
|
|
405
|
+
return names
|
|
406
|
+
|
|
407
|
+
|
|
408
|
+
def loop_problem(target: str, actor_chain: tuple[str, ...]) -> str | None:
|
|
409
|
+
"""Why a call to the agent `target` (an audience) would loop, or None.
|
|
410
|
+
|
|
411
|
+
It would when `target` is this agent (its A2A name or one of its own
|
|
412
|
+
audiences), or an agent already in the delegation chain of the request
|
|
413
|
+
(`actor_chain`, current first): A -> B -> A. An actor the policy knows only
|
|
414
|
+
by its client (`client:<azp>`, an issuer that names no actor) counts by
|
|
415
|
+
that client's name.
|
|
416
|
+
"""
|
|
417
|
+
me = os.environ.get("A2A_NAME") or "{{cookiecutter.agent_directory}}"
|
|
418
|
+
if target in own_names():
|
|
419
|
+
return f"calling {target} would call this agent itself ({me})"
|
|
420
|
+
if target in actor_chain or f"{CLIENT_ACTOR_PREFIX}{target}" in actor_chain:
|
|
421
|
+
path = " -> ".join([*reversed(actor_chain), me, target])
|
|
422
|
+
return f"calling {target} would loop back through the delegation chain ({path})"
|
|
423
|
+
return None
|
|
424
|
+
|
|
425
|
+
|
|
426
|
+
# ---------------------------------------------------------------------------
|
|
427
|
+
# The exchange
|
|
428
|
+
# ---------------------------------------------------------------------------
|
|
429
|
+
|
|
430
|
+
|
|
431
|
+
def _observe(api: str, outcome: str, seconds: float | None = None) -> None:
|
|
432
|
+
try:
|
|
433
|
+
from {{cookiecutter.agent_directory}}.app_utils.metrics import observe_token_exchange
|
|
434
|
+
except ImportError: # loaded outside its package, or without prometheus_client
|
|
435
|
+
return
|
|
436
|
+
observe_token_exchange(api, outcome, seconds)
|
|
437
|
+
|
|
438
|
+
|
|
439
|
+
def _client_auth(settings: ExchangeSettings) -> tuple[dict[str, str], dict[str, str]]:
|
|
440
|
+
"""(headers, form fields) that authenticate this agent's client (RFC 6749 section 2.3.1)."""
|
|
441
|
+
client_id, secret = settings.client_id or "", settings.client_secret or ""
|
|
442
|
+
if settings.client_auth == CLIENT_SECRET_POST:
|
|
443
|
+
return {}, {"client_id": client_id, "client_secret": secret}
|
|
444
|
+
# Each part form-encoded first, then base64 (RFC 6749 section 2.3.1).
|
|
445
|
+
pair = f"{quote(client_id, safe='')}:{quote(secret, safe='')}"
|
|
446
|
+
return {"Authorization": "Basic " + base64.b64encode(pair.encode("utf-8")).decode("ascii")}, {}
|
|
447
|
+
|
|
448
|
+
|
|
449
|
+
@dataclass
|
|
450
|
+
class _Breaker:
|
|
451
|
+
"""A token URL's circuit breaker: consecutive failures, open until, one probe."""
|
|
452
|
+
|
|
453
|
+
failures: int = 0
|
|
454
|
+
open_until: float | None = None
|
|
455
|
+
# When the call probing the issuer (half open) started; None when none is.
|
|
456
|
+
probing: float | None = None
|
|
457
|
+
|
|
458
|
+
|
|
459
|
+
class _Unavailable(Exception):
|
|
460
|
+
def __init__(self, detail: str) -> None:
|
|
461
|
+
super().__init__(detail)
|
|
462
|
+
self.detail = detail
|
|
463
|
+
|
|
464
|
+
|
|
465
|
+
class _Refused(Exception):
|
|
466
|
+
def __init__(self, code: str) -> None:
|
|
467
|
+
super().__init__(code)
|
|
468
|
+
self.code = code
|
|
469
|
+
|
|
470
|
+
|
|
471
|
+
class TokenExchanger:
|
|
472
|
+
"""The process's exchanged tokens, refusals, in-flight exchanges and breakers."""
|
|
473
|
+
|
|
474
|
+
def __init__(
|
|
475
|
+
self,
|
|
476
|
+
*,
|
|
477
|
+
clock: Callable[[], float] = time.monotonic,
|
|
478
|
+
wall: Callable[[], float] = time.time,
|
|
479
|
+
transport: httpx.AsyncBaseTransport | None = None,
|
|
480
|
+
) -> None:
|
|
481
|
+
self._clock = clock
|
|
482
|
+
self._wall = wall
|
|
483
|
+
self._transport = transport
|
|
484
|
+
self._lock = threading.Lock()
|
|
485
|
+
# key -> (token, drop at, on self._clock)
|
|
486
|
+
self._tokens: OrderedDict[tuple[str, ...], tuple[str, float]] = OrderedDict()
|
|
487
|
+
# key -> (the issuer's error code, forget at)
|
|
488
|
+
self._refusals: OrderedDict[tuple[str, ...], tuple[str, float]] = OrderedDict()
|
|
489
|
+
self._inflight: dict[tuple[str, ...], asyncio.Task[str]] = {}
|
|
490
|
+
self._breakers: dict[str, _Breaker] = {}
|
|
491
|
+
# Whether the warning for an allowed token that names no actor was logged.
|
|
492
|
+
self._warned_no_actor = False
|
|
493
|
+
|
|
494
|
+
def clear(self) -> None:
|
|
495
|
+
"""Forget every token, refusal and breaker (tests)."""
|
|
496
|
+
with self._lock:
|
|
497
|
+
self._tokens.clear()
|
|
498
|
+
self._refusals.clear()
|
|
499
|
+
self._breakers.clear()
|
|
500
|
+
self._warned_no_actor = False
|
|
501
|
+
|
|
502
|
+
def cached(self) -> int:
|
|
503
|
+
"""How many exchanged tokens are kept."""
|
|
504
|
+
with self._lock:
|
|
505
|
+
return len(self._tokens)
|
|
506
|
+
|
|
507
|
+
async def token(
|
|
508
|
+
self,
|
|
509
|
+
api: str,
|
|
510
|
+
subject_token: str,
|
|
511
|
+
*,
|
|
512
|
+
audience: str,
|
|
513
|
+
scope: str | None = None,
|
|
514
|
+
resource: str | None = None,
|
|
515
|
+
subject_expires_at: float | None = None,
|
|
516
|
+
settings: ExchangeSettings | None = None,
|
|
517
|
+
allow_actorless: bool = False,
|
|
518
|
+
) -> str:
|
|
519
|
+
"""A token for `api` exchanged for `subject_token`; `TokenExchangeError` otherwise.
|
|
520
|
+
|
|
521
|
+
`subject_expires_at` is the subject token's `exp` (epoch seconds), when known.
|
|
522
|
+
`allow_actorless` (the API's `exchange.allow_actorless`) accepts a token
|
|
523
|
+
that names no actor; without it such a token is refused (`no_actor`).
|
|
524
|
+
"""
|
|
525
|
+
if settings is None:
|
|
526
|
+
try:
|
|
527
|
+
settings = exchange_settings()
|
|
528
|
+
except SettingsError as exc:
|
|
529
|
+
raise TokenExchangeError(
|
|
530
|
+
f"API {api!r} uses auth: exchange, but the token exchange settings are "
|
|
531
|
+
f"invalid ({exc}); nothing was sent.",
|
|
532
|
+
outcome=None,
|
|
533
|
+
reason="token exchange misconfigured",
|
|
534
|
+
) from None
|
|
535
|
+
missing = settings.missing()
|
|
536
|
+
if missing:
|
|
537
|
+
raise TokenExchangeError(
|
|
538
|
+
f"API {api!r} uses auth: exchange, but {', '.join(missing)} "
|
|
539
|
+
f"{'is' if len(missing) == 1 else 'are'} not set; nothing was sent.",
|
|
540
|
+
outcome=None,
|
|
541
|
+
reason="token exchange not configured",
|
|
542
|
+
)
|
|
543
|
+
if (
|
|
544
|
+
subject_expires_at is not None
|
|
545
|
+
and subject_expires_at - self._wall() <= MIN_SUBJECT_LIFETIME_S
|
|
546
|
+
):
|
|
547
|
+
raise TokenExchangeError(
|
|
548
|
+
f"API {api!r} uses auth: exchange, but the caller's token has expired; nothing "
|
|
549
|
+
"was sent.",
|
|
550
|
+
outcome=None,
|
|
551
|
+
reason="caller's token expired",
|
|
552
|
+
)
|
|
553
|
+
assert settings.url is not None and settings.client_id is not None
|
|
554
|
+
key = (
|
|
555
|
+
hashlib.sha256(subject_token.encode("utf-8")).hexdigest(),
|
|
556
|
+
settings.url,
|
|
557
|
+
settings.client_id,
|
|
558
|
+
audience,
|
|
559
|
+
scope or "",
|
|
560
|
+
resource or "",
|
|
561
|
+
# A token kept for an API that accepts one naming no actor is never handed to
|
|
562
|
+
# an API that does not.
|
|
563
|
+
"allow_actorless" if allow_actorless else "",
|
|
564
|
+
)
|
|
565
|
+
now = self._clock()
|
|
566
|
+
with self._lock:
|
|
567
|
+
kept = self._tokens.get(key)
|
|
568
|
+
if kept is not None and kept[1] > now:
|
|
569
|
+
self._tokens.move_to_end(key)
|
|
570
|
+
_observe(api, CACHED)
|
|
571
|
+
return kept[0]
|
|
572
|
+
if kept is not None:
|
|
573
|
+
del self._tokens[key]
|
|
574
|
+
refusal = self._refusals.get(key)
|
|
575
|
+
if refusal is not None and refusal[1] <= now:
|
|
576
|
+
del self._refusals[key]
|
|
577
|
+
refusal = None
|
|
578
|
+
if refusal is not None:
|
|
579
|
+
if refusal[0] == _NO_ACTOR_CODE:
|
|
580
|
+
_observe(api, NO_ACTOR)
|
|
581
|
+
raise self._no_actor_error(api)
|
|
582
|
+
_observe(api, REFUSED)
|
|
583
|
+
raise self._refused_error(api, refusal[0])
|
|
584
|
+
|
|
585
|
+
task = self._inflight.get(key)
|
|
586
|
+
joined = (
|
|
587
|
+
task is not None and not task.done() and task.get_loop() is asyncio.get_running_loop()
|
|
588
|
+
)
|
|
589
|
+
if not joined:
|
|
590
|
+
self._admit(api, settings) # the breaker: raises while it is open
|
|
591
|
+
task = asyncio.get_running_loop().create_task(
|
|
592
|
+
self._exchange(
|
|
593
|
+
api,
|
|
594
|
+
key,
|
|
595
|
+
subject_token,
|
|
596
|
+
audience,
|
|
597
|
+
scope,
|
|
598
|
+
resource,
|
|
599
|
+
subject_expires_at,
|
|
600
|
+
settings,
|
|
601
|
+
allow_actorless,
|
|
602
|
+
)
|
|
603
|
+
)
|
|
604
|
+
self._inflight[key] = task
|
|
605
|
+
task.add_done_callback(lambda done, k=key: self._forget_inflight(k, done))
|
|
606
|
+
assert task is not None
|
|
607
|
+
try:
|
|
608
|
+
# Shielded: a caller that goes away does not cancel the exchange others share.
|
|
609
|
+
token = await asyncio.shield(task)
|
|
610
|
+
except TokenExchangeError as exc:
|
|
611
|
+
if joined and exc.outcome:
|
|
612
|
+
_observe(api, exc.outcome)
|
|
613
|
+
raise
|
|
614
|
+
if joined:
|
|
615
|
+
_observe(api, CACHED)
|
|
616
|
+
return token
|
|
617
|
+
|
|
618
|
+
def _forget_inflight(self, key: tuple[str, ...], task: asyncio.Task[Any]) -> None:
|
|
619
|
+
if self._inflight.get(key) is task:
|
|
620
|
+
del self._inflight[key]
|
|
621
|
+
if task.cancelled():
|
|
622
|
+
# Cancelled, perhaps before it ran (its loop closed): it probes nothing any more.
|
|
623
|
+
with self._lock:
|
|
624
|
+
breaker = self._breakers.get(key[1])
|
|
625
|
+
if breaker is not None:
|
|
626
|
+
breaker.probing = None
|
|
627
|
+
else:
|
|
628
|
+
task.exception() # retrieved: a failed exchange nobody awaits is not "never retrieved"
|
|
629
|
+
|
|
630
|
+
def _admit(self, api: str, settings: ExchangeSettings) -> None:
|
|
631
|
+
"""Let an exchange go to the issuer, or fail at once while its breaker is open."""
|
|
632
|
+
assert settings.url is not None
|
|
633
|
+
now = self._clock()
|
|
634
|
+
with self._lock:
|
|
635
|
+
breaker = self._breakers.setdefault(settings.url, _Breaker())
|
|
636
|
+
if breaker.open_until is None:
|
|
637
|
+
return
|
|
638
|
+
# A probe that has run past its deadline is gone (its loop closed): probe again.
|
|
639
|
+
probing = (
|
|
640
|
+
breaker.probing is not None and now - breaker.probing <= settings.timeout_s + 1
|
|
641
|
+
)
|
|
642
|
+
if now < breaker.open_until or probing:
|
|
643
|
+
wait = max(1, round(max(breaker.open_until, now + 1) - now))
|
|
644
|
+
else:
|
|
645
|
+
breaker.probing = now # half open: this call probes the issuer
|
|
646
|
+
return
|
|
647
|
+
_observe(api, CIRCUIT_OPEN)
|
|
648
|
+
raise TokenExchangeError(
|
|
649
|
+
f"token issuer unavailable (retry in {wait} s); nothing was sent to API {api!r}.",
|
|
650
|
+
outcome=CIRCUIT_OPEN,
|
|
651
|
+
reason="token issuer unavailable (circuit open)",
|
|
652
|
+
)
|
|
653
|
+
|
|
654
|
+
def _settle(self, settings: ExchangeSettings, *, failed: bool) -> None:
|
|
655
|
+
"""Record the issuer's answer (or its failure) on the token URL's breaker."""
|
|
656
|
+
assert settings.url is not None
|
|
657
|
+
opened = False
|
|
658
|
+
with self._lock:
|
|
659
|
+
breaker = self._breakers.setdefault(settings.url, _Breaker())
|
|
660
|
+
if not failed:
|
|
661
|
+
breaker.failures, breaker.open_until, breaker.probing = 0, None, None
|
|
662
|
+
return
|
|
663
|
+
breaker.failures += 1
|
|
664
|
+
if breaker.probing is not None or breaker.failures >= BREAKER_THRESHOLD:
|
|
665
|
+
opened = breaker.open_until is None
|
|
666
|
+
breaker.open_until = self._clock() + settings.failure_ttl_s
|
|
667
|
+
breaker.probing = None
|
|
668
|
+
if opened:
|
|
669
|
+
logger.warning(
|
|
670
|
+
"token exchange: the issuer failed %d times in a row; calls to auth: exchange "
|
|
671
|
+
"APIs fail at once for %d s, then one call tries it again",
|
|
672
|
+
BREAKER_THRESHOLD,
|
|
673
|
+
settings.failure_ttl_s,
|
|
674
|
+
)
|
|
675
|
+
|
|
676
|
+
@staticmethod
|
|
677
|
+
def _no_actor_error(api: str) -> TokenExchangeError:
|
|
678
|
+
return TokenExchangeError(
|
|
679
|
+
f"token exchange for API {api!r}: the issuer's token names no actor (no "
|
|
680
|
+
f"{actor_claim()} claim, or not a readable JWT), so the agent behind the API would "
|
|
681
|
+
"take this agent's call for the user's own; nothing was sent. Have the issuer name "
|
|
682
|
+
"this agent in the token, or set exchange.allow_actorless: true for the API once "
|
|
683
|
+
"that agent sets AUTH_JWT_DIRECT_CLIENTS.",
|
|
684
|
+
outcome=NO_ACTOR,
|
|
685
|
+
reason="exchanged token names no actor",
|
|
686
|
+
)
|
|
687
|
+
|
|
688
|
+
@staticmethod
|
|
689
|
+
def _refused_error(api: str, code: str) -> TokenExchangeError:
|
|
690
|
+
return TokenExchangeError(
|
|
691
|
+
f"token exchange for API {api!r} was refused ({code}); nothing was sent.",
|
|
692
|
+
outcome=REFUSED,
|
|
693
|
+
reason=f"token exchange refused ({code})",
|
|
694
|
+
)
|
|
695
|
+
|
|
696
|
+
async def _exchange(
|
|
697
|
+
self,
|
|
698
|
+
api: str,
|
|
699
|
+
key: tuple[str, ...],
|
|
700
|
+
subject_token: str,
|
|
701
|
+
audience: str,
|
|
702
|
+
scope: str | None,
|
|
703
|
+
resource: str | None,
|
|
704
|
+
subject_expires_at: float | None,
|
|
705
|
+
settings: ExchangeSettings,
|
|
706
|
+
allow_actorless: bool = False,
|
|
707
|
+
) -> str:
|
|
708
|
+
started = time.perf_counter()
|
|
709
|
+
try:
|
|
710
|
+
token, expires_in = await asyncio.wait_for(
|
|
711
|
+
self._post(subject_token, audience, scope, resource, settings),
|
|
712
|
+
timeout=settings.timeout_s,
|
|
713
|
+
)
|
|
714
|
+
except _Refused as exc:
|
|
715
|
+
self._settle(settings, failed=False) # the issuer answered
|
|
716
|
+
with self._lock:
|
|
717
|
+
self._refusals[key] = (exc.code, self._clock() + settings.failure_ttl_s)
|
|
718
|
+
self._refusals.move_to_end(key)
|
|
719
|
+
while len(self._refusals) > settings.cache_max:
|
|
720
|
+
self._refusals.popitem(last=False)
|
|
721
|
+
self._log(api, audience, f"refused ({exc.code})", started)
|
|
722
|
+
_observe(api, REFUSED, time.perf_counter() - started)
|
|
723
|
+
raise self._refused_error(api, exc.code) from None
|
|
724
|
+
except (_Unavailable, TimeoutError, httpx.HTTPError) as exc:
|
|
725
|
+
detail = (
|
|
726
|
+
exc.detail
|
|
727
|
+
if isinstance(exc, _Unavailable)
|
|
728
|
+
else "timed out"
|
|
729
|
+
if isinstance(exc, TimeoutError | httpx.TimeoutException)
|
|
730
|
+
else type(exc).__name__
|
|
731
|
+
)
|
|
732
|
+
self._settle(settings, failed=True)
|
|
733
|
+
self._log(api, audience, f"unavailable ({detail})", started, failed=True)
|
|
734
|
+
_observe(api, UNAVAILABLE, time.perf_counter() - started)
|
|
735
|
+
raise TokenExchangeError(
|
|
736
|
+
f"token issuer unavailable ({detail}); nothing was sent to API {api!r}.",
|
|
737
|
+
outcome=UNAVAILABLE,
|
|
738
|
+
reason=f"token issuer unavailable ({detail})",
|
|
739
|
+
) from None
|
|
740
|
+
except BaseException:
|
|
741
|
+
# Cancelled (the loop is closing), or a bug: never leave a probe marked running.
|
|
742
|
+
with self._lock:
|
|
743
|
+
breaker = self._breakers.get(settings.url or "")
|
|
744
|
+
if breaker is not None:
|
|
745
|
+
breaker.probing = None
|
|
746
|
+
raise
|
|
747
|
+
self._settle(settings, failed=False)
|
|
748
|
+
claim = actor_claim()
|
|
749
|
+
try:
|
|
750
|
+
unnamed = names_no_actor(token, claim)
|
|
751
|
+
except Exception: # a bug while reading the claims: judged as naming no actor
|
|
752
|
+
unnamed = True
|
|
753
|
+
if unnamed and not allow_actorless:
|
|
754
|
+
with self._lock:
|
|
755
|
+
self._refusals[key] = (_NO_ACTOR_CODE, self._clock() + settings.failure_ttl_s)
|
|
756
|
+
self._refusals.move_to_end(key)
|
|
757
|
+
while len(self._refusals) > settings.cache_max:
|
|
758
|
+
self._refusals.popitem(last=False)
|
|
759
|
+
self._log(api, audience, "refused (the token names no actor)", started)
|
|
760
|
+
_observe(api, NO_ACTOR, time.perf_counter() - started)
|
|
761
|
+
raise self._no_actor_error(api)
|
|
762
|
+
self._keep(key, token, expires_in, subject_expires_at, settings)
|
|
763
|
+
self._log(api, audience, "issued", started)
|
|
764
|
+
_observe(api, ISSUED, time.perf_counter() - started)
|
|
765
|
+
if unnamed:
|
|
766
|
+
self._warn_actorless(api, audience, claim, settings)
|
|
767
|
+
return token
|
|
768
|
+
|
|
769
|
+
def _warn_actorless(
|
|
770
|
+
self, api: str, audience: str, claim: str, settings: ExchangeSettings
|
|
771
|
+
) -> None:
|
|
772
|
+
"""Warn once per process when an API that allows it gets a token naming no actor.
|
|
773
|
+
|
|
774
|
+
The agent behind the API reads this agent's calls as the user's own
|
|
775
|
+
(it could let this agent decide the user's approvals) unless it sets
|
|
776
|
+
`AUTH_JWT_DIRECT_CLIENTS`; this agent cannot check that it does.
|
|
777
|
+
"""
|
|
778
|
+
with self._lock:
|
|
779
|
+
if self._warned_no_actor:
|
|
780
|
+
return
|
|
781
|
+
self._warned_no_actor = True
|
|
782
|
+
logger.warning(
|
|
783
|
+
"token exchange: the token the issuer minted for %s (audience %s) names no actor "
|
|
784
|
+
"(no %s claim), and exchange.allow_actorless lets it through: the agent behind it "
|
|
785
|
+
"reads this agent's calls as the user's own, and may let this agent decide the "
|
|
786
|
+
"user's approvals there, unless it sets AUTH_JWT_DIRECT_CLIENTS to the clients "
|
|
787
|
+
"people sign in with and lists client:%s in AUTH_ALLOWED_ACTORS",
|
|
788
|
+
api,
|
|
789
|
+
audience,
|
|
790
|
+
claim,
|
|
791
|
+
settings.client_id,
|
|
792
|
+
)
|
|
793
|
+
|
|
794
|
+
def _keep(
|
|
795
|
+
self,
|
|
796
|
+
key: tuple[str, ...],
|
|
797
|
+
token: str,
|
|
798
|
+
expires_in: int,
|
|
799
|
+
subject_expires_at: float | None,
|
|
800
|
+
settings: ExchangeSettings,
|
|
801
|
+
) -> None:
|
|
802
|
+
"""Keep the token for min(expires_in, the cap, the subject's lifetime) less the margin."""
|
|
803
|
+
lifetime = float(min(expires_in, settings.max_ttl_s))
|
|
804
|
+
if subject_expires_at is not None:
|
|
805
|
+
lifetime = min(lifetime, subject_expires_at - self._wall())
|
|
806
|
+
ttl = lifetime - EXPIRY_MARGIN_S
|
|
807
|
+
if ttl < MIN_CACHE_TTL_S:
|
|
808
|
+
return
|
|
809
|
+
with self._lock:
|
|
810
|
+
self._tokens[key] = (token, self._clock() + ttl)
|
|
811
|
+
self._tokens.move_to_end(key)
|
|
812
|
+
while len(self._tokens) > settings.cache_max:
|
|
813
|
+
self._tokens.popitem(last=False)
|
|
814
|
+
|
|
815
|
+
def _log(
|
|
816
|
+
self, api: str, audience: str, outcome: str, started: float, *, failed: bool = False
|
|
817
|
+
) -> None:
|
|
818
|
+
latency_ms = int((time.perf_counter() - started) * 1000)
|
|
819
|
+
logger.log(
|
|
820
|
+
logging.WARNING if failed else logging.INFO,
|
|
821
|
+
"token exchange for %s (audience %s): %s (%d ms)",
|
|
822
|
+
api,
|
|
823
|
+
audience,
|
|
824
|
+
outcome,
|
|
825
|
+
latency_ms,
|
|
826
|
+
extra={"api": api, "outcome": outcome, "latency_ms": latency_ms},
|
|
827
|
+
)
|
|
828
|
+
|
|
829
|
+
async def _post(
|
|
830
|
+
self,
|
|
831
|
+
subject_token: str,
|
|
832
|
+
audience: str,
|
|
833
|
+
scope: str | None,
|
|
834
|
+
resource: str | None,
|
|
835
|
+
settings: ExchangeSettings,
|
|
836
|
+
) -> tuple[str, int]:
|
|
837
|
+
"""POST the RFC 8693 request; `(access_token, expires_in)`, or `_Refused`/`_Unavailable`."""
|
|
838
|
+
headers, auth_fields = _client_auth(settings)
|
|
839
|
+
form = {
|
|
840
|
+
"grant_type": GRANT_TYPE,
|
|
841
|
+
"subject_token": subject_token,
|
|
842
|
+
"subject_token_type": settings.subject_token_type,
|
|
843
|
+
"requested_token_type": ACCESS_TOKEN_TYPE,
|
|
844
|
+
"audience": audience,
|
|
845
|
+
**({"scope": scope} if scope else {}),
|
|
846
|
+
**({"resource": resource} if resource else {}),
|
|
847
|
+
**auth_fields,
|
|
848
|
+
}
|
|
849
|
+
timeout = httpx.Timeout(settings.timeout_s, connect=min(MAX_CONNECT_S, settings.timeout_s))
|
|
850
|
+
body = bytearray()
|
|
851
|
+
assert settings.url is not None
|
|
852
|
+
async with httpx.AsyncClient(
|
|
853
|
+
transport=self._transport, timeout=timeout, follow_redirects=False
|
|
854
|
+
) as client:
|
|
855
|
+
async with client.stream(
|
|
856
|
+
"POST",
|
|
857
|
+
settings.url,
|
|
858
|
+
data=form,
|
|
859
|
+
headers={**headers, "Accept": "application/json"},
|
|
860
|
+
) as response:
|
|
861
|
+
status = response.status_code
|
|
862
|
+
async for chunk in response.aiter_bytes():
|
|
863
|
+
body += chunk
|
|
864
|
+
if len(body) > RESPONSE_MAX_BYTES:
|
|
865
|
+
raise _Unavailable("unusable issuer response: too large")
|
|
866
|
+
if 400 <= status < 500 and status not in (408, 429):
|
|
867
|
+
raise _Refused(_error_code(bytes(body)) or f"HTTP {status}")
|
|
868
|
+
if status != 200:
|
|
869
|
+
raise _Unavailable(f"HTTP {status}")
|
|
870
|
+
return _issued(bytes(body))
|
|
871
|
+
|
|
872
|
+
|
|
873
|
+
def _error_code(body: bytes) -> str | None:
|
|
874
|
+
"""The RFC 6749 `error` code of an error response, when it is one."""
|
|
875
|
+
try:
|
|
876
|
+
data = json.loads(body)
|
|
877
|
+
except ValueError:
|
|
878
|
+
return None
|
|
879
|
+
code = data.get("error") if isinstance(data, dict) else None
|
|
880
|
+
return code if isinstance(code, str) and _ERROR_CODE_RE.fullmatch(code) else None
|
|
881
|
+
|
|
882
|
+
|
|
883
|
+
def _issued(body: bytes) -> tuple[str, int]:
|
|
884
|
+
"""`(access_token, expires_in)` of a 200 answer, or `_Unavailable` (an unusable one)."""
|
|
885
|
+
try:
|
|
886
|
+
data = json.loads(body)
|
|
887
|
+
except ValueError:
|
|
888
|
+
raise _Unavailable("unusable issuer response: not JSON") from None
|
|
889
|
+
if not isinstance(data, dict):
|
|
890
|
+
raise _Unavailable("unusable issuer response: not a JSON object")
|
|
891
|
+
token = data.get("access_token")
|
|
892
|
+
if not isinstance(token, str) or not token or len(token) > ACCESS_TOKEN_MAX_CHARS:
|
|
893
|
+
raise _Unavailable("unusable issuer response: access_token")
|
|
894
|
+
token_type = data.get("token_type")
|
|
895
|
+
if not isinstance(token_type, str) or token_type.lower() != "bearer":
|
|
896
|
+
raise _Unavailable("unusable issuer response: token_type is not Bearer")
|
|
897
|
+
issued_type = data.get("issued_token_type")
|
|
898
|
+
if issued_type is not None and issued_type != ACCESS_TOKEN_TYPE:
|
|
899
|
+
raise _Unavailable("unusable issuer response: issued_token_type is not an access token")
|
|
900
|
+
expires_in = data.get("expires_in", DEFAULT_EXPIRES_IN_S)
|
|
901
|
+
if not isinstance(expires_in, int) or isinstance(expires_in, bool) or expires_in <= 0:
|
|
902
|
+
raise _Unavailable("unusable issuer response: expires_in")
|
|
903
|
+
return token, expires_in
|
|
904
|
+
|
|
905
|
+
|
|
906
|
+
def actor_claim(env: Mapping[str, str] | None = None) -> str:
|
|
907
|
+
"""The claim naming the agent that presents a token: `AUTH_JWT_ACTOR_CLAIM`, else `act`."""
|
|
908
|
+
env = os.environ if env is None else env
|
|
909
|
+
return (env.get("AUTH_JWT_ACTOR_CLAIM") or "").strip() or ACTOR_CLAIM
|
|
910
|
+
|
|
911
|
+
|
|
912
|
+
def names_no_actor(token: str, claim: str = ACTOR_CLAIM) -> bool:
|
|
913
|
+
"""Whether `token` names no actor: it is not a readable signed JWT, or its claims lack `claim`.
|
|
914
|
+
|
|
915
|
+
`claim` is a dotted path, read as `jwt` reads it (a top-level claim of that
|
|
916
|
+
exact name first; a null value counts as present: the called agent refuses
|
|
917
|
+
it). The claims are read without verifying the token (the agent it is sent
|
|
918
|
+
to does): only to tell whether the issuer names the agent presenting it. A
|
|
919
|
+
token that cannot be read (opaque, encrypted, not base64url or JSON) names
|
|
920
|
+
no actor as far as this agent can tell: True.
|
|
921
|
+
"""
|
|
922
|
+
parts = token.split(".")
|
|
923
|
+
if len(parts) != 3 or not parts[1]:
|
|
924
|
+
return True
|
|
925
|
+
try:
|
|
926
|
+
payload = base64.urlsafe_b64decode(parts[1] + "=" * (-len(parts[1]) % 4))
|
|
927
|
+
claims = json.loads(payload)
|
|
928
|
+
except (ValueError, RecursionError): # not base64url, UTF-8 or JSON; nested too deep
|
|
929
|
+
return True
|
|
930
|
+
if not isinstance(claims, dict):
|
|
931
|
+
return True
|
|
932
|
+
if claim in claims:
|
|
933
|
+
return False
|
|
934
|
+
node: Any = claims
|
|
935
|
+
for part in claim.split("."):
|
|
936
|
+
if not isinstance(node, dict) or part not in node:
|
|
937
|
+
return True
|
|
938
|
+
node = node[part]
|
|
939
|
+
return False
|
|
940
|
+
|
|
941
|
+
|
|
942
|
+
_exchanger = TokenExchanger()
|
|
943
|
+
|
|
944
|
+
|
|
945
|
+
def exchanger() -> TokenExchanger:
|
|
946
|
+
"""The process's token exchanger."""
|
|
947
|
+
return _exchanger
|
|
948
|
+
|
|
949
|
+
|
|
950
|
+
def reset_token_exchange(
|
|
951
|
+
*,
|
|
952
|
+
clock: Callable[[], float] = time.monotonic,
|
|
953
|
+
wall: Callable[[], float] = time.time,
|
|
954
|
+
transport: httpx.AsyncBaseTransport | None = None,
|
|
955
|
+
) -> TokenExchanger:
|
|
956
|
+
"""Start from an empty exchanger (tests); returns it."""
|
|
957
|
+
global _exchanger
|
|
958
|
+
_exchanger = TokenExchanger(clock=clock, wall=wall, transport=transport)
|
|
959
|
+
return _exchanger
|