keywright-server 0.0.1__tar.gz → 0.0.2__tar.gz

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 (170) hide show
  1. {keywright_server-0.0.1 → keywright_server-0.0.2}/PKG-INFO +50 -22
  2. {keywright_server-0.0.1 → keywright_server-0.0.2}/README.md +48 -20
  3. {keywright_server-0.0.1 → keywright_server-0.0.2}/THIRD_PARTY_NOTICES +8 -0
  4. {keywright_server-0.0.1 → keywright_server-0.0.2}/keywright_server/__init__.py +1 -1
  5. {keywright_server-0.0.1 → keywright_server-0.0.2}/keywright_server/access.py +2 -0
  6. {keywright_server-0.0.1 → keywright_server-0.0.2}/keywright_server/approvals.py +32 -6
  7. keywright_server-0.0.2/keywright_server/cli_setup.py +159 -0
  8. keywright_server-0.0.2/keywright_server/cli_setup_remote.py +151 -0
  9. {keywright_server-0.0.1 → keywright_server-0.0.2}/keywright_server/db.py +27 -0
  10. keywright_server-0.0.2/keywright_server/github.py +561 -0
  11. keywright_server-0.0.2/keywright_server/github_client.py +178 -0
  12. keywright_server-0.0.2/keywright_server/homelab.py +346 -0
  13. keywright_server-0.0.2/keywright_server/integrations.py +124 -0
  14. {keywright_server-0.0.1 → keywright_server-0.0.2}/keywright_server/knowledge.py +6 -1
  15. keywright_server-0.0.2/keywright_server/knowledge_graph.py +340 -0
  16. {keywright_server-0.0.1 → keywright_server-0.0.2}/keywright_server/knowledge_mcp.py +13 -1
  17. {keywright_server-0.0.1 → keywright_server-0.0.2}/keywright_server/mcp_oauth.py +5 -1
  18. {keywright_server-0.0.1 → keywright_server-0.0.2}/keywright_server/mcp_proxy.py +7 -1
  19. {keywright_server-0.0.1 → keywright_server-0.0.2}/keywright_server/mcp_server.py +65 -4
  20. {keywright_server-0.0.1 → keywright_server-0.0.2}/keywright_server/models.py +277 -2
  21. {keywright_server-0.0.1 → keywright_server-0.0.2}/keywright_server/notify/base.py +19 -0
  22. {keywright_server-0.0.1 → keywright_server-0.0.2}/keywright_server/notify/homeassistant.py +13 -2
  23. {keywright_server-0.0.1 → keywright_server-0.0.2}/keywright_server/notify/log.py +5 -1
  24. {keywright_server-0.0.1 → keywright_server-0.0.2}/keywright_server/notify/manager.py +4 -1
  25. {keywright_server-0.0.1 → keywright_server-0.0.2}/keywright_server/notify/webhook.py +12 -0
  26. {keywright_server-0.0.1 → keywright_server-0.0.2}/keywright_server/policy.py +4 -1
  27. keywright_server-0.0.2/keywright_server/repositories.py +418 -0
  28. keywright_server-0.0.2/keywright_server/shopping.py +155 -0
  29. keywright_server-0.0.2/keywright_server/shopping_mcp.py +241 -0
  30. {keywright_server-0.0.1 → keywright_server-0.0.2}/keywright_server/ssh.py +8 -1
  31. keywright_server-0.0.2/keywright_server/task_notifications.py +52 -0
  32. keywright_server-0.0.2/keywright_server/tasks.py +483 -0
  33. keywright_server-0.0.2/keywright_server/tasks_mcp.py +88 -0
  34. {keywright_server-0.0.1 → keywright_server-0.0.2}/keywright_server/web/admin.py +78 -5
  35. {keywright_server-0.0.1 → keywright_server-0.0.2}/keywright_server/web/app.py +23 -1
  36. keywright_server-0.0.2/keywright_server/web/assets/brands/ATTRIBUTION.txt +28 -0
  37. keywright_server-0.0.2/keywright_server/web/assets/brands/JELLYFIN-LICENSE.txt +428 -0
  38. keywright_server-0.0.2/keywright_server/web/assets/brands/PI-HOLE-LICENSE.txt +21 -0
  39. keywright_server-0.0.2/keywright_server/web/assets/brands/jellyfin-dark.svg +19 -0
  40. keywright_server-0.0.2/keywright_server/web/assets/brands/jellyfin-light.svg +19 -0
  41. keywright_server-0.0.2/keywright_server/web/assets/brands/pihole.svg +16 -0
  42. keywright_server-0.0.2/keywright_server/web/assets/brands/sources.json +78 -0
  43. keywright_server-0.0.2/keywright_server/web/assets/github-picker.js +18 -0
  44. keywright_server-0.0.2/keywright_server/web/assets/work.css +7 -0
  45. keywright_server-0.0.2/keywright_server/web/assets/work.js +74 -0
  46. keywright_server-0.0.2/keywright_server/web/cli_setup_ui.py +58 -0
  47. keywright_server-0.0.2/keywright_server/web/github_ui.py +191 -0
  48. {keywright_server-0.0.1 → keywright_server-0.0.2}/keywright_server/web/knowledge_ui.py +72 -7
  49. keywright_server-0.0.2/keywright_server/web/repositories_ui.py +182 -0
  50. {keywright_server-0.0.1 → keywright_server-0.0.2}/keywright_server/web/security.py +1 -1
  51. keywright_server-0.0.2/keywright_server/web/templates/_checkout.html +21 -0
  52. {keywright_server-0.0.1 → keywright_server-0.0.2}/keywright_server/web/templates/_collections_style.html +1 -1
  53. {keywright_server-0.0.1 → keywright_server-0.0.2}/keywright_server/web/templates/_constraints.html +3 -0
  54. {keywright_server-0.0.1 → keywright_server-0.0.2}/keywright_server/web/templates/_decide.html +8 -1
  55. keywright_server-0.0.2/keywright_server/web/templates/_expiry_script.html +13 -0
  56. {keywright_server-0.0.1 → keywright_server-0.0.2}/keywright_server/web/templates/_icons.html +10 -1
  57. keywright_server-0.0.2/keywright_server/web/templates/_integration_availability.html +1 -0
  58. keywright_server-0.0.2/keywright_server/web/templates/_integration_mark.html +12 -0
  59. {keywright_server-0.0.1 → keywright_server-0.0.2}/keywright_server/web/templates/_integration_style.html +4 -0
  60. {keywright_server-0.0.1 → keywright_server-0.0.2}/keywright_server/web/templates/_inventory_style.html +2 -2
  61. keywright_server-0.0.2/keywright_server/web/templates/_knowledge_card.html +13 -0
  62. keywright_server-0.0.2/keywright_server/web/templates/_knowledge_graph.js +256 -0
  63. {keywright_server-0.0.1 → keywright_server-0.0.2}/keywright_server/web/templates/_live.html +3 -3
  64. {keywright_server-0.0.1 → keywright_server-0.0.2}/keywright_server/web/templates/_request_card.html +5 -1
  65. {keywright_server-0.0.1 → keywright_server-0.0.2}/keywright_server/web/templates/base.html +5 -2
  66. keywright_server-0.0.2/keywright_server/web/templates/cli_setup.html +39 -0
  67. {keywright_server-0.0.1 → keywright_server-0.0.2}/keywright_server/web/templates/dashboard.html +5 -4
  68. {keywright_server-0.0.1 → keywright_server-0.0.2}/keywright_server/web/templates/decision.html +3 -1
  69. {keywright_server-0.0.1 → keywright_server-0.0.2}/keywright_server/web/templates/integration_picker.html +4 -2
  70. {keywright_server-0.0.1 → keywright_server-0.0.2}/keywright_server/web/templates/knowledge.html +4 -2
  71. {keywright_server-0.0.1 → keywright_server-0.0.2}/keywright_server/web/templates/knowledge_document.html +1 -0
  72. keywright_server-0.0.2/keywright_server/web/templates/knowledge_graph.html +48 -0
  73. {keywright_server-0.0.1 → keywright_server-0.0.2}/keywright_server/web/templates/knowledge_proposal.html +2 -2
  74. {keywright_server-0.0.1 → keywright_server-0.0.2}/keywright_server/web/templates/request_detail.html +7 -1
  75. {keywright_server-0.0.1 → keywright_server-0.0.2}/keywright_server/web/templates/resource_form.html +47 -4
  76. {keywright_server-0.0.1 → keywright_server-0.0.2}/keywright_server/web/templates/resources.html +15 -5
  77. keywright_server-0.0.2/keywright_server/web/templates/work.html +42 -0
  78. keywright_server-0.0.2/keywright_server/web/templates/work_github.html +8 -0
  79. keywright_server-0.0.2/keywright_server/web/templates/work_github_attach.html +20 -0
  80. keywright_server-0.0.2/keywright_server/web/templates/work_github_discussion.html +4 -0
  81. keywright_server-0.0.2/keywright_server/web/templates/work_github_picker.html +31 -0
  82. keywright_server-0.0.2/keywright_server/web/templates/work_repositories.html +13 -0
  83. keywright_server-0.0.2/keywright_server/web/templates/work_repository.html +16 -0
  84. keywright_server-0.0.2/keywright_server/web/templates/work_task.html +31 -0
  85. keywright_server-0.0.2/keywright_server/web/work_ui.py +239 -0
  86. {keywright_server-0.0.1 → keywright_server-0.0.2}/pyproject.toml +2 -2
  87. keywright_server-0.0.1/keywright_server/integrations.py +0 -64
  88. {keywright_server-0.0.1 → keywright_server-0.0.2}/.gitignore +0 -0
  89. {keywright_server-0.0.1 → keywright_server-0.0.2}/LICENSE +0 -0
  90. {keywright_server-0.0.1 → keywright_server-0.0.2}/NOTICE +0 -0
  91. {keywright_server-0.0.1 → keywright_server-0.0.2}/keywright_server/__main__.py +0 -0
  92. {keywright_server-0.0.1 → keywright_server-0.0.2}/keywright_server/access_view.py +0 -0
  93. {keywright_server-0.0.1 → keywright_server-0.0.2}/keywright_server/actions.py +0 -0
  94. {keywright_server-0.0.1 → keywright_server-0.0.2}/keywright_server/audit.py +0 -0
  95. {keywright_server-0.0.1 → keywright_server-0.0.2}/keywright_server/bundles.py +0 -0
  96. {keywright_server-0.0.1 → keywright_server-0.0.2}/keywright_server/config.py +0 -0
  97. {keywright_server-0.0.1 → keywright_server-0.0.2}/keywright_server/discovery.py +0 -0
  98. {keywright_server-0.0.1 → keywright_server-0.0.2}/keywright_server/events.py +0 -0
  99. {keywright_server-0.0.1 → keywright_server-0.0.2}/keywright_server/friction.py +0 -0
  100. {keywright_server-0.0.1 → keywright_server-0.0.2}/keywright_server/inventory.py +0 -0
  101. {keywright_server-0.0.1 → keywright_server-0.0.2}/keywright_server/inventory_mcp.py +0 -0
  102. {keywright_server-0.0.1 → keywright_server-0.0.2}/keywright_server/jev.py +0 -0
  103. {keywright_server-0.0.1 → keywright_server-0.0.2}/keywright_server/knowledge_chunking.py +0 -0
  104. {keywright_server-0.0.1 → keywright_server-0.0.2}/keywright_server/knowledge_index.py +0 -0
  105. {keywright_server-0.0.1 → keywright_server-0.0.2}/keywright_server/llm.py +0 -0
  106. {keywright_server-0.0.1 → keywright_server-0.0.2}/keywright_server/llm_proxy.py +0 -0
  107. {keywright_server-0.0.1 → keywright_server-0.0.2}/keywright_server/llm_usage.py +0 -0
  108. {keywright_server-0.0.1 → keywright_server-0.0.2}/keywright_server/mail.py +0 -0
  109. {keywright_server-0.0.1 → keywright_server-0.0.2}/keywright_server/network_map.py +0 -0
  110. {keywright_server-0.0.1 → keywright_server-0.0.2}/keywright_server/notify/__init__.py +0 -0
  111. {keywright_server-0.0.1 → keywright_server-0.0.2}/keywright_server/nudges.py +0 -0
  112. {keywright_server-0.0.1 → keywright_server-0.0.2}/keywright_server/omada.py +0 -0
  113. {keywright_server-0.0.1 → keywright_server-0.0.2}/keywright_server/policy_preview.py +0 -0
  114. {keywright_server-0.0.1 → keywright_server-0.0.2}/keywright_server/reconciliation.py +0 -0
  115. {keywright_server-0.0.1 → keywright_server-0.0.2}/keywright_server/result_inspection.py +0 -0
  116. {keywright_server-0.0.1 → keywright_server-0.0.2}/keywright_server/semantic.py +0 -0
  117. {keywright_server-0.0.1 → keywright_server-0.0.2}/keywright_server/semantic_questions.json +0 -0
  118. {keywright_server-0.0.1 → keywright_server-0.0.2}/keywright_server/settings_store.py +0 -0
  119. {keywright_server-0.0.1 → keywright_server-0.0.2}/keywright_server/tool_access.py +0 -0
  120. {keywright_server-0.0.1 → keywright_server-0.0.2}/keywright_server/usage.py +0 -0
  121. {keywright_server-0.0.1 → keywright_server-0.0.2}/keywright_server/web/__init__.py +0 -0
  122. {keywright_server-0.0.1 → keywright_server-0.0.2}/keywright_server/web/auth.py +0 -0
  123. {keywright_server-0.0.1 → keywright_server-0.0.2}/keywright_server/web/discovery_ui.py +0 -0
  124. {keywright_server-0.0.1 → keywright_server-0.0.2}/keywright_server/web/friction_ui.py +0 -0
  125. {keywright_server-0.0.1 → keywright_server-0.0.2}/keywright_server/web/inventory_ui.py +0 -0
  126. {keywright_server-0.0.1 → keywright_server-0.0.2}/keywright_server/web/mail_ui.py +0 -0
  127. {keywright_server-0.0.1 → keywright_server-0.0.2}/keywright_server/web/mcp_oauth_ui.py +0 -0
  128. {keywright_server-0.0.1 → keywright_server-0.0.2}/keywright_server/web/network_map_ui.py +0 -0
  129. {keywright_server-0.0.1 → keywright_server-0.0.2}/keywright_server/web/oidc.py +0 -0
  130. {keywright_server-0.0.1 → keywright_server-0.0.2}/keywright_server/web/templates/_activity_row.html +0 -0
  131. {keywright_server-0.0.1 → keywright_server-0.0.2}/keywright_server/web/templates/_agent_setup.html +0 -0
  132. {keywright_server-0.0.1 → keywright_server-0.0.2}/keywright_server/web/templates/_device_kind_picker.html +0 -0
  133. {keywright_server-0.0.1 → keywright_server-0.0.2}/keywright_server/web/templates/_hazards.html +0 -0
  134. {keywright_server-0.0.1 → keywright_server-0.0.2}/keywright_server/web/templates/_infrastructure_nav.html +0 -0
  135. {keywright_server-0.0.1 → keywright_server-0.0.2}/keywright_server/web/templates/_inventory_forms.html +0 -0
  136. {keywright_server-0.0.1 → keywright_server-0.0.2}/keywright_server/web/templates/_mail_nav.html +0 -0
  137. {keywright_server-0.0.1 → keywright_server-0.0.2}/keywright_server/web/templates/_mail_style.html +0 -0
  138. {keywright_server-0.0.1 → keywright_server-0.0.2}/keywright_server/web/templates/_network_map_script.html +0 -0
  139. {keywright_server-0.0.1 → keywright_server-0.0.2}/keywright_server/web/templates/_nudge_card.html +0 -0
  140. {keywright_server-0.0.1 → keywright_server-0.0.2}/keywright_server/web/templates/_patterns.html +0 -0
  141. {keywright_server-0.0.1 → keywright_server-0.0.2}/keywright_server/web/templates/_policy_preview_results.html +0 -0
  142. {keywright_server-0.0.1 → keywright_server-0.0.2}/keywright_server/web/templates/_semantic.html +0 -0
  143. {keywright_server-0.0.1 → keywright_server-0.0.2}/keywright_server/web/templates/_topology_controller.js +0 -0
  144. {keywright_server-0.0.1 → keywright_server-0.0.2}/keywright_server/web/templates/_topology_layout.js +0 -0
  145. {keywright_server-0.0.1 → keywright_server-0.0.2}/keywright_server/web/templates/_topology_render.js +0 -0
  146. {keywright_server-0.0.1 → keywright_server-0.0.2}/keywright_server/web/templates/access.html +0 -0
  147. {keywright_server-0.0.1 → keywright_server-0.0.2}/keywright_server/web/templates/agents.html +0 -0
  148. {keywright_server-0.0.1 → keywright_server-0.0.2}/keywright_server/web/templates/audit.html +0 -0
  149. {keywright_server-0.0.1 → keywright_server-0.0.2}/keywright_server/web/templates/friction.html +0 -0
  150. {keywright_server-0.0.1 → keywright_server-0.0.2}/keywright_server/web/templates/friction_review.html +0 -0
  151. {keywright_server-0.0.1 → keywright_server-0.0.2}/keywright_server/web/templates/grants.html +0 -0
  152. {keywright_server-0.0.1 → keywright_server-0.0.2}/keywright_server/web/templates/inventory.html +0 -0
  153. {keywright_server-0.0.1 → keywright_server-0.0.2}/keywright_server/web/templates/inventory_asset.html +0 -0
  154. {keywright_server-0.0.1 → keywright_server-0.0.2}/keywright_server/web/templates/inventory_discovery.html +0 -0
  155. {keywright_server-0.0.1 → keywright_server-0.0.2}/keywright_server/web/templates/inventory_discovery_review.html +0 -0
  156. {keywright_server-0.0.1 → keywright_server-0.0.2}/keywright_server/web/templates/inventory_map.html +0 -0
  157. {keywright_server-0.0.1 → keywright_server-0.0.2}/keywright_server/web/templates/inventory_networks.html +0 -0
  158. {keywright_server-0.0.1 → keywright_server-0.0.2}/keywright_server/web/templates/inventory_subnet.html +0 -0
  159. {keywright_server-0.0.1 → keywright_server-0.0.2}/keywright_server/web/templates/inventory_topology.html +0 -0
  160. {keywright_server-0.0.1 → keywright_server-0.0.2}/keywright_server/web/templates/login.html +0 -0
  161. {keywright_server-0.0.1 → keywright_server-0.0.2}/keywright_server/web/templates/mail.html +0 -0
  162. {keywright_server-0.0.1 → keywright_server-0.0.2}/keywright_server/web/templates/mail_rules.html +0 -0
  163. {keywright_server-0.0.1 → keywright_server-0.0.2}/keywright_server/web/templates/mail_settings.html +0 -0
  164. {keywright_server-0.0.1 → keywright_server-0.0.2}/keywright_server/web/templates/mcp_oauth_continue.html +0 -0
  165. {keywright_server-0.0.1 → keywright_server-0.0.2}/keywright_server/web/templates/policies.html +0 -0
  166. {keywright_server-0.0.1 → keywright_server-0.0.2}/keywright_server/web/templates/requests.html +0 -0
  167. {keywright_server-0.0.1 → keywright_server-0.0.2}/keywright_server/web/templates/settings.html +0 -0
  168. {keywright_server-0.0.1 → keywright_server-0.0.2}/keywright_server/web/templates/setup.html +0 -0
  169. {keywright_server-0.0.1 → keywright_server-0.0.2}/keywright_server/web/templates/usage.html +0 -0
  170. {keywright_server-0.0.1 → keywright_server-0.0.2}/keywright_server/web/usage_ui.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: keywright-server
3
- Version: 0.0.1
3
+ Version: 0.0.2
4
4
  Summary: Keywright server: just-in-time credential broker for AI agents. One MCP front door, per-request human approval, no keys in agent context.
5
5
  Project-URL: Repository, https://github.com/johnrcarty/keywright
6
6
  Project-URL: Issues, https://github.com/johnrcarty/keywright/issues
@@ -18,7 +18,7 @@ Requires-Dist: httpx2<3,>=2.13
18
18
  Requires-Dist: httpx>=0.27
19
19
  Requires-Dist: itsdangerous>=2.2
20
20
  Requires-Dist: jinja2>=3.1
21
- Requires-Dist: keywright<0.1,>=0.0.1
21
+ Requires-Dist: keywright<0.1,>=0.0.2
22
22
  Requires-Dist: mcp<3,>=2.2
23
23
  Requires-Dist: pydantic>=2.7
24
24
  Requires-Dist: python-multipart>=0.0.9
@@ -40,34 +40,34 @@ Give AI agents temporary access to infrastructure without handing them your cred
40
40
  Keywright is a self-hosted broker for SSH hosts and MCP tools, with human approval,
41
41
  scoped access, revocation and an audit trail.
42
42
 
43
- **v0.0.1 is in preparation.** Prebuilt preview images are published to GitHub Container
44
- Registry for Linux x86-64 and ARM64. The repository and image remain private during
45
- release preparation; the stable release, public availability and licensing review are pending.
43
+ **[v0.0.1 is available](https://github.com/johnrcarty/keywright/releases/tag/v0.0.1).**
44
+ This first experimental release includes public prebuilt images for Linux x86-64 and
45
+ ARM64, Python packages, and verified source and license materials. Keywright is Apache-2.0.
46
+
47
+ **0.0.2 is in preparation:** expanded integrations, GitHub repository and issue imports,
48
+ native task boards, and knowledge graph improvements. See the [release candidate notes](docs/releases/0.0.2.md).
49
+ The installation instructions below use the latest published release until 0.0.2 is published.
46
50
 
47
51
  ## Install the prebuilt broker
48
52
 
49
53
  You need Docker Engine with the Compose plugin. No Git checkout, Python installation
50
54
  or local image build is needed.
51
55
 
52
- 1. Download the **keywright-install** artifact from a successful
53
- [test build and publish workflow](https://github.com/johnrcarty/keywright/actions/workflows/ci.yml).
54
- Extract it into a dedicated installation directory. Its `compose.yaml` pins the exact
55
- multi-platform image digest; `SHA256SUMS` covers the installation files.
56
- 2. For the private preview, sign in with a GitHub personal access token (classic) that has
57
- `read:packages` and access to this package. Enter the token at Docker's password prompt:
58
- ```bash
59
- docker login ghcr.io -u YOUR_GITHUB_USERNAME
60
- ```
61
- 3. In the installation directory:
56
+ 1. Download `keywright-install.tar.gz` and `SHA256SUMS` from the
57
+ [v0.0.1 release](https://github.com/johnrcarty/keywright/releases/tag/v0.0.1)
58
+ into a dedicated installation directory. The bundle pins the exact multi-platform image digest.
59
+ 2. Verify the archive, extract it, verify its files, and start the broker:
62
60
  ```bash
61
+ sha256sum --ignore-missing -c SHA256SUMS
62
+ tar -xzf keywright-install.tar.gz
63
63
  sha256sum -c SHA256SUMS
64
64
  docker compose up -d --wait
65
65
  ```
66
66
 
67
67
  The source copy at `deploy/compose.yaml` also works on its own; it follows
68
- `ghcr.io/johnrcarty/keywright:0.0.1-preview` unless `KEYWRIGHT_IMAGE` selects another tag
69
- or digest. The install artifact is the reproducible choice for testing a particular build.
70
- When the image becomes public, registry login will no longer be required.
68
+ `ghcr.io/johnrcarty/keywright:0.0.2-preview` unless `KEYWRIGHT_IMAGE` selects another tag
69
+ or digest. Use the release bundle for the pinned stable installation.
70
+ Public image downloads do not require a registry login.
71
71
 
72
72
  Open <http://localhost:8700/ui> and set the administrator password. The default port is
73
73
  bound to loopback. For a remote VM, use an SSH tunnel first:
@@ -83,13 +83,27 @@ agent machines and use disposable targets for initial tests.
83
83
  1. Add an SSH host or upstream MCP server under **Resources**.
84
84
  2. Create an agent and copy its token once.
85
85
  3. Point an MCP client at `/mcp` with `Authorization: Bearer <agent-token>`, or install
86
- the CLI from source using the instructions below.
86
+ the published CLI using the instructions below.
87
87
  4. Request one operation, approve it in the UI, and inspect its audit record.
88
88
  5. Revoke the access and verify the operation needs approval again.
89
89
 
90
90
  Phone notifications through Home Assistant or a webhook are optional. The base stack
91
91
  needs no cloud account, model provider, identity provider or external database.
92
92
 
93
+ ## Install the client
94
+
95
+ With Python 3.12 or newer and pipx:
96
+
97
+ ```bash
98
+ pipx install keywright==0.0.1
99
+ kw login http://localhost:8700 --paste
100
+ kw setup check
101
+ ```
102
+
103
+ Paste the agent token at the prompt. Run setup as the user who runs the agent.
104
+ Optional integration commands are `kw setup claude-code` and `kw setup codex`.
105
+ See the [client guide](client/README.md) for installing a separate Python runtime.
106
+
93
107
  ## Develop from source
94
108
 
95
109
  For a local Docker build, clone the repository and explicitly add the development overlay:
@@ -126,17 +140,31 @@ Never point test configuration at a production database.
126
140
 
127
141
  ## Optional integrations
128
142
 
143
+ - [DoorDash (Beta)](docs/DOORDASH.md): guided `dd-cli` setup through a managed SSH host,
144
+ including headless authentication. Waitlist-only. Full functionality requires an approved account.
145
+ [Join the waitlist here](https://forms.gle/gvCQZvu9C1EKA6aM6) (0.0.2 candidate).
146
+ - [Robinhood](docs/ROBINHOOD.md): accounts, market data, and trading through browser
147
+ sign-in (0.0.2 candidate).
148
+ - [Coinbase](docs/COINBASE.md): guided Coinbase CLI setup through a managed SSH host
149
+ (0.0.2 candidate).
150
+ - [CoinStats](docs/FEATURES.md#oauth-for-upstream-mcp-servers-including-notion-and-coinstats):
151
+ crypto data and portfolios through browser sign-in (0.0.2 candidate).
152
+ - [Homelab connectors](docs/CONNECTORS.md): Proxmox VE, Pi-hole v6, Home Assistant,
153
+ Jellyfin, Sonarr, Radarr and authentik setup and read-only tools (0.0.2 candidate).
154
+ - [GitHub and Work](docs/WORK_BOARDS.md): repository discovery, inbound issue sync, environment
155
+ locations, project boards, All tasks and Needs you views (0.0.2 candidate).
129
156
  - [Feature and configuration reference](docs/FEATURES.md): permissions, notifications,
130
157
  LLM gateways, OIDC, inventory, shared knowledge and result inspection.
131
158
  - [Deployment guide](deploy/README.md): base stack, optional LLM/knowledge services and
132
159
  a disposable VM acceptance checklist.
133
160
  - [Mail router](router/README.md): optional agent that routes mail through brokered tools.
134
- - [Release checklist](docs/RELEASE_PLAN.md): remaining work before v0.0.1.
161
+ - [Release checklist](docs/RELEASE_PLAN.md): release evidence and publication record.
135
162
  - [Security reporting](SECURITY.md), [support scope](SUPPORT.md), and [contributing](CONTRIBUTING.md).
136
163
  - [Third-party source and notice materials](third_party/README.md).
137
164
 
138
- Knowledge chunking changes are experimental pending retrieval evaluation. Optional
139
- features are not all covered by the minimal-stack installation check.
165
+ Knowledge retrieval remains experimental; the synthetic evaluation does not establish
166
+ quality for every corpus or reliable no-answer detection. Optional features are not all
167
+ covered by the minimal-stack installation check.
140
168
 
141
169
  Keywright is a credential trust boundary. Protect its host and backups. Command scope
142
170
  matching is not a shell sandbox, and output inspection cannot prove arbitrary text is
@@ -4,34 +4,34 @@ Give AI agents temporary access to infrastructure without handing them your cred
4
4
  Keywright is a self-hosted broker for SSH hosts and MCP tools, with human approval,
5
5
  scoped access, revocation and an audit trail.
6
6
 
7
- **v0.0.1 is in preparation.** Prebuilt preview images are published to GitHub Container
8
- Registry for Linux x86-64 and ARM64. The repository and image remain private during
9
- release preparation; the stable release, public availability and licensing review are pending.
7
+ **[v0.0.1 is available](https://github.com/johnrcarty/keywright/releases/tag/v0.0.1).**
8
+ This first experimental release includes public prebuilt images for Linux x86-64 and
9
+ ARM64, Python packages, and verified source and license materials. Keywright is Apache-2.0.
10
+
11
+ **0.0.2 is in preparation:** expanded integrations, GitHub repository and issue imports,
12
+ native task boards, and knowledge graph improvements. See the [release candidate notes](docs/releases/0.0.2.md).
13
+ The installation instructions below use the latest published release until 0.0.2 is published.
10
14
 
11
15
  ## Install the prebuilt broker
12
16
 
13
17
  You need Docker Engine with the Compose plugin. No Git checkout, Python installation
14
18
  or local image build is needed.
15
19
 
16
- 1. Download the **keywright-install** artifact from a successful
17
- [test build and publish workflow](https://github.com/johnrcarty/keywright/actions/workflows/ci.yml).
18
- Extract it into a dedicated installation directory. Its `compose.yaml` pins the exact
19
- multi-platform image digest; `SHA256SUMS` covers the installation files.
20
- 2. For the private preview, sign in with a GitHub personal access token (classic) that has
21
- `read:packages` and access to this package. Enter the token at Docker's password prompt:
22
- ```bash
23
- docker login ghcr.io -u YOUR_GITHUB_USERNAME
24
- ```
25
- 3. In the installation directory:
20
+ 1. Download `keywright-install.tar.gz` and `SHA256SUMS` from the
21
+ [v0.0.1 release](https://github.com/johnrcarty/keywright/releases/tag/v0.0.1)
22
+ into a dedicated installation directory. The bundle pins the exact multi-platform image digest.
23
+ 2. Verify the archive, extract it, verify its files, and start the broker:
26
24
  ```bash
25
+ sha256sum --ignore-missing -c SHA256SUMS
26
+ tar -xzf keywright-install.tar.gz
27
27
  sha256sum -c SHA256SUMS
28
28
  docker compose up -d --wait
29
29
  ```
30
30
 
31
31
  The source copy at `deploy/compose.yaml` also works on its own; it follows
32
- `ghcr.io/johnrcarty/keywright:0.0.1-preview` unless `KEYWRIGHT_IMAGE` selects another tag
33
- or digest. The install artifact is the reproducible choice for testing a particular build.
34
- When the image becomes public, registry login will no longer be required.
32
+ `ghcr.io/johnrcarty/keywright:0.0.2-preview` unless `KEYWRIGHT_IMAGE` selects another tag
33
+ or digest. Use the release bundle for the pinned stable installation.
34
+ Public image downloads do not require a registry login.
35
35
 
36
36
  Open <http://localhost:8700/ui> and set the administrator password. The default port is
37
37
  bound to loopback. For a remote VM, use an SSH tunnel first:
@@ -47,13 +47,27 @@ agent machines and use disposable targets for initial tests.
47
47
  1. Add an SSH host or upstream MCP server under **Resources**.
48
48
  2. Create an agent and copy its token once.
49
49
  3. Point an MCP client at `/mcp` with `Authorization: Bearer <agent-token>`, or install
50
- the CLI from source using the instructions below.
50
+ the published CLI using the instructions below.
51
51
  4. Request one operation, approve it in the UI, and inspect its audit record.
52
52
  5. Revoke the access and verify the operation needs approval again.
53
53
 
54
54
  Phone notifications through Home Assistant or a webhook are optional. The base stack
55
55
  needs no cloud account, model provider, identity provider or external database.
56
56
 
57
+ ## Install the client
58
+
59
+ With Python 3.12 or newer and pipx:
60
+
61
+ ```bash
62
+ pipx install keywright==0.0.1
63
+ kw login http://localhost:8700 --paste
64
+ kw setup check
65
+ ```
66
+
67
+ Paste the agent token at the prompt. Run setup as the user who runs the agent.
68
+ Optional integration commands are `kw setup claude-code` and `kw setup codex`.
69
+ See the [client guide](client/README.md) for installing a separate Python runtime.
70
+
57
71
  ## Develop from source
58
72
 
59
73
  For a local Docker build, clone the repository and explicitly add the development overlay:
@@ -90,17 +104,31 @@ Never point test configuration at a production database.
90
104
 
91
105
  ## Optional integrations
92
106
 
107
+ - [DoorDash (Beta)](docs/DOORDASH.md): guided `dd-cli` setup through a managed SSH host,
108
+ including headless authentication. Waitlist-only. Full functionality requires an approved account.
109
+ [Join the waitlist here](https://forms.gle/gvCQZvu9C1EKA6aM6) (0.0.2 candidate).
110
+ - [Robinhood](docs/ROBINHOOD.md): accounts, market data, and trading through browser
111
+ sign-in (0.0.2 candidate).
112
+ - [Coinbase](docs/COINBASE.md): guided Coinbase CLI setup through a managed SSH host
113
+ (0.0.2 candidate).
114
+ - [CoinStats](docs/FEATURES.md#oauth-for-upstream-mcp-servers-including-notion-and-coinstats):
115
+ crypto data and portfolios through browser sign-in (0.0.2 candidate).
116
+ - [Homelab connectors](docs/CONNECTORS.md): Proxmox VE, Pi-hole v6, Home Assistant,
117
+ Jellyfin, Sonarr, Radarr and authentik setup and read-only tools (0.0.2 candidate).
118
+ - [GitHub and Work](docs/WORK_BOARDS.md): repository discovery, inbound issue sync, environment
119
+ locations, project boards, All tasks and Needs you views (0.0.2 candidate).
93
120
  - [Feature and configuration reference](docs/FEATURES.md): permissions, notifications,
94
121
  LLM gateways, OIDC, inventory, shared knowledge and result inspection.
95
122
  - [Deployment guide](deploy/README.md): base stack, optional LLM/knowledge services and
96
123
  a disposable VM acceptance checklist.
97
124
  - [Mail router](router/README.md): optional agent that routes mail through brokered tools.
98
- - [Release checklist](docs/RELEASE_PLAN.md): remaining work before v0.0.1.
125
+ - [Release checklist](docs/RELEASE_PLAN.md): release evidence and publication record.
99
126
  - [Security reporting](SECURITY.md), [support scope](SUPPORT.md), and [contributing](CONTRIBUTING.md).
100
127
  - [Third-party source and notice materials](third_party/README.md).
101
128
 
102
- Knowledge chunking changes are experimental pending retrieval evaluation. Optional
103
- features are not all covered by the minimal-stack installation check.
129
+ Knowledge retrieval remains experimental; the synthetic evaluation does not establish
130
+ quality for every corpus or reliable no-answer detection. Optional features are not all
131
+ covered by the minimal-stack installation check.
104
132
 
105
133
  Keywright is a credential trust boundary. Protect its host and backups. Command scope
106
134
  matching is not a shell sandbox, and output inspection cannot prove arbitrary text is
@@ -6,6 +6,14 @@ container base images, optional services and embedding models retain their own
6
6
  licenses. This file is a guide to those components, not a replacement for their
7
7
  license texts or a complete inventory of a particular image.
8
8
 
9
+ Connector artwork
10
+ -----------------
11
+ Bundled connector artwork is documented separately in
12
+ keywright_server/web/assets/brands/ATTRIBUTION.txt, with full licenses,
13
+ unmodified SVG source and pinned provenance in that directory. Pi-hole artwork
14
+ is MIT-licensed; Jellyfin artwork is CC-BY-SA-4.0. These asset licenses and
15
+ trademark guidelines remain in effect independently of Keywright's license.
16
+
9
17
  Python dependencies
10
18
  -------------------
11
19
  Keywright distributions declare dependencies rather than vendoring them. pip
@@ -1,3 +1,3 @@
1
1
  """Keywright: just-in-time credential broker for AI agents."""
2
2
 
3
- __version__ = "0.0.1"
3
+ __version__ = "0.0.2"
@@ -109,6 +109,8 @@ def operation_matches(constraints: dict | None, operation: dict | None) -> bool:
109
109
  def constraint_label(constraints: dict[str, Any] | None) -> str:
110
110
  if not constraints:
111
111
  return "Permission patterns · any matching arguments"
112
+ if constraints.get("shopping") and not constraints.get("command"):
113
+ return "Shopping session · browsing and cart preparation"
112
114
  if "command" in constraints:
113
115
  return "Exact command"
114
116
  if "arguments" in constraints:
@@ -31,11 +31,11 @@ from .models import AccessRequest, Agent, Grant, Policy, Resource, now
31
31
  log = logging.getLogger(__name__)
32
32
 
33
33
  TTL_PRESETS = {"ttl15": 900, "ttl30": 1800, "ttl": 3600}
34
- SCOPES = ("once", "session", "ttl15", "path15", "ttl30", "ttl", "always", "deny", "deny_always")
35
- PHONE_SCOPES = ("once", "session", "ttl15", "path15", "ttl30", "ttl", "deny")
34
+ SCOPES = ("once", "session", "shopping20", "ttl15", "path15", "ttl30", "ttl", "always", "deny", "deny_always")
35
+ PHONE_SCOPES = ("once", "session", "shopping20", "ttl15", "path15", "ttl30", "ttl", "deny")
36
36
  SCOPE_LABELS = {"once": "Allow once", "session": "This session", "ttl15": "15 minutes", "path15": "This path, 15 min",
37
37
  "ttl30": "30 minutes", "ttl": "1 hour", "always": "Always allow",
38
- "deny": "Deny", "deny_always": "Always deny"}
38
+ "deny": "Deny", "deny_always": "Always deny", "shopping20": "Shop for 20 minutes"}
39
39
 
40
40
 
41
41
  def decision_label(req: AccessRequest) -> str:
@@ -118,6 +118,11 @@ def offered_scopes(req: AccessRequest, scopes: tuple[str, ...] = PHONE_SCOPES) -
118
118
  id cannot be granted 'this session': there is nothing to bind the grant to, and an agent-wide
119
119
  grant would outlive the conversation that asked for it. A request flagged destructive can only
120
120
  be allowed once: nothing timed, nothing standing. An LLM lease is always timed."""
121
+ if req.detail.get("shopping_session"):
122
+ return tuple(s for s in scopes if s in ("shopping20", "deny") and (s != "shopping20" or req.session_id))
123
+ scopes = tuple(s for s in scopes if s != "shopping20")
124
+ if req.detail.get("checkout"):
125
+ return tuple(s for s in scopes if s in ("once", "deny"))
121
126
  if blocks(hazard_level(req.detail)):
122
127
  scopes = tuple(s for s in scopes if s in HAZARD_SCOPES)
123
128
  if llm_only(list(req.patterns)):
@@ -185,6 +190,14 @@ async def create(
185
190
  semantic=semantic,
186
191
  expires_at=now() + timedelta(seconds=settings.request_timeout_s),
187
192
  )
193
+ if req.detail.get("checkout"):
194
+ from .shopping_mcp import remaining
195
+
196
+ cap = 300
197
+ link = req.detail["checkout"].get("link")
198
+ if link:
199
+ cap = min(cap, remaining(link.get("expires_at")) or 0)
200
+ req.expires_at = min(req.expires_at, now() + timedelta(seconds=cap))
188
201
  db.add(req)
189
202
  await db.commit()
190
203
  await db.refresh(req)
@@ -261,6 +274,12 @@ async def decide(
261
274
  raise LookupError("no such request")
262
275
  if req.status != "pending":
263
276
  return req
277
+ shopping_session = bool(req.detail.get("shopping_session"))
278
+ if shopping_session or req.detail.get("checkout") or scope == "shopping20":
279
+ if scope not in offered_scopes(req, SCOPES) or via == "jev":
280
+ raise ValueError("Shopping requires a human decision: 20 minutes for preparation, once for checkout")
281
+ if req.detail.get("checkout") and extent not in (None, "exact"):
282
+ raise ValueError("Checkout approval must bind the exact quoted order")
264
283
  if path_grant:
265
284
  if not path_scoped(req.detail):
266
285
  raise ValueError("Path access is only offered for a single file write")
@@ -276,6 +295,11 @@ async def decide(
276
295
  # One (patterns, constraints) pair per operation; a pattern request is one pair with none.
277
296
  bound = [(pats if pats is not None else list(req.patterns), operation_constraints(op, extent), op)
278
297
  for pats, op in operations(req.detail)] or [(list(req.patterns), None, None)]
298
+ if shopping_session:
299
+ target = await db.get(Resource, req.detail["resource_id"])
300
+ if target is None:
301
+ raise ValueError("Shopping resource no longer exists")
302
+ bound = [(list(req.patterns), {**operation_constraints(req.detail, "patterns"), "shopping": True}, req.detail)]
279
303
  if scope == "session" and not req.session_id:
280
304
  raise ValueError("This request has no session id; choose Allow once or 1 hour instead")
281
305
  if scope not in LLM_SCOPES and any(p.split("/", 1)[0] == "llm" for p in req.patterns):
@@ -313,7 +337,7 @@ async def decide(
313
337
  (AccessRequest.expires_at.is_(None)) | (AccessRequest.expires_at > decided_at),
314
338
  ).values(status="denied" if scope in ("deny", "deny_always") else "approved", decided_at=decided_at,
315
339
  decision_scope=scope, decided_via=via,
316
- decision_options={"extent": extent, "ttl_s": ttl_s if scope == "ttl" else None}
340
+ decision_options={"extent": extent, "ttl_s": 1200 if scope == "shopping20" else ttl_s if scope == "ttl" else None}
317
341
  ).execution_options(synchronize_session=False))
318
342
  if changed.rowcount != 1:
319
343
  await db.rollback()
@@ -343,9 +367,11 @@ async def decide(
343
367
  agent_id=req.agent_id,
344
368
  pattern=p,
345
369
  constraints=constraints,
346
- scope=scope,
347
- session_id=req.session_id if scope == "session" else None,
370
+ scope="session" if scope == "shopping20" else scope,
371
+ session_id=req.session_id if scope in ("session", "shopping20") else None,
348
372
  expires_at=(decided_at + timedelta(seconds=ttl_s)) if scope == "ttl"
373
+ else (decided_at + timedelta(seconds=1200)) if scope == "shopping20"
374
+ else min(req.expires_at, decided_at + timedelta(minutes=5)) if req.detail.get("checkout")
349
375
  else (decided_at + timedelta(seconds=settings.session_grant_ttl_s)) if scope == "session"
350
376
  else None,
351
377
  uses_remaining=1 if scope == "once" else None,
@@ -0,0 +1,159 @@
1
+ """Resumable admin CLI setup. Jobs contain status only; user secrets travel over SSH stdin."""
2
+ from __future__ import annotations
3
+
4
+ import asyncio
5
+ import json
6
+ import re
7
+ import secrets
8
+ import shlex
9
+ import time
10
+ from pathlib import Path
11
+ from urllib.parse import urlsplit
12
+
13
+ from sqlalchemy import update
14
+
15
+ from . import audit, ssh
16
+ from .db import session
17
+ from .models import Resource
18
+
19
+ CONNECTION_KEYS = ("host", "port", "user", "key_path", "host_key")
20
+ TASKS: set[asyncio.Task] = set()
21
+ LEASE_SECONDS = 300
22
+ MESSAGES = {
23
+ "installed": "DoorDash CLI installed. Continue to sign in.",
24
+ "signin": "Open DoorDash sign-in, then paste the return address below within 10 minutes.",
25
+ "signed_in": "Sign-in saved on the selected host. Verify access to finish.",
26
+ "ready": "DoorDash is ready to use through Keywright.",
27
+ "unsupported_host": "Guided installation needs glibc Linux on x86_64. Choose another host or use manual setup.",
28
+ "download_integrity": "The download failed its integrity check. Nothing from that archive was installed.",
29
+ "binary_failed": "The CLI could not run on this host. Check its OS compatibility or choose another host.",
30
+ "not_installed": "Install the CLI on this host before signing in.",
31
+ "signin_failed": "DoorDash sign-in did not complete. Start a new sign-in and try again.",
32
+ "signin_url": "The CLI did not return a recognized DoorDash sign-in link. You can use an exported token instead.",
33
+ "invalid_input": "Enter the complete return address, sign-in code, or exported token in the appropriate field.",
34
+ "sign_in_required": "Sign in before verifying access.",
35
+ "verification_failed": "DoorDash access could not be verified. Check beta access and try signing in again; the token may have expired.",
36
+ "host_setup_failed": "Setup could not finish. The host needs Python 3.9+, outbound HTTPS, and a writable home directory. Check the host before retrying.",
37
+ "interrupted": "Setup was interrupted. Check the host before retrying; the last operation may have completed.",
38
+ }
39
+
40
+
41
+ def state(config):
42
+ value = dict(config.get("cli_setup") or {})
43
+ if value.get("status") == "running" and time.time() - value.get("started_at", 0) > LEASE_SECONDS:
44
+ value.update(status="error", message=MESSAGES["interrupted"])
45
+ return value
46
+
47
+
48
+ def allowed_actions(value):
49
+ phase = value.get("resume", "new") if value.get("status") == "error" else value.get("status", "new")
50
+ if phase == "running":
51
+ return set()
52
+ if phase == "new":
53
+ return {"install"}
54
+ if phase == "installed":
55
+ return {"signin", "token"}
56
+ if phase == "signin":
57
+ return {"signin", "finish", "token"}
58
+ if phase in ("signed_in", "ready"):
59
+ return {"signin", "token", "verify"}
60
+ return set()
61
+
62
+
63
+ async def start(rid, action, value=""):
64
+ async with session() as db:
65
+ res = await db.get(Resource, rid)
66
+ if not res or res.kind != "ssh" or res.config.get("integration") != "doordash" or not res.config.get("cli_setup"):
67
+ raise ValueError("No managed DoorDash setup found.")
68
+ before = dict(res.config)
69
+ previous = state(before)
70
+ if action not in allowed_actions(previous):
71
+ raise ValueError("This step is not available. Refresh setup to see its current status.")
72
+ if action in ("finish", "token") and (not value.strip() or len(value) > 16384 or any(c in value for c in "\r\n\x00")):
73
+ raise ValueError("Enter the return address or token on a single line.")
74
+ resume = previous.get("resume", "new") if previous.get("status") == "error" else previous.get("status", "new")
75
+ job = secrets.token_urlsafe(16)
76
+ current = {**previous, "status": "running", "resume": resume, "action": action, "job": job,
77
+ "started_at": time.time(), "message": "Working on the selected host…"}
78
+ current.pop("signin_url", None)
79
+ result = await db.execute(update(Resource).where(Resource.id == rid, Resource.config == before)
80
+ .values(config={**before, "cli_setup": current}))
81
+ if result.rowcount != 1:
82
+ raise ValueError("Another setup step started. Refresh this page.")
83
+ await audit.record(db, tool="cli.setup", decision="started", detail={"resource": res.name, "step": action})
84
+ connection = {key: before[key] for key in CONNECTION_KEYS if key in before}
85
+ task = asyncio.create_task(_perform(rid, job, action, value, connection), name="cli-setup")
86
+ TASKS.add(task)
87
+ task.add_done_callback(TASKS.discard)
88
+
89
+
90
+ async def _perform(rid, job, action, value, connection):
91
+ learned = None
92
+ try:
93
+ script = Path(__file__).with_name("cli_setup_remote.py").read_text()
94
+ command = "python3 -c " + shlex.quote(script) + " " + shlex.quote(action) + " " + shlex.quote(rid)
95
+ result = await ssh.run(connection, command, timeout=150,
96
+ stdin=json.dumps({"value": value}).encode())
97
+ learned = result.learned_host_key
98
+ output = json.loads(result.stdout) if not result.truncated else {}
99
+ expected = {"install": "installed", "signin": "signin", "finish": "signed_in", "token": "signed_in", "verify": "ready"}[action]
100
+ if result.exit_code == 0 and output.get("status") == expected:
101
+ outcome = {"status": expected, "message": MESSAGES[expected]}
102
+ if action == "signin":
103
+ url = output.get("signin_url", "")
104
+ parsed = urlsplit(url)
105
+ if (not isinstance(url, str) or len(url) > 8192 or parsed.scheme != "https" or not parsed.hostname
106
+ or not (parsed.hostname == "doordash.com" or parsed.hostname.endswith(".doordash.com"))
107
+ or parsed.username or parsed.password or parsed.port not in (None, 443)):
108
+ raise ValueError("Unexpected sign-in URL")
109
+ outcome["signin_url"] = url
110
+ else:
111
+ code = output.get("error", "host_setup_failed")
112
+ outcome = {"status": "error", "message": MESSAGES.get(code, MESSAGES["host_setup_failed"])}
113
+ except asyncio.CancelledError:
114
+ await _finish(rid, job, {"status": "error", "message": MESSAGES["interrupted"]}, learned)
115
+ raise
116
+ except Exception: # noqa: BLE001 — never expose SSH/CLI exception text containing sign-in data
117
+ # SSH and CLI error text can include secrets: keep it out of UI, logs and audits.
118
+ outcome = {"status": "error", "message": MESSAGES["host_setup_failed"]}
119
+ await _finish(rid, job, outcome, learned)
120
+
121
+
122
+ async def _finish(rid, job, outcome, learned):
123
+ async with session() as db:
124
+ res = await db.get(Resource, rid)
125
+ if not res or res.config.get("cli_setup", {}).get("job") != job:
126
+ return
127
+ before = dict(res.config)
128
+ config = {**before, "cli_setup": {**before["cli_setup"], **outcome}}
129
+ if learned:
130
+ config["host_key"] = learned
131
+ if outcome["status"] == "installed":
132
+ config["cli_runtime"] = rid
133
+ if outcome["status"] != "signin":
134
+ config["cli_setup"].pop("signin_url", None)
135
+ result = await db.execute(update(Resource).where(Resource.id == rid, Resource.config == before).values(config=config))
136
+ if result.rowcount:
137
+ await audit.record(db, tool="cli.setup", decision=outcome["status"],
138
+ detail={"resource": res.name, "step": before["cli_setup"]["action"]})
139
+
140
+
141
+ async def shutdown():
142
+ tasks = list(TASKS)
143
+ for task in tasks:
144
+ task.cancel()
145
+ await asyncio.gather(*tasks, return_exceptions=True)
146
+
147
+
148
+ def runtime_command(config, command):
149
+ """Apply the managed install only to this resource, preserving the approved command and stdin."""
150
+ directory = config.get("cli_runtime")
151
+ if not directory:
152
+ return command
153
+ if config.get("integration") != "doordash" or not re.fullmatch(r"res_[A-Za-z0-9_-]{1,80}", directory):
154
+ raise ValueError("Invalid managed CLI runtime")
155
+ script = ('unset DD_CLI_ACCESS_TOKEN; d="$HOME/.local/share/keywright/cli/' + directory + '"; '
156
+ 'PATH="$d/bin:$PATH"; export PATH; '
157
+ 'if [ -f "$d/token" ]; then DD_CLI_ACCESS_TOKEN=$(cat "$d/token") || exit; '
158
+ 'export DD_CLI_ACCESS_TOKEN; fi; exec sh -c "$1"')
159
+ return "sh -c " + shlex.quote(script) + " keywright " + shlex.quote(command)