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,650 @@
|
|
|
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
|
+
"""`auth: exchange` against an RFC 8693 issuer over HTTP (`fake_issuer.py`, loopback).
|
|
16
|
+
|
|
17
|
+
* Conformance: what `token_exchange` sends is what the issuer accepts (Basic and
|
|
18
|
+
`client_secret_post` client authentication, audience, scope, resource); the
|
|
19
|
+
token it gets back verifies at the callee as the user presented by this agent
|
|
20
|
+
(`act`); the issuer's refusals (an audience the client may not act for, a
|
|
21
|
+
service's own token, a wrong secret) reach the call as refusals. An issuer
|
|
22
|
+
that names no actor: its tokens are refused unless the API sets
|
|
23
|
+
`exchange.allow_actorless` (then one warning), over `/chat` too.
|
|
24
|
+
* Depth: nested exchanges carry the chain the callee's `AUTH_MAX_DELEGATION_DEPTH`
|
|
25
|
+
reads, hop by hop.
|
|
26
|
+
* An issuer that hangs: each call waits at most the deadline, the breaker then
|
|
27
|
+
fails calls at once, and the first call after the window recovers.
|
|
28
|
+
* This app as the concierge, end to end over `/chat` with alice's token: the
|
|
29
|
+
exchange happens in the tool call, never while authenticating; once per user
|
|
30
|
+
and API; not at all for a run that calls no such API; for a gated call only
|
|
31
|
+
once the person approved; and neither alice's token nor the exchanged one is
|
|
32
|
+
in the logs or (with `TEST_POSTGRES_DSN`) in any table of the database.
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
from __future__ import annotations
|
|
36
|
+
|
|
37
|
+
import asyncio
|
|
38
|
+
import json
|
|
39
|
+
import logging
|
|
40
|
+
import os
|
|
41
|
+
import time
|
|
42
|
+
import uuid
|
|
43
|
+
from collections.abc import AsyncIterator, Iterator
|
|
44
|
+
from pathlib import Path
|
|
45
|
+
from typing import Any
|
|
46
|
+
from urllib.parse import urlsplit
|
|
47
|
+
|
|
48
|
+
# The environment must be in place before the app (and the graph) is imported.
|
|
49
|
+
os.environ.update(
|
|
50
|
+
{
|
|
51
|
+
"MODEL_PROVIDER": "fake",
|
|
52
|
+
"MODEL_NAME": "fake",
|
|
53
|
+
"CHECKPOINTER": "memory",
|
|
54
|
+
"AUTH_POLICY": "shared-bearer",
|
|
55
|
+
"API_KEY": "test-key",
|
|
56
|
+
"APP_ENV": "dev",
|
|
57
|
+
"TRACING_ENABLED": "false",
|
|
58
|
+
"RUNTIME": "fastapi",
|
|
59
|
+
"APP_URL": "http://testserver",
|
|
60
|
+
}
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
import httpx
|
|
64
|
+
import jwt
|
|
65
|
+
import pytest
|
|
66
|
+
from fake_issuer import FakeIssuer
|
|
67
|
+
from fastapi import HTTPException
|
|
68
|
+
from langchain.tools import ToolRuntime
|
|
69
|
+
from langchain_core.tools import tool
|
|
70
|
+
from starlette.requests import Request
|
|
71
|
+
|
|
72
|
+
from {{cookiecutter.agent_directory}}.app_utils.api_client import get_client
|
|
73
|
+
from {{cookiecutter.agent_directory}}.app_utils.api_client import (
|
|
74
|
+
reset_policy_cache as reset_api_policy,
|
|
75
|
+
)
|
|
76
|
+
from {{cookiecutter.agent_directory}}.app_utils.auth import require, reset_policy_cache
|
|
77
|
+
from {{cookiecutter.agent_directory}}.app_utils.token_exchange import (
|
|
78
|
+
TokenExchangeError,
|
|
79
|
+
exchanger,
|
|
80
|
+
reset_token_exchange,
|
|
81
|
+
)
|
|
82
|
+
from {{cookiecutter.agent_directory}}.fast_api_app import app
|
|
83
|
+
|
|
84
|
+
SECRET = "s3cret"
|
|
85
|
+
# In the refusal of an exchanged token that names no actor, and in the warning an API that
|
|
86
|
+
# allows one (exchange.allow_actorless) logs (token_exchange).
|
|
87
|
+
NO_ACTOR = "names no actor"
|
|
88
|
+
CLIENTS = {
|
|
89
|
+
"concierge": (SECRET, {"billing", "orders"}),
|
|
90
|
+
"billing": (SECRET, {"orders"}),
|
|
91
|
+
"orders": (SECRET, {"shipping"}),
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
@pytest.fixture(scope="module")
|
|
96
|
+
def issuer() -> Iterator[FakeIssuer]:
|
|
97
|
+
server = FakeIssuer(CLIENTS)
|
|
98
|
+
try:
|
|
99
|
+
yield server
|
|
100
|
+
finally:
|
|
101
|
+
server.close()
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
@pytest.fixture
|
|
105
|
+
def exchange_env(issuer: FakeIssuer, monkeypatch: pytest.MonkeyPatch) -> Iterator[FakeIssuer]:
|
|
106
|
+
"""This agent is the concierge client at the issuer; the callee reads the issuer's keys."""
|
|
107
|
+
for name, value in {
|
|
108
|
+
"TOKEN_EXCHANGE_URL": issuer.token_url,
|
|
109
|
+
"TOKEN_EXCHANGE_CLIENT_ID": "concierge",
|
|
110
|
+
"TOKEN_EXCHANGE_CLIENT_SECRET": SECRET,
|
|
111
|
+
"AUTH_POLICY": "jwt",
|
|
112
|
+
"AUTH_JWT_JWKS_URL": issuer.jwks_url,
|
|
113
|
+
"AUTH_JWT_ISSUER": issuer.issuer,
|
|
114
|
+
}.items():
|
|
115
|
+
monkeypatch.setenv(name, value)
|
|
116
|
+
for name in (
|
|
117
|
+
"TOKEN_EXCHANGE_CLIENT_AUTH",
|
|
118
|
+
"TOKEN_EXCHANGE_TIMEOUT_MS",
|
|
119
|
+
"TOKEN_EXCHANGE_FAILURE_TTL_S",
|
|
120
|
+
"AUTH_JWT_PUBLIC_KEY",
|
|
121
|
+
"AUTH_JWT_DIRECT_CLIENTS",
|
|
122
|
+
"AUTH_MAX_DELEGATION_DEPTH",
|
|
123
|
+
):
|
|
124
|
+
monkeypatch.delenv(name, raising=False)
|
|
125
|
+
issuer.answers.clear()
|
|
126
|
+
issuer.hold.set()
|
|
127
|
+
issuer.expires_in = 300
|
|
128
|
+
issuer.act = True
|
|
129
|
+
reset_token_exchange()
|
|
130
|
+
reset_policy_cache()
|
|
131
|
+
yield issuer
|
|
132
|
+
issuer.hold.set()
|
|
133
|
+
reset_token_exchange()
|
|
134
|
+
reset_policy_cache()
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _request(token: str) -> Request:
|
|
138
|
+
return Request(
|
|
139
|
+
{
|
|
140
|
+
"type": "http",
|
|
141
|
+
"method": "POST",
|
|
142
|
+
"path": "/a2a/x",
|
|
143
|
+
"headers": [(b"authorization", f"Bearer {token}".encode())],
|
|
144
|
+
"query_string": b"",
|
|
145
|
+
"path_params": {},
|
|
146
|
+
}
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
async def _at(
|
|
151
|
+
monkeypatch: pytest.MonkeyPatch, audience: str, token: str, allowed: str, depth: int = 3
|
|
152
|
+
) -> Any:
|
|
153
|
+
"""The principal the agent `audience` (jwt, the issuer's keys) sees for `token`."""
|
|
154
|
+
monkeypatch.setenv("AUTH_JWT_AUDIENCE", audience)
|
|
155
|
+
monkeypatch.setenv("AUTH_ALLOWED_ACTORS", allowed)
|
|
156
|
+
monkeypatch.setenv("AUTH_MAX_DELEGATION_DEPTH", str(depth))
|
|
157
|
+
reset_policy_cache()
|
|
158
|
+
return await require("a2a.invoke")(_request(token))
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def _as(client: str, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
162
|
+
monkeypatch.setenv("TOKEN_EXCHANGE_CLIENT_ID", client)
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def _claims(token: str) -> dict[str, Any]:
|
|
166
|
+
return jwt.decode(token, options={"verify_signature": False})
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
# ---------------------------------------------------------------------------
|
|
170
|
+
# Conformance
|
|
171
|
+
# ---------------------------------------------------------------------------
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
async def test_the_exchanged_token_is_the_user_presented_by_this_agent(
|
|
175
|
+
exchange_env: FakeIssuer, monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture
|
|
176
|
+
) -> None:
|
|
177
|
+
issuer = exchange_env
|
|
178
|
+
caplog.set_level(logging.WARNING)
|
|
179
|
+
alice = issuer.login("alice", audience="concierge")
|
|
180
|
+
token = await exchanger().token(
|
|
181
|
+
"orders_agent",
|
|
182
|
+
alice,
|
|
183
|
+
audience="orders",
|
|
184
|
+
scope="orders.read",
|
|
185
|
+
resource="https://orders.example.com",
|
|
186
|
+
subject_expires_at=_claims(alice)["exp"],
|
|
187
|
+
)
|
|
188
|
+
assert issuer.requests[-1]["authorization"].startswith("Basic ")
|
|
189
|
+
claims = _claims(token)
|
|
190
|
+
assert (claims["aud"], claims["azp"], claims["act"]) == (
|
|
191
|
+
"orders",
|
|
192
|
+
"concierge",
|
|
193
|
+
{"sub": "concierge"},
|
|
194
|
+
)
|
|
195
|
+
assert (claims["scope"], claims["resource"]) == ("orders.read", "https://orders.example.com")
|
|
196
|
+
assert NO_ACTOR not in caplog.text # the issuer names the agent: nothing to warn about
|
|
197
|
+
principal = await _at(monkeypatch, "orders", token, allowed="concierge")
|
|
198
|
+
assert principal.id == "alice"
|
|
199
|
+
assert principal.actor.id == "concierge" and principal.actor.chain == ("concierge",)
|
|
200
|
+
# A token minted for orders is refused at billing (the audience is pinned).
|
|
201
|
+
with pytest.raises(HTTPException) as refused:
|
|
202
|
+
await _at(monkeypatch, "billing", token, allowed="concierge")
|
|
203
|
+
assert refused.value.status_code == 401
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
async def test_client_secret_post_conforms(
|
|
207
|
+
exchange_env: FakeIssuer, monkeypatch: pytest.MonkeyPatch
|
|
208
|
+
) -> None:
|
|
209
|
+
issuer = exchange_env
|
|
210
|
+
monkeypatch.setenv("TOKEN_EXCHANGE_CLIENT_AUTH", "client_secret_post")
|
|
211
|
+
token = await exchanger().token(
|
|
212
|
+
"billing_agent", issuer.login("bob", audience="concierge"), audience="billing"
|
|
213
|
+
)
|
|
214
|
+
sent = issuer.requests[-1]
|
|
215
|
+
assert sent["authorization"] == ""
|
|
216
|
+
assert (sent["form"]["client_id"], sent["form"]["client_secret"]) == ("concierge", SECRET)
|
|
217
|
+
assert _claims(token)["act"] == {"sub": "concierge"}
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
@pytest.mark.parametrize(
|
|
221
|
+
("setup", "code"),
|
|
222
|
+
[
|
|
223
|
+
("audience", "invalid_target"), # the client may not act for that audience
|
|
224
|
+
("service", "invalid_grant"), # a service's own token is never exchanged
|
|
225
|
+
("secret", "invalid_client"), # a wrong client secret
|
|
226
|
+
("foreign", "invalid_grant"), # a token the issuer did not sign
|
|
227
|
+
],
|
|
228
|
+
)
|
|
229
|
+
async def test_the_issuers_refusals_reach_the_call(
|
|
230
|
+
exchange_env: FakeIssuer, monkeypatch: pytest.MonkeyPatch, setup: str, code: str
|
|
231
|
+
) -> None:
|
|
232
|
+
issuer = exchange_env
|
|
233
|
+
subject, audience = issuer.login("alice", audience="concierge"), "orders"
|
|
234
|
+
if setup == "audience":
|
|
235
|
+
audience = "shipping"
|
|
236
|
+
elif setup == "service":
|
|
237
|
+
subject = issuer.login("concierge", audience="concierge", client="concierge")
|
|
238
|
+
elif setup == "secret":
|
|
239
|
+
monkeypatch.setenv("TOKEN_EXCHANGE_CLIENT_SECRET", "wrong")
|
|
240
|
+
else:
|
|
241
|
+
subject = FakeIssuer(CLIENTS).login("alice", audience="concierge")
|
|
242
|
+
with pytest.raises(TokenExchangeError) as exc:
|
|
243
|
+
await exchanger().token("orders_agent", subject, audience=audience)
|
|
244
|
+
assert str(exc.value) == (
|
|
245
|
+
f"token exchange for API 'orders_agent' was refused ({code}); nothing was sent."
|
|
246
|
+
)
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
async def test_an_issuer_that_names_no_actor(
|
|
250
|
+
exchange_env: FakeIssuer, monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture
|
|
251
|
+
) -> None:
|
|
252
|
+
"""Some issuers (Keycloak's standard token exchange among them) put no `act` in an
|
|
253
|
+
exchanged token, only `azp`. The caller refuses such a token unless the API opts in
|
|
254
|
+
(`exchange.allow_actorless`); then it warns once that the callee must tell the agent by
|
|
255
|
+
its client."""
|
|
256
|
+
issuer = exchange_env
|
|
257
|
+
issuer.act = False
|
|
258
|
+
caplog.set_level(logging.WARNING)
|
|
259
|
+
alice = issuer.login("alice", audience="concierge")
|
|
260
|
+
with pytest.raises(TokenExchangeError) as exc:
|
|
261
|
+
await exchanger().token("orders_agent", alice, audience="orders")
|
|
262
|
+
assert exc.value.outcome == "no_actor"
|
|
263
|
+
assert "names no actor" in str(exc.value) and "nothing was sent" in str(exc.value)
|
|
264
|
+
assert [r for r in caplog.records if r.levelno >= logging.WARNING] == []
|
|
265
|
+
token = await exchanger().token("orders_agent", alice, audience="orders", allow_actorless=True)
|
|
266
|
+
assert "act" not in _claims(token) and _claims(token)["azp"] == "concierge"
|
|
267
|
+
[warning] = [
|
|
268
|
+
r.getMessage()
|
|
269
|
+
for r in caplog.records
|
|
270
|
+
if r.levelno >= logging.WARNING and NO_ACTOR in r.getMessage()
|
|
271
|
+
]
|
|
272
|
+
assert "AUTH_JWT_DIRECT_CLIENTS" in warning and "client:concierge" in warning
|
|
273
|
+
assert token not in caplog.text
|
|
274
|
+
# Without AUTH_JWT_DIRECT_CLIENTS such a token reads as alice's own (why the opt-in needs
|
|
275
|
+
# it): list the sign-in clients, and the concierge presenting it is `client:concierge`.
|
|
276
|
+
assert (await _at(monkeypatch, "orders", token, allowed="")).actor is None
|
|
277
|
+
monkeypatch.setenv("AUTH_JWT_DIRECT_CLIENTS", "web")
|
|
278
|
+
principal = await _at(monkeypatch, "orders", token, allowed="client:concierge")
|
|
279
|
+
assert principal.id == "alice" and principal.actor.id == "client:concierge"
|
|
280
|
+
own = issuer.login("alice", audience="orders") # alice's own sign-in (client web)
|
|
281
|
+
assert (await _at(monkeypatch, "orders", own, allowed="client:concierge")).actor is None
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
async def test_the_issuers_lifetime_is_capped(
|
|
285
|
+
exchange_env: FakeIssuer, monkeypatch: pytest.MonkeyPatch
|
|
286
|
+
) -> None:
|
|
287
|
+
"""An answer without `expires_in` is kept 30 s (60 s less the margin); a long one 270 s."""
|
|
288
|
+
issuer = exchange_env
|
|
289
|
+
now = [0.0]
|
|
290
|
+
reset_token_exchange(clock=lambda: now[0])
|
|
291
|
+
alice = issuer.login("alice", audience="concierge", ttl_s=7200)
|
|
292
|
+
for expires_in, kept in ((None, 30), (3600, 270)):
|
|
293
|
+
exchanger().clear()
|
|
294
|
+
issuer.expires_in = expires_in
|
|
295
|
+
first = await exchanger().token("orders_agent", alice, audience="orders")
|
|
296
|
+
now[0] += kept - 1
|
|
297
|
+
assert await exchanger().token("orders_agent", alice, audience="orders") == first
|
|
298
|
+
now[0] += 2
|
|
299
|
+
assert await exchanger().token("orders_agent", alice, audience="orders") != first
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
# ---------------------------------------------------------------------------
|
|
303
|
+
# Depth across real exchanges
|
|
304
|
+
# ---------------------------------------------------------------------------
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
async def test_the_delegation_depth_holds_across_real_exchanges(
|
|
308
|
+
exchange_env: FakeIssuer, monkeypatch: pytest.MonkeyPatch
|
|
309
|
+
) -> None:
|
|
310
|
+
"""concierge -> billing -> orders passes at depth 2; one more hop is 401 at the callee."""
|
|
311
|
+
issuer = exchange_env
|
|
312
|
+
at_concierge = issuer.login("alice", audience="concierge")
|
|
313
|
+
at_billing = await exchanger().token("billing_agent", at_concierge, audience="billing")
|
|
314
|
+
_as("billing", monkeypatch)
|
|
315
|
+
at_orders = await exchanger().token("orders_agent", at_billing, audience="orders")
|
|
316
|
+
principal = await _at(monkeypatch, "orders", at_orders, allowed="billing", depth=2)
|
|
317
|
+
assert principal.id == "alice" and principal.actor.chain == ("billing", "concierge")
|
|
318
|
+
_as("orders", monkeypatch)
|
|
319
|
+
at_shipping = await exchanger().token("shipping_agent", at_orders, audience="shipping")
|
|
320
|
+
with pytest.raises(HTTPException) as exc:
|
|
321
|
+
await _at(monkeypatch, "shipping", at_shipping, allowed="orders", depth=2)
|
|
322
|
+
assert (exc.value.status_code, exc.value.detail) == (
|
|
323
|
+
401,
|
|
324
|
+
"Invalid bearer token: delegation too deep.",
|
|
325
|
+
)
|
|
326
|
+
principal = await _at(monkeypatch, "shipping", at_shipping, allowed="orders", depth=3)
|
|
327
|
+
assert principal.actor.chain == ("orders", "billing", "concierge")
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
# ---------------------------------------------------------------------------
|
|
331
|
+
# An issuer that hangs
|
|
332
|
+
# ---------------------------------------------------------------------------
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
async def test_an_issuer_that_hangs(
|
|
336
|
+
exchange_env: FakeIssuer, monkeypatch: pytest.MonkeyPatch
|
|
337
|
+
) -> None:
|
|
338
|
+
issuer = exchange_env
|
|
339
|
+
monkeypatch.setenv("TOKEN_EXCHANGE_TIMEOUT_MS", "300")
|
|
340
|
+
monkeypatch.setenv("TOKEN_EXCHANGE_FAILURE_TTL_S", "1")
|
|
341
|
+
issuer.hold.clear()
|
|
342
|
+
for user in ("u1", "u2", "u3"):
|
|
343
|
+
started = time.monotonic()
|
|
344
|
+
with pytest.raises(TokenExchangeError, match=r"unavailable \(timed out\)"):
|
|
345
|
+
await exchanger().token(
|
|
346
|
+
"orders_agent", issuer.login(user, audience="concierge"), audience="orders"
|
|
347
|
+
)
|
|
348
|
+
assert time.monotonic() - started < 1.0
|
|
349
|
+
started = time.monotonic()
|
|
350
|
+
with pytest.raises(TokenExchangeError, match=r"retry in 1 s"):
|
|
351
|
+
await exchanger().token(
|
|
352
|
+
"orders_agent", issuer.login("u4", audience="concierge"), audience="orders"
|
|
353
|
+
)
|
|
354
|
+
assert time.monotonic() - started < 0.05
|
|
355
|
+
issuer.hold.set()
|
|
356
|
+
await asyncio.sleep(1.1)
|
|
357
|
+
token = await exchanger().token(
|
|
358
|
+
"orders_agent", issuer.login("u5", audience="concierge"), audience="orders"
|
|
359
|
+
)
|
|
360
|
+
assert _claims(token)["sub"] == "u5"
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
# ---------------------------------------------------------------------------
|
|
364
|
+
# This app as the concierge, end to end
|
|
365
|
+
# ---------------------------------------------------------------------------
|
|
366
|
+
|
|
367
|
+
POLICY = """
|
|
368
|
+
apis:
|
|
369
|
+
orders_agent:
|
|
370
|
+
base_url_env: ORDERS_AGENT_URL
|
|
371
|
+
auth: exchange
|
|
372
|
+
exchange: {audience: orders}
|
|
373
|
+
allowed_methods: [GET, POST]
|
|
374
|
+
approval:
|
|
375
|
+
required_for:
|
|
376
|
+
operations:
|
|
377
|
+
- {operationId: cancelOrder, path: "/orders/{order_id}/cancel", methods: [POST]}
|
|
378
|
+
approvers: [requester]
|
|
379
|
+
timeout_s: 60
|
|
380
|
+
"""
|
|
381
|
+
|
|
382
|
+
# What the orders agent received: the verified claims of each bearer, and the tokens.
|
|
383
|
+
RECEIVED: list[dict[str, Any]] = []
|
|
384
|
+
TOKENS: list[str] = []
|
|
385
|
+
ISSUER: list[FakeIssuer] = []
|
|
386
|
+
|
|
387
|
+
|
|
388
|
+
def _orders_backend(request: httpx.Request) -> httpx.Response:
|
|
389
|
+
token = request.headers["authorization"].removeprefix("Bearer ")
|
|
390
|
+
claims = jwt.decode(
|
|
391
|
+
token,
|
|
392
|
+
ISSUER[0].key.public_key(),
|
|
393
|
+
algorithms=["RS256"],
|
|
394
|
+
audience="orders",
|
|
395
|
+
issuer=ISSUER[0].issuer,
|
|
396
|
+
)
|
|
397
|
+
RECEIVED.append({"path": request.url.path, "method": request.method, **claims})
|
|
398
|
+
TOKENS.append(token)
|
|
399
|
+
return httpx.Response(200, json={"status": "shipped"})
|
|
400
|
+
|
|
401
|
+
|
|
402
|
+
@tool
|
|
403
|
+
async def check_orders(runtime: ToolRuntime[Any]) -> str:
|
|
404
|
+
"""Check the caller's orders at the orders agent."""
|
|
405
|
+
client = get_client(
|
|
406
|
+
"orders_agent", context=runtime.context, transport=httpx.MockTransport(_orders_backend)
|
|
407
|
+
)
|
|
408
|
+
return json.dumps(await client.get("/orders/status"))
|
|
409
|
+
|
|
410
|
+
|
|
411
|
+
@tool
|
|
412
|
+
async def cancel_order(order_id: str, runtime: ToolRuntime[Any]) -> str:
|
|
413
|
+
"""Cancel an order by its id."""
|
|
414
|
+
client = get_client(
|
|
415
|
+
"orders_agent", context=runtime.context, transport=httpx.MockTransport(_orders_backend)
|
|
416
|
+
)
|
|
417
|
+
data = await client.post(
|
|
418
|
+
"/orders/{order_id}/cancel", operation_id="cancelOrder", path_params={"order_id": order_id}
|
|
419
|
+
)
|
|
420
|
+
return json.dumps(data)
|
|
421
|
+
|
|
422
|
+
|
|
423
|
+
ADMIN_DSN = os.environ.get("TEST_POSTGRES_DSN", "")
|
|
424
|
+
|
|
425
|
+
|
|
426
|
+
@pytest.fixture(params=["memory", "postgres"])
|
|
427
|
+
async def database(
|
|
428
|
+
request: pytest.FixtureRequest, monkeypatch: pytest.MonkeyPatch
|
|
429
|
+
) -> AsyncIterator[str | None]:
|
|
430
|
+
if request.param == "memory":
|
|
431
|
+
yield None
|
|
432
|
+
return
|
|
433
|
+
if not ADMIN_DSN:
|
|
434
|
+
pytest.skip("TEST_POSTGRES_DSN is not set")
|
|
435
|
+
import psycopg
|
|
436
|
+
|
|
437
|
+
name = f"gac_test_{uuid.uuid4().hex[:12]}"
|
|
438
|
+
async with await psycopg.AsyncConnection.connect(ADMIN_DSN, autocommit=True) as admin:
|
|
439
|
+
await admin.execute(f'CREATE DATABASE "{name}"')
|
|
440
|
+
dsn = urlsplit(ADMIN_DSN)._replace(path=f"/{name}").geturl()
|
|
441
|
+
monkeypatch.setenv("CHECKPOINTER", "postgres")
|
|
442
|
+
monkeypatch.setenv("POSTGRES_DSN", dsn)
|
|
443
|
+
yield dsn
|
|
444
|
+
async with await psycopg.AsyncConnection.connect(ADMIN_DSN, autocommit=True) as admin:
|
|
445
|
+
await admin.execute(f'DROP DATABASE IF EXISTS "{name}" WITH (FORCE)')
|
|
446
|
+
|
|
447
|
+
|
|
448
|
+
@pytest.fixture
|
|
449
|
+
async def concierge(
|
|
450
|
+
database: str | None,
|
|
451
|
+
exchange_env: FakeIssuer,
|
|
452
|
+
tmp_path: Path,
|
|
453
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
454
|
+
use_test_tools: Any,
|
|
455
|
+
) -> AsyncIterator[httpx.AsyncClient]:
|
|
456
|
+
policy = tmp_path / "api-policy.yaml"
|
|
457
|
+
policy.write_text(POLICY, encoding="utf-8")
|
|
458
|
+
monkeypatch.setenv("API_POLICY_PATH", str(policy))
|
|
459
|
+
monkeypatch.setenv("ORDERS_AGENT_URL", "https://orders.test")
|
|
460
|
+
monkeypatch.setenv("AUTH_JWT_AUDIENCE", "concierge")
|
|
461
|
+
monkeypatch.delenv("AUTH_ALLOWED_ACTORS", raising=False)
|
|
462
|
+
reset_policy_cache()
|
|
463
|
+
reset_api_policy()
|
|
464
|
+
RECEIVED.clear()
|
|
465
|
+
TOKENS.clear()
|
|
466
|
+
ISSUER[:] = [exchange_env]
|
|
467
|
+
async with app.router.lifespan_context(app):
|
|
468
|
+
transport = httpx.ASGITransport(app=app, raise_app_exceptions=False)
|
|
469
|
+
async with httpx.AsyncClient(
|
|
470
|
+
transport=transport, base_url="http://testserver", timeout=30
|
|
471
|
+
) as client:
|
|
472
|
+
started = asyncio.get_running_loop().time()
|
|
473
|
+
while (await client.get("/ready")).status_code != 200: # postgres: the schema
|
|
474
|
+
assert asyncio.get_running_loop().time() - started < 30, "not ready"
|
|
475
|
+
await asyncio.sleep(0.1)
|
|
476
|
+
client.use_tools = use_test_tools # type: ignore[attr-defined]
|
|
477
|
+
client.dsn = database # type: ignore[attr-defined]
|
|
478
|
+
yield client
|
|
479
|
+
reset_policy_cache()
|
|
480
|
+
reset_api_policy()
|
|
481
|
+
|
|
482
|
+
|
|
483
|
+
def _events(text: str) -> list[tuple[str, dict[str, Any]]]:
|
|
484
|
+
events, event = [], None
|
|
485
|
+
for line in text.splitlines():
|
|
486
|
+
if line.startswith("event:"):
|
|
487
|
+
event = line[6:].strip()
|
|
488
|
+
elif line.startswith("data:") and event:
|
|
489
|
+
events.append((event, json.loads(line[5:].strip())))
|
|
490
|
+
event = None
|
|
491
|
+
return events
|
|
492
|
+
|
|
493
|
+
|
|
494
|
+
async def _chat(client: httpx.AsyncClient, token: str, message: str) -> list[tuple[str, dict]]:
|
|
495
|
+
r = await client.post(
|
|
496
|
+
"/chat", json={"message": message}, headers={"Authorization": f"Bearer {token}"}
|
|
497
|
+
)
|
|
498
|
+
assert r.status_code == 200, r.text
|
|
499
|
+
return _events(r.text)
|
|
500
|
+
|
|
501
|
+
|
|
502
|
+
async def test_one_exchange_per_user_and_api_in_the_tool_call_only(
|
|
503
|
+
concierge: httpx.AsyncClient,
|
|
504
|
+
) -> None:
|
|
505
|
+
issuer = ISSUER[0]
|
|
506
|
+
concierge.use_tools(check_orders) # type: ignore[attr-defined]
|
|
507
|
+
alice = issuer.login(f"alice-{uuid.uuid4().hex[:6]}", audience="concierge")
|
|
508
|
+
bob = issuer.login(f"bob-{uuid.uuid4().hex[:6]}", audience="concierge")
|
|
509
|
+
before = len(issuer.exchanges)
|
|
510
|
+
# Authenticating alone never exchanges (the subject token is kept, not used).
|
|
511
|
+
listed = await concierge.get("/threads", headers={"Authorization": f"Bearer {alice}"})
|
|
512
|
+
assert listed.status_code == 200 and len(issuer.exchanges) == before
|
|
513
|
+
# A run that calls no exchange API exchanges nothing.
|
|
514
|
+
await _chat(concierge, alice, "Hello there")
|
|
515
|
+
assert len(issuer.exchanges) == before and RECEIVED == []
|
|
516
|
+
for token in (alice, alice, bob, alice):
|
|
517
|
+
events = await _chat(concierge, token, "Check my orders please")
|
|
518
|
+
assert any(name == "tool.result" for name, _ in events), events
|
|
519
|
+
assert len(RECEIVED) == 4
|
|
520
|
+
assert len(issuer.exchanges) - before == 2 # the distinct (user, API) pairs
|
|
521
|
+
first = RECEIVED[0]
|
|
522
|
+
assert first["sub"] == _claims(alice)["sub"]
|
|
523
|
+
assert (first["aud"], first["azp"], first["act"]) == (
|
|
524
|
+
"orders",
|
|
525
|
+
"concierge",
|
|
526
|
+
{"sub": "concierge"},
|
|
527
|
+
)
|
|
528
|
+
assert {r["sub"] for r in RECEIVED} == {_claims(alice)["sub"], _claims(bob)["sub"]}
|
|
529
|
+
|
|
530
|
+
|
|
531
|
+
async def test_the_concierge_refuses_an_exchanged_token_that_names_no_actor(
|
|
532
|
+
concierge: httpx.AsyncClient, caplog: pytest.LogCaptureFixture
|
|
533
|
+
) -> None:
|
|
534
|
+
"""Over /chat, with an issuer that puts no `act` in exchanged tokens: the call fails
|
|
535
|
+
closed, the orders agent receives nothing, the model reads why, and no token is logged."""
|
|
536
|
+
issuer = ISSUER[0]
|
|
537
|
+
issuer.act = False
|
|
538
|
+
caplog.set_level(logging.DEBUG)
|
|
539
|
+
concierge.use_tools(check_orders) # type: ignore[attr-defined]
|
|
540
|
+
alice = issuer.login(f"alice-{uuid.uuid4().hex[:6]}", audience="concierge")
|
|
541
|
+
before = len(issuer.exchanges)
|
|
542
|
+
for _ in range(2):
|
|
543
|
+
events = await _chat(concierge, alice, "Check my orders please")
|
|
544
|
+
[result] = [data for name, data in events if name == "tool.result"]
|
|
545
|
+
assert result["is_error"] is True and NO_ACTOR in result["result"], result
|
|
546
|
+
assert "exchange.allow_actorless" in result["result"]
|
|
547
|
+
assert RECEIVED == [] and TOKENS == []
|
|
548
|
+
assert len(issuer.exchanges) - before == 1 # the refusal is remembered
|
|
549
|
+
logged = caplog.text + "".join(str(record.__dict__) for record in caplog.records)
|
|
550
|
+
assert alice not in logged and SECRET not in logged
|
|
551
|
+
|
|
552
|
+
|
|
553
|
+
async def test_the_concierge_warns_once_when_an_api_allows_tokens_naming_no_actor(
|
|
554
|
+
concierge: httpx.AsyncClient, caplog: pytest.LogCaptureFixture
|
|
555
|
+
) -> None:
|
|
556
|
+
"""The same issuer, with `exchange.allow_actorless: true` for orders_agent: the calls go
|
|
557
|
+
through, the first logs one warning naming what the called agent needs, and no token is
|
|
558
|
+
logged."""
|
|
559
|
+
Path(os.environ["API_POLICY_PATH"]).write_text(
|
|
560
|
+
POLICY.replace(
|
|
561
|
+
"exchange: {audience: orders}", "exchange: {audience: orders, allow_actorless: true}"
|
|
562
|
+
),
|
|
563
|
+
encoding="utf-8",
|
|
564
|
+
)
|
|
565
|
+
reset_api_policy()
|
|
566
|
+
issuer = ISSUER[0]
|
|
567
|
+
issuer.act = False
|
|
568
|
+
caplog.set_level(logging.DEBUG)
|
|
569
|
+
concierge.use_tools(check_orders) # type: ignore[attr-defined]
|
|
570
|
+
alice = issuer.login(f"alice-{uuid.uuid4().hex[:6]}", audience="concierge")
|
|
571
|
+
bob = issuer.login(f"bob-{uuid.uuid4().hex[:6]}", audience="concierge")
|
|
572
|
+
for token in (alice, bob):
|
|
573
|
+
events = await _chat(concierge, token, "Check my orders please")
|
|
574
|
+
assert any(name == "tool.result" for name, _ in events), events
|
|
575
|
+
assert len(TOKENS) == 2 and all("act" not in _claims(t) for t in TOKENS)
|
|
576
|
+
warnings = [
|
|
577
|
+
r for r in caplog.records if r.levelno >= logging.WARNING and NO_ACTOR in r.getMessage()
|
|
578
|
+
]
|
|
579
|
+
assert len(warnings) == 1 and warnings[0].levelno == logging.WARNING
|
|
580
|
+
assert "AUTH_JWT_DIRECT_CLIENTS" in warnings[0].getMessage()
|
|
581
|
+
assert "exchange.allow_actorless lets it through" in warnings[0].getMessage()
|
|
582
|
+
logged = caplog.text + "".join(str(record.__dict__) for record in caplog.records)
|
|
583
|
+
assert [s for s in (alice, bob, *TOKENS, SECRET) if s in logged] == []
|
|
584
|
+
|
|
585
|
+
|
|
586
|
+
async def test_a_gated_call_exchanges_only_after_the_person_approves(
|
|
587
|
+
concierge: httpx.AsyncClient,
|
|
588
|
+
) -> None:
|
|
589
|
+
issuer = ISSUER[0]
|
|
590
|
+
concierge.use_tools(cancel_order) # type: ignore[attr-defined]
|
|
591
|
+
alice = issuer.login(f"alice-{uuid.uuid4().hex[:6]}", audience="concierge")
|
|
592
|
+
before = len(issuer.exchanges)
|
|
593
|
+
events = await _chat(concierge, alice, "Cancel the order for 7")
|
|
594
|
+
end = events[-1][1]
|
|
595
|
+
assert end["status"] == "awaiting_approval", events
|
|
596
|
+
assert len(issuer.exchanges) == before and RECEIVED == [] # paused: nothing exchanged
|
|
597
|
+
approval = end["approval"]
|
|
598
|
+
decided = await concierge.post(
|
|
599
|
+
f"/threads/{end['thread_id']}/approvals/{approval['approval_id']}",
|
|
600
|
+
json={"decision": "approve"},
|
|
601
|
+
headers={"Authorization": f"Bearer {alice}"},
|
|
602
|
+
)
|
|
603
|
+
assert decided.status_code == 200, decided.text
|
|
604
|
+
assert [(r["method"], r["path"]) for r in RECEIVED] == [("POST", "/orders/7/cancel")]
|
|
605
|
+
assert len(issuer.exchanges) - before == 1
|
|
606
|
+
|
|
607
|
+
|
|
608
|
+
async def test_no_token_at_rest(
|
|
609
|
+
concierge: httpx.AsyncClient, caplog: pytest.LogCaptureFixture
|
|
610
|
+
) -> None:
|
|
611
|
+
"""Neither the user's token nor the exchanged one is logged or stored (T5)."""
|
|
612
|
+
issuer = ISSUER[0]
|
|
613
|
+
caplog.set_level(logging.DEBUG)
|
|
614
|
+
concierge.use_tools(check_orders) # type: ignore[attr-defined]
|
|
615
|
+
alice = issuer.login(f"alice-{uuid.uuid4().hex[:6]}", audience="concierge")
|
|
616
|
+
await _chat(concierge, alice, "Check my orders please")
|
|
617
|
+
concierge.use_tools(cancel_order) # type: ignore[attr-defined]
|
|
618
|
+
end = (await _chat(concierge, alice, "Cancel the order for 7"))[-1][1]
|
|
619
|
+
await concierge.post(
|
|
620
|
+
f"/threads/{end['thread_id']}/approvals/{end['approval']['approval_id']}",
|
|
621
|
+
json={"decision": "approve"},
|
|
622
|
+
headers={"Authorization": f"Bearer {alice}"},
|
|
623
|
+
)
|
|
624
|
+
assert len(TOKENS) == 2
|
|
625
|
+
secrets = {alice, *TOKENS, SECRET}
|
|
626
|
+
logged = caplog.text + "".join(str(record.__dict__) for record in caplog.records)
|
|
627
|
+
assert [s for s in secrets if s in logged] == []
|
|
628
|
+
dsn = concierge.dsn # type: ignore[attr-defined]
|
|
629
|
+
if dsn is None:
|
|
630
|
+
return
|
|
631
|
+
import psycopg
|
|
632
|
+
|
|
633
|
+
found: list[str] = []
|
|
634
|
+
async with await psycopg.AsyncConnection.connect(dsn) as conn:
|
|
635
|
+
tables = await (
|
|
636
|
+
await conn.execute(
|
|
637
|
+
"SELECT table_name FROM information_schema.tables WHERE table_schema = 'public'"
|
|
638
|
+
)
|
|
639
|
+
).fetchall()
|
|
640
|
+
assert tables, "the app created no tables"
|
|
641
|
+
for (table,) in tables:
|
|
642
|
+
for row in await (await conn.execute(f'SELECT * FROM "{table}"')).fetchall():
|
|
643
|
+
for value in row:
|
|
644
|
+
data = (
|
|
645
|
+
bytes(value)
|
|
646
|
+
if isinstance(value, bytes | memoryview)
|
|
647
|
+
else json.dumps(value, default=str).encode()
|
|
648
|
+
)
|
|
649
|
+
found.extend(table for s in secrets if s.encode() in data)
|
|
650
|
+
assert found == []
|
|
File without changes
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# Load testing
|
|
2
|
+
|
|
3
|
+
[Locust](http://locust.io) drives `POST /chat`, the SSE endpoint this project serves.
|
|
4
|
+
|
|
5
|
+
Local, against `graph-agents-cli playground` or `uv run uvicorn {{cookiecutter.agent_directory}}.fast_api_app:app --port 8000`:
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
export GRAPH_AGENTS_CLI_API_KEY=<the API_KEY from .env> # jwt: a token, e.g. from graph-agents-cli auth dev-token
|
|
9
|
+
export LOAD_TEST_PROMPT="<a request that exercises your tools>" # optional
|
|
10
|
+
uv run --with locust locust -f tests/load_test/load_test.py -H http://127.0.0.1:8000 \
|
|
11
|
+
-u 10 -r 2 -t 30s --headless
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
Against a deployed environment, point at its URL and send that environment's key:
|
|
15
|
+
|
|
16
|
+
```bash
|
|
17
|
+
export GRAPH_AGENTS_CLI_API_KEY=<staging API_KEY>
|
|
18
|
+
uv run --with locust locust -f tests/load_test/load_test.py -H https://agent.staging.example.com \
|
|
19
|
+
-u 10 -r 2 -t 60s --headless --csv=tests/load_test/.results/report
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
`--csv` writes the latency and failure tables Locust prints at the end.
|