gdkbox 0.1.19 → 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: 17efb5fee81f15043aed3c8dbabe3e9259c70a8d8a03dd400b8103231b36a9d6
4
- data.tar.gz: d926bbae45cbe226fec0530f43e11e12f1ef346450e5801909c251e6f159bf66
3
+ metadata.gz: 6a5d8a8a1fbb33083f8cfb0b306ee0d6fc4efaffad04a110ef9f1a55141baae5
4
+ data.tar.gz: f4ec5a9b60bf6ab209ced26b1c0ec9c6b95503ae6a65484844506553055ea900
5
5
  SHA512:
6
- metadata.gz: 179859b546c7b76825d22d6e100b39446d96205adaab696982f960409f14041a8135daf67600d3755545613537a011131302c238de44fefd64df5a7980834227
7
- data.tar.gz: 59f9e916539ca282cc16ec86e8fde2ab8437541aad4c433d4c5a2895f49017cf747880f75e3777e80c39b88e767591c9a8b18d115badfae9fcabf4525bc22f2d
6
+ metadata.gz: 596f76ed57ea61c0fc0a00a7e0cd72a917a076aa9fcf022127d8b3ac7e5303c6ccb2c60e30c24727dcedb402f14065da1776be58832d1d28446b070850b09145
7
+ data.tar.gz: 1e4e94bc489cfc712b5e7a7386cad211e5cdf26b826eddeb43fa5e2bf47cacfde8c945677d896c5a21e97deef8dae99458133ebb4a3253a2530142c66404ff77
data/README.md CHANGED
@@ -122,6 +122,8 @@ Or run it straight from the checkout without installing:
122
122
  | `--ttl SECS` | no expiry | Lease duration for `--owner`'s claim. |
123
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
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`). |
125
127
 
126
128
  ## Typical workflow
127
129
 
@@ -373,8 +375,8 @@ harness the box was created with.
373
375
  | --- | --- | --- | --- | --- |
374
376
  | `claude` | Claude Code | `claude -p` | `ANTHROPIC_API_KEY` | `~/.claude/skills` |
375
377
  | `codex` | OpenAI Codex | `codex exec` | `OPENAI_API_KEY` | `~/.codex/skills` |
376
- | `opencode` | opencode (sst) | `opencode run` | `OPENAI_API_KEY` | `~/.config/opencode/skills` |
377
- | `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` |
378
380
 
379
381
  ```sh
380
382
  gdkbox up reviewer --harness codex # a Codex box
@@ -385,8 +387,39 @@ gdkbox harnesses # list available harnesses
385
387
  ```
386
388
 
387
389
  Skills use the same `SKILL.md` format across all four, so a skill installed by
388
- `add-skill`/`--skill` works regardless of harness. The key env / provider for
389
- `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.
390
423
 
391
424
  ## Shell completion
392
425
 
@@ -439,15 +472,17 @@ skills:
439
472
  # - ./path/to/a/local/skill
440
473
 
441
474
  # API keys seeded into boxes for unattended `gdkbox dispatch`, per provider.
442
- # anthropic_api_key: sk-ant-... # used by the claude / pi harnesses
443
- # 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
444
478
  ```
445
479
 
446
480
  `gdkbox up` installs the skills on top of any `--skill` you pass; use
447
481
  `--no-default-skills` to skip them for one run. Precedence for the image is
448
482
  `--image` > `$GDKBOX_IMAGE` > `config.yml` > built-in default; for the API key
449
483
  it is `--api-key` > the harness's provider env var (e.g. `$ANTHROPIC_API_KEY`,
450
- `$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:`).
451
486
 
452
487
  > **API keys in `config.yml` are plaintext secrets on disk.** Prefer the
453
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, docker_access: false, extra_ports: [])
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)
@@ -168,6 +177,8 @@ module GDKBox
168
177
  "harness" => harness.id,
169
178
  "created_at" => Time.now.utc.iso8601
170
179
  )
180
+ @data["key_env"] = harness.key_env if key_env
181
+ @data["model"] = model if model
171
182
  if docker_access
172
183
  @data["docker_access"] = true
173
184
  @data["docker_network"] = net
@@ -493,12 +504,12 @@ module GDKBox
493
504
 
494
505
  # Run this box's agent harness non-interactively and capture its output.
495
506
  # This is the primitive an orchestrator uses to dispatch a task to a box.
496
- # The task text is passed through the environment so arbitrary prompts
497
- # 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.
498
509
  def run_agent(task:, json: false, yolo: true, timeout: nil)
499
510
  raise Error, "Box '#{name}' does not exist" unless exists?
500
511
 
501
- agent = harness.agent_command(json: json, yolo: yolo)
512
+ agent = harness.agent_command(json: json, yolo: yolo, model: model)
502
513
  agent = "timeout #{Integer(timeout)} #{agent}" if timeout
503
514
  # Source the seeded API key (if any) so the agent authenticates unattended.
504
515
  command = %([ -f "$HOME/.gdkbox/env" ] && . "$HOME/.gdkbox/env"; #{agent})
@@ -507,7 +518,7 @@ module GDKBox
507
518
  container_name, command,
508
519
  user: @config.ssh_user,
509
520
  workdir: remote_path,
510
- env: { "GDKBOX_TASK" => task },
521
+ env: { "GDKBOX_TASK" => task, "GDKBOX_MODEL" => model }.compact,
511
522
  check: false
512
523
  )
513
524
  AgentResult.new(result.stdout, result.stderr, result.status)
@@ -515,6 +526,7 @@ module GDKBox
515
526
 
516
527
  # A machine-readable summary for orchestrators (`gdkbox ls --json`).
517
528
  def summary(state: nil)
529
+ harness = self.harness
518
530
  {
519
531
  "name" => name,
520
532
  "state" => (state || self.state).to_s,
@@ -529,6 +541,8 @@ module GDKBox
529
541
  # Back-compat: orchestrators predating multi-harness check this field.
530
542
  "claude_installed" => (harness.id == "claude" && (data && data["agent_installed"])) || false,
531
543
  "api_key_set" => (data && data["api_key_set"]) || false,
544
+ "key_env" => harness.key_env,
545
+ "model" => model,
532
546
  # Vite assets: the published port (nil on boxes created before vite
533
547
  # support) and whether the in-box alignment step succeeded.
534
548
  "vite_port" => vite_port,
data/lib/gdkbox/cli.rb CHANGED
@@ -54,6 +54,12 @@ module GDKBox
54
54
  option :publish, type: :array, default: [],
55
55
  desc: "Extra container port(s) to publish on 127.0.0.1, same number on both sides " \
56
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)"
57
63
  def up(name)
58
64
  if options[:ttl] && options[:owner].to_s.strip.empty?
59
65
  raise Error, "--ttl only makes sense with --owner (it is the claim's lease)."
@@ -61,7 +67,8 @@ module GDKBox
61
67
  ensure_docker!
62
68
  box = build_box(name)
63
69
  raise Error, "Box '#{name}' already exists. Use `gdkbox rm #{name}` first." if box.exists?
64
- 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])
65
72
  api_key = resolve_api_key(harness)
66
73
 
67
74
  say "Spinning up GDK box '#{name}' (#{harness.summary}; first run pulls a large image)...", :green unless options[:json]
@@ -76,7 +83,9 @@ module GDKBox
76
83
  claim_owner: options[:owner],
77
84
  claim_ttl: options[:ttl],
78
85
  docker_access: options[:docker],
79
- extra_ports: parse_extra_ports(options[:publish])
86
+ extra_ports: parse_extra_ports(options[:publish]),
87
+ key_env: options[:key_env],
88
+ model: model
80
89
  )
81
90
  rewrite_ssh_config
82
91
  seed_skills(box)
@@ -754,6 +763,7 @@ module GDKBox
754
763
  say " Web UI: #{box.web_url} (http://#{HostsFile::HOSTNAME}:#{box.web_port})", :cyan
755
764
  say " Assets: vite on port #{box.vite_port}", :cyan if box.vite_port
756
765
  say " Agent: ssh #{box.ssh_host_alias} -t #{box.harness.bin}", :cyan
766
+ say " Model: #{box.model} (on dispatch)", :cyan if box.model
757
767
  end
758
768
  end
759
769
  end
@@ -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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module GDKBox
4
- VERSION = "0.1.19"
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
 
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.19
4
+ version: 0.1.20
5
5
  platform: ruby
6
6
  authors:
7
7
  - jotolo