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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: fbe76156f93120de2292273e79d4c49cb904abdd94ad795846d4d5f9f1799cc3
4
- data.tar.gz: 35fbbb2d4117272ba07c9cbaebed129224aea4ee0762095324139d896e50cd21
3
+ metadata.gz: 6a5d8a8a1fbb33083f8cfb0b306ee0d6fc4efaffad04a110ef9f1a55141baae5
4
+ data.tar.gz: f4ec5a9b60bf6ab209ced26b1c0ec9c6b95503ae6a65484844506553055ea900
5
5
  SHA512:
6
- metadata.gz: 01243dc3ba079e8e2120a14bd7d352d6e8ac41977afa4cb53da4677fc0dc2f0ed0e7eab0e47ebcd2ced1fe19ee7e781dc0d5f56674a5fcfc1f1843a5ae828ec7
7
- data.tar.gz: 95fe2b453bf0a0e44453633c0f947069bfecb550bdc7317f1c996f6fe9019cf6f5aed0e2324c52a45db843abd0ded19ddffc62654b7c3164f1a3ed3293aab40c
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` | `OPENAI_API_KEY` | `~/.config/opencode/skills` |
272
- | `pi` | pi (earendil-works) | `pi -p` | `ANTHROPIC_API_KEY` | `~/.pi/skills` |
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 / provider for
284
- `opencode` and `pi` are configurable defaults — adjust per your provider.
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 / pi harnesses
338
- # openai_api_key: sk-... # used by the codex / opencode harnesses
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
- `$OPENAI_API_KEY`) > `config.yml`.
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.0.0.1:#{ssh_port}:#{Config::SSH_CONTAINER_PORT}",
127
- "127.0.0.1:#{web_port}:#{Config::GDK_WEB_CONTAINER_PORT}",
128
- # Same number on both sides: asset URLs embed this port, so what
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 environment so arbitrary prompts
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, claim_owner: nil, claim_ttl: nil)
522
- { "--ssh-port" => ssh_port, "--web-port" => web_port,
523
- "--vite-port" => vite_port }.each do |flag, port|
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, "Port #{port} (#{flag}) is already in use on 127.0.0.1."
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
@@ -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 hydrate
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.
@@ -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
- def agent_command(json:, yolo:)
66
- @run.call(json: json, yolo: yolo)
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 OpenAI.
107
- key_env: "OPENAI_API_KEY",
108
- config_key: "openai_api_key",
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 Anthropic.
125
- key_env: "ANTHROPIC_API_KEY",
126
- config_key: "anthropic_api_key",
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
@@ -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 { |box| [box["ssh_port"], box["web_port"], box["vite_port"]] }.compact
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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module GDKBox
4
- VERSION = "0.1.18"
4
+ VERSION = "0.1.20"
5
5
  end
@@ -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
- | Seed/rotate API key | `gdkbox set-key <name>` (uses `$ANTHROPIC_API_KEY`) |
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 Claude's structured result, which you can parse per task.
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.
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: gdkbox
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.18
4
+ version: 0.1.20
5
5
  platform: ruby
6
6
  authors:
7
7
  - jotolo