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.
- {keywright_server-0.0.1 → keywright_server-0.0.2}/PKG-INFO +50 -22
- {keywright_server-0.0.1 → keywright_server-0.0.2}/README.md +48 -20
- {keywright_server-0.0.1 → keywright_server-0.0.2}/THIRD_PARTY_NOTICES +8 -0
- {keywright_server-0.0.1 → keywright_server-0.0.2}/keywright_server/__init__.py +1 -1
- {keywright_server-0.0.1 → keywright_server-0.0.2}/keywright_server/access.py +2 -0
- {keywright_server-0.0.1 → keywright_server-0.0.2}/keywright_server/approvals.py +32 -6
- keywright_server-0.0.2/keywright_server/cli_setup.py +159 -0
- keywright_server-0.0.2/keywright_server/cli_setup_remote.py +151 -0
- {keywright_server-0.0.1 → keywright_server-0.0.2}/keywright_server/db.py +27 -0
- keywright_server-0.0.2/keywright_server/github.py +561 -0
- keywright_server-0.0.2/keywright_server/github_client.py +178 -0
- keywright_server-0.0.2/keywright_server/homelab.py +346 -0
- keywright_server-0.0.2/keywright_server/integrations.py +124 -0
- {keywright_server-0.0.1 → keywright_server-0.0.2}/keywright_server/knowledge.py +6 -1
- keywright_server-0.0.2/keywright_server/knowledge_graph.py +340 -0
- {keywright_server-0.0.1 → keywright_server-0.0.2}/keywright_server/knowledge_mcp.py +13 -1
- {keywright_server-0.0.1 → keywright_server-0.0.2}/keywright_server/mcp_oauth.py +5 -1
- {keywright_server-0.0.1 → keywright_server-0.0.2}/keywright_server/mcp_proxy.py +7 -1
- {keywright_server-0.0.1 → keywright_server-0.0.2}/keywright_server/mcp_server.py +65 -4
- {keywright_server-0.0.1 → keywright_server-0.0.2}/keywright_server/models.py +277 -2
- {keywright_server-0.0.1 → keywright_server-0.0.2}/keywright_server/notify/base.py +19 -0
- {keywright_server-0.0.1 → keywright_server-0.0.2}/keywright_server/notify/homeassistant.py +13 -2
- {keywright_server-0.0.1 → keywright_server-0.0.2}/keywright_server/notify/log.py +5 -1
- {keywright_server-0.0.1 → keywright_server-0.0.2}/keywright_server/notify/manager.py +4 -1
- {keywright_server-0.0.1 → keywright_server-0.0.2}/keywright_server/notify/webhook.py +12 -0
- {keywright_server-0.0.1 → keywright_server-0.0.2}/keywright_server/policy.py +4 -1
- keywright_server-0.0.2/keywright_server/repositories.py +418 -0
- keywright_server-0.0.2/keywright_server/shopping.py +155 -0
- keywright_server-0.0.2/keywright_server/shopping_mcp.py +241 -0
- {keywright_server-0.0.1 → keywright_server-0.0.2}/keywright_server/ssh.py +8 -1
- keywright_server-0.0.2/keywright_server/task_notifications.py +52 -0
- keywright_server-0.0.2/keywright_server/tasks.py +483 -0
- keywright_server-0.0.2/keywright_server/tasks_mcp.py +88 -0
- {keywright_server-0.0.1 → keywright_server-0.0.2}/keywright_server/web/admin.py +78 -5
- {keywright_server-0.0.1 → keywright_server-0.0.2}/keywright_server/web/app.py +23 -1
- keywright_server-0.0.2/keywright_server/web/assets/brands/ATTRIBUTION.txt +28 -0
- keywright_server-0.0.2/keywright_server/web/assets/brands/JELLYFIN-LICENSE.txt +428 -0
- keywright_server-0.0.2/keywright_server/web/assets/brands/PI-HOLE-LICENSE.txt +21 -0
- keywright_server-0.0.2/keywright_server/web/assets/brands/jellyfin-dark.svg +19 -0
- keywright_server-0.0.2/keywright_server/web/assets/brands/jellyfin-light.svg +19 -0
- keywright_server-0.0.2/keywright_server/web/assets/brands/pihole.svg +16 -0
- keywright_server-0.0.2/keywright_server/web/assets/brands/sources.json +78 -0
- keywright_server-0.0.2/keywright_server/web/assets/github-picker.js +18 -0
- keywright_server-0.0.2/keywright_server/web/assets/work.css +7 -0
- keywright_server-0.0.2/keywright_server/web/assets/work.js +74 -0
- keywright_server-0.0.2/keywright_server/web/cli_setup_ui.py +58 -0
- keywright_server-0.0.2/keywright_server/web/github_ui.py +191 -0
- {keywright_server-0.0.1 → keywright_server-0.0.2}/keywright_server/web/knowledge_ui.py +72 -7
- keywright_server-0.0.2/keywright_server/web/repositories_ui.py +182 -0
- {keywright_server-0.0.1 → keywright_server-0.0.2}/keywright_server/web/security.py +1 -1
- keywright_server-0.0.2/keywright_server/web/templates/_checkout.html +21 -0
- {keywright_server-0.0.1 → keywright_server-0.0.2}/keywright_server/web/templates/_collections_style.html +1 -1
- {keywright_server-0.0.1 → keywright_server-0.0.2}/keywright_server/web/templates/_constraints.html +3 -0
- {keywright_server-0.0.1 → keywright_server-0.0.2}/keywright_server/web/templates/_decide.html +8 -1
- keywright_server-0.0.2/keywright_server/web/templates/_expiry_script.html +13 -0
- {keywright_server-0.0.1 → keywright_server-0.0.2}/keywright_server/web/templates/_icons.html +10 -1
- keywright_server-0.0.2/keywright_server/web/templates/_integration_availability.html +1 -0
- keywright_server-0.0.2/keywright_server/web/templates/_integration_mark.html +12 -0
- {keywright_server-0.0.1 → keywright_server-0.0.2}/keywright_server/web/templates/_integration_style.html +4 -0
- {keywright_server-0.0.1 → keywright_server-0.0.2}/keywright_server/web/templates/_inventory_style.html +2 -2
- keywright_server-0.0.2/keywright_server/web/templates/_knowledge_card.html +13 -0
- keywright_server-0.0.2/keywright_server/web/templates/_knowledge_graph.js +256 -0
- {keywright_server-0.0.1 → keywright_server-0.0.2}/keywright_server/web/templates/_live.html +3 -3
- {keywright_server-0.0.1 → keywright_server-0.0.2}/keywright_server/web/templates/_request_card.html +5 -1
- {keywright_server-0.0.1 → keywright_server-0.0.2}/keywright_server/web/templates/base.html +5 -2
- keywright_server-0.0.2/keywright_server/web/templates/cli_setup.html +39 -0
- {keywright_server-0.0.1 → keywright_server-0.0.2}/keywright_server/web/templates/dashboard.html +5 -4
- {keywright_server-0.0.1 → keywright_server-0.0.2}/keywright_server/web/templates/decision.html +3 -1
- {keywright_server-0.0.1 → keywright_server-0.0.2}/keywright_server/web/templates/integration_picker.html +4 -2
- {keywright_server-0.0.1 → keywright_server-0.0.2}/keywright_server/web/templates/knowledge.html +4 -2
- {keywright_server-0.0.1 → keywright_server-0.0.2}/keywright_server/web/templates/knowledge_document.html +1 -0
- keywright_server-0.0.2/keywright_server/web/templates/knowledge_graph.html +48 -0
- {keywright_server-0.0.1 → keywright_server-0.0.2}/keywright_server/web/templates/knowledge_proposal.html +2 -2
- {keywright_server-0.0.1 → keywright_server-0.0.2}/keywright_server/web/templates/request_detail.html +7 -1
- {keywright_server-0.0.1 → keywright_server-0.0.2}/keywright_server/web/templates/resource_form.html +47 -4
- {keywright_server-0.0.1 → keywright_server-0.0.2}/keywright_server/web/templates/resources.html +15 -5
- keywright_server-0.0.2/keywright_server/web/templates/work.html +42 -0
- keywright_server-0.0.2/keywright_server/web/templates/work_github.html +8 -0
- keywright_server-0.0.2/keywright_server/web/templates/work_github_attach.html +20 -0
- keywright_server-0.0.2/keywright_server/web/templates/work_github_discussion.html +4 -0
- keywright_server-0.0.2/keywright_server/web/templates/work_github_picker.html +31 -0
- keywright_server-0.0.2/keywright_server/web/templates/work_repositories.html +13 -0
- keywright_server-0.0.2/keywright_server/web/templates/work_repository.html +16 -0
- keywright_server-0.0.2/keywright_server/web/templates/work_task.html +31 -0
- keywright_server-0.0.2/keywright_server/web/work_ui.py +239 -0
- {keywright_server-0.0.1 → keywright_server-0.0.2}/pyproject.toml +2 -2
- keywright_server-0.0.1/keywright_server/integrations.py +0 -64
- {keywright_server-0.0.1 → keywright_server-0.0.2}/.gitignore +0 -0
- {keywright_server-0.0.1 → keywright_server-0.0.2}/LICENSE +0 -0
- {keywright_server-0.0.1 → keywright_server-0.0.2}/NOTICE +0 -0
- {keywright_server-0.0.1 → keywright_server-0.0.2}/keywright_server/__main__.py +0 -0
- {keywright_server-0.0.1 → keywright_server-0.0.2}/keywright_server/access_view.py +0 -0
- {keywright_server-0.0.1 → keywright_server-0.0.2}/keywright_server/actions.py +0 -0
- {keywright_server-0.0.1 → keywright_server-0.0.2}/keywright_server/audit.py +0 -0
- {keywright_server-0.0.1 → keywright_server-0.0.2}/keywright_server/bundles.py +0 -0
- {keywright_server-0.0.1 → keywright_server-0.0.2}/keywright_server/config.py +0 -0
- {keywright_server-0.0.1 → keywright_server-0.0.2}/keywright_server/discovery.py +0 -0
- {keywright_server-0.0.1 → keywright_server-0.0.2}/keywright_server/events.py +0 -0
- {keywright_server-0.0.1 → keywright_server-0.0.2}/keywright_server/friction.py +0 -0
- {keywright_server-0.0.1 → keywright_server-0.0.2}/keywright_server/inventory.py +0 -0
- {keywright_server-0.0.1 → keywright_server-0.0.2}/keywright_server/inventory_mcp.py +0 -0
- {keywright_server-0.0.1 → keywright_server-0.0.2}/keywright_server/jev.py +0 -0
- {keywright_server-0.0.1 → keywright_server-0.0.2}/keywright_server/knowledge_chunking.py +0 -0
- {keywright_server-0.0.1 → keywright_server-0.0.2}/keywright_server/knowledge_index.py +0 -0
- {keywright_server-0.0.1 → keywright_server-0.0.2}/keywright_server/llm.py +0 -0
- {keywright_server-0.0.1 → keywright_server-0.0.2}/keywright_server/llm_proxy.py +0 -0
- {keywright_server-0.0.1 → keywright_server-0.0.2}/keywright_server/llm_usage.py +0 -0
- {keywright_server-0.0.1 → keywright_server-0.0.2}/keywright_server/mail.py +0 -0
- {keywright_server-0.0.1 → keywright_server-0.0.2}/keywright_server/network_map.py +0 -0
- {keywright_server-0.0.1 → keywright_server-0.0.2}/keywright_server/notify/__init__.py +0 -0
- {keywright_server-0.0.1 → keywright_server-0.0.2}/keywright_server/nudges.py +0 -0
- {keywright_server-0.0.1 → keywright_server-0.0.2}/keywright_server/omada.py +0 -0
- {keywright_server-0.0.1 → keywright_server-0.0.2}/keywright_server/policy_preview.py +0 -0
- {keywright_server-0.0.1 → keywright_server-0.0.2}/keywright_server/reconciliation.py +0 -0
- {keywright_server-0.0.1 → keywright_server-0.0.2}/keywright_server/result_inspection.py +0 -0
- {keywright_server-0.0.1 → keywright_server-0.0.2}/keywright_server/semantic.py +0 -0
- {keywright_server-0.0.1 → keywright_server-0.0.2}/keywright_server/semantic_questions.json +0 -0
- {keywright_server-0.0.1 → keywright_server-0.0.2}/keywright_server/settings_store.py +0 -0
- {keywright_server-0.0.1 → keywright_server-0.0.2}/keywright_server/tool_access.py +0 -0
- {keywright_server-0.0.1 → keywright_server-0.0.2}/keywright_server/usage.py +0 -0
- {keywright_server-0.0.1 → keywright_server-0.0.2}/keywright_server/web/__init__.py +0 -0
- {keywright_server-0.0.1 → keywright_server-0.0.2}/keywright_server/web/auth.py +0 -0
- {keywright_server-0.0.1 → keywright_server-0.0.2}/keywright_server/web/discovery_ui.py +0 -0
- {keywright_server-0.0.1 → keywright_server-0.0.2}/keywright_server/web/friction_ui.py +0 -0
- {keywright_server-0.0.1 → keywright_server-0.0.2}/keywright_server/web/inventory_ui.py +0 -0
- {keywright_server-0.0.1 → keywright_server-0.0.2}/keywright_server/web/mail_ui.py +0 -0
- {keywright_server-0.0.1 → keywright_server-0.0.2}/keywright_server/web/mcp_oauth_ui.py +0 -0
- {keywright_server-0.0.1 → keywright_server-0.0.2}/keywright_server/web/network_map_ui.py +0 -0
- {keywright_server-0.0.1 → keywright_server-0.0.2}/keywright_server/web/oidc.py +0 -0
- {keywright_server-0.0.1 → keywright_server-0.0.2}/keywright_server/web/templates/_activity_row.html +0 -0
- {keywright_server-0.0.1 → keywright_server-0.0.2}/keywright_server/web/templates/_agent_setup.html +0 -0
- {keywright_server-0.0.1 → keywright_server-0.0.2}/keywright_server/web/templates/_device_kind_picker.html +0 -0
- {keywright_server-0.0.1 → keywright_server-0.0.2}/keywright_server/web/templates/_hazards.html +0 -0
- {keywright_server-0.0.1 → keywright_server-0.0.2}/keywright_server/web/templates/_infrastructure_nav.html +0 -0
- {keywright_server-0.0.1 → keywright_server-0.0.2}/keywright_server/web/templates/_inventory_forms.html +0 -0
- {keywright_server-0.0.1 → keywright_server-0.0.2}/keywright_server/web/templates/_mail_nav.html +0 -0
- {keywright_server-0.0.1 → keywright_server-0.0.2}/keywright_server/web/templates/_mail_style.html +0 -0
- {keywright_server-0.0.1 → keywright_server-0.0.2}/keywright_server/web/templates/_network_map_script.html +0 -0
- {keywright_server-0.0.1 → keywright_server-0.0.2}/keywright_server/web/templates/_nudge_card.html +0 -0
- {keywright_server-0.0.1 → keywright_server-0.0.2}/keywright_server/web/templates/_patterns.html +0 -0
- {keywright_server-0.0.1 → keywright_server-0.0.2}/keywright_server/web/templates/_policy_preview_results.html +0 -0
- {keywright_server-0.0.1 → keywright_server-0.0.2}/keywright_server/web/templates/_semantic.html +0 -0
- {keywright_server-0.0.1 → keywright_server-0.0.2}/keywright_server/web/templates/_topology_controller.js +0 -0
- {keywright_server-0.0.1 → keywright_server-0.0.2}/keywright_server/web/templates/_topology_layout.js +0 -0
- {keywright_server-0.0.1 → keywright_server-0.0.2}/keywright_server/web/templates/_topology_render.js +0 -0
- {keywright_server-0.0.1 → keywright_server-0.0.2}/keywright_server/web/templates/access.html +0 -0
- {keywright_server-0.0.1 → keywright_server-0.0.2}/keywright_server/web/templates/agents.html +0 -0
- {keywright_server-0.0.1 → keywright_server-0.0.2}/keywright_server/web/templates/audit.html +0 -0
- {keywright_server-0.0.1 → keywright_server-0.0.2}/keywright_server/web/templates/friction.html +0 -0
- {keywright_server-0.0.1 → keywright_server-0.0.2}/keywright_server/web/templates/friction_review.html +0 -0
- {keywright_server-0.0.1 → keywright_server-0.0.2}/keywright_server/web/templates/grants.html +0 -0
- {keywright_server-0.0.1 → keywright_server-0.0.2}/keywright_server/web/templates/inventory.html +0 -0
- {keywright_server-0.0.1 → keywright_server-0.0.2}/keywright_server/web/templates/inventory_asset.html +0 -0
- {keywright_server-0.0.1 → keywright_server-0.0.2}/keywright_server/web/templates/inventory_discovery.html +0 -0
- {keywright_server-0.0.1 → keywright_server-0.0.2}/keywright_server/web/templates/inventory_discovery_review.html +0 -0
- {keywright_server-0.0.1 → keywright_server-0.0.2}/keywright_server/web/templates/inventory_map.html +0 -0
- {keywright_server-0.0.1 → keywright_server-0.0.2}/keywright_server/web/templates/inventory_networks.html +0 -0
- {keywright_server-0.0.1 → keywright_server-0.0.2}/keywright_server/web/templates/inventory_subnet.html +0 -0
- {keywright_server-0.0.1 → keywright_server-0.0.2}/keywright_server/web/templates/inventory_topology.html +0 -0
- {keywright_server-0.0.1 → keywright_server-0.0.2}/keywright_server/web/templates/login.html +0 -0
- {keywright_server-0.0.1 → keywright_server-0.0.2}/keywright_server/web/templates/mail.html +0 -0
- {keywright_server-0.0.1 → keywright_server-0.0.2}/keywright_server/web/templates/mail_rules.html +0 -0
- {keywright_server-0.0.1 → keywright_server-0.0.2}/keywright_server/web/templates/mail_settings.html +0 -0
- {keywright_server-0.0.1 → keywright_server-0.0.2}/keywright_server/web/templates/mcp_oauth_continue.html +0 -0
- {keywright_server-0.0.1 → keywright_server-0.0.2}/keywright_server/web/templates/policies.html +0 -0
- {keywright_server-0.0.1 → keywright_server-0.0.2}/keywright_server/web/templates/requests.html +0 -0
- {keywright_server-0.0.1 → keywright_server-0.0.2}/keywright_server/web/templates/settings.html +0 -0
- {keywright_server-0.0.1 → keywright_server-0.0.2}/keywright_server/web/templates/setup.html +0 -0
- {keywright_server-0.0.1 → keywright_server-0.0.2}/keywright_server/web/templates/usage.html +0 -0
- {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.
|
|
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.
|
|
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
|
|
44
|
-
|
|
45
|
-
|
|
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
|
|
53
|
-
[
|
|
54
|
-
|
|
55
|
-
|
|
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.
|
|
69
|
-
or digest.
|
|
70
|
-
|
|
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
|
|
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):
|
|
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
|
|
139
|
-
|
|
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
|
|
8
|
-
|
|
9
|
-
|
|
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
|
|
17
|
-
[
|
|
18
|
-
|
|
19
|
-
|
|
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.
|
|
33
|
-
or digest.
|
|
34
|
-
|
|
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
|
|
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):
|
|
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
|
|
103
|
-
|
|
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
|
|
@@ -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
|
|
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)
|