Class: GDKBox::Box

Inherits:
Object
  • Object
show all
Defined in:
lib/gdkbox/box.rb

Overview

A single GDK-in-a-box instance: a Docker container plus its persisted metadata. This is the orchestration layer the CLI talks to.

Defined Under Namespace

Classes: AgentResult

Constant Summary collapse

HYDRATE_SCRIPT =

The hydrate fetch, run inside the box. The filter is passed to git fetch directly and only persisted to config after the fetch succeeds (and, for a full hydrate, the previous filter is put back on failure), so an interrupted hydrate cannot leave the config claiming objects the store does not have (issue #8).

<<~'BASH'
  set -e
  if [ -n "$GDKBOX_HYDRATE_FILTER" ]; then
    git fetch --refetch --progress --filter="$GDKBOX_HYDRATE_FILTER" origin
    git config remote.origin.partialclonefilter "$GDKBOX_HYDRATE_FILTER"
  else
    prev=$(git config --get remote.origin.partialclonefilter || true)
    git config --unset-all remote.origin.partialclonefilter || true
    if ! git fetch --refetch --progress origin; then
      if [ -n "$prev" ]; then git config remote.origin.partialclonefilter "$prev"; fi
      echo "hydrate: fetch failed; previous filter config restored" >&2
      exit 1
    fi
    git config --unset remote.origin.promisor || true
  fi
BASH

Instance Attribute Summary collapse

Class Method Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(name, config:, shell: Shell.new, docker: nil, store: nil, ssh_key: nil) ⇒ Box

Returns a new instance of Box.



19
20
21
22
23
24
25
26
# File 'lib/gdkbox/box.rb', line 19

def initialize(name, config:, shell: Shell.new, docker: nil, store: nil, ssh_key: nil)
  @name = name
  @config = config
  @shell = shell
  @docker = docker || Docker.new(shell: shell)
  @store = store || Store.new(config: config)
  @ssh_key = ssh_key || SSHKey.new(config: config, shell: shell)
end

Instance Attribute Details

#configObject (readonly)

Returns the value of attribute config.



17
18
19
# File 'lib/gdkbox/box.rb', line 17

def config
  @config
end

#nameObject (readonly)

Returns the value of attribute name.



17
18
19
# File 'lib/gdkbox/box.rb', line 17

def name
  @name
end

Class Method Details

.all(config:, store: nil, **kwargs) ⇒ Object



28
29
30
31
# File 'lib/gdkbox/box.rb', line 28

def self.all(config:, store: nil, **kwargs)
  store ||= Store.new(config: config)
  store.all.map { |data| from_data(data, config: config, store: store, **kwargs) }
end

.claim_any(config:, owner:, ttl: nil, **kwargs) ⇒ Object

Atomically claim any free box (unclaimed, or with an expired lease) for owner, returning it. Boxes the owner already holds are skipped — each call yields a distinct box, so claiming K boxes is K calls (renewal is explicit, by name). Each attempt is itself atomic, so racing orchestrators simply end up with different boxes. Raises Error when no free box remains.

Raises:



270
271
272
273
274
275
276
277
278
279
# File 'lib/gdkbox/box.rb', line 270

def self.claim_any(config:, owner:, ttl: nil, **kwargs)
  all(config: config, **kwargs).sort_by(&:name).each do |box|
    next if box.claimed_by # anyone's active claim, including our own

    return box.claim!(owner: owner, ttl: ttl)
  rescue Error
    next # claimed since we listed — try the next
  end
  raise Error, "No free box to claim. See `gdkbox ls` for current claims."
end

.expand_remote(token) ⇒ Object

The full git URL for a remote given as a URL or a "namespace/project" shorthand (expanded against gitlab.com over SSH, so pushes ride the forwarded agent).



284
285
286
287
288
# File 'lib/gdkbox/box.rb', line 284

def self.expand_remote(token)
  return token if token.include?("://") || token.include?(":")

  "git@gitlab.com:#{token}.git"
end

.from_data(data, config:, **kwargs) ⇒ Object



33
34
35
36
37
# File 'lib/gdkbox/box.rb', line 33

def self.from_data(data, config:, **kwargs)
  box = new(data["name"], config: config, **kwargs)
  box.instance_variable_set(:@data, data)
  box
end

.remote_ssh_host(url) ⇒ Object

The host an SSH-style git URL connects to, or nil for non-SSH remotes (https needs no SSH client preparation).



292
293
294
295
296
297
298
# File 'lib/gdkbox/box.rb', line 292

def self.remote_ssh_host(url)
  if (m = url.match(%r{\Assh://(?:[^@/]+@)?([^:/]+)}))
    m[1]
  elsif (m = url.match(/\A(?:[^@:\/]+@)([^:]+):/))
    m[1]
  end
end

Instance Method Details

#claim!(owner:, ttl: nil) ⇒ Object

Claim this box for exclusive use by owner — an advisory lock for fleet orchestrators, so two agents cannot pick the same box. Runs under the create lock, so concurrent claims serialize; exactly one wins. Re-claiming with the same owner renews (and can extend a --ttl lease). Raises Error when another owner holds an unexpired claim.

Raises:



211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
# File 'lib/gdkbox/box.rb', line 211

def claim!(owner:, ttl: nil)
  raise Error, "Box '#{name}' does not exist" unless exists?

  with_create_lock do
    @data = @store.load(name) # fresh read under the lock
    holder = claimed_by
    if holder && holder != owner
      raise Error, "Box '#{name}' is claimed by '#{holder}'. " \
        "Pick another box or use `gdkbox release #{name} --force`."
    end

    @data["claimed_by"] = owner
    @data["claimed_at"] = Time.now.utc.iso8601
    if ttl
      @data["claim_expires_at"] = (Time.now.utc + ttl).iso8601
    else
      @data.delete("claim_expires_at")
    end
    @store.save(@data)
  end
  self
end

#claimed_byObject

The owner of the active claim, or nil when unclaimed or the claim's lease has expired (an expired claim counts as free).



255
256
257
258
259
260
261
262
# File 'lib/gdkbox/box.rb', line 255

def claimed_by
  return nil unless data && data["claimed_by"]

  expires = data["claim_expires_at"]
  return nil if expires && Time.parse(expires) <= Time.now.utc

  data["claimed_by"]
end

#container_nameObject



47
48
49
# File 'lib/gdkbox/box.rb', line 47

def container_name
  data ? data["container_name"] : @config.container_name(name)
end

#create!(image: nil, ssh_port: nil, web_port: nil, vite_port: nil, harness: Harness.default, install_agent: true, api_key: nil, claim_owner: nil, claim_ttl: nil) ⇒ Object

Provision a brand new box end to end: pull image, run container, enable SSH, optionally install the agent harness, then persist metadata.

With claim_owner, the box is born claimed by that owner: the claim fields go into the same locked store write as the port reservation, so there is no window in which another orchestrator's claim can grab a box this one is still provisioning.

Raises:



92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
# File 'lib/gdkbox/box.rb', line 92

def create!(image: nil, ssh_port: nil, web_port: nil, vite_port: nil,
  harness: Harness.default, install_agent: true, api_key: nil,
  claim_owner: nil, claim_ttl: nil)
  raise Error, "Box '#{name}' already exists" if exists?

  harness = Harness[harness]
  image ||= @config.default_image
  public_key = @ssh_key.ensure!
  cname = @config.container_name(name)

  # Only pull when the image is missing locally, so `gdkbox up` on a warm
  # machine does not hit the registry; `gdkbox update-image` is the
  # explicit way to refresh.
  @docker.pull(image) unless @docker.image_exists?(image)

  # Reserve the host ports atomically. `next_port` reads the store, so
  # parallel `gdkbox up` runs would otherwise all pick the same "next free"
  # port and collide at `docker run`. Holding an exclusive lock while we
  # choose ports *and* persist a preliminary record makes each sibling see
  # the others' reservations.
  ssh_port, web_port, vite_port = reserve_ports!(
    cname, ssh_port, web_port, vite_port,
    claim_owner: claim_owner, claim_ttl: claim_ttl
  )

  begin
    @docker.run_container(
      name: cname,
      image: image,
      # GDK's services resolve and redirect to this hostname; without it
      # set inside the container, workhorse and the http-router crash-loop
      # on DNS timeouts.
      hostname: Config::GDK_HOSTNAME,
      publish: [
        "127.0.0.1:#{ssh_port}:#{Config::SSH_CONTAINER_PORT}",
        "127.0.0.1:#{web_port}:#{Config::GDK_WEB_CONTAINER_PORT}",
        # Same number on both sides: asset URLs embed this port, so what
        # the browser dials must be where vite listens (see VITE_PORT_BASE).
        "127.0.0.1:#{vite_port}:#{vite_port}"
      ],
      labels: { "gdkbox" => "true", "gdkbox.name" => name }
    )
  rescue StandardError
    # The container never started, so release the reserved ports rather
    # than stranding a record that points at nothing.
    @store.delete(name)
    raise
  end

  provisioner = Provisioner.new(docker: @docker, config: @config)
  provisioner.setup_ssh(cname, public_key)

  # Enrich the reserved record now that the box is reachable over SSH,
  # before the optional Claude/API-key steps. That way a failure in those
  # steps leaves a box that `gdkbox ls`/`rm` can still see and clean up,
  # rather than an orphan container with no record.
  @data = @data.merge(
    "image" => image,
    "ssh_user" => @config.ssh_user,
    "remote_path" => @config.remote_path,
    "harness" => harness.id,
    "created_at" => Time.now.utc.iso8601
  )
  @store.save(@data)

  # Align vite's in-container listen port with the published one so asset
  # URLs work from the host browser. Best-effort — the box is fully
  # usable for SSH/agent work without it — but the outcome is recorded
  # so `status`/`ls --json` can surface a box whose assets won't load.
  @data["vite_port_configured"] = begin
    provisioner.setup_vite_port(cname, vite_port)
    true
  rescue StandardError
    false
  end

  # Best-effort: a host without a git identity (or a transient exec
  # failure) should not abort the box; `gdkbox set-git` can seed it later.
  begin
    git_name, git_email = host_git_identity
    provisioner.setup_git_identity(cname, name: git_name, email: git_email) if git_name || git_email
  rescue StandardError
    nil
  end

  provisioner.setup_agent(cname, harness) if install_agent
  @data["agent_installed"] = install_agent

  if api_key && !api_key.strip.empty?
    provisioner.setup_api_key(cname, api_key, harness.key_env)
    @data["api_key_set"] = true
  end

  @store.save(@data)
  @data
end

#dataObject



39
40
41
# File 'lib/gdkbox/box.rb', line 39

def data
  @data ||= @store.load(name)
end

#destroy!Object



201
202
203
204
# File 'lib/gdkbox/box.rb', line 201

def destroy!
  @docker.rm(container_name, force: true)
  @store.delete(name)
end

#exists?Boolean

Returns:

  • (Boolean)


43
44
45
# File 'lib/gdkbox/box.rb', line 43

def exists?
  @store.exists?(name)
end

#harnessObject

The agent harness this box runs (defaults to Claude for boxes created before harness support existed).



77
78
79
# File 'lib/gdkbox/box.rb', line 77

def harness
  Harness[(data && data["harness"]) || Harness.default]
end

#hydrate!(trees: false, &progress) ⇒ Object

Backfill the box's treeless GitLab clone so deep-history operations (old rebases, blame, bisect) work. trees: true fetches only the missing trees (much smaller; file contents stay lazy).

The fetch runs over SSH (ssh -F <generated config> <alias>), not docker exec: the remote is typically git@gitlab.com, and only an SSH session carries the user's forwarded agent — docker exec has no SSH_AUTH_SOCK, so it cannot authenticate at all (issue #8). The SSH client stanza (host key + multiplexing) is seeded first for SSH origins. Streams git's progress lines to the block; raises CommandError when the fetch fails.

Raises:



348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
# File 'lib/gdkbox/box.rb', line 348

def hydrate!(trees: false, &progress)
  origin = @docker.exec(
    container_name, "git remote get-url origin",
    user: @config.ssh_user, workdir: @config.gitlab_checkout_path
  ).stdout.strip
  if (host = self.class.remote_ssh_host(origin))
    Provisioner.new(docker: @docker, config: @config)
      .setup_git_ssh(container_name, host)
  end

  filter = trees ? "blob:none" : ""
  remote_cmd = "cd #{Shellwords.escape(@config.gitlab_checkout_path)} && " \
    "GDKBOX_HYDRATE_FILTER=#{Shellwords.escape(filter)} " \
    "bash -c #{Shellwords.escape(HYDRATE_SCRIPT)}"
  argv = ["ssh", "-F", @config.ssh_config_path, ssh_host_alias, remote_cmd]
  result = @shell.stream_tty(*argv, &progress)
  raise CommandError.new(argv, result.status, "") unless result.success?

  result
end

#install_agent!Object

(Re)install this box's agent harness inside the container.



387
388
389
390
391
392
393
# File 'lib/gdkbox/box.rb', line 387

def install_agent!
  Provisioner.new(docker: @docker, config: @config).setup_agent(container_name, harness)
  return unless data

  @data = data.merge("agent_installed" => true)
  @store.save(@data)
end

#install_skill(token, project_dir: Dir.pwd, force: false) ⇒ Object

Install an agent skill into this box's harness skills directory so agents dispatched into the box can use it. token is a discovered skill name (bundled, ~/.claude/skills, or ./.claude/skills) or a path to a skill directory. Skills use the same SKILL.md format across harnesses; only the destination directory differs. Returns the resolved Skill.

Raises:



400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
# File 'lib/gdkbox/box.rb', line 400

def install_skill(token, project_dir: Dir.pwd, force: false)
  raise Error, "Box '#{name}' does not exist" unless exists?

  skill = Skills.resolve(token, project_dir: project_dir)
  skills_dir = "/home/#{@config.ssh_user}/#{harness.skills_subdir}"
  target = "#{skills_dir}/#{skill.name}"

  @docker.exec(container_name, 'mkdir -p "$GDKBOX_SKILLS_DIR"',
    user: @config.ssh_user, env: { "GDKBOX_SKILLS_DIR" => skills_dir })

  present = @docker.exec(
    container_name, '[ -e "$GDKBOX_TARGET" ] && echo yes || echo no',
    user: @config.ssh_user, env: { "GDKBOX_TARGET" => target }, check: false
  ).stdout.strip == "yes"
  raise Error, "Skill '#{skill.name}' is already in box '#{name}'. Use --force to overwrite." if present && !force

  @docker.exec(container_name, 'rm -rf "$GDKBOX_TARGET"',
    user: @config.ssh_user, env: { "GDKBOX_TARGET" => target }) if present

  @docker.cp_into(container_name, skill.path, target)
  @docker.exec(container_name, 'chown -R "$GDKBOX_USER:$GDKBOX_USER" "$GDKBOX_TARGET"',
    user: "root", env: { "GDKBOX_USER" => @config.ssh_user, "GDKBOX_TARGET" => target })

  skill
end

#release!(owner: nil, force: false) ⇒ Object

Release this box's claim. Only the claiming owner may (force: true overrides — the janitor path). Returns :released, or :unclaimed when there was nothing to release.



237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
# File 'lib/gdkbox/box.rb', line 237

def release!(owner: nil, force: false)
  with_create_lock do
    @data = @store.load(name)
    holder = @data && @data["claimed_by"]
    return :unclaimed unless holder

    if !force && holder != owner
      raise Error, "Box '#{name}' is claimed by '#{holder}', not " \
        "'#{owner}'. Use --force to override."
    end
    %w[claimed_by claimed_at claim_expires_at].each { |k| @data.delete(k) }
    @store.save(@data)
    :released
  end
end

#remote_pathObject



71
72
73
# File 'lib/gdkbox/box.rb', line 71

def remote_path
  data ? data["remote_path"] : @config.remote_path
end

#run_agent(task:, json: false, yolo: true, timeout: nil) ⇒ Object

Run this box's agent harness non-interactively and capture its output. This is the primitive an orchestrator uses to dispatch a task to a box. The task text is passed through the environment so arbitrary prompts cannot break out of the shell command.

Raises:



443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
# File 'lib/gdkbox/box.rb', line 443

def run_agent(task:, json: false, yolo: true, timeout: nil)
  raise Error, "Box '#{name}' does not exist" unless exists?

  agent = harness.agent_command(json: json, yolo: yolo)
  agent = "timeout #{Integer(timeout)} #{agent}" if timeout
  # Source the seeded API key (if any) so the agent authenticates unattended.
  command = %([ -f "$HOME/.gdkbox/env" ] && . "$HOME/.gdkbox/env"; #{agent})

  result = @docker.exec(
    container_name, command,
    user: @config.ssh_user,
    workdir: remote_path,
    env: { "GDKBOX_TASK" => task },
    check: false
  )
  AgentResult.new(result.stdout, result.stderr, result.status)
end

#set_api_key!(api_key) ⇒ Object

Seed or rotate the agent API key inside an existing box so dispatched agents can authenticate unattended. The key is exported under the env var this box's harness reads.

Raises:



429
430
431
432
433
434
435
436
437
# File 'lib/gdkbox/box.rb', line 429

def set_api_key!(api_key)
  raise Error, "Box '#{name}' does not exist" unless exists?
  raise Error, "An API key is required" if api_key.nil? || api_key.strip.empty?

  Provisioner.new(docker: @docker, config: @config)
    .setup_api_key(container_name, api_key, harness.key_env)
  @data = data.merge("api_key_set" => true)
  @store.save(@data)
end

#set_git_identity!(git_name: nil, git_email: nil) ⇒ Object

Seed a git identity into the box so git commit works there. Falls back to the host's git config when name/email are not given; raises when neither source has anything to seed. Returns the [name, email] seeded.



372
373
374
375
376
377
378
379
380
381
382
383
384
# File 'lib/gdkbox/box.rb', line 372

def set_git_identity!(git_name: nil, git_email: nil)
  host_name, host_email = host_git_identity
  git_name ||= host_name
  git_email ||= host_email
  if git_name.nil? && git_email.nil?
    raise Error, "No git identity found. Pass --name/--email or set " \
      "`git config --global user.name/user.email` on the host."
  end

  Provisioner.new(docker: @docker, config: @config)
    .setup_git_identity(container_name, name: git_name, email: git_email)
  [git_name, git_email]
end

#set_gitlab_remote!(remote) ⇒ Object

Point the box's GitLab checkout at a different remote (URL or "namespace/project"). SSH remotes get the box's SSH client prepared first (host key acceptance + connection multiplexing) so the follow-up fetch — and later lazy fetches from the treeless clone — succeed. Returns the URL that origin now uses.



305
306
307
308
309
310
311
312
313
# File 'lib/gdkbox/box.rb', line 305

def set_gitlab_remote!(remote)
  url = self.class.expand_remote(remote)
  provisioner = Provisioner.new(docker: @docker, config: @config)
  if (host = self.class.remote_ssh_host(url))
    provisioner.setup_git_ssh(container_name, host)
  end
  provisioner.setup_gitlab_remote(container_name, url)
  url
end

#ssh_commandObject

argv to open an interactive SSH session using the generated config.



488
489
490
# File 'lib/gdkbox/box.rb', line 488

def ssh_command
  ["ssh", ssh_host_alias]
end

#ssh_host_aliasObject



67
68
69
# File 'lib/gdkbox/box.rb', line 67

def ssh_host_alias
  @config.ssh_host_alias(name)
end

#ssh_portObject



51
52
53
# File 'lib/gdkbox/box.rb', line 51

def ssh_port
  data && data["ssh_port"]
end

#start!Object

Start a stopped box and make sure sshd is running again (processes started via docker exec do not survive a container restart).



191
192
193
194
195
# File 'lib/gdkbox/box.rb', line 191

def start!
  @docker.start(container_name)
  Provisioner.new(docker: @docker, config: @config)
    .setup_ssh(container_name, @ssh_key.ensure!)
end

#stateObject



81
82
83
# File 'lib/gdkbox/box.rb', line 81

def state
  @docker.state(container_name)
end

#stop!Object



197
198
199
# File 'lib/gdkbox/box.rb', line 197

def stop!
  @docker.stop(container_name)
end

#summary(state: nil) ⇒ Object

A machine-readable summary for orchestrators (gdkbox ls --json).



462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
# File 'lib/gdkbox/box.rb', line 462

def summary(state: nil)
  {
    "name" => name,
    "state" => (state || self.state).to_s,
    "container_name" => container_name,
    "ssh_host" => ssh_host_alias,
    "ssh_port" => ssh_port,
    "web_port" => web_port,
    "web_url" => web_url,
    "remote_path" => remote_path,
    "harness" => harness.id,
    "agent_installed" => (data && data["agent_installed"]) || false,
    # Back-compat: orchestrators predating multi-harness check this field.
    "claude_installed" => (harness.id == "claude" && (data && data["agent_installed"])) || false,
    "api_key_set" => (data && data["api_key_set"]) || false,
    # Vite assets: the published port (nil on boxes created before vite
    # support) and whether the in-box alignment step succeeded.
    "vite_port" => vite_port,
    "vite_port_configured" => (data && data["vite_port_configured"]) || false,
    # Active claim (nil when free; an expired lease counts as free).
    "claimed_by" => claimed_by,
    "claim_expires_at" => (claimed_by ? data["claim_expires_at"] : nil)
  }
end

#vite_portObject



59
60
61
# File 'lib/gdkbox/box.rb', line 59

def vite_port
  data && data["vite_port"]
end

#web_portObject



55
56
57
# File 'lib/gdkbox/box.rb', line 55

def web_port
  data && data["web_port"]
end

#web_urlObject



63
64
65
# File 'lib/gdkbox/box.rb', line 63

def web_url
  "http://127.0.0.1:#{web_port}"
end