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