gdkbox 0.1.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.
- checksums.yaml +7 -0
- data/README.md +334 -0
- data/exe/gdkbox +11 -0
- data/lib/gdkbox/box.rb +293 -0
- data/lib/gdkbox/cli.rb +410 -0
- data/lib/gdkbox/completion.rb +125 -0
- data/lib/gdkbox/config.rb +150 -0
- data/lib/gdkbox/docker.rb +89 -0
- data/lib/gdkbox/harness.rb +136 -0
- data/lib/gdkbox/provisioner.rb +183 -0
- data/lib/gdkbox/shell.rb +57 -0
- data/lib/gdkbox/skills.rb +127 -0
- data/lib/gdkbox/ssh_config.rb +53 -0
- data/lib/gdkbox/ssh_key.rb +36 -0
- data/lib/gdkbox/store.rb +47 -0
- data/lib/gdkbox/version.rb +5 -0
- data/lib/gdkbox/vscode.rb +26 -0
- data/lib/gdkbox.rb +20 -0
- data/skills/gdkbox-fleet/SKILL.md +170 -0
- metadata +95 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: e21ef2a459e26b8fa85bc98435127d9b32c9659a529c4a6892dedd7085a0e855
|
|
4
|
+
data.tar.gz: 506f31bc04b24adb46b25e122e9ae320caab6d68c7a80e4c3b93f431ac8fda80
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 2e4a771f534a6c6abfb283dde4b162aa2ea594ef2638f74b5c4eaa49c233ba6eb0a690a7e879b42cfd8c065ff70b202f91bf30df120229af8c5aa2e92fe592ca
|
|
7
|
+
data.tar.gz: 1fb3efe4175029720b3272eebab8f5b2f083de8262a66655a055267234cc473019712f9a13a8894718c9329d7e20bdf4bdbcd6450e70a6143e9d92459e29f699
|
data/README.md
ADDED
|
@@ -0,0 +1,334 @@
|
|
|
1
|
+
# gdkbox
|
|
2
|
+
|
|
3
|
+
Spin up a [GitLab Development Kit (GDK)](https://gitlab.com/gitlab-org/gitlab-development-kit)
|
|
4
|
+
"in a box" with a single command, then connect to it from **VS Code** and run
|
|
5
|
+
**Claude Code** agents against a real GitLab development environment.
|
|
6
|
+
|
|
7
|
+
`gdkbox` runs GitLab's official GDK image in a Docker container, enables SSH so
|
|
8
|
+
VS Code Remote-SSH can attach, and installs Claude Code inside the box for you.
|
|
9
|
+
|
|
10
|
+
```
|
|
11
|
+
gdkbox up myenv # pull GDK image, start container, wire SSH + Claude Code
|
|
12
|
+
gdkbox code myenv # open the box in VS Code (Remote-SSH)
|
|
13
|
+
gdkbox ssh myenv # drop into a shell inside the box
|
|
14
|
+
gdkbox ls # list your boxes and their status
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
## How it works
|
|
18
|
+
|
|
19
|
+
```
|
|
20
|
+
gdkbox up demo
|
|
21
|
+
│
|
|
22
|
+
┌──────────────┼─────────────────────────────────────────────┐
|
|
23
|
+
│ ▼ │
|
|
24
|
+
│ 1. docker pull <official GDK image> │
|
|
25
|
+
│ 2. docker run -p 127.0.0.1:2222:22 -p 127.0.0.1:3000:3000
|
|
26
|
+
│ 3. provision install + start sshd, authorize gdkbox key │
|
|
27
|
+
│ 4. provision npm install -g @anthropic-ai/claude-code │
|
|
28
|
+
│ 5. register Host gdkbox-demo -> ~/.gdkbox/ssh_config │
|
|
29
|
+
└──────────────────────────────────────────────────────────────┘
|
|
30
|
+
│
|
|
31
|
+
┌───────────┴────────────┐
|
|
32
|
+
▼ ▼
|
|
33
|
+
VS Code Remote-SSH ssh gdkbox-demo -t claude
|
|
34
|
+
(ssh-remote+gdkbox-demo) (run agents in the box)
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
- **Infra:** a Docker container per box, ports published to `127.0.0.1`.
|
|
38
|
+
- **Image:** GitLab's official GDK-in-a-box image (overridable).
|
|
39
|
+
- **Editor:** VS Code Remote-SSH, via a generated `~/.gdkbox/ssh_config`
|
|
40
|
+
that is `Include`d from your `~/.ssh/config`.
|
|
41
|
+
- **Agents:** Claude Code is installed inside each box.
|
|
42
|
+
|
|
43
|
+
## Requirements
|
|
44
|
+
|
|
45
|
+
- Ruby >= 3.0
|
|
46
|
+
- [Docker](https://docs.docker.com/get-docker/) installed and running
|
|
47
|
+
- `ssh` and `ssh-keygen` (standard on macOS/Linux)
|
|
48
|
+
- [VS Code](https://code.visualstudio.com/) with the **Remote - SSH**
|
|
49
|
+
extension, and the `code` command on your `PATH` (optional, only for
|
|
50
|
+
`gdkbox code`)
|
|
51
|
+
|
|
52
|
+
## Install
|
|
53
|
+
|
|
54
|
+
From this directory:
|
|
55
|
+
|
|
56
|
+
```sh
|
|
57
|
+
gem build gdkbox.gemspec
|
|
58
|
+
gem install ./gdkbox-0.1.0.gem
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
Or run it straight from a checkout without installing:
|
|
62
|
+
|
|
63
|
+
```sh
|
|
64
|
+
./bin/gdkbox up myenv
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
## Commands
|
|
68
|
+
|
|
69
|
+
| Command | Description |
|
|
70
|
+
| --- | --- |
|
|
71
|
+
| `gdkbox up NAME` | Pull the GDK image, start a box, enable SSH, install Claude Code, register VS Code host. |
|
|
72
|
+
| `gdkbox ls` | List boxes and their container status (`--json` for orchestrators). |
|
|
73
|
+
| `gdkbox status NAME` | Show container state and connection details (`--json`). |
|
|
74
|
+
| `gdkbox dispatch NAME` | Run a Claude Code agent task headlessly in the box (`--task`/`--task-file`). |
|
|
75
|
+
| `gdkbox code NAME` | Open the box in VS Code via Remote-SSH. |
|
|
76
|
+
| `gdkbox ssh NAME` | Open an interactive SSH session into the box. |
|
|
77
|
+
| `gdkbox claude NAME` | Install Claude Code inside the box. |
|
|
78
|
+
| `gdkbox set-key NAME` | Seed/rotate the Anthropic API key in the box for unattended dispatch. |
|
|
79
|
+
| `gdkbox start NAME` | Start a stopped box (and re-enable SSH). |
|
|
80
|
+
| `gdkbox stop NAME` | Stop a running box. |
|
|
81
|
+
| `gdkbox rm NAME` | Remove a box: container, metadata, and SSH entry. |
|
|
82
|
+
| `gdkbox install-skill [NAME]` | Install the agent skill(s) bundled in this repo into your **host** Claude Code (`--project`, `--force`). |
|
|
83
|
+
| `gdkbox skills` | List agent skills available to install (bundled + `~/.claude/skills` + `./.claude/skills`). |
|
|
84
|
+
| `gdkbox add-skill BOX [SKILL...]` | Install skill(s) into a **box** so dispatched agents can use them; interactive picker if no SKILL (`--force`). |
|
|
85
|
+
| `gdkbox completion SHELL` | Print a bash or zsh completion script (completes subcommands, flags, live box and skill names). |
|
|
86
|
+
| `gdkbox harnesses` | List the agent harnesses gdkbox can install (claude, codex, opencode, pi). |
|
|
87
|
+
| `gdkbox install-agent NAME` | (Re)install the box's agent harness inside it. |
|
|
88
|
+
|
|
89
|
+
### `gdkbox up` options
|
|
90
|
+
|
|
91
|
+
| Option | Default | Description |
|
|
92
|
+
| --- | --- | --- |
|
|
93
|
+
| `--image` | official GDK image | Override the container image. |
|
|
94
|
+
| `--ssh-port` | next free from 2222 | Host port to publish SSH on. |
|
|
95
|
+
| `--web-port` | next free from 3000 | Host port to publish the GDK web UI on. |
|
|
96
|
+
| `--harness ID` | `claude` (or config.yml) | Agent harness to install: claude, codex, opencode, pi. |
|
|
97
|
+
| `--no-agent` | (agent installed) | Skip installing the agent harness. |
|
|
98
|
+
| `--skill NAME [NAME...]` | (none) | Seed skill(s) into the box at creation for dispatched agents. |
|
|
99
|
+
|
|
100
|
+
## Typical workflow
|
|
101
|
+
|
|
102
|
+
```sh
|
|
103
|
+
# 1. Create a box (first run pulls a large image, so give it a few minutes)
|
|
104
|
+
gdkbox up demo
|
|
105
|
+
|
|
106
|
+
# 2. Edit the code with VS Code Remote-SSH
|
|
107
|
+
gdkbox code demo
|
|
108
|
+
|
|
109
|
+
# 3. Inside the box, start GDK and run an agent
|
|
110
|
+
gdkbox ssh demo
|
|
111
|
+
# gdk start # boot the GitLab services
|
|
112
|
+
# claude # launch a Claude Code agent against the repo
|
|
113
|
+
|
|
114
|
+
# 4. The GitLab web UI is published locally
|
|
115
|
+
open http://127.0.0.1:3000
|
|
116
|
+
|
|
117
|
+
# 5. Stop or remove the box when you're done
|
|
118
|
+
gdkbox stop demo
|
|
119
|
+
gdkbox rm demo
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
## Orchestrating a fleet of agents
|
|
123
|
+
|
|
124
|
+
The end goal of `gdkbox` is to back an **orchestrator** that runs a pool of
|
|
125
|
+
reusable boxes and dispatches a Claude Code agent into each to execute tasks in
|
|
126
|
+
parallel. Two primitives make this possible:
|
|
127
|
+
|
|
128
|
+
- **Headless agent runs:** `gdkbox dispatch NAME --task "..."` runs `claude -p`
|
|
129
|
+
non-interactively inside the box and streams the agent's output. Exit status
|
|
130
|
+
mirrors the agent's. Add `--json` for structured output and `--timeout N` to
|
|
131
|
+
bound a run.
|
|
132
|
+
- **Machine-readable state:** `gdkbox ls --json` / `gdkbox status NAME --json`
|
|
133
|
+
emit JSON descriptors so an orchestrator can see which boxes exist and their
|
|
134
|
+
state.
|
|
135
|
+
|
|
136
|
+
For **unattended** dispatch the agents need Anthropic credentials. Seed an API
|
|
137
|
+
key into each box (kept only inside the container, in a `0600` file owned by the
|
|
138
|
+
GDK user — never in host-side metadata). `gdkbox up` and `gdkbox set-key` both
|
|
139
|
+
default to the `ANTHROPIC_API_KEY` environment variable:
|
|
140
|
+
|
|
141
|
+
```sh
|
|
142
|
+
export ANTHROPIC_API_KEY=sk-ant-...
|
|
143
|
+
|
|
144
|
+
# Warm a pool of 3 boxes (in parallel; first run pulls a large image).
|
|
145
|
+
# The key is seeded automatically because ANTHROPIC_API_KEY is set.
|
|
146
|
+
for i in 1 2 3; do gdkbox up "pool-$i" --json & done; wait
|
|
147
|
+
|
|
148
|
+
# Or seed/rotate the key on existing boxes:
|
|
149
|
+
gdkbox set-key pool-1
|
|
150
|
+
|
|
151
|
+
# Fan three tasks out, one per box. Agents authenticate with the seeded key.
|
|
152
|
+
gdkbox dispatch pool-1 --task "Run the test suite and fix the first failure" --json &
|
|
153
|
+
gdkbox dispatch pool-2 --task "Update the README install section" --json &
|
|
154
|
+
gdkbox dispatch pool-3 --task "Add a changelog entry" --json &
|
|
155
|
+
wait
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
`gdkbox ls --json` reports `"api_key_set": true|false` per box so an
|
|
159
|
+
orchestrator can tell which boxes are ready for unattended work. Prefer the
|
|
160
|
+
`ANTHROPIC_API_KEY` env var over `--anthropic-api-key`, which can leak into
|
|
161
|
+
shell history.
|
|
162
|
+
|
|
163
|
+
In this repo the orchestrator is meant to be **another Claude Code session**,
|
|
164
|
+
guided by the bundled **`gdkbox-fleet` skill** at
|
|
165
|
+
[`skills/gdkbox-fleet/SKILL.md`](skills/gdkbox-fleet/SKILL.md),
|
|
166
|
+
which documents the pool model, dispatch loop, state-reset-between-tasks
|
|
167
|
+
caveat, and guardrails.
|
|
168
|
+
|
|
169
|
+
### Installing the skill
|
|
170
|
+
|
|
171
|
+
The skills shipped with this repo live in the top-level
|
|
172
|
+
[`skills/`](skills/) directory. Install them into Claude Code with:
|
|
173
|
+
|
|
174
|
+
```sh
|
|
175
|
+
# Install every bundled skill into ~/.claude/skills (available everywhere)
|
|
176
|
+
gdkbox install-skill
|
|
177
|
+
|
|
178
|
+
# Install one skill, into the current project only
|
|
179
|
+
gdkbox install-skill gdkbox-fleet --project
|
|
180
|
+
|
|
181
|
+
# Re-install over an existing copy
|
|
182
|
+
gdkbox install-skill gdkbox-fleet --force
|
|
183
|
+
```
|
|
184
|
+
|
|
185
|
+
This copies each skill verbatim into `~/.claude/skills/<name>` (or
|
|
186
|
+
`./.claude/skills/<name>` with `--project`). Start a new Claude Code session
|
|
187
|
+
afterwards to pick it up.
|
|
188
|
+
|
|
189
|
+
### Giving skills to agents in a box
|
|
190
|
+
|
|
191
|
+
`install-skill` targets your **host** Claude Code. Agents dispatched into a box
|
|
192
|
+
(`gdkbox dispatch`) run *inside* the container and read the box's own
|
|
193
|
+
`~/.claude/skills`, so they need the skill installed there. Skills can come
|
|
194
|
+
from this repo (`skills/`), your host (`~/.claude/skills`), your project
|
|
195
|
+
(`./.claude/skills`), or any directory path.
|
|
196
|
+
|
|
197
|
+
```sh
|
|
198
|
+
# See everything gdkbox can install (bundled + host + project), with summaries
|
|
199
|
+
gdkbox skills
|
|
200
|
+
|
|
201
|
+
# Install skill(s) into a running box; agents dispatched there can then use them
|
|
202
|
+
gdkbox add-skill pool-1 gdkbox-fleet
|
|
203
|
+
gdkbox add-skill pool-1 ./path/to/my-skill --force
|
|
204
|
+
|
|
205
|
+
# No skill given → list and pick interactively
|
|
206
|
+
gdkbox add-skill pool-1
|
|
207
|
+
|
|
208
|
+
# Or seed skills when the box is first created
|
|
209
|
+
gdkbox up pool-1 --skill gdkbox-fleet --skill code-history
|
|
210
|
+
```
|
|
211
|
+
|
|
212
|
+
Each skill is copied to `~/.claude/skills/<name>` inside the box and owned by
|
|
213
|
+
the GDK user, so a dispatched `claude` agent discovers it automatically.
|
|
214
|
+
|
|
215
|
+
## Agent harnesses
|
|
216
|
+
|
|
217
|
+
Each box runs one agent **harness**, chosen at `gdkbox up --harness` (default
|
|
218
|
+
`claude`, or the `harness:` in config.yml). `gdkbox dispatch` then runs whatever
|
|
219
|
+
harness the box was created with.
|
|
220
|
+
|
|
221
|
+
| Harness | CLI | Headless dispatch | Default key env | Skills dir (in box) |
|
|
222
|
+
| --- | --- | --- | --- | --- |
|
|
223
|
+
| `claude` | Claude Code | `claude -p` | `ANTHROPIC_API_KEY` | `~/.claude/skills` |
|
|
224
|
+
| `codex` | OpenAI Codex | `codex exec` | `OPENAI_API_KEY` | `~/.codex/skills` |
|
|
225
|
+
| `opencode` | opencode (sst) | `opencode run` | `OPENAI_API_KEY` | `~/.config/opencode/skills` |
|
|
226
|
+
| `pi` | pi (earendil-works) | `pi -p` | `ANTHROPIC_API_KEY` | `~/.pi/skills` |
|
|
227
|
+
|
|
228
|
+
```sh
|
|
229
|
+
gdkbox up reviewer --harness codex # a Codex box
|
|
230
|
+
gdkbox set-key reviewer # seeds $OPENAI_API_KEY
|
|
231
|
+
gdkbox add-skill reviewer my-skill # installs into ~/.codex/skills
|
|
232
|
+
gdkbox dispatch reviewer --task "review the diff"
|
|
233
|
+
gdkbox harnesses # list available harnesses
|
|
234
|
+
```
|
|
235
|
+
|
|
236
|
+
Skills use the same `SKILL.md` format across all four, so a skill installed by
|
|
237
|
+
`add-skill`/`--skill` works regardless of harness. The key env / provider for
|
|
238
|
+
`opencode` and `pi` are configurable defaults — adjust per your provider.
|
|
239
|
+
|
|
240
|
+
## Shell completion
|
|
241
|
+
|
|
242
|
+
`gdkbox completion <shell>` prints a completion script for `bash` or `zsh`. It
|
|
243
|
+
completes subcommands and flags, and — dynamically, by calling back into
|
|
244
|
+
`gdkbox` — live **box names** (for `ssh`, `dispatch`, `rm`, …) and **skill
|
|
245
|
+
names** (for `add-skill`, `install-skill`, and `--skill`).
|
|
246
|
+
|
|
247
|
+
```sh
|
|
248
|
+
# bash — add to ~/.bashrc
|
|
249
|
+
eval "$(gdkbox completion bash)"
|
|
250
|
+
|
|
251
|
+
# zsh — add to ~/.zshrc (after `compinit` has run)
|
|
252
|
+
eval "$(gdkbox completion zsh)"
|
|
253
|
+
```
|
|
254
|
+
|
|
255
|
+
Or write it to your completions directory, e.g.
|
|
256
|
+
`gdkbox completion bash > $(brew --prefix)/etc/bash_completion.d/gdkbox`.
|
|
257
|
+
|
|
258
|
+
## Configuration
|
|
259
|
+
|
|
260
|
+
| Environment variable | Purpose |
|
|
261
|
+
| --- | --- |
|
|
262
|
+
| `GDKBOX_HOME` | Where gdkbox stores keys, box metadata, and the SSH config (default `~/.gdkbox`). |
|
|
263
|
+
| `GDKBOX_IMAGE` | Default image used by `gdkbox up` when `--image` is not given. |
|
|
264
|
+
|
|
265
|
+
### `config.yml`
|
|
266
|
+
|
|
267
|
+
Defaults for `gdkbox up` can be set in `~/.gdkbox/config.yml` (or
|
|
268
|
+
`$GDKBOX_HOME/config.yml`). Most usefully, list skills to transfer into every
|
|
269
|
+
new box so dispatched agents always have them:
|
|
270
|
+
|
|
271
|
+
```yaml
|
|
272
|
+
# ~/.gdkbox/config.yml
|
|
273
|
+
|
|
274
|
+
# Default image for `gdkbox up` (overridden by --image or $GDKBOX_IMAGE).
|
|
275
|
+
# image: registry.gitlab.com/gitlab-org/gitlab-development-kit/gitlab-gdk-in-a-box:main
|
|
276
|
+
|
|
277
|
+
# Default agent harness for `gdkbox up` (overridden by --harness).
|
|
278
|
+
# harness: claude
|
|
279
|
+
|
|
280
|
+
# Skills transferred into every new box. Each entry is a discovered skill
|
|
281
|
+
# name (see `gdkbox skills`) or a path to a skill directory.
|
|
282
|
+
skills:
|
|
283
|
+
- gdkbox-fleet
|
|
284
|
+
# - code-history
|
|
285
|
+
# - ./path/to/a/local/skill
|
|
286
|
+
|
|
287
|
+
# API keys seeded into boxes for unattended `gdkbox dispatch`, per provider.
|
|
288
|
+
# anthropic_api_key: sk-ant-... # used by the claude / pi harnesses
|
|
289
|
+
# openai_api_key: sk-... # used by the codex / opencode harnesses
|
|
290
|
+
```
|
|
291
|
+
|
|
292
|
+
`gdkbox up` installs the skills on top of any `--skill` you pass; use
|
|
293
|
+
`--no-default-skills` to skip them for one run. Precedence for the image is
|
|
294
|
+
`--image` > `$GDKBOX_IMAGE` > `config.yml` > built-in default; for the API key
|
|
295
|
+
it is `--api-key` > the harness's provider env var (e.g. `$ANTHROPIC_API_KEY`,
|
|
296
|
+
`$OPENAI_API_KEY`) > `config.yml`.
|
|
297
|
+
|
|
298
|
+
> **API keys in `config.yml` are plaintext secrets on disk.** Prefer the
|
|
299
|
+
> provider env var; if you do put a key in `config.yml`, keep the file mode
|
|
300
|
+
> `600` (`chmod 600 ~/.gdkbox/config.yml`).
|
|
301
|
+
|
|
302
|
+
State on disk:
|
|
303
|
+
|
|
304
|
+
```
|
|
305
|
+
~/.gdkbox/
|
|
306
|
+
├── config.yml # optional; defaults for `gdkbox up`
|
|
307
|
+
├── boxes/<name>.json # metadata for each box
|
|
308
|
+
├── keys/id_ed25519(.pub) # dedicated keypair authorized into every box
|
|
309
|
+
└── ssh_config # generated; Included from ~/.ssh/config
|
|
310
|
+
```
|
|
311
|
+
|
|
312
|
+
`gdkbox` never touches your personal SSH keys: it creates and uses its own
|
|
313
|
+
`id_ed25519` under `~/.gdkbox/keys`.
|
|
314
|
+
|
|
315
|
+
## Development
|
|
316
|
+
|
|
317
|
+
```sh
|
|
318
|
+
bundle install
|
|
319
|
+
bundle exec rspec # or: ruby -e "require 'rspec/core'; RSpec::Core::Runner.run(['spec'])"
|
|
320
|
+
```
|
|
321
|
+
|
|
322
|
+
The Docker, SSH, and filesystem boundaries are isolated behind small wrapper
|
|
323
|
+
classes (`Docker`, `Shell`, `Store`, `SSHConfig`, `SSHKey`, `Provisioner`,
|
|
324
|
+
`VSCode`), so the orchestration logic in `Box` is fully unit-tested without
|
|
325
|
+
needing a running Docker daemon.
|
|
326
|
+
|
|
327
|
+
## Notes & limitations
|
|
328
|
+
|
|
329
|
+
- Ports are published to `127.0.0.1` only, so boxes are reachable from your
|
|
330
|
+
machine but not the wider network.
|
|
331
|
+
- Processes started via `docker exec` (like `sshd`) do not survive a container
|
|
332
|
+
restart, so `gdkbox start` re-runs the SSH provisioning step to bring it back.
|
|
333
|
+
- Claude Code is installed in the box but still needs your Anthropic
|
|
334
|
+
credentials at runtime — sign in the first time you run `claude` inside it.
|
data/exe/gdkbox
ADDED
data/lib/gdkbox/box.rb
ADDED
|
@@ -0,0 +1,293 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "time"
|
|
4
|
+
|
|
5
|
+
module GDKBox
|
|
6
|
+
# A single GDK-in-a-box instance: a Docker container plus its persisted
|
|
7
|
+
# metadata. This is the orchestration layer the CLI talks to.
|
|
8
|
+
class Box
|
|
9
|
+
# Outcome of a headless agent run inside a box.
|
|
10
|
+
AgentResult = Struct.new(:stdout, :stderr, :status) do
|
|
11
|
+
def success?
|
|
12
|
+
status.zero?
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
attr_reader :name, :config
|
|
17
|
+
|
|
18
|
+
def initialize(name, config:, shell: Shell.new, docker: nil, store: nil, ssh_key: nil)
|
|
19
|
+
@name = name
|
|
20
|
+
@config = config
|
|
21
|
+
@shell = shell
|
|
22
|
+
@docker = docker || Docker.new(shell: shell)
|
|
23
|
+
@store = store || Store.new(config: config)
|
|
24
|
+
@ssh_key = ssh_key || SSHKey.new(config: config, shell: shell)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def self.all(config:, store: nil, **kwargs)
|
|
28
|
+
store ||= Store.new(config: config)
|
|
29
|
+
store.all.map { |data| from_data(data, config: config, store: store, **kwargs) }
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def self.from_data(data, config:, **kwargs)
|
|
33
|
+
box = new(data["name"], config: config, **kwargs)
|
|
34
|
+
box.instance_variable_set(:@data, data)
|
|
35
|
+
box
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def data
|
|
39
|
+
@data ||= @store.load(name)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def exists?
|
|
43
|
+
@store.exists?(name)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def container_name
|
|
47
|
+
data ? data["container_name"] : @config.container_name(name)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def ssh_port
|
|
51
|
+
data && data["ssh_port"]
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def web_port
|
|
55
|
+
data && data["web_port"]
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def web_url
|
|
59
|
+
"http://127.0.0.1:#{web_port}"
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def ssh_host_alias
|
|
63
|
+
@config.ssh_host_alias(name)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def remote_path
|
|
67
|
+
data ? data["remote_path"] : @config.remote_path
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# The agent harness this box runs (defaults to Claude for boxes created
|
|
71
|
+
# before harness support existed).
|
|
72
|
+
def harness
|
|
73
|
+
Harness[(data && data["harness"]) || Harness.default]
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def state
|
|
77
|
+
@docker.state(container_name)
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# Provision a brand new box end to end: pull image, run container, enable
|
|
81
|
+
# SSH, optionally install the agent harness, then persist metadata.
|
|
82
|
+
def create!(image: nil, ssh_port: nil, web_port: nil, harness: Harness.default,
|
|
83
|
+
install_agent: true, api_key: nil)
|
|
84
|
+
raise Error, "Box '#{name}' already exists" if exists?
|
|
85
|
+
|
|
86
|
+
harness = Harness[harness]
|
|
87
|
+
image ||= @config.default_image
|
|
88
|
+
public_key = @ssh_key.ensure!
|
|
89
|
+
cname = @config.container_name(name)
|
|
90
|
+
|
|
91
|
+
@docker.pull(image)
|
|
92
|
+
|
|
93
|
+
# Reserve the host ports atomically. `next_port` reads the store, so
|
|
94
|
+
# parallel `gdkbox up` runs would otherwise all pick the same "next free"
|
|
95
|
+
# port and collide at `docker run`. Holding an exclusive lock while we
|
|
96
|
+
# choose ports *and* persist a preliminary record makes each sibling see
|
|
97
|
+
# the others' reservations.
|
|
98
|
+
ssh_port, web_port = reserve_ports!(cname, ssh_port, web_port)
|
|
99
|
+
|
|
100
|
+
begin
|
|
101
|
+
@docker.run_container(
|
|
102
|
+
name: cname,
|
|
103
|
+
image: image,
|
|
104
|
+
publish: [
|
|
105
|
+
"127.0.0.1:#{ssh_port}:#{Config::SSH_CONTAINER_PORT}",
|
|
106
|
+
"127.0.0.1:#{web_port}:#{Config::GDK_WEB_CONTAINER_PORT}"
|
|
107
|
+
],
|
|
108
|
+
labels: { "gdkbox" => "true", "gdkbox.name" => name }
|
|
109
|
+
)
|
|
110
|
+
rescue StandardError
|
|
111
|
+
# The container never started, so release the reserved ports rather
|
|
112
|
+
# than stranding a record that points at nothing.
|
|
113
|
+
@store.delete(name)
|
|
114
|
+
raise
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
provisioner = Provisioner.new(docker: @docker, config: @config)
|
|
118
|
+
provisioner.setup_ssh(cname, public_key)
|
|
119
|
+
|
|
120
|
+
# Enrich the reserved record now that the box is reachable over SSH,
|
|
121
|
+
# before the optional Claude/API-key steps. That way a failure in those
|
|
122
|
+
# steps leaves a box that `gdkbox ls`/`rm` can still see and clean up,
|
|
123
|
+
# rather than an orphan container with no record.
|
|
124
|
+
@data = @data.merge(
|
|
125
|
+
"image" => image,
|
|
126
|
+
"ssh_user" => @config.ssh_user,
|
|
127
|
+
"remote_path" => @config.remote_path,
|
|
128
|
+
"harness" => harness.id,
|
|
129
|
+
"created_at" => Time.now.utc.iso8601
|
|
130
|
+
)
|
|
131
|
+
@store.save(@data)
|
|
132
|
+
|
|
133
|
+
provisioner.setup_agent(cname, harness) if install_agent
|
|
134
|
+
@data["agent_installed"] = install_agent
|
|
135
|
+
|
|
136
|
+
if api_key && !api_key.strip.empty?
|
|
137
|
+
provisioner.setup_api_key(cname, api_key, harness.key_env)
|
|
138
|
+
@data["api_key_set"] = true
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
@store.save(@data)
|
|
142
|
+
@data
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
# Start a stopped box and make sure sshd is running again (processes
|
|
146
|
+
# started via `docker exec` do not survive a container restart).
|
|
147
|
+
def start!
|
|
148
|
+
@docker.start(container_name)
|
|
149
|
+
Provisioner.new(docker: @docker, config: @config)
|
|
150
|
+
.setup_ssh(container_name, @ssh_key.ensure!)
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
def stop!
|
|
154
|
+
@docker.stop(container_name)
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
def destroy!
|
|
158
|
+
@docker.rm(container_name, force: true)
|
|
159
|
+
@store.delete(name)
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
# (Re)install this box's agent harness inside the container.
|
|
163
|
+
def install_agent!
|
|
164
|
+
Provisioner.new(docker: @docker, config: @config).setup_agent(container_name, harness)
|
|
165
|
+
return unless data
|
|
166
|
+
|
|
167
|
+
@data = data.merge("agent_installed" => true)
|
|
168
|
+
@store.save(@data)
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
# Install an agent skill into this box's harness skills directory so agents
|
|
172
|
+
# dispatched into the box can use it. `token` is a discovered skill name
|
|
173
|
+
# (bundled, ~/.claude/skills, or ./.claude/skills) or a path to a skill
|
|
174
|
+
# directory. Skills use the same SKILL.md format across harnesses; only the
|
|
175
|
+
# destination directory differs. Returns the resolved Skill.
|
|
176
|
+
def install_skill(token, project_dir: Dir.pwd, force: false)
|
|
177
|
+
raise Error, "Box '#{name}' does not exist" unless exists?
|
|
178
|
+
|
|
179
|
+
skill = Skills.resolve(token, project_dir: project_dir)
|
|
180
|
+
skills_dir = "/home/#{@config.ssh_user}/#{harness.skills_subdir}"
|
|
181
|
+
target = "#{skills_dir}/#{skill.name}"
|
|
182
|
+
|
|
183
|
+
@docker.exec(container_name, 'mkdir -p "$GDKBOX_SKILLS_DIR"',
|
|
184
|
+
user: @config.ssh_user, env: { "GDKBOX_SKILLS_DIR" => skills_dir })
|
|
185
|
+
|
|
186
|
+
present = @docker.exec(
|
|
187
|
+
container_name, '[ -e "$GDKBOX_TARGET" ] && echo yes || echo no',
|
|
188
|
+
user: @config.ssh_user, env: { "GDKBOX_TARGET" => target }, check: false
|
|
189
|
+
).stdout.strip == "yes"
|
|
190
|
+
raise Error, "Skill '#{skill.name}' is already in box '#{name}'. Use --force to overwrite." if present && !force
|
|
191
|
+
|
|
192
|
+
@docker.exec(container_name, 'rm -rf "$GDKBOX_TARGET"',
|
|
193
|
+
user: @config.ssh_user, env: { "GDKBOX_TARGET" => target }) if present
|
|
194
|
+
|
|
195
|
+
@docker.cp_into(container_name, skill.path, target)
|
|
196
|
+
@docker.exec(container_name, 'chown -R "$GDKBOX_USER:$GDKBOX_USER" "$GDKBOX_TARGET"',
|
|
197
|
+
user: "root", env: { "GDKBOX_USER" => @config.ssh_user, "GDKBOX_TARGET" => target })
|
|
198
|
+
|
|
199
|
+
skill
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
# Seed or rotate the agent API key inside an existing box so dispatched
|
|
203
|
+
# agents can authenticate unattended. The key is exported under the env
|
|
204
|
+
# var this box's harness reads.
|
|
205
|
+
def set_api_key!(api_key)
|
|
206
|
+
raise Error, "Box '#{name}' does not exist" unless exists?
|
|
207
|
+
raise Error, "An API key is required" if api_key.nil? || api_key.strip.empty?
|
|
208
|
+
|
|
209
|
+
Provisioner.new(docker: @docker, config: @config)
|
|
210
|
+
.setup_api_key(container_name, api_key, harness.key_env)
|
|
211
|
+
@data = data.merge("api_key_set" => true)
|
|
212
|
+
@store.save(@data)
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
# Run this box's agent harness non-interactively and capture its output.
|
|
216
|
+
# This is the primitive an orchestrator uses to dispatch a task to a box.
|
|
217
|
+
# The task text is passed through the environment so arbitrary prompts
|
|
218
|
+
# cannot break out of the shell command.
|
|
219
|
+
def run_agent(task:, json: false, yolo: true, timeout: nil)
|
|
220
|
+
raise Error, "Box '#{name}' does not exist" unless exists?
|
|
221
|
+
|
|
222
|
+
agent = harness.agent_command(json: json, yolo: yolo)
|
|
223
|
+
agent = "timeout #{Integer(timeout)} #{agent}" if timeout
|
|
224
|
+
# Source the seeded API key (if any) so the agent authenticates unattended.
|
|
225
|
+
command = %([ -f "$HOME/.gdkbox/env" ] && . "$HOME/.gdkbox/env"; #{agent})
|
|
226
|
+
|
|
227
|
+
result = @docker.exec(
|
|
228
|
+
container_name, command,
|
|
229
|
+
user: @config.ssh_user,
|
|
230
|
+
workdir: remote_path,
|
|
231
|
+
env: { "GDKBOX_TASK" => task },
|
|
232
|
+
check: false
|
|
233
|
+
)
|
|
234
|
+
AgentResult.new(result.stdout, result.stderr, result.status)
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
# A machine-readable summary for orchestrators (`gdkbox ls --json`).
|
|
238
|
+
def summary(state: nil)
|
|
239
|
+
{
|
|
240
|
+
"name" => name,
|
|
241
|
+
"state" => (state || self.state).to_s,
|
|
242
|
+
"container_name" => container_name,
|
|
243
|
+
"ssh_host" => ssh_host_alias,
|
|
244
|
+
"ssh_port" => ssh_port,
|
|
245
|
+
"web_port" => web_port,
|
|
246
|
+
"web_url" => web_url,
|
|
247
|
+
"remote_path" => remote_path,
|
|
248
|
+
"harness" => harness.id,
|
|
249
|
+
"agent_installed" => (data && data["agent_installed"]) || false,
|
|
250
|
+
# Back-compat: orchestrators predating multi-harness check this field.
|
|
251
|
+
"claude_installed" => (harness.id == "claude" && (data && data["agent_installed"])) || false,
|
|
252
|
+
"api_key_set" => (data && data["api_key_set"]) || false
|
|
253
|
+
}
|
|
254
|
+
end
|
|
255
|
+
|
|
256
|
+
# argv to open an interactive SSH session using the generated config.
|
|
257
|
+
def ssh_command
|
|
258
|
+
["ssh", ssh_host_alias]
|
|
259
|
+
end
|
|
260
|
+
|
|
261
|
+
private
|
|
262
|
+
|
|
263
|
+
# Choose free host ports and persist a preliminary record claiming them,
|
|
264
|
+
# all while holding an exclusive lock so concurrent `create!` calls in
|
|
265
|
+
# separate processes serialize and cannot pick the same ports. Returns the
|
|
266
|
+
# chosen [ssh_port, web_port]. Caller-supplied ports are honored as-is.
|
|
267
|
+
def reserve_ports!(cname, ssh_port, web_port)
|
|
268
|
+
@config.ensure_dirs!
|
|
269
|
+
File.open(@config.lock_path, File::RDWR | File::CREAT, 0o644) do |lock|
|
|
270
|
+
lock.flock(File::LOCK_EX)
|
|
271
|
+
ssh_port ||= next_port(Config::SSH_PORT_BASE)
|
|
272
|
+
web_port ||= next_port(Config::WEB_PORT_BASE, exclude: [ssh_port])
|
|
273
|
+
@data = {
|
|
274
|
+
"name" => name,
|
|
275
|
+
"container_name" => cname,
|
|
276
|
+
"ssh_port" => ssh_port,
|
|
277
|
+
"web_port" => web_port,
|
|
278
|
+
"agent_installed" => false,
|
|
279
|
+
"api_key_set" => false
|
|
280
|
+
}
|
|
281
|
+
@store.save(@data)
|
|
282
|
+
[ssh_port, web_port]
|
|
283
|
+
end
|
|
284
|
+
end
|
|
285
|
+
|
|
286
|
+
def next_port(base, exclude: [])
|
|
287
|
+
used = @store.used_ports + exclude
|
|
288
|
+
port = base
|
|
289
|
+
port += 1 while used.include?(port)
|
|
290
|
+
port
|
|
291
|
+
end
|
|
292
|
+
end
|
|
293
|
+
end
|