gdkbox 0.1.18 → 0.1.20
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 +4 -4
- data/README.md +147 -7
- data/lib/gdkbox/box.rb +100 -20
- data/lib/gdkbox/cli.rb +64 -2
- data/lib/gdkbox/completion.rb +2 -2
- data/lib/gdkbox/config.rb +12 -0
- data/lib/gdkbox/docker.rb +19 -1
- data/lib/gdkbox/harness.rb +56 -10
- data/lib/gdkbox/provisioner.rb +41 -0
- data/lib/gdkbox/store.rb +3 -1
- data/lib/gdkbox/version.rb +1 -1
- data/skills/gdkbox-fleet/SKILL.md +66 -2
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 6a5d8a8a1fbb33083f8cfb0b306ee0d6fc4efaffad04a110ef9f1a55141baae5
|
|
4
|
+
data.tar.gz: f4ec5a9b60bf6ab209ced26b1c0ec9c6b95503ae6a65484844506553055ea900
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 596f76ed57ea61c0fc0a00a7e0cd72a917a076aa9fcf022127d8b3ac7e5303c6ccb2c60e30c24727dcedb402f14065da1776be58832d1d28446b070850b09145
|
|
7
|
+
data.tar.gz: 1e4e94bc489cfc712b5e7a7386cad211e5cdf26b826eddeb43fa5e2bf47cacfde8c945677d896c5a21e97deef8dae99458133ebb4a3253a2530142c66404ff77
|
data/README.md
CHANGED
|
@@ -91,6 +91,7 @@ Or run it straight from the checkout without installing:
|
|
|
91
91
|
| `gdkbox set-key NAME` | Seed/rotate the Anthropic API key in the box for unattended dispatch. |
|
|
92
92
|
| `gdkbox set-host` | Add the `gdk.local` entry to your `/etc/hosts` so box web UIs resolve (`--remove` to undo). Also runs during `up`. |
|
|
93
93
|
| `gdkbox set-git NAME` | Seed your git identity (`user.name`/`user.email`) into the box so `git commit` works. Also runs during `up`. |
|
|
94
|
+
| `gdkbox set-gcloud NAME` | Copy the host's Google Cloud application-default credentials into the box (Duo / Vertex AI model access; `--file` to override). |
|
|
94
95
|
| `gdkbox set-remote NAME [REMOTE]` | Point the box's GitLab checkout at a different remote (URL or `namespace/project`, e.g. `gitlab-org/gitlab`); default from config.yml. |
|
|
95
96
|
| `gdkbox hydrate NAME` | Backfill the box's treeless GitLab clone so deep rebases/blame/bisect work (`--trees` for a much smaller trees-only fetch). |
|
|
96
97
|
| `gdkbox claim [NAME]` | Claim a box for exclusive use — any free box when NAME is omitted (`--owner ID`, optional `--ttl SECS` lease, `--json`). Advisory lock for orchestrators. |
|
|
@@ -119,6 +120,10 @@ Or run it straight from the checkout without installing:
|
|
|
119
120
|
| `--gitlab-remote REMOTE` | config.yml, else image default | Point the GitLab checkout at this remote (URL or `namespace/project`); the image ships pointing at the community mirror. |
|
|
120
121
|
| `--owner ID` | (unclaimed) | Claim the box for this owner at creation — no window for another orchestrator to grab it. |
|
|
121
122
|
| `--ttl SECS` | no expiry | Lease duration for `--owner`'s claim. |
|
|
123
|
+
| `--docker` | off | Mount the host Docker socket + per-box network so docker-executor CI jobs work. **Grants the box root-equivalent host access** — see "Docker inside a box". |
|
|
124
|
+
| `--publish PORT [PORT...]` | (none) | Publish extra container ports on `127.0.0.1`, same number on both sides (e.g. AI Gateway `5052`, Duo Workflow `50052`). |
|
|
125
|
+
| `--key-env NAME` | the harness's | Env var this box's API key is seeded under (e.g. `GITLAB_TOKEN`); see "opencode with GitLab Duo". |
|
|
126
|
+
| `--model ID` | the harness's own | Model the box's agent runs with on `dispatch` (claude, codex, opencode; e.g. `gitlab/duo-chat-sonnet-4-5`). |
|
|
122
127
|
|
|
123
128
|
## Typical workflow
|
|
124
129
|
|
|
@@ -159,6 +164,108 @@ gdkbox hydrate demo # full backfill: several GB, then the clone
|
|
|
159
164
|
# behaves like a normal full clone
|
|
160
165
|
```
|
|
161
166
|
|
|
167
|
+
### Docker inside a box (CI docker executor)
|
|
168
|
+
|
|
169
|
+
Some workloads — notably GitLab Duo agentic flows — finish by running a CI job
|
|
170
|
+
with the **docker executor**, which needs a Docker daemon. Boxes have none by
|
|
171
|
+
default. `gdkbox up NAME --docker` wires it up:
|
|
172
|
+
|
|
173
|
+
- The **host Docker socket** is mounted into the box and the Docker CLI is
|
|
174
|
+
installed there, so the box drives the *host* daemon. Containers the box
|
|
175
|
+
starts are **siblings on the host**, not children of the box.
|
|
176
|
+
- The box joins a per-box network (`gdkbox-NAME`) with the alias `gdk.local`,
|
|
177
|
+
so sibling containers can reach the box's GitLab by hostname. Point the
|
|
178
|
+
box's GitLab Runner at it in `config.toml`:
|
|
179
|
+
|
|
180
|
+
```toml
|
|
181
|
+
[runners.docker]
|
|
182
|
+
network_mode = "gdkbox-NAME" # job containers resolve gdk.local natively
|
|
183
|
+
```
|
|
184
|
+
|
|
185
|
+
**Security:** the socket is root-equivalent control of the **host** — anything
|
|
186
|
+
running in a `--docker` box (including dispatched agents, which skip permission
|
|
187
|
+
prompts) can start privileged containers, mount host paths, and read other
|
|
188
|
+
boxes. Only use `--docker` on boxes whose workloads you trust like your own
|
|
189
|
+
shell, and never combine it with unattended agent tasks you wouldn't run on
|
|
190
|
+
the host directly.
|
|
191
|
+
|
|
192
|
+
### Running Duo agentic flows in a box
|
|
193
|
+
|
|
194
|
+
The main consumer of `--docker` is the GitLab Duo Agent Platform: a flow ends
|
|
195
|
+
by running a docker-executor CI job.
|
|
196
|
+
|
|
197
|
+
<details>
|
|
198
|
+
<summary><strong>Step-by-step setup</strong> (distilled from a real end-to-end
|
|
199
|
+
run; verify commands against the
|
|
200
|
+
<a href="https://gitlab-org.gitlab.io/gitlab-development-kit/howto/ai">GDK AI
|
|
201
|
+
howto</a>)</summary>
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
1. **Provision**:
|
|
206
|
+
`gdkbox up duo-1 --docker --publish 5052 50052` then
|
|
207
|
+
`gdkbox set-gcloud duo-1` (Vertex AI credentials; the flow model routes
|
|
208
|
+
through Vertex, project selected by `AIGW_GOOGLE_CLOUD_PLATFORM__PROJECT`).
|
|
209
|
+
2. **AI services** (inside the box): `gdk rake setup_ai_services` clones the
|
|
210
|
+
`gitlab-ai-gateway` monorepo and builds its Python env — **~20 minutes**,
|
|
211
|
+
needs a valid `ANTHROPIC_API_KEY` (a dead key blocks Chat, service boot,
|
|
212
|
+
and flows). Budget memory: GDK + AI stack + flow containers want well over
|
|
213
|
+
10 GB free.
|
|
214
|
+
3. **The env asymmetry that causes most failures**: the AI Gateway reads
|
|
215
|
+
`gitlab-ai-gateway/.env`, but the Duo Workflow Service reads **`env.runit`**
|
|
216
|
+
at the GDK root. `ANTHROPIC_API_KEY` (unquoted),
|
|
217
|
+
`DUO_WORKFLOW_AUTH__ENABLED=false`, the demo JWT keys from `example.env`,
|
|
218
|
+
and `AIGW_GOOGLE_CLOUD_PLATFORM__PROJECT` all go in `env.runit`.
|
|
219
|
+
4. **Agentic WebSocket 403 fix**: `gdk config set gitlab_http_router.enabled
|
|
220
|
+
false && gdk config set nginx.enabled true && gdk reconfigure` — the
|
|
221
|
+
http-router rewrites the upstream Host and workhorse rejects the flow's
|
|
222
|
+
WebSocket upgrade (classic Chat works, flows 403). Also set the acting
|
|
223
|
+
user's `duo_default_namespace_id` to the seeded `gitlab-duo` group.
|
|
224
|
+
5. **Point at the local gateway and seed**:
|
|
225
|
+
`gdk config set gitlab_ai_gateway.environment local`, then from `gitlab/`:
|
|
226
|
+
`DEVELOPMENT_AI_GATEWAY_URL="http://gdk.local:5052" GITLAB_SIMULATE_SAAS=1
|
|
227
|
+
bundle exec rake gitlab:duo:setup` and `... gitlab:duo:onboard_dap`.
|
|
228
|
+
A fresh box has no license, so `gitlab:duo:setup` aborts with
|
|
229
|
+
**"No license found"** — import one first (e.g. copy the `data` column from
|
|
230
|
+
your host GDK's `licenses` table and load it in the box's Rails console
|
|
231
|
+
with `License.new(data: data, cloud: true).save!`).
|
|
232
|
+
6. **Runner**: in a `--docker` box the socket is already at
|
|
233
|
+
`/var/run/docker.sock` (no `docker_host` override needed). Three
|
|
234
|
+
`[runners.docker]` settings in the generated `gitlab-runner-config.toml`
|
|
235
|
+
matter — GDK's template doesn't persist them, so re-apply after any
|
|
236
|
+
`gdk reconfigure`:
|
|
237
|
+
|
|
238
|
+
```toml
|
|
239
|
+
[runners.docker]
|
|
240
|
+
network_mode = "gdkbox-duo-1" # job containers resolve gdk.local natively
|
|
241
|
+
privileged = false # privileged breaks the flow sandbox (below)
|
|
242
|
+
volumes = ["/tmp/gdkbox-docker-cfg/certs.d:/etc/docker/certs.d",
|
|
243
|
+
"/certs/client", "/cache"]
|
|
244
|
+
```
|
|
245
|
+
|
|
246
|
+
Because the box drives the **host** daemon, volume *source* paths must
|
|
247
|
+
exist **on the host**, not in the box — a box-only path fails the job with
|
|
248
|
+
`mounts denied: path not shared from host`. And with `privileged = true`
|
|
249
|
+
the flow's `unshare` sandbox loses network access to `gdk.local`, so the
|
|
250
|
+
job dies with "Workflow could not connect to the Workflow service /
|
|
251
|
+
User: not authenticated"; keep the runner unprivileged.
|
|
252
|
+
7. **Verify**: AI Gateway answers on `http://gdk.local:5052/docs` (published
|
|
253
|
+
by `--publish`), the workflow service binds `:50052`, and triggering the
|
|
254
|
+
`developer` flow from an issue in the seeded `gitlab-duo` project completes
|
|
255
|
+
and opens a Draft MR.
|
|
256
|
+
|
|
257
|
+
> **Note — rate-limited Anthropic keys**: if your key returns `429
|
|
258
|
+
> rate_limit_error` on the default Sonnet models, chat and flows fail even
|
|
259
|
+
> though the setup is correct. Pin Duo to a model your key can use (e.g.
|
|
260
|
+
> Haiku): per namespace, run
|
|
261
|
+
> `Ai::ModelSelection::Namespace::UpdateService.new(feature_setting, group,
|
|
262
|
+
> {offered_model_ref: "claude_haiku_4_5_20251001"}).execute` in the box's
|
|
263
|
+
> Rails console (needs `GITLAB_SIMULATE_SAAS=1`, silently returns `nil`
|
|
264
|
+
> without it), and for the AI Gateway defaults rewrite the model refs in
|
|
265
|
+
> `gitlab-ai-gateway/ai_gateway/model_selection/unit_primitives.yml`.
|
|
266
|
+
|
|
267
|
+
</details>
|
|
268
|
+
|
|
162
269
|
## Orchestrating a fleet of agents
|
|
163
270
|
|
|
164
271
|
The end goal of `gdkbox` is to back an **orchestrator** that runs a pool of
|
|
@@ -268,8 +375,8 @@ harness the box was created with.
|
|
|
268
375
|
| --- | --- | --- | --- | --- |
|
|
269
376
|
| `claude` | Claude Code | `claude -p` | `ANTHROPIC_API_KEY` | `~/.claude/skills` |
|
|
270
377
|
| `codex` | OpenAI Codex | `codex exec` | `OPENAI_API_KEY` | `~/.codex/skills` |
|
|
271
|
-
| `opencode` | opencode (sst) | `opencode run` | `
|
|
272
|
-
| `pi` | pi (earendil-works) | `pi -p` | `
|
|
378
|
+
| `opencode` | opencode (sst) | `opencode run` | `GITLAB_TOKEN` | `~/.config/opencode/skills` |
|
|
379
|
+
| `pi` | pi (earendil-works) | `pi -p` | `GITLAB_TOKEN` | `~/.pi/skills` |
|
|
273
380
|
|
|
274
381
|
```sh
|
|
275
382
|
gdkbox up reviewer --harness codex # a Codex box
|
|
@@ -280,8 +387,39 @@ gdkbox harnesses # list available harnesses
|
|
|
280
387
|
```
|
|
281
388
|
|
|
282
389
|
Skills use the same `SKILL.md` format across all four, so a skill installed by
|
|
283
|
-
`add-skill`/`--skill` works regardless of harness. The key env
|
|
284
|
-
|
|
390
|
+
`add-skill`/`--skill` works regardless of harness. The key env is a per-harness
|
|
391
|
+
default (claude → `ANTHROPIC_API_KEY`, codex → `OPENAI_API_KEY`, opencode and
|
|
392
|
+
pi → `GITLAB_TOKEN`): point a box at another provider with
|
|
393
|
+
`gdkbox up --key-env NAME`.
|
|
394
|
+
`gdkbox up --model ID` pins the model a box's agent is dispatched with
|
|
395
|
+
(claude, codex, and opencode; not pi yet). Both are fixed when the box is
|
|
396
|
+
created.
|
|
397
|
+
|
|
398
|
+
### opencode with GitLab Duo
|
|
399
|
+
|
|
400
|
+
The `opencode` harness defaults to opencode's GitLab Duo provider, which reads
|
|
401
|
+
a personal access token from `GITLAB_TOKEN`. Pin a Duo model with `--model`:
|
|
402
|
+
|
|
403
|
+
```sh
|
|
404
|
+
GITLAB_TOKEN=<token> gdkbox up duo --harness opencode --model gitlab/duo-chat-sonnet-4-5
|
|
405
|
+
gdkbox dispatch duo --task "review the diff"
|
|
406
|
+
```
|
|
407
|
+
|
|
408
|
+
- The `duo-chat-*` models are GitLab Duo Agentic Chat and work with the token
|
|
409
|
+
alone. The `duo-workflow-*` models need a login stored with opencode's
|
|
410
|
+
`/connect`, so they are not available this way.
|
|
411
|
+
- GitLab Duo needs a Premium or Ultimate subscription with the Duo Agent
|
|
412
|
+
Platform turned on.
|
|
413
|
+
- Without `--model`, opencode picks its own default model.
|
|
414
|
+
- For another provider, seed its key instead, e.g.
|
|
415
|
+
`gdkbox up NAME --harness opencode --key-env OPENAI_API_KEY`.
|
|
416
|
+
|
|
417
|
+
> **The token acts as you.** opencode needs the `api` scope, which lets the
|
|
418
|
+
> agent read and write anything you can on GitLab. That is what lets it post
|
|
419
|
+
> MR review comments, but it also means a prompt injection in an issue, MR, or
|
|
420
|
+
> file the agent reads can act with your permissions. Use a dedicated,
|
|
421
|
+
> short-lived token. `gdkbox up --harness opencode` picks up `$GITLAB_TOKEN`
|
|
422
|
+
> from your shell, so check what that variable holds first.
|
|
285
423
|
|
|
286
424
|
## Shell completion
|
|
287
425
|
|
|
@@ -334,15 +472,17 @@ skills:
|
|
|
334
472
|
# - ./path/to/a/local/skill
|
|
335
473
|
|
|
336
474
|
# API keys seeded into boxes for unattended `gdkbox dispatch`, per provider.
|
|
337
|
-
# anthropic_api_key: sk-ant-... # used by the claude
|
|
338
|
-
# openai_api_key: sk-... # used by the codex
|
|
475
|
+
# anthropic_api_key: sk-ant-... # used by the claude harness
|
|
476
|
+
# openai_api_key: sk-... # used by the codex harness
|
|
477
|
+
# gitlab_token: <personal access token, api scope> # used by the opencode / pi harnesses
|
|
339
478
|
```
|
|
340
479
|
|
|
341
480
|
`gdkbox up` installs the skills on top of any `--skill` you pass; use
|
|
342
481
|
`--no-default-skills` to skip them for one run. Precedence for the image is
|
|
343
482
|
`--image` > `$GDKBOX_IMAGE` > `config.yml` > built-in default; for the API key
|
|
344
483
|
it is `--api-key` > the harness's provider env var (e.g. `$ANTHROPIC_API_KEY`,
|
|
345
|
-
`$
|
|
484
|
+
`$GITLAB_TOKEN`, or the `--key-env` name) > `config.yml` (the env var's name
|
|
485
|
+
lowercased, e.g. `gitlab_token:`).
|
|
346
486
|
|
|
347
487
|
> **API keys in `config.yml` are plaintext secrets on disk.** Prefer the
|
|
348
488
|
> provider env var; if you do put a key in `config.yml`, keep the file mode
|
data/lib/gdkbox/box.rb
CHANGED
|
@@ -73,9 +73,16 @@ module GDKBox
|
|
|
73
73
|
end
|
|
74
74
|
|
|
75
75
|
# The agent harness this box runs (defaults to Claude for boxes created
|
|
76
|
-
# before harness support existed)
|
|
76
|
+
# before harness support existed), with the box's own key env override
|
|
77
|
+
# (`up --key-env`) applied.
|
|
77
78
|
def harness
|
|
78
|
-
Harness[(data && data["harness"]) || Harness.default]
|
|
79
|
+
Harness[(data && data["harness"]) || Harness.default].with_key_env(data && data["key_env"])
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# The model the box's agent is pinned to (`up --model`), or nil to let
|
|
83
|
+
# the harness pick its own default.
|
|
84
|
+
def model
|
|
85
|
+
data && data["model"]
|
|
79
86
|
end
|
|
80
87
|
|
|
81
88
|
def state
|
|
@@ -91,10 +98,12 @@ module GDKBox
|
|
|
91
98
|
# box this one is still provisioning.
|
|
92
99
|
def create!(image: nil, ssh_port: nil, web_port: nil, vite_port: nil,
|
|
93
100
|
harness: Harness.default, install_agent: true, api_key: nil,
|
|
94
|
-
claim_owner: nil, claim_ttl: nil
|
|
101
|
+
claim_owner: nil, claim_ttl: nil, docker_access: false, extra_ports: [],
|
|
102
|
+
key_env: nil, model: nil)
|
|
95
103
|
raise Error, "Box '#{name}' already exists" if exists?
|
|
96
104
|
|
|
97
|
-
harness = Harness[harness]
|
|
105
|
+
harness = Harness[harness].with_key_env(key_env)
|
|
106
|
+
model = harness.check_model!(model)
|
|
98
107
|
image ||= @config.default_image
|
|
99
108
|
public_key = @ssh_key.ensure!
|
|
100
109
|
cname = @config.container_name(name)
|
|
@@ -110,10 +119,28 @@ module GDKBox
|
|
|
110
119
|
# choose ports *and* persist a preliminary record makes each sibling see
|
|
111
120
|
# the others' reservations.
|
|
112
121
|
ssh_port, web_port, vite_port = reserve_ports!(
|
|
113
|
-
cname, ssh_port, web_port, vite_port,
|
|
122
|
+
cname, ssh_port, web_port, vite_port, extra_ports,
|
|
114
123
|
claim_owner: claim_owner, claim_ttl: claim_ttl
|
|
115
124
|
)
|
|
116
125
|
|
|
126
|
+
publish = [
|
|
127
|
+
"127.0.0.1:#{ssh_port}:#{Config::SSH_CONTAINER_PORT}",
|
|
128
|
+
"127.0.0.1:#{web_port}:#{Config::GDK_WEB_CONTAINER_PORT}",
|
|
129
|
+
# Same number on both sides: asset URLs embed this port, so what
|
|
130
|
+
# the browser dials must be where vite listens (see VITE_PORT_BASE).
|
|
131
|
+
"127.0.0.1:#{vite_port}:#{vite_port}"
|
|
132
|
+
]
|
|
133
|
+
# Extra ports publish same-numbered too: in-box services advertise
|
|
134
|
+
# their own port, so what the host dials must match.
|
|
135
|
+
extra_ports.each { |port| publish << "127.0.0.1:#{port}:#{port}" }
|
|
136
|
+
|
|
137
|
+
# Docker access: mount the host socket (containers the box starts are
|
|
138
|
+
# siblings on the host daemon) and put the box on its own network with
|
|
139
|
+
# a gdk.local alias, so those siblings — docker-executor CI jobs — can
|
|
140
|
+
# reach the box's GitLab by hostname.
|
|
141
|
+
net = docker_access ? @config.docker_network(name) : nil
|
|
142
|
+
@docker.ensure_network(net) if net
|
|
143
|
+
|
|
117
144
|
begin
|
|
118
145
|
@docker.run_container(
|
|
119
146
|
name: cname,
|
|
@@ -122,19 +149,17 @@ module GDKBox
|
|
|
122
149
|
# set inside the container, workhorse and the http-router crash-loop
|
|
123
150
|
# on DNS timeouts.
|
|
124
151
|
hostname: Config::GDK_HOSTNAME,
|
|
125
|
-
publish:
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
# the browser dials must be where vite listens (see VITE_PORT_BASE).
|
|
130
|
-
"127.0.0.1:#{vite_port}:#{vite_port}"
|
|
131
|
-
],
|
|
152
|
+
publish: publish,
|
|
153
|
+
volumes: (docker_access ? ["#{Config::DOCKER_SOCKET}:#{Config::DOCKER_SOCKET}"] : []),
|
|
154
|
+
network: net,
|
|
155
|
+
network_alias: (net ? Config::GDK_HOSTNAME : nil),
|
|
132
156
|
labels: { "gdkbox" => "true", "gdkbox.name" => name }
|
|
133
157
|
)
|
|
134
158
|
rescue StandardError
|
|
135
159
|
# The container never started, so release the reserved ports rather
|
|
136
160
|
# than stranding a record that points at nothing.
|
|
137
161
|
@store.delete(name)
|
|
162
|
+
@docker.rm_network(net) if net
|
|
138
163
|
raise
|
|
139
164
|
end
|
|
140
165
|
|
|
@@ -152,8 +177,26 @@ module GDKBox
|
|
|
152
177
|
"harness" => harness.id,
|
|
153
178
|
"created_at" => Time.now.utc.iso8601
|
|
154
179
|
)
|
|
180
|
+
@data["key_env"] = harness.key_env if key_env
|
|
181
|
+
@data["model"] = model if model
|
|
182
|
+
if docker_access
|
|
183
|
+
@data["docker_access"] = true
|
|
184
|
+
@data["docker_network"] = net
|
|
185
|
+
end
|
|
155
186
|
@store.save(@data)
|
|
156
187
|
|
|
188
|
+
# Docker CLI + socket access for the gdk user. Best-effort (needs the
|
|
189
|
+
# network for the static CLI download); the outcome is recorded so the
|
|
190
|
+
# CLI can print a retry hint.
|
|
191
|
+
if docker_access
|
|
192
|
+
@data["docker_cli_installed"] = begin
|
|
193
|
+
provisioner.setup_docker_cli(cname)
|
|
194
|
+
true
|
|
195
|
+
rescue StandardError
|
|
196
|
+
false
|
|
197
|
+
end
|
|
198
|
+
end
|
|
199
|
+
|
|
157
200
|
# Align vite's in-container listen port with the published one so asset
|
|
158
201
|
# URLs work from the host browser. Best-effort — the box is fully
|
|
159
202
|
# usable for SSH/agent work without it — but the outcome is recorded
|
|
@@ -199,7 +242,9 @@ module GDKBox
|
|
|
199
242
|
end
|
|
200
243
|
|
|
201
244
|
def destroy!
|
|
245
|
+
network = data && data["docker_network"]
|
|
202
246
|
@docker.rm(container_name, force: true)
|
|
247
|
+
@docker.rm_network(network) if network
|
|
203
248
|
@store.delete(name)
|
|
204
249
|
end
|
|
205
250
|
|
|
@@ -366,6 +411,27 @@ module GDKBox
|
|
|
366
411
|
result
|
|
367
412
|
end
|
|
368
413
|
|
|
414
|
+
# Copy Google Cloud application-default credentials into the box so Duo /
|
|
415
|
+
# Vertex AI model calls can authenticate. The file holds a refresh token,
|
|
416
|
+
# so copying works where an in-box `gcloud auth` login cannot (no
|
|
417
|
+
# browser). Stored 0600, owned by the GDK user, inside the container only.
|
|
418
|
+
def set_gcloud_credentials!(source_path)
|
|
419
|
+
unless File.file?(source_path)
|
|
420
|
+
raise Error, "No credentials file at #{source_path}. Run " \
|
|
421
|
+
"`gcloud auth application-default login` on the host first, or pass --file."
|
|
422
|
+
end
|
|
423
|
+
|
|
424
|
+
dir = "/home/#{@config.ssh_user}/.config/gcloud"
|
|
425
|
+
dest = "#{dir}/application_default_credentials.json"
|
|
426
|
+
@docker.exec(container_name, 'install -d -m 700 -o "$GDKBOX_USER" -g "$GDKBOX_USER" "$GDKBOX_DIR"',
|
|
427
|
+
user: "root", env: { "GDKBOX_USER" => @config.ssh_user, "GDKBOX_DIR" => dir })
|
|
428
|
+
@docker.cp_into(container_name, source_path, dest)
|
|
429
|
+
@docker.exec(container_name,
|
|
430
|
+
'chown "$GDKBOX_USER:$GDKBOX_USER" "$GDKBOX_DEST" && chmod 600 "$GDKBOX_DEST"',
|
|
431
|
+
user: "root", env: { "GDKBOX_USER" => @config.ssh_user, "GDKBOX_DEST" => dest })
|
|
432
|
+
dest
|
|
433
|
+
end
|
|
434
|
+
|
|
369
435
|
# Seed a git identity into the box so `git commit` works there. Falls back
|
|
370
436
|
# to the host's `git config` when name/email are not given; raises when
|
|
371
437
|
# neither source has anything to seed. Returns the [name, email] seeded.
|
|
@@ -438,12 +504,12 @@ module GDKBox
|
|
|
438
504
|
|
|
439
505
|
# Run this box's agent harness non-interactively and capture its output.
|
|
440
506
|
# This is the primitive an orchestrator uses to dispatch a task to a box.
|
|
441
|
-
# The task text is passed through the
|
|
442
|
-
# cannot break out of the shell command.
|
|
507
|
+
# The task text (and the pinned model, if any) is passed through the
|
|
508
|
+
# environment so arbitrary values cannot break out of the shell command.
|
|
443
509
|
def run_agent(task:, json: false, yolo: true, timeout: nil)
|
|
444
510
|
raise Error, "Box '#{name}' does not exist" unless exists?
|
|
445
511
|
|
|
446
|
-
agent = harness.agent_command(json: json, yolo: yolo)
|
|
512
|
+
agent = harness.agent_command(json: json, yolo: yolo, model: model)
|
|
447
513
|
agent = "timeout #{Integer(timeout)} #{agent}" if timeout
|
|
448
514
|
# Source the seeded API key (if any) so the agent authenticates unattended.
|
|
449
515
|
command = %([ -f "$HOME/.gdkbox/env" ] && . "$HOME/.gdkbox/env"; #{agent})
|
|
@@ -452,7 +518,7 @@ module GDKBox
|
|
|
452
518
|
container_name, command,
|
|
453
519
|
user: @config.ssh_user,
|
|
454
520
|
workdir: remote_path,
|
|
455
|
-
env: { "GDKBOX_TASK" => task },
|
|
521
|
+
env: { "GDKBOX_TASK" => task, "GDKBOX_MODEL" => model }.compact,
|
|
456
522
|
check: false
|
|
457
523
|
)
|
|
458
524
|
AgentResult.new(result.stdout, result.stderr, result.status)
|
|
@@ -460,6 +526,7 @@ module GDKBox
|
|
|
460
526
|
|
|
461
527
|
# A machine-readable summary for orchestrators (`gdkbox ls --json`).
|
|
462
528
|
def summary(state: nil)
|
|
529
|
+
harness = self.harness
|
|
463
530
|
{
|
|
464
531
|
"name" => name,
|
|
465
532
|
"state" => (state || self.state).to_s,
|
|
@@ -474,10 +541,18 @@ module GDKBox
|
|
|
474
541
|
# Back-compat: orchestrators predating multi-harness check this field.
|
|
475
542
|
"claude_installed" => (harness.id == "claude" && (data && data["agent_installed"])) || false,
|
|
476
543
|
"api_key_set" => (data && data["api_key_set"]) || false,
|
|
544
|
+
"key_env" => harness.key_env,
|
|
545
|
+
"model" => model,
|
|
477
546
|
# Vite assets: the published port (nil on boxes created before vite
|
|
478
547
|
# support) and whether the in-box alignment step succeeded.
|
|
479
548
|
"vite_port" => vite_port,
|
|
480
549
|
"vite_port_configured" => (data && data["vite_port_configured"]) || false,
|
|
550
|
+
"extra_ports" => (data && data["extra_ports"]) || [],
|
|
551
|
+
# Docker access (up --docker): host socket mounted; docker_network is
|
|
552
|
+
# the per-box network CI job containers should use as network_mode.
|
|
553
|
+
"docker_access" => (data && data["docker_access"]) || false,
|
|
554
|
+
"docker_network" => (data && data["docker_network"]),
|
|
555
|
+
"docker_cli_installed" => (data && data["docker_cli_installed"]) || false,
|
|
481
556
|
# Active claim (nil when free; an expired lease counts as free).
|
|
482
557
|
"claimed_by" => claimed_by,
|
|
483
558
|
"claim_expires_at" => (claimed_by ? data["claim_expires_at"] : nil)
|
|
@@ -518,11 +593,15 @@ module GDKBox
|
|
|
518
593
|
# than the cryptic bind error `docker run` would produce later.
|
|
519
594
|
# A claim_owner is written into this same record, so the box is claimed
|
|
520
595
|
# from the instant it becomes visible to other processes.
|
|
521
|
-
def reserve_ports!(cname, ssh_port, web_port, vite_port,
|
|
522
|
-
|
|
523
|
-
|
|
596
|
+
def reserve_ports!(cname, ssh_port, web_port, vite_port, extra_ports,
|
|
597
|
+
claim_owner: nil, claim_ttl: nil)
|
|
598
|
+
checks = { "--ssh-port" => ssh_port, "--web-port" => web_port,
|
|
599
|
+
"--vite-port" => vite_port }
|
|
600
|
+
extra_ports.each_with_index { |port, i| checks["--publish #{i}"] = port }
|
|
601
|
+
checks.each do |flag, port|
|
|
524
602
|
if port && Ports.bound?(port)
|
|
525
|
-
raise Error,
|
|
603
|
+
raise Error,
|
|
604
|
+
"Port #{port} (#{flag.sub(/ \d+\z/, '')}) is already in use on 127.0.0.1."
|
|
526
605
|
end
|
|
527
606
|
end
|
|
528
607
|
|
|
@@ -536,6 +615,7 @@ module GDKBox
|
|
|
536
615
|
"ssh_port" => ssh_port,
|
|
537
616
|
"web_port" => web_port,
|
|
538
617
|
"vite_port" => vite_port,
|
|
618
|
+
"extra_ports" => extra_ports,
|
|
539
619
|
"agent_installed" => false,
|
|
540
620
|
"api_key_set" => false
|
|
541
621
|
}
|
data/lib/gdkbox/cli.rb
CHANGED
|
@@ -48,6 +48,18 @@ module GDKBox
|
|
|
48
48
|
"orchestrator to grab it; see `gdkbox claim`)"
|
|
49
49
|
option :ttl, type: :numeric,
|
|
50
50
|
desc: "Lease duration in seconds for --owner's claim (default: no expiry)"
|
|
51
|
+
option :docker, type: :boolean, default: false,
|
|
52
|
+
desc: "Mount the host Docker socket so the box can run docker-executor CI jobs " \
|
|
53
|
+
"(grants the box root-equivalent control of the HOST — see README)"
|
|
54
|
+
option :publish, type: :array, default: [],
|
|
55
|
+
desc: "Extra container port(s) to publish on 127.0.0.1, same number on both sides " \
|
|
56
|
+
"(e.g. --publish 5052 50052)"
|
|
57
|
+
option :key_env, type: :string,
|
|
58
|
+
desc: "Env var to seed the harness API key under for this box " \
|
|
59
|
+
"(e.g. GITLAB_TOKEN for opencode + GitLab Duo; default: the harness's own)"
|
|
60
|
+
option :model, type: :string,
|
|
61
|
+
desc: "Model this box's agent runs with on dispatch " \
|
|
62
|
+
"(e.g. gitlab/duo-chat-sonnet-4-5 for opencode; default: the harness's own)"
|
|
51
63
|
def up(name)
|
|
52
64
|
if options[:ttl] && options[:owner].to_s.strip.empty?
|
|
53
65
|
raise Error, "--ttl only makes sense with --owner (it is the claim's lease)."
|
|
@@ -55,7 +67,8 @@ module GDKBox
|
|
|
55
67
|
ensure_docker!
|
|
56
68
|
box = build_box(name)
|
|
57
69
|
raise Error, "Box '#{name}' already exists. Use `gdkbox rm #{name}` first." if box.exists?
|
|
58
|
-
harness = Harness[options[:harness] || config.default_harness]
|
|
70
|
+
harness = Harness[options[:harness] || config.default_harness].with_key_env(options[:key_env])
|
|
71
|
+
model = harness.check_model!(options[:model])
|
|
59
72
|
api_key = resolve_api_key(harness)
|
|
60
73
|
|
|
61
74
|
say "Spinning up GDK box '#{name}' (#{harness.summary}; first run pulls a large image)...", :green unless options[:json]
|
|
@@ -68,7 +81,11 @@ module GDKBox
|
|
|
68
81
|
install_agent: options[:agent],
|
|
69
82
|
api_key: api_key,
|
|
70
83
|
claim_owner: options[:owner],
|
|
71
|
-
claim_ttl: options[:ttl]
|
|
84
|
+
claim_ttl: options[:ttl],
|
|
85
|
+
docker_access: options[:docker],
|
|
86
|
+
extra_ports: parse_extra_ports(options[:publish]),
|
|
87
|
+
key_env: options[:key_env],
|
|
88
|
+
model: model
|
|
72
89
|
)
|
|
73
90
|
rewrite_ssh_config
|
|
74
91
|
seed_skills(box)
|
|
@@ -82,6 +99,18 @@ module GDKBox
|
|
|
82
99
|
setup_host
|
|
83
100
|
|
|
84
101
|
say "\nBox '#{name}' is up.", :green
|
|
102
|
+
if options[:docker]
|
|
103
|
+
if box.data["docker_cli_installed"]
|
|
104
|
+
say "\n Docker: host socket mounted; the box drives the HOST daemon " \
|
|
105
|
+
"(containers it starts are siblings).", :yellow
|
|
106
|
+
say " CI docker-executor jobs reach this GitLab via the box's network — " \
|
|
107
|
+
"in the runner's config.toml set:", :cyan
|
|
108
|
+
say " network_mode = \"#{box.data['docker_network']}\"", :cyan
|
|
109
|
+
else
|
|
110
|
+
say "\n Could not install the Docker CLI in the box (network hiccup?). " \
|
|
111
|
+
"Retry: gdkbox rm #{name} && gdkbox up #{name} --docker", :yellow
|
|
112
|
+
end
|
|
113
|
+
end
|
|
85
114
|
unless box.data["vite_port_configured"]
|
|
86
115
|
say "\n Could not align the box's vite port with the published " \
|
|
87
116
|
"#{box.vite_port} — browser asset loading may fail.", :yellow
|
|
@@ -401,6 +430,26 @@ module GDKBox
|
|
|
401
430
|
say "Done. '#{name}' can now work across the full history.", :green
|
|
402
431
|
end
|
|
403
432
|
|
|
433
|
+
desc "set-gcloud NAME", "Copy Google Cloud application-default credentials into the box"
|
|
434
|
+
long_desc <<~DESC
|
|
435
|
+
Duo / Vertex AI model calls from inside a box need Google Cloud
|
|
436
|
+
application-default credentials, and `gcloud auth application-default
|
|
437
|
+
login` cannot run in a box (no browser). This copies the host's ADC
|
|
438
|
+
file — which holds a refresh token, so it works from inside the
|
|
439
|
+
container — into the box at ~/.config/gcloud/, mode 0600, owned by the
|
|
440
|
+
GDK user. Defaults to the host's own ADC file; override with --file.
|
|
441
|
+
DESC
|
|
442
|
+
option :file, type: :string,
|
|
443
|
+
desc: "Credentials file to copy (default: ~/.config/gcloud/application_default_credentials.json)"
|
|
444
|
+
def set_gcloud(name)
|
|
445
|
+
box = load_box!(name)
|
|
446
|
+
source = options[:file] ||
|
|
447
|
+
File.join(Dir.home, ".config", "gcloud", "application_default_credentials.json")
|
|
448
|
+
dest = box.set_gcloud_credentials!(source)
|
|
449
|
+
say "Copied gcloud application-default credentials into '#{name}' (#{dest}).", :green
|
|
450
|
+
end
|
|
451
|
+
map "set-gcloud" => :set_gcloud
|
|
452
|
+
|
|
404
453
|
desc "set-host", "Add (default) or remove the gdk.local entry in /etc/hosts"
|
|
405
454
|
long_desc <<~DESC
|
|
406
455
|
GDK generates URLs and redirects that use the `gdk.local` hostname, so
|
|
@@ -615,6 +664,18 @@ module GDKBox
|
|
|
615
664
|
warn "gdkbox: retry later with: gdkbox set-remote #{box.name} #{remote}"
|
|
616
665
|
end
|
|
617
666
|
|
|
667
|
+
# --publish values as integer container ports, validated.
|
|
668
|
+
def parse_extra_ports(values)
|
|
669
|
+
values.map do |value|
|
|
670
|
+
port = Integer(value, exception: false)
|
|
671
|
+
unless port&.between?(1, 65_535)
|
|
672
|
+
raise Error, "--publish expects port numbers (got '#{value}')."
|
|
673
|
+
end
|
|
674
|
+
|
|
675
|
+
port
|
|
676
|
+
end.uniq
|
|
677
|
+
end
|
|
678
|
+
|
|
618
679
|
# Ensure gdk.local resolves on the host so the box's web UI is reachable
|
|
619
680
|
# (GDK redirects to that hostname). Adding the entry needs sudo, so warn
|
|
620
681
|
# before any password prompt appears. Failures are non-fatal: the box is
|
|
@@ -702,6 +763,7 @@ module GDKBox
|
|
|
702
763
|
say " Web UI: #{box.web_url} (http://#{HostsFile::HOSTNAME}:#{box.web_port})", :cyan
|
|
703
764
|
say " Assets: vite on port #{box.vite_port}", :cyan if box.vite_port
|
|
704
765
|
say " Agent: ssh #{box.ssh_host_alias} -t #{box.harness.bin}", :cyan
|
|
766
|
+
say " Model: #{box.model} (on dispatch)", :cyan if box.model
|
|
705
767
|
end
|
|
706
768
|
end
|
|
707
769
|
end
|
data/lib/gdkbox/completion.rb
CHANGED
|
@@ -11,8 +11,8 @@ module GDKBox
|
|
|
11
11
|
SHELLS = %w[bash zsh].freeze
|
|
12
12
|
|
|
13
13
|
# Subcommands whose first positional argument is an existing box.
|
|
14
|
-
BOX_COMMANDS = %w[status dispatch ssh code install-agent set-key set-git set-remote
|
|
15
|
-
claim release start stop rm add-skill].freeze
|
|
14
|
+
BOX_COMMANDS = %w[status dispatch ssh code install-agent set-key set-git set-remote
|
|
15
|
+
set-gcloud hydrate claim release start stop rm add-skill].freeze
|
|
16
16
|
|
|
17
17
|
# Marker identifying the rc-file block `install` manages.
|
|
18
18
|
MARKER = "# gdkbox:completion"
|
data/lib/gdkbox/config.rb
CHANGED
|
@@ -29,6 +29,11 @@ module GDKBox
|
|
|
29
29
|
# follow GDK's redirects to it).
|
|
30
30
|
GDK_HOSTNAME = "gdk.local"
|
|
31
31
|
|
|
32
|
+
# The host Docker socket, mounted into a box by `up --docker` so the box
|
|
33
|
+
# can run docker-executor CI jobs (containers it starts are siblings on
|
|
34
|
+
# the host daemon).
|
|
35
|
+
DOCKER_SOCKET = "/var/run/docker.sock"
|
|
36
|
+
|
|
32
37
|
CONTAINER_PREFIX = "gdkbox-"
|
|
33
38
|
HOST_ALIAS_PREFIX = "gdkbox-"
|
|
34
39
|
|
|
@@ -159,6 +164,13 @@ module GDKBox
|
|
|
159
164
|
"#{CONTAINER_PREFIX}#{name}"
|
|
160
165
|
end
|
|
161
166
|
|
|
167
|
+
# Per-box user-defined network for --docker boxes. Per-box (not shared)
|
|
168
|
+
# so each box can hold the gdk.local alias without DNS round-robin
|
|
169
|
+
# between boxes.
|
|
170
|
+
def docker_network(name)
|
|
171
|
+
"#{CONTAINER_PREFIX}#{name}"
|
|
172
|
+
end
|
|
173
|
+
|
|
162
174
|
def ssh_host_alias(name)
|
|
163
175
|
"#{HOST_ALIAS_PREFIX}#{name}"
|
|
164
176
|
end
|
data/lib/gdkbox/docker.rb
CHANGED
|
@@ -196,9 +196,15 @@ module GDKBox
|
|
|
196
196
|
# Docker also maps it to the container's IP in the container's /etc/hosts
|
|
197
197
|
# and re-applies that mapping on every start (a manual edit would be lost:
|
|
198
198
|
# Docker regenerates the file when the container starts).
|
|
199
|
-
def run_container(name:, image:, publish: [], labels: {}, env: {}, args: [], hostname: nil
|
|
199
|
+
def run_container(name:, image:, publish: [], labels: {}, env: {}, args: [], hostname: nil,
|
|
200
|
+
volumes: [], network: nil, network_alias: nil)
|
|
200
201
|
cmd = ["docker", "run", "-d", "--name", name]
|
|
201
202
|
cmd.push("--hostname", hostname) if hostname
|
|
203
|
+
cmd.push("--network", network) if network
|
|
204
|
+
# A network alias gives the box a DNS name *on that network*, so sibling
|
|
205
|
+
# containers (e.g. docker-executor CI jobs) can reach it by hostname.
|
|
206
|
+
cmd.push("--network-alias", network_alias) if network_alias
|
|
207
|
+
volumes.each { |mapping| cmd.push("-v", mapping) }
|
|
202
208
|
labels.each { |key, value| cmd.push("--label", "#{key}=#{value}") }
|
|
203
209
|
env.each { |key, value| cmd.push("-e", "#{key}=#{value}") }
|
|
204
210
|
publish.each { |mapping| cmd.push("-p", mapping) }
|
|
@@ -207,6 +213,18 @@ module GDKBox
|
|
|
207
213
|
@shell.run!(*cmd).stdout.strip
|
|
208
214
|
end
|
|
209
215
|
|
|
216
|
+
# Create a user-defined bridge network if it does not exist (idempotent).
|
|
217
|
+
def ensure_network(name)
|
|
218
|
+
return if @shell.run("docker", "network", "inspect", name).success?
|
|
219
|
+
|
|
220
|
+
@shell.run!("docker", "network", "create", name)
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
# Remove a network; a failure (still in use, already gone) is not fatal.
|
|
224
|
+
def rm_network(name)
|
|
225
|
+
@shell.run("docker", "network", "rm", name)
|
|
226
|
+
end
|
|
227
|
+
|
|
210
228
|
# Run a bash script inside a running container. The script is passed to
|
|
211
229
|
# `bash -lc` as a single argument; values that vary (keys, usernames) are
|
|
212
230
|
# passed through the environment to avoid quoting pitfalls.
|
data/lib/gdkbox/harness.rb
CHANGED
|
@@ -10,6 +10,10 @@ module GDKBox
|
|
|
10
10
|
# /usr/local/bin). A harness declares the env var its provider reads for
|
|
11
11
|
# credentials and the config.yml key gdkbox looks under for a default.
|
|
12
12
|
class Harness
|
|
13
|
+
# Env var names end up in an `export NAME=...` line inside the box, so
|
|
14
|
+
# only plain shell identifiers are accepted.
|
|
15
|
+
ENV_VAR_NAME = /\A[A-Z_][A-Z0-9_]*\z/
|
|
16
|
+
|
|
13
17
|
@registry = {}
|
|
14
18
|
|
|
15
19
|
class << self
|
|
@@ -43,8 +47,9 @@ module GDKBox
|
|
|
43
47
|
:summary, :skills_subdir
|
|
44
48
|
|
|
45
49
|
def initialize(id:, bin:, key_env:, config_key:, summary:, run:, skills_subdir:,
|
|
46
|
-
npm_package: nil, npm_flags: nil)
|
|
50
|
+
npm_package: nil, npm_flags: nil, model_flag: nil)
|
|
47
51
|
@id = id
|
|
52
|
+
@model_flag = model_flag
|
|
48
53
|
@npm_package = npm_package
|
|
49
54
|
@npm_flags = npm_flags
|
|
50
55
|
@bin = bin
|
|
@@ -55,15 +60,45 @@ module GDKBox
|
|
|
55
60
|
@skills_subdir = skills_subdir
|
|
56
61
|
end
|
|
57
62
|
|
|
63
|
+
# A copy of this harness that seeds its key under `key_env` instead
|
|
64
|
+
# (`up --key-env`), or self when blank. The config.yml fallback key
|
|
65
|
+
# follows the env var (GITLAB_TOKEN -> gitlab_token) so the override stays
|
|
66
|
+
# self-consistent.
|
|
67
|
+
def with_key_env(key_env)
|
|
68
|
+
key_env = key_env.to_s.strip
|
|
69
|
+
return self if key_env.empty?
|
|
70
|
+
unless ENV_VAR_NAME.match?(key_env)
|
|
71
|
+
raise Error, "Invalid --key-env '#{key_env}': not an env var name (use e.g. GITLAB_TOKEN)."
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
dup.tap do |copy|
|
|
75
|
+
copy.key_env = key_env
|
|
76
|
+
copy.config_key = key_env.downcase
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
|
|
58
80
|
# True when gdkbox knows how to install this harness automatically.
|
|
59
81
|
def installable?
|
|
60
82
|
!@npm_package.nil?
|
|
61
83
|
end
|
|
62
84
|
|
|
85
|
+
# The model id (`up --model`) stripped, or nil when blank. Raises when
|
|
86
|
+
# this harness cannot pin a model. The id reaches the agent only through
|
|
87
|
+
# the environment, so it needs no further validation.
|
|
88
|
+
def check_model!(model)
|
|
89
|
+
model = model.to_s.strip
|
|
90
|
+
return nil if model.empty?
|
|
91
|
+
raise Error, "The #{id} harness does not support --model yet." unless @model_flag
|
|
92
|
+
|
|
93
|
+
model
|
|
94
|
+
end
|
|
95
|
+
|
|
63
96
|
# The in-box shell snippet that runs the agent against $GDKBOX_TASK. The
|
|
64
|
-
# caller wraps it with a timeout and sources the seeded API key.
|
|
65
|
-
|
|
66
|
-
|
|
97
|
+
# caller wraps it with a timeout and sources the seeded API key. With a
|
|
98
|
+
# model, the snippet reads it from $GDKBOX_MODEL, which the caller sets.
|
|
99
|
+
def agent_command(json:, yolo:, model: nil)
|
|
100
|
+
cmd = @run.call(json: json, yolo: yolo)
|
|
101
|
+
check_model!(model) ? %(#{cmd} #{@model_flag} "$GDKBOX_MODEL") : cmd
|
|
67
102
|
end
|
|
68
103
|
|
|
69
104
|
register(
|
|
@@ -74,6 +109,7 @@ module GDKBox
|
|
|
74
109
|
config_key: "anthropic_api_key",
|
|
75
110
|
summary: "Anthropic Claude Code",
|
|
76
111
|
skills_subdir: ".claude/skills",
|
|
112
|
+
model_flag: "--model",
|
|
77
113
|
run: lambda do |json:, yolo:|
|
|
78
114
|
cmd = +%(claude -p "$GDKBOX_TASK")
|
|
79
115
|
cmd << " --output-format json" if json
|
|
@@ -90,6 +126,7 @@ module GDKBox
|
|
|
90
126
|
config_key: "openai_api_key",
|
|
91
127
|
summary: "OpenAI Codex CLI",
|
|
92
128
|
skills_subdir: ".codex/skills",
|
|
129
|
+
model_flag: "-m",
|
|
93
130
|
run: lambda do |json:, yolo:|
|
|
94
131
|
cmd = +%(codex exec "$GDKBOX_TASK")
|
|
95
132
|
cmd << " --json" if json
|
|
@@ -103,11 +140,15 @@ module GDKBox
|
|
|
103
140
|
id: "opencode",
|
|
104
141
|
npm_package: "opencode-ai",
|
|
105
142
|
bin: "opencode",
|
|
106
|
-
# opencode reads provider keys from the environment; default to
|
|
107
|
-
|
|
108
|
-
|
|
143
|
+
# opencode reads provider keys from the environment; default to its
|
|
144
|
+
# GitLab Duo provider (a personal access token with the `api` scope).
|
|
145
|
+
# Point a box at another provider with `up --key-env` (e.g.
|
|
146
|
+
# OPENAI_API_KEY).
|
|
147
|
+
key_env: "GITLAB_TOKEN",
|
|
148
|
+
config_key: "gitlab_token",
|
|
109
149
|
summary: "opencode (sst)",
|
|
110
150
|
skills_subdir: ".config/opencode/skills",
|
|
151
|
+
model_flag: "-m",
|
|
111
152
|
run: lambda do |json:, yolo:|
|
|
112
153
|
# `opencode run` is non-interactive and has no skip-permissions flag.
|
|
113
154
|
+%(opencode run "$GDKBOX_TASK")
|
|
@@ -121,9 +162,10 @@ module GDKBox
|
|
|
121
162
|
# docs recommend installing with --ignore-scripts.
|
|
122
163
|
npm_flags: "--ignore-scripts",
|
|
123
164
|
bin: "pi",
|
|
124
|
-
# pi is multi-provider; default the seeded key to
|
|
125
|
-
|
|
126
|
-
|
|
165
|
+
# pi is multi-provider; default the seeded key to GitLab Duo
|
|
166
|
+
# (GITLAB_TOKEN). Point a box at another provider with `up --key-env`.
|
|
167
|
+
key_env: "GITLAB_TOKEN",
|
|
168
|
+
config_key: "gitlab_token",
|
|
127
169
|
summary: "pi (earendil-works)",
|
|
128
170
|
skills_subdir: ".pi/skills",
|
|
129
171
|
run: lambda do |json:, yolo:|
|
|
@@ -132,5 +174,9 @@ module GDKBox
|
|
|
132
174
|
cmd
|
|
133
175
|
end
|
|
134
176
|
)
|
|
177
|
+
|
|
178
|
+
protected
|
|
179
|
+
|
|
180
|
+
attr_writer :key_env, :config_key
|
|
135
181
|
end
|
|
136
182
|
end
|
data/lib/gdkbox/provisioner.rb
CHANGED
|
@@ -205,6 +205,35 @@ module GDKBox
|
|
|
205
205
|
fi
|
|
206
206
|
BASH
|
|
207
207
|
|
|
208
|
+
# Static Docker CLI release installed into --docker boxes. A pinned
|
|
209
|
+
# version keeps provisioning reproducible; the static tarball needs no
|
|
210
|
+
# apt repo and exists for both x86_64 and aarch64.
|
|
211
|
+
DOCKER_CLI_VERSION = "27.5.1"
|
|
212
|
+
|
|
213
|
+
# Give a --docker box a working `docker` command against the mounted host
|
|
214
|
+
# socket: install the (arch-matched) static CLI, then grant the GDK user
|
|
215
|
+
# access to the socket via whatever group owns it — gid 0 on Docker
|
|
216
|
+
# Desktop, the docker group's gid on Linux hosts. Group membership takes
|
|
217
|
+
# effect on new login sessions, which is what dispatch/ssh create.
|
|
218
|
+
DOCKER_CLI_SETUP = <<~'BASH'
|
|
219
|
+
set -e
|
|
220
|
+
if ! command -v docker >/dev/null 2>&1; then
|
|
221
|
+
arch=$(uname -m)
|
|
222
|
+
curl -fsSL "https://download.docker.com/linux/static/stable/${arch}/docker-${GDKBOX_DOCKER_CLI_VERSION}.tgz" \
|
|
223
|
+
| tar -xz -C /tmp docker/docker
|
|
224
|
+
install -m 755 /tmp/docker/docker /usr/local/bin/docker
|
|
225
|
+
rm -rf /tmp/docker
|
|
226
|
+
fi
|
|
227
|
+
sock_gid=$(stat -c %g "$GDKBOX_DOCKER_SOCKET")
|
|
228
|
+
group=$(getent group "$sock_gid" | cut -d: -f1)
|
|
229
|
+
if [ -z "$group" ]; then
|
|
230
|
+
group=gdkbox-docker
|
|
231
|
+
groupadd -g "$sock_gid" "$group"
|
|
232
|
+
fi
|
|
233
|
+
usermod -aG "$group" "$GDKBOX_USER"
|
|
234
|
+
docker --version
|
|
235
|
+
BASH
|
|
236
|
+
|
|
208
237
|
def initialize(docker:, config:)
|
|
209
238
|
@docker = docker
|
|
210
239
|
@config = config
|
|
@@ -242,6 +271,18 @@ module GDKBox
|
|
|
242
271
|
)
|
|
243
272
|
end
|
|
244
273
|
|
|
274
|
+
def setup_docker_cli(container_name)
|
|
275
|
+
@docker.exec(
|
|
276
|
+
container_name, DOCKER_CLI_SETUP,
|
|
277
|
+
user: "root",
|
|
278
|
+
env: {
|
|
279
|
+
"GDKBOX_USER" => @config.ssh_user,
|
|
280
|
+
"GDKBOX_DOCKER_SOCKET" => Config::DOCKER_SOCKET,
|
|
281
|
+
"GDKBOX_DOCKER_CLI_VERSION" => DOCKER_CLI_VERSION
|
|
282
|
+
}
|
|
283
|
+
)
|
|
284
|
+
end
|
|
285
|
+
|
|
245
286
|
def setup_vite_port(container_name, port)
|
|
246
287
|
@docker.exec(
|
|
247
288
|
container_name, VITE_PORT_SETUP,
|
data/lib/gdkbox/store.rb
CHANGED
|
@@ -41,7 +41,9 @@ module GDKBox
|
|
|
41
41
|
|
|
42
42
|
# Every host port already claimed by an existing box.
|
|
43
43
|
def used_ports
|
|
44
|
-
all.flat_map
|
|
44
|
+
all.flat_map do |box|
|
|
45
|
+
[box["ssh_port"], box["web_port"], box["vite_port"], *box["extra_ports"]]
|
|
46
|
+
end.compact
|
|
45
47
|
end
|
|
46
48
|
end
|
|
47
49
|
end
|
data/lib/gdkbox/version.rb
CHANGED
|
@@ -66,6 +66,30 @@ Violating these is how "another agent stole my box" happens:
|
|
|
66
66
|
user) and never in host-side metadata. Check readiness with the
|
|
67
67
|
`api_key_set` field from `gdkbox ls --json` / `status --json`.
|
|
68
68
|
|
|
69
|
+
### Other harnesses (e.g. opencode with GitLab Duo)
|
|
70
|
+
|
|
71
|
+
The steps above assume the default Claude Code harness. If you are told to use
|
|
72
|
+
another agent, or GitLab Duo, the harness, key env var and model are fixed
|
|
73
|
+
**when the box is created**, so pass them to every `up`:
|
|
74
|
+
|
|
75
|
+
```sh
|
|
76
|
+
export GITLAB_TOKEN=<token> # personal access token, api scope
|
|
77
|
+
gdkbox up "pool-$i" --owner "$OWNER" --ttl 7200 --json \
|
|
78
|
+
--harness opencode --model gitlab/duo-chat-sonnet-4-5
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
- The opencode harness seeds `$GITLAB_TOKEN` (GitLab Duo) by default. For
|
|
82
|
+
another provider, pass `--key-env <VAR>` (e.g. `OPENAI_API_KEY`) and export
|
|
83
|
+
that variable instead.
|
|
84
|
+
- `--model` pins the model `dispatch` uses. Use a `gitlab/duo-chat-*` model;
|
|
85
|
+
`duo-workflow-*` models do not work with a token.
|
|
86
|
+
- An existing box cannot switch harness, key env or model. `gdkbox rm` it and
|
|
87
|
+
`up` it again.
|
|
88
|
+
- Verify with `gdkbox ls --json`: `"harness"`, `"key_env"` and `"model"` must
|
|
89
|
+
match what you asked for, and `"api_key_set"` must be `true`.
|
|
90
|
+
- `dispatch --json` output is harness-specific. For opencode it is plain text,
|
|
91
|
+
so rely on the exit status rather than parsing it.
|
|
92
|
+
|
|
69
93
|
In the examples below, `gdkbox` means "the gdkbox CLI, however it is invoked".
|
|
70
94
|
|
|
71
95
|
## Command reference (what the orchestrator uses)
|
|
@@ -73,7 +97,8 @@ In the examples below, `gdkbox` means "the gdkbox CLI, however it is invoked".
|
|
|
73
97
|
| Goal | Command |
|
|
74
98
|
| --- | --- |
|
|
75
99
|
| Create/start a box (claimed from birth) | `gdkbox up <name> --owner <id> --ttl <secs> [--json]` |
|
|
76
|
-
|
|
|
100
|
+
| Create a non-Claude box | add `--harness <id> [--key-env <VAR>] [--model <id>]` to `up` (see above) |
|
|
101
|
+
| Seed/rotate API key | `gdkbox set-key <name>` (uses the box's `key_env`, `$ANTHROPIC_API_KEY` by default) |
|
|
77
102
|
| List the fleet (parseable) | `gdkbox ls --json` |
|
|
78
103
|
| Inspect one box | `gdkbox status <name> --json` |
|
|
79
104
|
| **Claim a free box** | `gdkbox claim --owner <id> --ttl <secs> --json` (or `gdkbox claim <name> ...`) |
|
|
@@ -97,8 +122,11 @@ In the examples below, `gdkbox` means "the gdkbox CLI, however it is invoked".
|
|
|
97
122
|
"web_port": 3000,
|
|
98
123
|
"web_url": "http://127.0.0.1:3000",
|
|
99
124
|
"remote_path": "/home/gdk/gdk",
|
|
125
|
+
"harness": "claude",
|
|
100
126
|
"claude_installed": true,
|
|
101
127
|
"api_key_set": true,
|
|
128
|
+
"key_env": "ANTHROPIC_API_KEY",
|
|
129
|
+
"model": null,
|
|
102
130
|
"claimed_by": "agent-7",
|
|
103
131
|
"claim_expires_at": "2026-08-12T13:00:00Z"
|
|
104
132
|
}
|
|
@@ -108,7 +136,8 @@ In the examples below, `gdkbox` means "the gdkbox CLI, however it is invoked".
|
|
|
108
136
|
`claimed_by` is `null` for a free box (an expired lease counts as free).
|
|
109
137
|
|
|
110
138
|
`dispatch` exits with the agent's own exit status (0 = success). With `--json`
|
|
111
|
-
its stdout is
|
|
139
|
+
its stdout is the agent's structured result (Claude's by default), which you
|
|
140
|
+
can parse per task.
|
|
112
141
|
|
|
113
142
|
## Orchestration workflow
|
|
114
143
|
|
|
@@ -240,6 +269,34 @@ Free a box stranded by a dead orchestrator (check `gdkbox ls` first):
|
|
|
240
269
|
gdkbox release pool-2 --force
|
|
241
270
|
```
|
|
242
271
|
|
|
272
|
+
## Running Duo agentic flows (needs `--docker`)
|
|
273
|
+
|
|
274
|
+
Duo Agent Platform flows finish in a docker-executor CI job, so a flow-capable
|
|
275
|
+
box needs Docker access — this is the sanctioned use of `--docker` (see the
|
|
276
|
+
guardrail below; keep such boxes confined to the flow work). Provision it as
|
|
277
|
+
its own claimed box:
|
|
278
|
+
|
|
279
|
+
```sh
|
|
280
|
+
gdkbox up duo-1 --owner "$OWNER" --ttl 14400 --docker --publish 5052 50052
|
|
281
|
+
gdkbox set-gcloud duo-1 # Vertex AI credentials for the flow model
|
|
282
|
+
```
|
|
283
|
+
|
|
284
|
+
Then, inside the box (dispatch these as tasks or run them over `gdkbox ssh`),
|
|
285
|
+
follow the README's "Running Duo agentic flows in a box" steps — in short:
|
|
286
|
+
`gdk rake setup_ai_services` (~20 min, needs a valid `ANTHROPIC_API_KEY`);
|
|
287
|
+
put the workflow service's env in **`env.runit`** (not `.env` — the two
|
|
288
|
+
services read different files); disable `gitlab_http_router` and enable nginx
|
|
289
|
+
(the router 403s the flow's WebSocket upgrade); set
|
|
290
|
+
`gitlab_ai_gateway.environment local` and run the `gitlab:duo:setup` /
|
|
291
|
+
`onboard_dap` seeds; add `network_mode = "gdkbox-duo-1"` to the generated
|
|
292
|
+
runner config (re-apply after `gdk reconfigure`). Verified end state: the
|
|
293
|
+
`developer` flow triggered from an issue in the seeded `gitlab-duo` project
|
|
294
|
+
completes and opens a Draft MR.
|
|
295
|
+
|
|
296
|
+
Plan for this box being expensive: the AI setup is ~20 minutes and the box
|
|
297
|
+
wants >10 GB free memory once the AI stack and flow containers run — size the
|
|
298
|
+
rest of the pool accordingly, and give the claim a generous `--ttl`.
|
|
299
|
+
|
|
243
300
|
## Guardrails
|
|
244
301
|
|
|
245
302
|
- **Don't exceed the box count the machine can handle** — each GDK box is
|
|
@@ -263,3 +320,10 @@ gdkbox release pool-2 --force
|
|
|
263
320
|
- **Boxes are isolated.** Dispatch runs with `--dangerously-skip-permissions`
|
|
264
321
|
by default because the work happens inside a disposable container; pass
|
|
265
322
|
`--no-yolo` if you need permission prompts honored.
|
|
323
|
+
- **`--docker` boxes are NOT isolated.** `up --docker` mounts the host Docker
|
|
324
|
+
socket, which is root-equivalent control of the host machine — the
|
|
325
|
+
isolation argument above does not hold there. Provision `--docker` when
|
|
326
|
+
the orchestration goal requires docker-executor CI (running Duo agentic
|
|
327
|
+
flows is the canonical case), and confine such boxes to that goal's own
|
|
328
|
+
tasks: never route unrelated, untrusted, or third-party work into a
|
|
329
|
+
`--docker` box, and prefer plain boxes for everything else.
|