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,702 @@
|
|
|
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
|
+
"""The LangGraph Server authorization rules (`auth.build_sdk_auth`), without a server.
|
|
16
|
+
|
|
17
|
+
Handlers are resolved with the server's documented precedence (resource and
|
|
18
|
+
action, then resource, then action, then the global handler).
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import asyncio
|
|
24
|
+
from collections.abc import Iterator
|
|
25
|
+
from types import SimpleNamespace
|
|
26
|
+
from typing import Any
|
|
27
|
+
|
|
28
|
+
import httpx
|
|
29
|
+
import pytest
|
|
30
|
+
from langgraph_sdk import Auth
|
|
31
|
+
from starlette.requests import Request
|
|
32
|
+
|
|
33
|
+
from {{cookiecutter.agent_directory}}.app_utils.auth import (
|
|
34
|
+
Principal,
|
|
35
|
+
build_sdk_auth,
|
|
36
|
+
reset_policy_cache,
|
|
37
|
+
)
|
|
38
|
+
from {{cookiecutter.agent_directory}}.app_utils.middleware import AuthErrorMiddleware
|
|
39
|
+
|
|
40
|
+
RESOURCE_ACTIONS = {
|
|
41
|
+
"threads": ("create", "read", "update", "delete", "search", "create_run"),
|
|
42
|
+
"assistants": ("create", "read", "update", "delete", "search"),
|
|
43
|
+
"crons": ("create", "read", "update", "delete", "search"),
|
|
44
|
+
"store": ("put", "get", "search", "list_namespaces", "delete"),
|
|
45
|
+
}
|
|
46
|
+
READS = {
|
|
47
|
+
("assistants", "read"),
|
|
48
|
+
("assistants", "search"),
|
|
49
|
+
("crons", "read"),
|
|
50
|
+
("crons", "search"),
|
|
51
|
+
("store", "get"),
|
|
52
|
+
("store", "search"),
|
|
53
|
+
("store", "list_namespaces"),
|
|
54
|
+
}
|
|
55
|
+
WRITES = {
|
|
56
|
+
(resource, action)
|
|
57
|
+
for resource in ("assistants", "crons", "store")
|
|
58
|
+
for action in RESOURCE_ACTIONS[resource]
|
|
59
|
+
if (resource, action) not in READS
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
SOURCE = "11111111-1111-1111-1111-111111111111"
|
|
64
|
+
COPY = "22222222-2222-2222-2222-222222222222"
|
|
65
|
+
INPUT = {"messages": [{"role": "user", "content": "hello"}]}
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _no_approvals(monkeypatch: pytest.MonkeyPatch, refusal: str | None = None) -> list[str]:
|
|
69
|
+
"""The chat runtime as the hook sees it: no pending approval; `refusal` for a replay
|
|
70
|
+
or a copy. Returns the threads whose replay or copy was checked.
|
|
71
|
+
"""
|
|
72
|
+
from {{cookiecutter.agent_directory}}.app_utils.chat import RUNTIME
|
|
73
|
+
|
|
74
|
+
checked: list[str] = []
|
|
75
|
+
|
|
76
|
+
async def pending(thread_id: str) -> list[Any]:
|
|
77
|
+
return []
|
|
78
|
+
|
|
79
|
+
async def replay_refusal(thread_id: str) -> str | None:
|
|
80
|
+
checked.append(thread_id)
|
|
81
|
+
return refusal
|
|
82
|
+
|
|
83
|
+
monkeypatch.setattr(RUNTIME, "pending_approvals", pending)
|
|
84
|
+
monkeypatch.setattr(RUNTIME, "replay_refusal", replay_refusal)
|
|
85
|
+
monkeypatch.setattr(RUNTIME, "copy_refusal", replay_refusal)
|
|
86
|
+
return checked
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
@pytest.fixture
|
|
90
|
+
def auth(monkeypatch: pytest.MonkeyPatch) -> Iterator[Any]:
|
|
91
|
+
monkeypatch.setenv("AUTH_POLICY", "shared-bearer")
|
|
92
|
+
monkeypatch.setenv("API_KEY", "k")
|
|
93
|
+
monkeypatch.delenv("PRINCIPAL_HASH_SALT", raising=False)
|
|
94
|
+
monkeypatch.setenv("AUTH_ADMIN_ROLES", "admin, platform")
|
|
95
|
+
monkeypatch.setenv("AUTH_READ_ACROSS_ROLES", "support")
|
|
96
|
+
monkeypatch.setenv("APP_ENV", "prod")
|
|
97
|
+
reset_policy_cache()
|
|
98
|
+
yield build_sdk_auth()
|
|
99
|
+
reset_policy_cache()
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _user(identity: str, *roles: str) -> Any:
|
|
103
|
+
return SimpleNamespace(
|
|
104
|
+
identity=identity,
|
|
105
|
+
display_name=identity,
|
|
106
|
+
is_authenticated=True,
|
|
107
|
+
permissions=["chat.send", *(f"role:{r}" for r in roles)],
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _http(method: str, path: str, authorization: str | None = "Bearer k") -> Request:
|
|
112
|
+
headers = [(b"authorization", authorization.encode())] if authorization else []
|
|
113
|
+
return Request(
|
|
114
|
+
{"type": "http", "method": method, "path": path, "headers": headers, "query_string": b""}
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
async def _authenticate(auth: Any, method: str, path: str) -> None:
|
|
119
|
+
"""What the server does first for every native API request (per-request state included)."""
|
|
120
|
+
await auth._authenticate_handler(request=_http(method, path))
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _handler(auth: Any, resource: str, action: str) -> Any:
|
|
124
|
+
for key in ((resource, action), (resource, "*"), ("*", action), ("*", "*")):
|
|
125
|
+
if key in auth._handlers:
|
|
126
|
+
return auth._handlers[key][-1]
|
|
127
|
+
return auth._global_handlers[-1] if auth._global_handlers else None
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
async def _dispatch(auth: Any, user: Any, resource: str, action: str, value: Any = None) -> Any:
|
|
131
|
+
ctx = Auth.types.AuthContext(
|
|
132
|
+
user=user, permissions=list(user.permissions), resource=resource, action=action
|
|
133
|
+
)
|
|
134
|
+
handler = _handler(auth, resource, action)
|
|
135
|
+
assert handler is not None, f"{resource}.{action} would be allowed without a rule"
|
|
136
|
+
return await handler(ctx=ctx, value={} if value is None else value)
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def _sdk_resource_actions(auth: Any) -> set[tuple[str, str]]:
|
|
140
|
+
"""Every (resource, action) the installed SDK can register a handler for.
|
|
141
|
+
|
|
142
|
+
The same walk LangGraph Server does at startup to warn about paths that
|
|
143
|
+
would be allowed without a rule, so a newer SDK action shows up here.
|
|
144
|
+
"""
|
|
145
|
+
pairs: set[tuple[str, str]] = set()
|
|
146
|
+
for slot in getattr(type(auth.on), "__slots__", ()) or ():
|
|
147
|
+
namespace = getattr(auth.on, slot, None)
|
|
148
|
+
if slot.startswith("_") or namespace is None:
|
|
149
|
+
continue
|
|
150
|
+
for name in dir(namespace):
|
|
151
|
+
action = getattr(getattr(namespace, name, None), "action", None)
|
|
152
|
+
if not name.startswith("_") and isinstance(action, str):
|
|
153
|
+
pairs.add((slot, action))
|
|
154
|
+
return pairs
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def test_every_dispatch_path_has_a_rule_and_the_rest_is_denied(auth: Any) -> None:
|
|
158
|
+
assert auth._global_handlers, "no default-deny handler"
|
|
159
|
+
known = {(r, a) for r, actions in RESOURCE_ACTIONS.items() for a in actions}
|
|
160
|
+
assert known <= _sdk_resource_actions(auth)
|
|
161
|
+
for resource, action in known:
|
|
162
|
+
assert (resource, action) in auth._handlers, f"{resource}.{action}"
|
|
163
|
+
# A pair a newer SDK adds falls to the default-deny handler (checked below).
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
@pytest.mark.parametrize(("resource", "action"), sorted(READS))
|
|
167
|
+
async def test_reads_are_open_to_every_authenticated_principal(
|
|
168
|
+
auth: Any, resource: str, action: str
|
|
169
|
+
) -> None:
|
|
170
|
+
assert await _dispatch(auth, _user("bob"), resource, action) is True
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
@pytest.mark.parametrize(("resource", "action"), sorted(WRITES))
|
|
174
|
+
async def test_writes_need_an_admin_role(auth: Any, resource: str, action: str) -> None:
|
|
175
|
+
with pytest.raises(Auth.exceptions.HTTPException) as exc:
|
|
176
|
+
await _dispatch(auth, _user("bob", "support"), resource, action)
|
|
177
|
+
assert exc.value.status_code == 403 and "AUTH_ADMIN_ROLES" in str(exc.value.detail)
|
|
178
|
+
assert await _dispatch(auth, _user("carol", "admin"), resource, action) is True
|
|
179
|
+
assert await _dispatch(auth, _user("dan", "viewer", "platform"), resource, action) is True
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
async def test_no_admin_roles_means_nobody_may_change_shared_resources(
|
|
183
|
+
auth: Any, monkeypatch: pytest.MonkeyPatch
|
|
184
|
+
) -> None:
|
|
185
|
+
monkeypatch.setenv("AUTH_ADMIN_ROLES", "")
|
|
186
|
+
with pytest.raises(Auth.exceptions.HTTPException):
|
|
187
|
+
await _dispatch(auth, _user("carol", "admin"), "assistants", "update")
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
async def test_anything_without_a_rule_is_denied_even_for_admins(auth: Any) -> None:
|
|
191
|
+
for resource, action in (("runs", "read"), ("threads", "prune"), ("profiles", "create")):
|
|
192
|
+
with pytest.raises(Auth.exceptions.HTTPException) as exc:
|
|
193
|
+
await _dispatch(auth, _user("carol", "admin"), resource, action)
|
|
194
|
+
assert exc.value.status_code == 403
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
async def test_studio_user_is_trusted_only_under_dev(
|
|
198
|
+
auth: Any, monkeypatch: pytest.MonkeyPatch
|
|
199
|
+
) -> None:
|
|
200
|
+
studio = Auth.types.StudioUser("langgraph-studio-user")
|
|
201
|
+
with pytest.raises(Auth.exceptions.HTTPException):
|
|
202
|
+
await _dispatch(auth, studio, "assistants", "update")
|
|
203
|
+
monkeypatch.setenv("APP_ENV", "dev")
|
|
204
|
+
assert await _dispatch(auth, studio, "assistants", "update") is True
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
async def test_thread_rules_are_owner_scoped(auth: Any) -> None:
|
|
208
|
+
alice, support = _user("alice"), _user("sam", "support")
|
|
209
|
+
value: dict[str, Any] = {"metadata": {"principal_id": "mallory", "topic": "x"}}
|
|
210
|
+
assert await _dispatch(auth, alice, "threads", "create", value) == {"principal_id": "alice"}
|
|
211
|
+
assert value["metadata"] == {"principal_id": "alice", "topic": "x", "tenant": None}
|
|
212
|
+
for action in ("read", "search"):
|
|
213
|
+
assert await _dispatch(auth, alice, "threads", action) == {"principal_id": "alice"}
|
|
214
|
+
assert await _dispatch(auth, support, "threads", action) is None # read-across
|
|
215
|
+
for action in ("update", "delete", "create_run"):
|
|
216
|
+
# Read-across never extends to changes.
|
|
217
|
+
assert await _dispatch(auth, support, "threads", action) == {"principal_id": "sam"}
|
|
218
|
+
run: dict[str, Any] = {"metadata": {"principal_id": "mallory", "tenant": "acme"}}
|
|
219
|
+
await _dispatch(auth, alice, "threads", "create_run", run)
|
|
220
|
+
assert run["metadata"] == {"principal_id": "alice", "tenant": None}
|
|
221
|
+
# A native caller cannot vouch for a tenant.
|
|
222
|
+
claimed: dict[str, Any] = {"metadata": {"tenant": "acme"}}
|
|
223
|
+
await _dispatch(auth, alice, "threads", "create", claimed)
|
|
224
|
+
assert claimed["metadata"] == {"principal_id": "alice", "tenant": None}
|
|
225
|
+
# Ownership that cannot be stamped is refused, not skipped.
|
|
226
|
+
for action in ("create", "create_run"):
|
|
227
|
+
with pytest.raises(Auth.exceptions.HTTPException) as exc:
|
|
228
|
+
await _dispatch(auth, alice, "threads", action, {"metadata": None})
|
|
229
|
+
assert exc.value.status_code == 403
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
async def test_an_owner_cannot_hand_a_thread_to_another_principal(auth: Any) -> None:
|
|
233
|
+
value: dict[str, Any] = {
|
|
234
|
+
"thread_id": "t1",
|
|
235
|
+
"metadata": {"principal_id": "bob", "tenant": "t-bob", "topic": "kept"},
|
|
236
|
+
}
|
|
237
|
+
result = await _dispatch(auth, _user("alice"), "threads", "update", value)
|
|
238
|
+
assert result == {"principal_id": "alice"}
|
|
239
|
+
assert value["metadata"] == {"principal_id": "alice", "topic": "kept"}
|
|
240
|
+
# An update without metadata (for example a run cancellation) passes through.
|
|
241
|
+
assert await _dispatch(auth, _user("alice"), "threads", "update", {"thread_id": "t1"}) == {
|
|
242
|
+
"principal_id": "alice"
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
async def test_a_copy_is_a_write_even_for_read_across_roles(
|
|
247
|
+
auth: Any, monkeypatch: pytest.MonkeyPatch
|
|
248
|
+
) -> None:
|
|
249
|
+
"""The server's copy reads its source with the read filter, then authorizes a create
|
|
250
|
+
without metadata (the copy keeps the source's metadata, owner included)."""
|
|
251
|
+
_no_approvals(monkeypatch)
|
|
252
|
+
support = _user("sam", "support")
|
|
253
|
+
|
|
254
|
+
async def copy_as(user: Any) -> Any:
|
|
255
|
+
await _authenticate(auth, "POST", f"/threads/{SOURCE}/copy")
|
|
256
|
+
source_filter = await _dispatch(auth, user, "threads", "read", {"thread_id": SOURCE})
|
|
257
|
+
await _dispatch(auth, user, "threads", "create", {"thread_id": COPY})
|
|
258
|
+
return source_filter
|
|
259
|
+
|
|
260
|
+
# The source is limited to the caller's own threads: alice's is "not found" to sam.
|
|
261
|
+
assert await copy_as(support) == {"principal_id": "sam"}
|
|
262
|
+
assert await copy_as(_user("alice")) == {"principal_id": "alice"}
|
|
263
|
+
# Everywhere else sam still reads across.
|
|
264
|
+
await _authenticate(auth, "GET", f"/threads/{SOURCE}")
|
|
265
|
+
assert await _dispatch(auth, support, "threads", "read", {"thread_id": SOURCE}) is None
|
|
266
|
+
await _authenticate(auth, "POST", "/threads/search")
|
|
267
|
+
assert await _dispatch(auth, support, "threads", "search", {}) is None
|
|
268
|
+
# A metadata-less create the handler cannot tell apart from a copy fails
|
|
269
|
+
# closed for read-across roles (the source filter may have been lifted).
|
|
270
|
+
await _authenticate(auth, "POST", f"/threads/{SOURCE}/fork")
|
|
271
|
+
with pytest.raises(Auth.exceptions.HTTPException) as exc:
|
|
272
|
+
await _dispatch(auth, support, "threads", "create", {"thread_id": COPY})
|
|
273
|
+
assert exc.value.status_code == 403
|
|
274
|
+
assert await _dispatch(auth, _user("bob"), "threads", "create", {"thread_id": COPY}) == {
|
|
275
|
+
"principal_id": "bob"
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
async def test_the_copy_flag_is_per_request(auth: Any, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
280
|
+
"""Concurrent requests (separate tasks, like the server's) never see each other's flag."""
|
|
281
|
+
_no_approvals(monkeypatch)
|
|
282
|
+
support = _user("sam", "support")
|
|
283
|
+
gate = asyncio.Event()
|
|
284
|
+
|
|
285
|
+
async def copying() -> Any:
|
|
286
|
+
await _authenticate(auth, "POST", f"/threads/{SOURCE}/copy")
|
|
287
|
+
await gate.wait()
|
|
288
|
+
return await _dispatch(auth, support, "threads", "read", {"thread_id": SOURCE})
|
|
289
|
+
|
|
290
|
+
async def reading() -> Any:
|
|
291
|
+
await _authenticate(auth, "GET", f"/threads/{SOURCE}")
|
|
292
|
+
gate.set()
|
|
293
|
+
return await _dispatch(auth, support, "threads", "read", {"thread_id": SOURCE})
|
|
294
|
+
|
|
295
|
+
assert await asyncio.gather(copying(), reading()) == [{"principal_id": "sam"}, None]
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
async def test_a_thread_with_gated_calls_is_not_copied(
|
|
299
|
+
auth: Any, monkeypatch: pytest.MonkeyPatch
|
|
300
|
+
) -> None:
|
|
301
|
+
"""A copy would carry the thread's tool calls without their approvals."""
|
|
302
|
+
checked = _no_approvals(monkeypatch, "the thread has approvals of gated API calls")
|
|
303
|
+
await _authenticate(auth, "POST", f"/threads/{SOURCE}/copy")
|
|
304
|
+
with pytest.raises(Auth.exceptions.HTTPException) as exc:
|
|
305
|
+
await _dispatch(auth, _user("alice"), "threads", "read", {"thread_id": SOURCE})
|
|
306
|
+
assert exc.value.status_code == 403
|
|
307
|
+
assert "cannot be copied: the thread has approvals of gated API calls" in str(exc.value.detail)
|
|
308
|
+
# Reading it is not copying it.
|
|
309
|
+
await _authenticate(auth, "GET", f"/threads/{SOURCE}")
|
|
310
|
+
assert await _dispatch(auth, _user("alice"), "threads", "read", {"thread_id": SOURCE}) == {
|
|
311
|
+
"principal_id": "alice"
|
|
312
|
+
}
|
|
313
|
+
assert checked == [SOURCE]
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
async def test_native_runs_on_existing_threads_carry_the_hashed_owner_id(
|
|
317
|
+
auth: Any, monkeypatch: pytest.MonkeyPatch
|
|
318
|
+
) -> None:
|
|
319
|
+
monkeypatch.setenv("PRINCIPAL_HASH_SALT", "pepper")
|
|
320
|
+
_no_approvals(monkeypatch)
|
|
321
|
+
alice = _user("alice@example.com")
|
|
322
|
+
hashed = Principal(id="alice@example.com").hashed_id()
|
|
323
|
+
run: dict[str, Any] = {
|
|
324
|
+
"thread_id": SOURCE,
|
|
325
|
+
"if_not_exists": "reject",
|
|
326
|
+
"metadata": {},
|
|
327
|
+
"kwargs": {"input": INPUT},
|
|
328
|
+
}
|
|
329
|
+
assert await _dispatch(auth, alice, "threads", "create_run", run) == {
|
|
330
|
+
"principal_id": "alice@example.com" # the filter still checks the thread's raw stamp
|
|
331
|
+
}
|
|
332
|
+
assert run["metadata"] == {"principal_id": hashed, "tenant": None}
|
|
333
|
+
# A run that may create its thread stamps the raw id: it becomes the thread's owner.
|
|
334
|
+
for creating in (
|
|
335
|
+
{
|
|
336
|
+
"thread_id": SOURCE,
|
|
337
|
+
"if_not_exists": "create",
|
|
338
|
+
"metadata": {},
|
|
339
|
+
"kwargs": {"input": INPUT},
|
|
340
|
+
},
|
|
341
|
+
{"thread_id": None, "metadata": {"principal_id": "mallory"}},
|
|
342
|
+
):
|
|
343
|
+
await _dispatch(auth, alice, "threads", "create_run", creating)
|
|
344
|
+
assert creating["metadata"]["principal_id"] == "alice@example.com"
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
@pytest.mark.parametrize(
|
|
348
|
+
"kwargs",
|
|
349
|
+
[
|
|
350
|
+
{"input": None},
|
|
351
|
+
{},
|
|
352
|
+
{"input": INPUT, "config": {"configurable": {"checkpoint_id": "cp-1"}}},
|
|
353
|
+
{"input": INPUT, "config": {"configurable": {"checkpoint": {"checkpoint_id": "cp-1"}}}},
|
|
354
|
+
{"input": None, "context": {"checkpoint_id": "cp-1"}},
|
|
355
|
+
{"input": INPUT, "config": {"configurable": {"checkpoint_map": {"": "cp-1"}}}},
|
|
356
|
+
],
|
|
357
|
+
ids=["input null", "no input", "checkpoint_id", "checkpoint", "context", "checkpoint_map"],
|
|
358
|
+
)
|
|
359
|
+
async def test_a_native_run_that_reruns_a_paused_step_is_refused_where_calls_were_gated(
|
|
360
|
+
auth: Any, monkeypatch: pytest.MonkeyPatch, kwargs: dict[str, Any]
|
|
361
|
+
) -> None:
|
|
362
|
+
"""Continued without input, or replayed from a checkpoint, a step runs its tool calls again."""
|
|
363
|
+
checked = _no_approvals(monkeypatch, "the thread has approvals of gated API calls")
|
|
364
|
+
run: dict[str, Any] = {"thread_id": SOURCE, "metadata": {}, "kwargs": kwargs}
|
|
365
|
+
with pytest.raises(Auth.exceptions.HTTPException) as exc:
|
|
366
|
+
await _dispatch(auth, _user("alice"), "threads", "create_run", run)
|
|
367
|
+
assert exc.value.status_code == 403
|
|
368
|
+
assert "the thread has approvals of gated API calls" in str(exc.value.detail)
|
|
369
|
+
assert checked == [SOURCE]
|
|
370
|
+
|
|
371
|
+
|
|
372
|
+
async def test_a_native_run_with_input_or_on_a_thread_without_approvals_goes_ahead(
|
|
373
|
+
auth: Any, monkeypatch: pytest.MonkeyPatch
|
|
374
|
+
) -> None:
|
|
375
|
+
checked = _no_approvals(monkeypatch, "the thread has approvals of gated API calls")
|
|
376
|
+
run: dict[str, Any] = {
|
|
377
|
+
"thread_id": SOURCE,
|
|
378
|
+
"metadata": {},
|
|
379
|
+
"kwargs": {"input": INPUT, "config": {"configurable": {"checkpoint_ns": ""}}},
|
|
380
|
+
}
|
|
381
|
+
assert await _dispatch(auth, _user("alice"), "threads", "create_run", run) == {
|
|
382
|
+
"principal_id": "alice"
|
|
383
|
+
}
|
|
384
|
+
assert checked == [] # a new message is not a replay: not even checked
|
|
385
|
+
checked = _no_approvals(monkeypatch, None)
|
|
386
|
+
replay: dict[str, Any] = {"thread_id": SOURCE, "metadata": {}, "kwargs": {"input": None}}
|
|
387
|
+
assert await _dispatch(auth, _user("alice"), "threads", "create_run", replay) == {
|
|
388
|
+
"principal_id": "alice"
|
|
389
|
+
}
|
|
390
|
+
assert checked == [SOURCE]
|
|
391
|
+
|
|
392
|
+
|
|
393
|
+
async def test_the_native_api_gets_the_policys_challenge_and_status(
|
|
394
|
+
auth: Any, monkeypatch: pytest.MonkeyPatch
|
|
395
|
+
) -> None:
|
|
396
|
+
"""LangGraph Server keeps only the detail of a 401 and turns a 503 into a 500.
|
|
397
|
+
|
|
398
|
+
The fake below behaves like its auth middleware: it calls the authenticate
|
|
399
|
+
handler, answers 401/403 bare and lets any other status escape.
|
|
400
|
+
"""
|
|
401
|
+
|
|
402
|
+
async def server(scope: Any, receive: Any, send: Any) -> None:
|
|
403
|
+
try:
|
|
404
|
+
await auth._authenticate_handler(request=Request(scope, receive))
|
|
405
|
+
except Auth.exceptions.HTTPException as exc:
|
|
406
|
+
if exc.status_code not in (401, 403):
|
|
407
|
+
raise
|
|
408
|
+
await send({"type": "http.response.start", "status": exc.status_code, "headers": []})
|
|
409
|
+
await send({"type": "http.response.body", "body": b'{"detail": "bare"}'})
|
|
410
|
+
return
|
|
411
|
+
await send({"type": "http.response.start", "status": 200, "headers": []})
|
|
412
|
+
await send({"type": "http.response.body", "body": b"{}"})
|
|
413
|
+
|
|
414
|
+
client = httpx.AsyncClient(
|
|
415
|
+
transport=httpx.ASGITransport(app=AuthErrorMiddleware(server)), base_url="http://server"
|
|
416
|
+
)
|
|
417
|
+
async with client:
|
|
418
|
+
r = await client.get("/assistants/search", headers={"Authorization": "Bearer wrong"})
|
|
419
|
+
assert r.status_code == 401 and r.headers["www-authenticate"] == "Bearer"
|
|
420
|
+
r = await client.get("/assistants/search", headers={"Authorization": "Bearer k"})
|
|
421
|
+
assert r.status_code == 200 and "www-authenticate" not in r.headers
|
|
422
|
+
monkeypatch.delenv("API_KEY") # a misconfigured policy answers 503, not 500
|
|
423
|
+
r = await client.get("/assistants/search", headers={"Authorization": "Bearer k"})
|
|
424
|
+
assert r.status_code == 503 and "API_KEY is not configured" in r.json()["detail"]
|
|
425
|
+
|
|
426
|
+
|
|
427
|
+
async def test_the_auth_error_middleware_leaves_other_errors_alone() -> None:
|
|
428
|
+
async def failing(scope: Any, receive: Any, send: Any) -> None:
|
|
429
|
+
raise RuntimeError("not an auth error")
|
|
430
|
+
|
|
431
|
+
async def late(scope: Any, receive: Any, send: Any) -> None:
|
|
432
|
+
await send({"type": "http.response.start", "status": 200, "headers": []})
|
|
433
|
+
raise Auth.exceptions.HTTPException(status_code=503)
|
|
434
|
+
|
|
435
|
+
for app in (failing, late):
|
|
436
|
+
transport = httpx.ASGITransport(app=AuthErrorMiddleware(app))
|
|
437
|
+
async with httpx.AsyncClient(transport=transport, base_url="http://server") as client:
|
|
438
|
+
with pytest.raises((RuntimeError, Auth.exceptions.HTTPException)):
|
|
439
|
+
await client.get("/threads")
|
|
440
|
+
|
|
441
|
+
|
|
442
|
+
# --- agents calling for a user (0.3): the `actor:` permission -------------------------------
|
|
443
|
+
|
|
444
|
+
|
|
445
|
+
def _agent(identity: str, actor: str, *roles: str) -> Any:
|
|
446
|
+
user = _user(identity, *roles)
|
|
447
|
+
user.permissions.append(f"actor:{actor}")
|
|
448
|
+
return user
|
|
449
|
+
|
|
450
|
+
|
|
451
|
+
async def test_a_delegated_callers_filters_name_its_actor(auth: Any) -> None:
|
|
452
|
+
concierge = _agent("alice", "concierge")
|
|
453
|
+
both = {"principal_id": "alice", "actor": "concierge"}
|
|
454
|
+
for action in ("read", "search", "update", "delete", "create_run"):
|
|
455
|
+
assert await _dispatch(auth, concierge, "threads", action) == both
|
|
456
|
+
# The person's own (direct) filter is a containment filter: it matches the threads its
|
|
457
|
+
# agents started for it too, while an agent's never matches a thread without `actor`
|
|
458
|
+
# (a direct one, or one created before 0.3).
|
|
459
|
+
for action in ("read", "search", "update", "delete", "create_run"):
|
|
460
|
+
assert await _dispatch(auth, _user("alice"), "threads", action) == {"principal_id": "alice"}
|
|
461
|
+
|
|
462
|
+
|
|
463
|
+
async def test_threads_are_stamped_with_the_actor_and_nobody_chooses_it(auth: Any) -> None:
|
|
464
|
+
created: dict[str, Any] = {"metadata": {"actor": "billing", "topic": "x"}}
|
|
465
|
+
await _dispatch(auth, _agent("alice", "concierge"), "threads", "create", created)
|
|
466
|
+
assert created["metadata"] == {
|
|
467
|
+
"actor": "concierge",
|
|
468
|
+
"topic": "x",
|
|
469
|
+
"principal_id": "alice",
|
|
470
|
+
"tenant": None,
|
|
471
|
+
}
|
|
472
|
+
# A direct caller cannot create a thread that looks like an agent's.
|
|
473
|
+
forged: dict[str, Any] = {"metadata": {"actor": "concierge"}}
|
|
474
|
+
await _dispatch(auth, _user("alice"), "threads", "create", forged)
|
|
475
|
+
assert forged["metadata"] == {"principal_id": "alice", "tenant": None}
|
|
476
|
+
run: dict[str, Any] = {"metadata": {"actor": "billing"}}
|
|
477
|
+
await _dispatch(auth, _agent("alice", "concierge"), "threads", "create_run", run)
|
|
478
|
+
assert run["metadata"]["actor"] == "concierge"
|
|
479
|
+
direct_run: dict[str, Any] = {"metadata": {"actor": "concierge"}}
|
|
480
|
+
await _dispatch(auth, _user("alice"), "threads", "create_run", direct_run)
|
|
481
|
+
assert "actor" not in direct_run["metadata"]
|
|
482
|
+
|
|
483
|
+
|
|
484
|
+
async def test_an_update_cannot_adopt_or_strip_an_actor(auth: Any) -> None:
|
|
485
|
+
# The person (direct) updating its agent's thread: the thread keeps its actor (the
|
|
486
|
+
# server merges the update's metadata, and the update carries none).
|
|
487
|
+
by_person: dict[str, Any] = {"thread_id": "t1", "metadata": {"actor": "", "topic": "y"}}
|
|
488
|
+
await _dispatch(auth, _user("alice"), "threads", "update", by_person)
|
|
489
|
+
assert by_person["metadata"] == {"principal_id": "alice", "topic": "y"}
|
|
490
|
+
# An agent cannot move a thread to another agent.
|
|
491
|
+
by_agent: dict[str, Any] = {"thread_id": "t1", "metadata": {"actor": "billing"}}
|
|
492
|
+
result = await _dispatch(auth, _agent("alice", "concierge"), "threads", "update", by_agent)
|
|
493
|
+
assert result == {"principal_id": "alice", "actor": "concierge"}
|
|
494
|
+
assert by_agent["metadata"] == {"principal_id": "alice", "actor": "concierge"}
|
|
495
|
+
|
|
496
|
+
|
|
497
|
+
async def test_a_delegated_callers_roles_never_read_across_or_administer(auth: Any) -> None:
|
|
498
|
+
agent = _agent("alice", "concierge", "support", "admin")
|
|
499
|
+
for action in ("read", "search"):
|
|
500
|
+
assert await _dispatch(auth, agent, "threads", action) == {
|
|
501
|
+
"principal_id": "alice",
|
|
502
|
+
"actor": "concierge",
|
|
503
|
+
}
|
|
504
|
+
with pytest.raises(Auth.exceptions.HTTPException) as exc:
|
|
505
|
+
await _dispatch(auth, agent, "assistants", "update")
|
|
506
|
+
assert exc.value.status_code == 403
|
|
507
|
+
|
|
508
|
+
|
|
509
|
+
async def test_the_authenticate_handler_publishes_only_the_policys_actor(
|
|
510
|
+
auth: Any, monkeypatch: pytest.MonkeyPatch
|
|
511
|
+
) -> None:
|
|
512
|
+
from {{cookiecutter.agent_directory}}.app_utils import auth as auth_module
|
|
513
|
+
from {{cookiecutter.agent_directory}}.app_utils.auth import ACTIONS, Actor
|
|
514
|
+
|
|
515
|
+
monkeypatch.setenv("AUTH_ALLOWED_ACTORS", "concierge")
|
|
516
|
+
|
|
517
|
+
class _Delegating:
|
|
518
|
+
principal = Principal(
|
|
519
|
+
id="alice",
|
|
520
|
+
permissions={*ACTIONS, "actor:billing"}, # a policy's own actor: permission
|
|
521
|
+
actor=Actor(id="concierge"),
|
|
522
|
+
)
|
|
523
|
+
|
|
524
|
+
async def authenticate(self, request: Any) -> Principal:
|
|
525
|
+
return self.principal
|
|
526
|
+
|
|
527
|
+
async def authorize(self, *args: Any) -> None:
|
|
528
|
+
return None
|
|
529
|
+
|
|
530
|
+
monkeypatch.setattr(auth_module, "get_policy", lambda: _Delegating())
|
|
531
|
+
user = await auth._authenticate_handler(request=_http("GET", "/threads"))
|
|
532
|
+
assert user["identity"] == "alice"
|
|
533
|
+
assert [p for p in user["permissions"] if p.startswith("actor:")] == ["actor:concierge"]
|
|
534
|
+
monkeypatch.setenv("AUTH_ALLOWED_ACTORS", "")
|
|
535
|
+
with pytest.raises(Auth.exceptions.HTTPException) as exc:
|
|
536
|
+
await auth._authenticate_handler(request=_http("GET", "/threads"))
|
|
537
|
+
assert exc.value.status_code == 403
|
|
538
|
+
|
|
539
|
+
|
|
540
|
+
# --- a native run's context: the caller's own, whatever the request sends ----------------
|
|
541
|
+
|
|
542
|
+
|
|
543
|
+
class _ServerUser(dict): # type: ignore[type-arg]
|
|
544
|
+
"""The server's user as its handlers get it: the authenticate handler's dict, whose
|
|
545
|
+
fields read as keys (`get`) and as attributes."""
|
|
546
|
+
|
|
547
|
+
def __getattr__(self, name: str) -> Any:
|
|
548
|
+
try:
|
|
549
|
+
return self[name]
|
|
550
|
+
except KeyError as exc:
|
|
551
|
+
raise AttributeError(name) from exc
|
|
552
|
+
|
|
553
|
+
|
|
554
|
+
async def _server_user(auth: Any, monkeypatch: pytest.MonkeyPatch, principal: Principal) -> Any:
|
|
555
|
+
"""The user the server builds from the authenticate handler for a policy's `principal`."""
|
|
556
|
+
from {{cookiecutter.agent_directory}}.app_utils import auth as auth_module
|
|
557
|
+
|
|
558
|
+
class _Policy:
|
|
559
|
+
async def authenticate(self, request: Any) -> Principal:
|
|
560
|
+
return principal
|
|
561
|
+
|
|
562
|
+
async def authorize(self, *args: Any) -> None:
|
|
563
|
+
return None
|
|
564
|
+
|
|
565
|
+
monkeypatch.setattr(auth_module, "get_policy", lambda: _Policy())
|
|
566
|
+
return _ServerUser(await auth._authenticate_handler(request=_http("POST", "/threads")))
|
|
567
|
+
|
|
568
|
+
|
|
569
|
+
def _principal(*roles: str, actor: Any = None) -> Principal:
|
|
570
|
+
from {{cookiecutter.agent_directory}}.app_utils.auth import ACTIONS
|
|
571
|
+
|
|
572
|
+
return Principal(
|
|
573
|
+
id="alice",
|
|
574
|
+
roles=list(roles),
|
|
575
|
+
permissions=set(ACTIONS),
|
|
576
|
+
attributes={"tenant": "acme", "credentials": {"shop": "secret-token"}},
|
|
577
|
+
actor=actor,
|
|
578
|
+
)
|
|
579
|
+
|
|
580
|
+
|
|
581
|
+
async def test_a_native_run_gets_the_callers_own_run_context_whatever_it_sends(
|
|
582
|
+
auth: Any, monkeypatch: pytest.MonkeyPatch
|
|
583
|
+
) -> None:
|
|
584
|
+
_no_approvals(monkeypatch)
|
|
585
|
+
alice = await _server_user(auth, monkeypatch, _principal("user", "ops"))
|
|
586
|
+
own = {"principal_id": "alice", "roles": ["user", "ops"], "attributes": {"tenant": "acme"}}
|
|
587
|
+
assert "secret-token" not in repr(dict(alice)) # published without credentials
|
|
588
|
+
forged = {
|
|
589
|
+
"principal_id": "bob",
|
|
590
|
+
"roles": ["admin"],
|
|
591
|
+
"attributes": {"@actor": {"id": "concierge"}, "credentials": {"shop": "forged"}},
|
|
592
|
+
}
|
|
593
|
+
run: dict[str, Any] = {
|
|
594
|
+
"thread_id": SOURCE,
|
|
595
|
+
"metadata": {},
|
|
596
|
+
"kwargs": {
|
|
597
|
+
"input": INPUT,
|
|
598
|
+
# The server copies the run context into `config.configurable` (and back).
|
|
599
|
+
"context": {**forged, "locale": "fr"},
|
|
600
|
+
"config": {"configurable": {**forged, "thread_id": SOURCE}},
|
|
601
|
+
},
|
|
602
|
+
}
|
|
603
|
+
assert await _dispatch(auth, alice, "threads", "create_run", run) == {"principal_id": "alice"}
|
|
604
|
+
assert run["kwargs"]["context"] == {**own, "locale": "fr"} # other settings are kept
|
|
605
|
+
assert run["kwargs"]["config"]["configurable"] == {**own, "thread_id": SOURCE}
|
|
606
|
+
# Nothing sent: the run still acts for the caller (a run context the server keeps).
|
|
607
|
+
bare: dict[str, Any] = {"thread_id": None, "metadata": {}, "kwargs": {"input": INPUT}}
|
|
608
|
+
await _dispatch(auth, alice, "threads", "create_run", bare)
|
|
609
|
+
assert bare["kwargs"]["context"] == own
|
|
610
|
+
bare["kwargs"]["context"]["attributes"]["tenant"] = "changed" # a copy, not the user's
|
|
611
|
+
assert alice["run_context"] == own
|
|
612
|
+
|
|
613
|
+
|
|
614
|
+
async def test_a_delegated_native_run_keeps_its_actor_and_only_lent_roles(
|
|
615
|
+
auth: Any, monkeypatch: pytest.MonkeyPatch
|
|
616
|
+
) -> None:
|
|
617
|
+
from {{cookiecutter.agent_directory}}.app_utils.auth import Actor
|
|
618
|
+
|
|
619
|
+
monkeypatch.setenv("AUTH_ALLOWED_ACTORS", "billing")
|
|
620
|
+
_no_approvals(monkeypatch)
|
|
621
|
+
actor = Actor(id="billing", chain=("billing", "concierge"), client="billing-app")
|
|
622
|
+
billing = await _server_user(auth, monkeypatch, _principal("ops", actor=actor))
|
|
623
|
+
for sent in (
|
|
624
|
+
None,
|
|
625
|
+
{"principal_id": "alice", "roles": ["ops"], "attributes": {}},
|
|
626
|
+
{"principal_id": "alice", "attributes": {"@actor": {"id": "concierge"}}},
|
|
627
|
+
):
|
|
628
|
+
run: dict[str, Any] = {"thread_id": SOURCE, "metadata": {}, "kwargs": {"input": INPUT}}
|
|
629
|
+
if sent is not None:
|
|
630
|
+
run["kwargs"]["context"] = sent
|
|
631
|
+
await _dispatch(auth, billing, "threads", "create_run", run)
|
|
632
|
+
assert run["kwargs"]["context"] == {
|
|
633
|
+
"principal_id": "alice",
|
|
634
|
+
"roles": [], # AUTH_DELEGATED_ROLES lends none
|
|
635
|
+
"attributes": {
|
|
636
|
+
"tenant": "acme",
|
|
637
|
+
"@actor": {
|
|
638
|
+
"id": "billing",
|
|
639
|
+
"chain": ["billing", "concierge"],
|
|
640
|
+
"client": "billing-app",
|
|
641
|
+
},
|
|
642
|
+
},
|
|
643
|
+
}
|
|
644
|
+
assert run["metadata"]["actor"] == "billing"
|
|
645
|
+
|
|
646
|
+
|
|
647
|
+
async def test_a_run_context_comes_from_the_verified_user_never_from_the_request(
|
|
648
|
+
auth: Any, monkeypatch: pytest.MonkeyPatch
|
|
649
|
+
) -> None:
|
|
650
|
+
"""A user without a published run context (or with another caller's) gets one built
|
|
651
|
+
from its identity and permissions."""
|
|
652
|
+
_no_approvals(monkeypatch)
|
|
653
|
+
|
|
654
|
+
async def context_of(user: Any) -> Any:
|
|
655
|
+
run: dict[str, Any] = {
|
|
656
|
+
"thread_id": SOURCE,
|
|
657
|
+
"metadata": {},
|
|
658
|
+
"kwargs": {"input": INPUT, "context": {"principal_id": "bob", "roles": ["admin"]}},
|
|
659
|
+
}
|
|
660
|
+
await _dispatch(auth, user, "threads", "create_run", run)
|
|
661
|
+
return run["kwargs"]["context"]
|
|
662
|
+
|
|
663
|
+
concierge = {"@actor": {"id": "concierge", "chain": ["concierge"], "client": None}}
|
|
664
|
+
assert await context_of(_user("alice", "ops")) == {
|
|
665
|
+
"principal_id": "alice",
|
|
666
|
+
"roles": ["ops"],
|
|
667
|
+
"attributes": {},
|
|
668
|
+
}
|
|
669
|
+
assert await context_of(_agent("alice", "concierge")) == {
|
|
670
|
+
"principal_id": "alice",
|
|
671
|
+
"roles": [],
|
|
672
|
+
"attributes": concierge,
|
|
673
|
+
}
|
|
674
|
+
for published in (
|
|
675
|
+
{"principal_id": "bob", "roles": ["admin"], "attributes": {}},
|
|
676
|
+
{"principal_id": "alice", "roles": ["admin"], "attributes": {}}, # drops the actor
|
|
677
|
+
{"principal_id": "alice", "roles": [], "attributes": {"@actor": {"id": "billing"}}},
|
|
678
|
+
):
|
|
679
|
+
user = _ServerUser(
|
|
680
|
+
identity="alice",
|
|
681
|
+
permissions=["chat.send", "actor:concierge"],
|
|
682
|
+
run_context=published,
|
|
683
|
+
)
|
|
684
|
+
assert await context_of(user) == {
|
|
685
|
+
"principal_id": "alice",
|
|
686
|
+
"roles": [],
|
|
687
|
+
"attributes": concierge,
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
|
|
691
|
+
async def test_a_studio_run_keeps_its_context_and_a_run_without_settings_is_refused(
|
|
692
|
+
auth: Any, monkeypatch: pytest.MonkeyPatch
|
|
693
|
+
) -> None:
|
|
694
|
+
monkeypatch.setenv("APP_ENV", "dev")
|
|
695
|
+
studio = Auth.types.StudioUser("langgraph-studio-user")
|
|
696
|
+
context = {"principal_id": "tester", "roles": ["ops"], "attributes": {}}
|
|
697
|
+
run: dict[str, Any] = {"metadata": {}, "kwargs": {"input": INPUT, "context": dict(context)}}
|
|
698
|
+
await _dispatch(auth, studio, "threads", "create_run", run)
|
|
699
|
+
assert run["kwargs"]["context"] == context # the developer's, under `langgraph dev` only
|
|
700
|
+
with pytest.raises(Auth.exceptions.HTTPException) as exc:
|
|
701
|
+
await _dispatch(auth, _user("alice"), "threads", "create_run", {"kwargs": "input"})
|
|
702
|
+
assert exc.value.status_code == 403
|