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

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.



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

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.



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

def config
  @config
end

#nameObject (readonly)

Returns the value of attribute name.



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

def name
  @name
end

Class Method Details

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



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

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

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



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

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

Instance Method Details

#container_nameObject



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

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

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

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

Raises:



82
83
84
85
86
87
88
89
90
91
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
# File 'lib/gdkbox/box.rb', line 82

def create!(image: nil, ssh_port: nil, web_port: nil, harness: Harness.default,
  install_agent: true, api_key: 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)

  @docker.pull(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 = reserve_ports!(cname, ssh_port, web_port)

  begin
    @docker.run_container(
      name: cname,
      image: image,
      publish: [
        "127.0.0.1:#{ssh_port}:#{Config::SSH_CONTAINER_PORT}",
        "127.0.0.1:#{web_port}:#{Config::GDK_WEB_CONTAINER_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)

  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



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

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

#destroy!Object



157
158
159
160
# File 'lib/gdkbox/box.rb', line 157

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

#exists?Boolean

Returns:

  • (Boolean)


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

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

#harnessObject

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



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

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

#install_agent!Object

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



163
164
165
166
167
168
169
# File 'lib/gdkbox/box.rb', line 163

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:



176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
# File 'lib/gdkbox/box.rb', line 176

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

#remote_pathObject



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

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:



219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
# File 'lib/gdkbox/box.rb', line 219

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:



205
206
207
208
209
210
211
212
213
# File 'lib/gdkbox/box.rb', line 205

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

#ssh_commandObject

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



257
258
259
# File 'lib/gdkbox/box.rb', line 257

def ssh_command
  ["ssh", ssh_host_alias]
end

#ssh_host_aliasObject



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

def ssh_host_alias
  @config.ssh_host_alias(name)
end

#ssh_portObject



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

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).



147
148
149
150
151
# File 'lib/gdkbox/box.rb', line 147

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

#stateObject



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

def state
  @docker.state(container_name)
end

#stop!Object



153
154
155
# File 'lib/gdkbox/box.rb', line 153

def stop!
  @docker.stop(container_name)
end

#summary(state: nil) ⇒ Object

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



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

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
  }
end

#web_portObject



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

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

#web_urlObject



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

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