@openhands/extensions 0.25.0 → 0.27.0

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 (123) hide show
  1. package/.claude-plugin/marketplace.json +24 -0
  2. package/.codex-plugin/marketplace.json +24 -0
  3. package/.github/pull_request_template.md +17 -2
  4. package/.github/scripts/check_pr_description.py +64 -5
  5. package/.github/scripts/markdown_sections.py +95 -0
  6. package/.github/workflows/build-vscode-app.yml +30 -0
  7. package/.github/workflows/check-extensions.yml +3 -0
  8. package/.github/workflows/tests.yml +26 -0
  9. package/.plugin/marketplace.json +24 -0
  10. package/.release-please-manifest.json +1 -1
  11. package/AGENTS.md +3 -2
  12. package/README.md +9 -3
  13. package/apps/README.md +20 -0
  14. package/apps/catalog.json +10 -0
  15. package/apps/index.d.ts +11 -0
  16. package/apps/index.js +5 -0
  17. package/apps/vscode/README.md +13 -0
  18. package/apps/vscode/canvas-extension.json +19 -0
  19. package/apps/vscode/extension.js +114 -0
  20. package/apps/vscode/scripts/package.mjs +84 -0
  21. package/automations/README.md +7 -6
  22. package/automations/bundle-index.js +15 -15
  23. package/automations/catalog/github-agents-md-maintainer/manifest.json +1 -1
  24. package/automations/catalog/github-pr-reviewer/manifest.json +5 -5
  25. package/automations/catalog/github-stale-ci-pr-closer/manifest.json +3 -3
  26. package/automations/catalog/gitlab-issue-to-mr/manifest.json +1 -1
  27. package/automations/catalog/news-digest/manifest.json +2 -2
  28. package/automations/index.d.ts +9 -1
  29. package/automations/interface.json +9 -0
  30. package/automations/interface.schema.json +37 -2
  31. package/index.d.ts +2 -0
  32. package/index.js +1 -0
  33. package/marketplaces/openhands-extensions.json +24 -0
  34. package/package.json +9 -1
  35. package/plugins/openhands/skills/openhands-api/README.md +1 -1
  36. package/plugins/openhands/skills/openhands-api/SKILL.md +3 -3
  37. package/plugins/openhands/skills/openhands-api/scripts/openhands_api.py +13 -0
  38. package/plugins/openhands/skills/openhands-automation/SKILL.md +66 -4
  39. package/plugins/openhands/skills/openhands-automation/references/custom-automation.md +21 -7
  40. package/plugins/runtime-api-configs/.claude-plugin/plugin.json +20 -0
  41. package/plugins/runtime-api-configs/.codex-plugin/plugin.json +20 -0
  42. package/plugins/runtime-api-configs/.plugin/plugin.json +20 -0
  43. package/plugins/runtime-api-configs/README.md +59 -0
  44. package/plugins/runtime-api-configs/SKILL.md +240 -0
  45. package/plugins/runtime-api-configs/scripts/warm_runtime_configs.py +331 -0
  46. package/plugins/runtime-api-configs/tests/test_warm_runtime_configs.py +338 -0
  47. package/plugins/vulnerability-remediation/skills/github/scripts/agent_conversation.py +223 -43
  48. package/plugins/vulnerability-remediation/skills/github/scripts/github_client.py +67 -15
  49. package/pyproject.toml +1 -1
  50. package/python/openhands_extensions/_version.py +1 -1
  51. package/scripts/check_plugin_resources.py +116 -0
  52. package/scripts/sync_extensions.py +32 -5
  53. package/skills/canvas-extension-api/SKILL.md +34 -3
  54. package/skills/canvas-extension-api/references/backend-safety.md +14 -0
  55. package/skills/canvas-extension-api/references/connections.md +24 -0
  56. package/skills/canvas-extension-api/references/sidecar-pattern.md +49 -10
  57. package/skills/canvas-extension-api/references/v1-contract.md +53 -3
  58. package/skills/canvas-extension-api/scripts/validate-extension.mjs +52 -0
  59. package/skills/canvas-extension-api/scripts/validate-extension.test.mjs +192 -1
  60. package/skills/github/scripts/agent_conversation.py +223 -43
  61. package/skills/github/scripts/github_client.py +67 -15
  62. package/skills/github-agents-md-maintainer/README.md +7 -0
  63. package/skills/github-agents-md-maintainer/SKILL.md +13 -6
  64. package/skills/github-agents-md-maintainer/scripts/github_client.py +67 -15
  65. package/skills/github-agents-md-maintainer/scripts/main.py +199 -29
  66. package/skills/github-delivery-watchdog/scripts/github_client.py +67 -15
  67. package/skills/github-issue-to-pr/scripts/agent_conversation.py +223 -43
  68. package/skills/github-issue-to-pr/scripts/github_client.py +67 -15
  69. package/skills/github-issue-to-pr/scripts/worker.py +10 -1
  70. package/skills/github-issue-triage/scripts/agent_conversation.py +223 -43
  71. package/skills/github-issue-triage/scripts/github_client.py +67 -15
  72. package/skills/github-pr-reviewer/README.md +5 -4
  73. package/skills/github-pr-reviewer/SKILL.md +8 -17
  74. package/skills/github-pr-reviewer/references/state-schema.md +0 -15
  75. package/skills/github-pr-reviewer/scripts/agent_conversation.py +223 -43
  76. package/skills/github-pr-reviewer/scripts/github_client.py +67 -15
  77. package/skills/github-pr-reviewer/scripts/worker.py +131 -108
  78. package/skills/github-repo-monitor/scripts/github_client.py +67 -15
  79. package/skills/github-stale-ci-pr-closer/scripts/worker.py +223 -50
  80. package/skills/github-stale-ci-pr-closer/tests/test_worker.py +155 -15
  81. package/skills/gitlab-issue-to-mr/README.md +12 -0
  82. package/skills/gitlab-issue-to-mr/SKILL.md +13 -6
  83. package/skills/gitlab-issue-to-mr/scripts/main.py +217 -42
  84. package/skills/incident-retrospective/SKILL.md +5 -5
  85. package/skills/index.js +23 -14
  86. package/skills/jira-issue-to-pr/README.md +12 -4
  87. package/skills/jira-issue-to-pr/SKILL.md +129 -18
  88. package/skills/jira-issue-to-pr/references/setup.md +8 -0
  89. package/skills/jira-issue-to-pr/scripts/main.py +181 -54
  90. package/skills/jira-issue-to-pr/scripts/setup_webhook.py +290 -0
  91. package/skills/linear-triage/SKILL.md +5 -5
  92. package/skills/news-digest/README.md +6 -0
  93. package/skills/news-digest/SKILL.md +19 -9
  94. package/skills/news-digest/commands/news-digest-setup.md +1 -1
  95. package/skills/news-digest/scripts/main.py +133 -15
  96. package/skills/openhands-api/README.md +1 -1
  97. package/skills/openhands-api/SKILL.md +3 -3
  98. package/skills/openhands-api/scripts/openhands_api.py +13 -0
  99. package/skills/openhands-automation/SKILL.md +66 -4
  100. package/skills/openhands-automation/references/custom-automation.md +21 -7
  101. package/skills/research-brief/SKILL.md +5 -5
  102. package/skills/slack-standup-digest/SKILL.md +5 -5
  103. package/skills/ticket-to-code-change/SKILL.md +6 -3
  104. package/skills/use-jev/.claude-plugin/plugin.json +8 -0
  105. package/skills/use-jev/.codex-plugin/plugin.json +8 -0
  106. package/skills/use-jev/.plugin/plugin.json +8 -0
  107. package/skills/use-jev/README.md +66 -0
  108. package/skills/use-jev/SKILL.md +66 -0
  109. package/tests/fixtures/automations/github-pr-reviewer.json +9 -9
  110. package/tests/test_agent_conversation_dispatch.py +212 -0
  111. package/tests/test_apps_catalog.py +26 -0
  112. package/tests/test_automation_setup.py +44 -0
  113. package/tests/test_check_pr_description.py +89 -6
  114. package/tests/test_github_reviewer_delivery.py +254 -118
  115. package/tests/test_github_stale_ci_pr_closer.py +56 -0
  116. package/tests/test_interface_manifest.py +23 -1
  117. package/tests/test_jira_issue_to_pr.py +312 -0
  118. package/tests/test_jira_setup_webhook.py +309 -0
  119. package/tests/test_openhands_api_python.py +37 -0
  120. package/tests/test_skill_plugin_loading.py +25 -15
  121. package/tests/test_sync_extensions.py +102 -0
  122. package/tests/test_vscode_app.py +33 -0
  123. package/tests/vscode-app.test.mjs +43 -0
@@ -0,0 +1,59 @@
1
+ # runtime-api-configs
2
+
3
+ Python CLI for managing warm sandbox pools on OpenHands Enterprise (OHE) via
4
+ the `runtime-api` admin endpoints. This plugin supersedes the ad-hoc
5
+ `warm-runtime-configs.sh` snippets embedded in the docs.
6
+
7
+ - **Skill definition:** [`SKILL.md`](./SKILL.md)
8
+ - **CLI:** [`scripts/warm_runtime_configs.py`](./scripts/warm_runtime_configs.py)
9
+ - **Docs:** <https://docs.openhands.dev/enterprise/custom-sandbox-images/multiple-images-warm-pools>
10
+
11
+ ## Quick start
12
+
13
+ Export the runtime-api URL and the admin password you set in the OHE Admin
14
+ Console (VM installs) or that lives in the `admin-password` Kubernetes
15
+ secret (Helm installs):
16
+
17
+ ```bash
18
+ export RUNTIME_API_URL=https://runtime-api.<your-base-domain>
19
+ export ADMIN_PASSWORD=<the-runtime-api-admin-password>
20
+ ```
21
+
22
+ Then drive the CLI over HTTPS. No `kubectl`, no SSH:
23
+
24
+ ```bash
25
+ # 1. Inspect the effective set
26
+ python3 scripts/warm_runtime_configs.py list
27
+
28
+ # 2. Register a new custom sandbox image
29
+ python3 scripts/warm_runtime_configs.py template v1_current \
30
+ --image ghcr.io/your-org/openhands-php:8.4-v1 --count 1 \
31
+ | python3 scripts/warm_runtime_configs.py save php-web --file -
32
+
33
+ # 3. Delete when finished
34
+ python3 scripts/warm_runtime_configs.py delete php-web
35
+ ```
36
+
37
+ If you would rather pull all three env vars from Kubernetes secrets in one
38
+ step, see the `bootstrap` subcommand in [`SKILL.md`](./SKILL.md) - on
39
+ Replicated VM installs it needs `sudo -E`; on Helm it needs
40
+ `kubectl port-forward` and `--skip-url`.
41
+
42
+ ## Why a plugin instead of a shell script
43
+
44
+ - **Runs anywhere Python 3.9+ is available.**
45
+ - **No third-party dependencies** - pure stdlib (`urllib`, `hashlib`,
46
+ `argparse`).
47
+ - **First-class `template` subcommand** replaces `jq` gymnastics when
48
+ deriving custom configurations from the installer default.
49
+ - **Testable**: `tests/test_warm_runtime_configs.py` exercises the full
50
+ request/response path against an in-process HTTP server.
51
+
52
+ ## Development
53
+
54
+ ```bash
55
+ python3 -m unittest discover -s tests
56
+ ```
57
+
58
+ The tests spin up a stdlib `http.server` on an ephemeral port, so they need
59
+ no cluster access.
@@ -0,0 +1,240 @@
1
+ ---
2
+ name: runtime-api-configs
3
+ description: >-
4
+ Manage warm sandbox pools on OpenHands Enterprise by driving the runtime-api
5
+ admin endpoints (list, save, delete warm runtime configurations) from a
6
+ Python CLI. Use when an OpenHands Enterprise administrator asks to add,
7
+ update, delete, or inspect custom sandbox images, warm pools, or
8
+ `warm-runtime-configs`, or when the docs point to
9
+ `enterprise/custom-sandbox-images/multiple-images-warm-pools`.
10
+ triggers:
11
+ - warm-runtime-config
12
+ - warm runtime config
13
+ - warm pool
14
+ - warm-runtime-configs
15
+ - custom sandbox image
16
+ - runtime-api admin
17
+ ---
18
+
19
+ # Runtime API - Warm Runtime Configurations
20
+
21
+ This plugin manages the warm sandbox pools used by OpenHands Enterprise. It
22
+ replaces the ad-hoc `warm-runtime-configs.sh` shell script embedded in the
23
+ docs and gives administrators a single, testable Python CLI for the same
24
+ workflow.
25
+
26
+ The CLI talks HTTP to the runtime-api service. It never modifies the cluster
27
+ directly, and it uses only the Python standard library, so it runs anywhere
28
+ `python3` is available.
29
+
30
+ ## When to use this skill
31
+
32
+ Reach for this skill whenever the user wants to:
33
+
34
+ - **List** the effective warm-runtime configurations on an OHE install.
35
+ - **Add or update** a custom sandbox image (e.g. `openhands-php:8.4-v1`) so
36
+ users can select it from the **Settings → Application → Default Sandbox**
37
+ dropdown.
38
+ - **Delete** a database-managed configuration, restoring an installer-managed
39
+ entry with the same name.
40
+ - **Refresh configurations after upgrading OHE**, when API-managed frozen
41
+ snapshots need to be re-derived from the new installer template.
42
+ - **Bootstrap credentials** (`RUNTIME_API_URL`, `API_KEY`, `ADMIN_PASSWORD`)
43
+ from Kubernetes secrets so subsequent runs need no cluster access.
44
+
45
+ If the user is instead asking how to **build** a custom sandbox image
46
+ (Dockerfile, versioning), point them at
47
+ [Building a Custom Image](https://docs.openhands.dev/enterprise/custom-sandbox-images/building-custom-images)
48
+ - image building is out of scope for this skill.
49
+
50
+ ## Prerequisites
51
+
52
+ - OpenHands Enterprise **0.64.0 or later**.
53
+ - Python **3.9+** on the machine running the CLI. No third-party packages.
54
+ - The **Runtime API Admin Password**:
55
+ - **VM (Replicated) installs**: set via *Admin Console → Config → Sandbox
56
+ Configuration → Runtime API Admin Password*.
57
+ - **Helm installs**: the value stored in the `admin-password` Kubernetes
58
+ secret in the runtime-api namespace. Helm has no Admin Console.
59
+ - **Helm installs only**: the runtime-api chart must be running in **overlay
60
+ mode** and have warm pools enabled, so `v1_current` shows up in `list` and
61
+ can be used as a `template` source. Set both in your umbrella values:
62
+
63
+ ```yaml
64
+ runtime-api:
65
+ warmRuntimes:
66
+ enabled: true
67
+ env:
68
+ WARM_RUNTIME_CONFIG_OVERLAY: "1"
69
+ ```
70
+
71
+ Overlay mode is on by default on Replicated VM installs. Without it on
72
+ Helm, `list` returns `[]` on a fresh install and `template v1_current`
73
+ fails.
74
+
75
+ ## Setup
76
+
77
+ On any machine with HTTPS reachability to the runtime-api, export two env
78
+ vars:
79
+
80
+ ```bash
81
+ export RUNTIME_API_URL=https://runtime-api.<your-base-domain>
82
+ export ADMIN_PASSWORD=<runtime-api-admin-password> # see Prerequisites above
83
+ ```
84
+
85
+ That is the full setup on any install where the runtime-api ingress is
86
+ reachable from the machine running the CLI. No `kubectl`, no SSH, no
87
+ cluster access. If the runtime-api is cluster-internal (Helm default), see
88
+ the Helm entry in the Advanced section below to port-forward first.
89
+
90
+ Under the hood, the CLI uses the admin password directly for `save` and
91
+ `delete` (via the PBKDF2 handshake), and for `list` and `template` it logs
92
+ in as admin and fetches the `default` read-only API key over HTTPS from
93
+ `/api/admin/api-keys`.
94
+
95
+ If you already know the read-only API key (for example, from an operator
96
+ runbook), export it too and the CLI will skip the extra admin-login round
97
+ trip on reads:
98
+
99
+ ```bash
100
+ export API_KEY=<default api key>
101
+ ```
102
+
103
+ If you have `kubectl` access to the cluster and prefer to pull all three
104
+ env vars from Kubernetes secrets in one step, see
105
+ **[Advanced: bootstrap from Kubernetes](#advanced-bootstrap-from-kubernetes)**
106
+ at the bottom of this file.
107
+
108
+ ## CLI reference
109
+
110
+ Run with `python3 scripts/warm_runtime_configs.py <subcommand>`. Each
111
+ subcommand exits non-zero on error and prints the runtime-api's HTTP body
112
+ on failure.
113
+
114
+ | Subcommand | Auth | Purpose |
115
+ |---|---|---|
116
+ | `list` | `API_KEY` if set, else admin login | Print the effective set of configurations. |
117
+ | `template <source>` | `API_KEY` if set, else admin login | Fetch a config, strip identity fields, override image/count, print JSON ready to `save`. |
118
+ | `save <name> [--file F]` | Admin password | Upsert a configuration. Reads JSON from `--file` or stdin. |
119
+ | `delete <name>` | Admin password | Delete a database-managed configuration. |
120
+ | `bootstrap` | kubectl | Print `export` lines for `RUNTIME_API_URL`, `API_KEY`, `ADMIN_PASSWORD`. Optional; see [Advanced](#advanced-bootstrap-from-kubernetes) below. |
121
+
122
+ Full help: `python3 scripts/warm_runtime_configs.py --help` and
123
+ `python3 scripts/warm_runtime_configs.py <subcommand> --help`.
124
+
125
+ ## Typical workflow
126
+
127
+ Register a new PHP sandbox image once credentials are in the environment:
128
+
129
+ ```bash
130
+ # 1. Derive a config JSON from the current default, changing only the image
131
+ python3 scripts/warm_runtime_configs.py template v1_current \
132
+ --image ghcr.io/your-org/openhands-php:8.4-v1 --count 1 \
133
+ --output php-web.json
134
+
135
+ # 2. Save it
136
+ python3 scripts/warm_runtime_configs.py save php-web --file php-web.json
137
+
138
+ # 3. Confirm
139
+ python3 scripts/warm_runtime_configs.py list
140
+ ```
141
+
142
+ Within about a minute the pool is ready and `php-web` appears in
143
+ **Settings → Application → Default Sandbox**.
144
+
145
+ Deleting an API-managed configuration removes it from the effective set:
146
+
147
+ ```bash
148
+ python3 scripts/warm_runtime_configs.py delete php-web
149
+ ```
150
+
151
+ `delete` operates on database rows only, so it 404s for names that have
152
+ never been saved through this CLI (installer-managed entries like
153
+ `v1_current` on a fresh install fall in that bucket). In overlay mode, if
154
+ you had previously saved a database row that shadowed a same-named
155
+ ConfigMap entry, deleting the row reverts to the ConfigMap entry on the
156
+ next reconciler cycle.
157
+
158
+ ## After an OHE upgrade
159
+
160
+ Frozen API-managed configurations do not follow release bumps. After every
161
+ OHE upgrade, re-derive each API-managed configuration from the refreshed
162
+ default template, keeping each configuration's current image tag and pool
163
+ size unless you deliberately change them:
164
+
165
+ ```bash
166
+ # One "name image count" tuple per API-managed config; edit for your fleet.
167
+ # Preserving the existing pool count matters: hardcoding it would silently
168
+ # shrink pools on every refresh.
169
+ while read -r name image count; do
170
+ [ -z "$name" ] && continue
171
+ python3 scripts/warm_runtime_configs.py template v1_current \
172
+ --image "$image" --count "$count" \
173
+ | python3 scripts/warm_runtime_configs.py save "$name" --file -
174
+ done <<'EOF'
175
+ php-web ghcr.io/your-org/openhands-php:8.4-v2 3
176
+ ruby-app ghcr.io/your-org/openhands-ruby:3.3-v2 2
177
+ node-monorepo ghcr.io/your-org/openhands-node:20-v2 1
178
+ EOF
179
+ ```
180
+
181
+ Two caveats worth spelling out:
182
+
183
+ - `template` refreshes the *command* and *environment* to match the new
184
+ `v1_current`, but it keeps whatever image tag you pass. To actually
185
+ pick up a new agent-server version, the image itself must be rebuilt
186
+ against the matching agent-server base. This loop alone does not do
187
+ that.
188
+ - Skipping this step leaves configurations pointing at the previous
189
+ agent-server command/environment, and new conversations fail with a
190
+ version mismatch until the configurations are updated.
191
+
192
+ ## Environment variables
193
+
194
+ | Variable | Used by | Notes |
195
+ |---|---|---|
196
+ | `RUNTIME_API_URL` | all HTTP subcommands | e.g. `https://runtime-api.example.com` or `http://localhost:5000`. |
197
+ | `ADMIN_PASSWORD` | `save`, `delete`; also `list`/`template` when `API_KEY` is unset | Runs the PBKDF2 challenge-response handshake. |
198
+ | `ADMIN_TOKEN` | Anywhere `ADMIN_PASSWORD` is used | Pre-obtained admin JWT. When set, it wins over `ADMIN_PASSWORD` and skips the handshake. |
199
+ | `API_KEY` | `list`, `template` (optional) | Sent as `X-API-Key`. Optional: when unset, the CLI fetches the `default` key over HTTPS via admin login. |
200
+ | `NAMESPACE` | `bootstrap` | Defaults to `openhands`. Overridable via `--namespace`. |
201
+
202
+ ## Advanced: bootstrap from Kubernetes
203
+
204
+ `bootstrap` extracts all three env vars from Kubernetes secrets in one
205
+ step. Handy for cluster operators and CI, but the default HTTPS-only
206
+ workflow above is preferred for interactive admin use.
207
+
208
+ ### VM (Replicated) install
209
+
210
+ The k0s kubeconfig is root-owned, so the invocation runs under sudo:
211
+
212
+ ```bash
213
+ eval "$(sudo -E python3 scripts/warm_runtime_configs.py bootstrap --namespace openhands)"
214
+ ```
215
+
216
+ ### Helm install
217
+
218
+ The runtime-api is cluster-internal on Helm (chart default is
219
+ `ingress.enabled: false`). The Kubernetes service name is release-prefixed
220
+ via `include "runtime-api.fullname"`, so it is not literally `runtime-api`
221
+ - discover it and port-forward, then bootstrap without touching
222
+ `RUNTIME_API_URL`:
223
+
224
+ ```bash
225
+ SVC=$(kubectl -n openhands get svc \
226
+ -l app.kubernetes.io/name=runtime-api \
227
+ -o jsonpath='{.items[0].metadata.name}')
228
+ kubectl -n openhands port-forward "svc/$SVC" 5000:5000 &
229
+ export RUNTIME_API_URL=http://localhost:5000
230
+ eval "$(python3 scripts/warm_runtime_configs.py bootstrap --namespace openhands --skip-url)"
231
+ ```
232
+
233
+ `--skip-url` leaves `RUNTIME_API_URL` alone. If the operator set
234
+ `nameOverride` on the runtime-api subchart, adjust the label selector to
235
+ match.
236
+
237
+ ## See also
238
+
239
+ - Docs: [Configuring Custom Sandbox Images](https://docs.openhands.dev/enterprise/custom-sandbox-images/multiple-images-warm-pools)
240
+ - Docs: [Using Custom Images](https://docs.openhands.dev/enterprise/custom-sandbox-images/using-custom-images)
@@ -0,0 +1,331 @@
1
+ #!/usr/bin/env python3
2
+ """Manage warm sandbox configurations on the OpenHands Enterprise runtime-api.
3
+
4
+ Standard-library-only CLI. Runs on any host with Python 3.9+, including
5
+ inside the runtime-api pod on Helm installs.
6
+
7
+ Environment variables:
8
+ RUNTIME_API_URL Base URL, e.g. https://runtime-api.example.com
9
+ ADMIN_PASSWORD Password used for the PBKDF2 challenge/response handshake
10
+ API_KEY Sent as X-API-Key on read operations. Optional: when
11
+ unset, list/template log in as admin and fetch the
12
+ 'default' API key over HTTPS via /api/admin/api-keys
13
+ ADMIN_TOKEN Pre-obtained admin JWT; when set, skips the handshake
14
+ NAMESPACE Kubernetes namespace, default 'openhands' (bootstrap only)
15
+
16
+ Exit codes:
17
+ 0 success
18
+ 1 precondition failure (missing env vars, no matching config, etc.)
19
+ 2 HTTP or connection error against the runtime-api, or argparse usage
20
+ error (argparse's own default)
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ import argparse
26
+ import base64
27
+ import binascii
28
+ import hashlib
29
+ import json
30
+ import os
31
+ import shlex
32
+ import shutil
33
+ import subprocess
34
+ import sys
35
+ import urllib.error
36
+ import urllib.parse
37
+ import urllib.request
38
+ from typing import Any
39
+
40
+
41
+ # ---------- HTTP helpers ---------------------------------------------------
42
+
43
+
44
+ def _runtime_api_url() -> str:
45
+ url = os.environ.get("RUNTIME_API_URL", "").rstrip("/")
46
+ if not url:
47
+ _fail(
48
+ "RUNTIME_API_URL is not set. Export it (e.g. "
49
+ "https://runtime-api.<your-base-domain>) or run `bootstrap`."
50
+ )
51
+ return url
52
+
53
+
54
+ def _request(
55
+ path: str,
56
+ method: str = "GET",
57
+ body: Any = None,
58
+ headers: dict[str, str] | None = None,
59
+ ) -> Any:
60
+ api_url = _runtime_api_url()
61
+ req_headers = {"Content-Type": "application/json", **(headers or {})}
62
+ req = urllib.request.Request(
63
+ f"{api_url}{path}", method=method, headers=req_headers
64
+ )
65
+ if body is not None:
66
+ req.data = json.dumps(body).encode()
67
+ try:
68
+ with urllib.request.urlopen(req) as resp:
69
+ payload = resp.read().decode()
70
+ return json.loads(payload) if payload else {}
71
+ except urllib.error.HTTPError as exc:
72
+ _fail(f"HTTP {exc.code} {method} {path}: {exc.read().decode()}", code=2)
73
+ except urllib.error.URLError as exc:
74
+ _fail(f"Could not connect to {api_url}: {exc.reason}", code=2)
75
+
76
+
77
+ # ---------- Auth -----------------------------------------------------------
78
+
79
+
80
+ def _api_key() -> str:
81
+ key = os.environ.get("API_KEY")
82
+ if key:
83
+ return key
84
+ # No API_KEY exported. If admin creds are available, log in and pull the
85
+ # 'default' key value over HTTPS - the same value bootstrap would extract
86
+ # from the k8s secret. Lets an admin drive the CLI end-to-end with just
87
+ # RUNTIME_API_URL + ADMIN_PASSWORD, no cluster access.
88
+ if not (os.environ.get("ADMIN_TOKEN") or os.environ.get("ADMIN_PASSWORD")):
89
+ _fail(
90
+ "API_KEY is not set, and no ADMIN_PASSWORD (or ADMIN_TOKEN) to "
91
+ "fall back to. Either export API_KEY, or export ADMIN_PASSWORD "
92
+ "so the CLI can fetch the default API key over HTTPS."
93
+ )
94
+ token = _admin_token()
95
+ keys = _request(
96
+ "/api/admin/api-keys",
97
+ headers={"Authorization": f"Bearer {token}"},
98
+ )
99
+ for k in keys or []:
100
+ if k.get("name") == "default":
101
+ return k["key_value"]
102
+ _fail("No API key named 'default' returned by /api/admin/api-keys.")
103
+
104
+
105
+ def _admin_token() -> str:
106
+ """Return a valid admin JWT, using ADMIN_TOKEN if set or logging in."""
107
+ token = os.environ.get("ADMIN_TOKEN")
108
+ if token:
109
+ return token
110
+ password = os.environ.get("ADMIN_PASSWORD")
111
+ if not password:
112
+ _fail("ADMIN_PASSWORD (or ADMIN_TOKEN) is not set.")
113
+
114
+ challenge = _request("/api/admin/challenge")
115
+ derived = hashlib.pbkdf2_hmac(
116
+ "sha256",
117
+ password.encode(),
118
+ (challenge["salt"] + challenge["challenge"]).encode(),
119
+ challenge["iterations"],
120
+ dklen=32,
121
+ )
122
+ resp = _request(
123
+ "/api/admin/login",
124
+ method="POST",
125
+ body={
126
+ "challenge": challenge["challenge"],
127
+ "hash": binascii.hexlify(derived).decode(),
128
+ },
129
+ )
130
+ return resp["token"]
131
+
132
+
133
+ # ---------- Subcommand implementations -------------------------------------
134
+
135
+
136
+ def cmd_list(args: argparse.Namespace) -> None:
137
+ resp = _request(
138
+ "/api/warm-runtime-configs", headers={"X-API-Key": _api_key()}
139
+ )
140
+ print(json.dumps(resp["configs"], indent=2))
141
+
142
+
143
+ def cmd_template(args: argparse.Namespace) -> None:
144
+ resp = _request(
145
+ "/api/warm-runtime-configs", headers={"X-API-Key": _api_key()}
146
+ )
147
+ match = next(
148
+ (c for c in resp["configs"] if c.get("name") == args.source), None
149
+ )
150
+ if match is None:
151
+ _fail(f"No configuration named {args.source!r} in the effective set.")
152
+
153
+ # Strip identity fields that must not appear in a save body.
154
+ for field in ("name", "source"):
155
+ match.pop(field, None)
156
+
157
+ if args.image is not None:
158
+ match["image"] = args.image
159
+ if args.count is not None:
160
+ match["count"] = args.count
161
+
162
+ payload = json.dumps(match, indent=2)
163
+ if args.output and args.output != "-":
164
+ with open(args.output, "w") as fh:
165
+ fh.write(payload + "\n")
166
+ else:
167
+ print(payload)
168
+
169
+
170
+ def cmd_save(args: argparse.Namespace) -> None:
171
+ if args.file in (None, "-"):
172
+ raw = sys.stdin.read()
173
+ if not raw.strip():
174
+ _fail("No JSON body on stdin. Pass --file, or pipe JSON in.")
175
+ else:
176
+ with open(args.file) as fh:
177
+ raw = fh.read()
178
+ try:
179
+ body = json.loads(raw)
180
+ except json.JSONDecodeError as exc:
181
+ _fail(f"Invalid JSON: {exc}")
182
+
183
+ name = urllib.parse.quote(args.name, safe="")
184
+ token = _admin_token()
185
+ saved = _request(
186
+ f"/api/admin/warm-runtime-configs/{name}",
187
+ method="PUT",
188
+ body=body,
189
+ headers={"Authorization": f"Bearer {token}"},
190
+ )
191
+ print(
192
+ f"Saved {saved['name']} image={saved['image']} count={saved.get('count')}"
193
+ )
194
+
195
+
196
+ def cmd_delete(args: argparse.Namespace) -> None:
197
+ name = urllib.parse.quote(args.name, safe="")
198
+ token = _admin_token()
199
+ resp = _request(
200
+ f"/api/admin/warm-runtime-configs/{name}",
201
+ method="DELETE",
202
+ headers={"Authorization": f"Bearer {token}"},
203
+ )
204
+ print(resp.get("message", f"Deleted {args.name}"))
205
+
206
+
207
+ def cmd_bootstrap(args: argparse.Namespace) -> None:
208
+ if shutil.which("kubectl") is None:
209
+ _fail("kubectl not found on PATH. bootstrap needs cluster access.")
210
+
211
+ ns = args.namespace
212
+ lines: list[str] = []
213
+
214
+ if not args.skip_url:
215
+ host = _kubectl(
216
+ [
217
+ "get", "ingress", "-n", ns,
218
+ "-l", "app.kubernetes.io/name=runtime-api",
219
+ "-o", "jsonpath={.items[0].spec.rules[0].host}",
220
+ ],
221
+ allow_empty=True,
222
+ )
223
+ if host:
224
+ lines.append(f"export RUNTIME_API_URL={shlex.quote(f'https://{host}')}")
225
+ else:
226
+ print(
227
+ "# No runtime-api ingress found; export RUNTIME_API_URL manually",
228
+ file=sys.stderr,
229
+ )
230
+
231
+ api_key = _decode_secret(ns, "default-api-key", "default-api-key")
232
+ admin_password = _decode_secret(ns, "admin-password", "admin-password")
233
+ lines.append(f"export API_KEY={shlex.quote(api_key)}")
234
+ lines.append(f"export ADMIN_PASSWORD={shlex.quote(admin_password)}")
235
+ print("\n".join(lines))
236
+
237
+
238
+ # ---------- kubectl helpers (bootstrap only) -------------------------------
239
+
240
+
241
+ def _kubectl(argv: list[str], *, allow_empty: bool = False) -> str:
242
+ try:
243
+ out = subprocess.check_output(["kubectl", *argv], stderr=subprocess.PIPE)
244
+ except subprocess.CalledProcessError as exc:
245
+ _fail(
246
+ f"kubectl {' '.join(argv)} failed:\n{exc.stderr.decode()}",
247
+ code=2,
248
+ )
249
+ text = out.decode().strip()
250
+ if not text and not allow_empty:
251
+ _fail(f"kubectl {' '.join(argv)} returned no output.", code=2)
252
+ return text
253
+
254
+
255
+ def _decode_secret(namespace: str, secret: str, key: str) -> str:
256
+ encoded = _kubectl(
257
+ [
258
+ "get", "secret", secret, "-n", namespace,
259
+ "-o", f"jsonpath={{.data.{key}}}",
260
+ ]
261
+ )
262
+ try:
263
+ return base64.b64decode(encoded).decode()
264
+ except (binascii.Error, ValueError) as exc:
265
+ _fail(f"Could not decode secret {secret}/{key}: {exc}", code=2)
266
+
267
+
268
+ # ---------- Utility --------------------------------------------------------
269
+
270
+
271
+ def _fail(msg: str, *, code: int = 1) -> None:
272
+ print(msg, file=sys.stderr)
273
+ sys.exit(code)
274
+
275
+
276
+ def build_parser() -> argparse.ArgumentParser:
277
+ p = argparse.ArgumentParser(
278
+ prog="warm_runtime_configs",
279
+ description="Manage warm sandbox configurations on OpenHands Enterprise.",
280
+ )
281
+ sub = p.add_subparsers(dest="command", required=True)
282
+
283
+ sub.add_parser("list", help="List effective warm runtime configurations.").set_defaults(func=cmd_list)
284
+
285
+ t = sub.add_parser(
286
+ "template",
287
+ help="Fetch a config, strip name/source, override image/count, print JSON.",
288
+ )
289
+ t.add_argument("source", help="Name of an existing config to derive from (e.g. v1_current).")
290
+ t.add_argument("--image", help="Override the image field.")
291
+ t.add_argument("--count", type=int, help="Override the count field.")
292
+ t.add_argument("-o", "--output", help="Write to file instead of stdout ('-' = stdout).")
293
+ t.set_defaults(func=cmd_template)
294
+
295
+ s = sub.add_parser("save", help="Create or update a configuration (upsert).")
296
+ s.add_argument("name", help="Configuration name (URL identity).")
297
+ s.add_argument(
298
+ "--file", "-f",
299
+ help="Path to JSON body. Use '-' or omit to read from stdin.",
300
+ )
301
+ s.set_defaults(func=cmd_save)
302
+
303
+ d = sub.add_parser("delete", help="Delete a database-managed configuration.")
304
+ d.add_argument("name", help="Configuration name to delete.")
305
+ d.set_defaults(func=cmd_delete)
306
+
307
+ b = sub.add_parser(
308
+ "bootstrap",
309
+ help="Print `export` lines for RUNTIME_API_URL, API_KEY, ADMIN_PASSWORD.",
310
+ )
311
+ b.add_argument(
312
+ "--namespace", "-n",
313
+ default=os.environ.get("NAMESPACE", "openhands"),
314
+ help="Kubernetes namespace (default: %(default)s).",
315
+ )
316
+ b.add_argument(
317
+ "--skip-url", action="store_true",
318
+ help="Do not emit RUNTIME_API_URL (useful with `kubectl port-forward`).",
319
+ )
320
+ b.set_defaults(func=cmd_bootstrap)
321
+
322
+ return p
323
+
324
+
325
+ def main(argv: list[str] | None = None) -> None:
326
+ args = build_parser().parse_args(argv)
327
+ args.func(args)
328
+
329
+
330
+ if __name__ == "__main__":
331
+ main()