yatfa 1.0.89 → 1.0.90
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.
- package/lib/yatfa_agent/agent.rb +12 -356
- package/lib/yatfa_agent/command_builder.rb +159 -0
- package/lib/yatfa_agent/container.rb +65 -0
- package/lib/yatfa_agent/interaction.rb +163 -0
- package/package.json +1 -1
- package/setup/agent_bridge.rb +228 -0
- package/setup/claude_settings.rb +217 -0
- package/setup/repositories.rb +98 -0
- package/setup/setup-agent.rb +25 -743
- package/setup/setup-github.rb +12 -4
- package/setup/skills.rb +136 -0
- package/setup/system_prompt.rb +110 -0
package/lib/yatfa_agent/agent.rb
CHANGED
|
@@ -1,150 +1,18 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require_relative "container"
|
|
4
|
+
require_relative "command_builder"
|
|
5
|
+
require_relative "interaction"
|
|
6
|
+
|
|
3
7
|
module YatfaAgent
|
|
8
|
+
# Agent orchestrator.
|
|
9
|
+
# Coordinates Container, CommandBuilder, and Interaction modules to run and attach agents.
|
|
10
|
+
# Sub-modules are mixed in via extend so their methods are available as Agent.xxx
|
|
11
|
+
# and test stubs (define_singleton_method) intercept internal calls correctly.
|
|
4
12
|
module Agent
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
!result.empty?
|
|
9
|
-
end
|
|
10
|
-
|
|
11
|
-
# Check if container exists (running or stopped)
|
|
12
|
-
def self.container_exists?(container_name)
|
|
13
|
-
result = IO.popen(["docker", "ps", "-a", "--filter", "name=^#{container_name}$", "--format", "{{.Names}}"], err: File::NULL, &:read).strip
|
|
14
|
-
!result.empty?
|
|
15
|
-
end
|
|
16
|
-
|
|
17
|
-
# Get container creation time
|
|
18
|
-
def self.container_creation_time(container_name)
|
|
19
|
-
result = IO.popen(["docker", "inspect", "--format", "{{.Created}}", container_name], err: File::NULL, &:read).strip
|
|
20
|
-
return nil if result.empty?
|
|
21
|
-
Time.parse(result)
|
|
22
|
-
rescue ArgumentError
|
|
23
|
-
nil
|
|
24
|
-
end
|
|
25
|
-
|
|
26
|
-
def self.format_time(t)
|
|
27
|
-
t.strftime("%H:%M")
|
|
28
|
-
end
|
|
29
|
-
|
|
30
|
-
# Prompt user for input with a default value
|
|
31
|
-
def self.prompt(message, default: nil, options: nil)
|
|
32
|
-
loop do
|
|
33
|
-
print message
|
|
34
|
-
input = $stdin.gets&.strip&.downcase || ""
|
|
35
|
-
|
|
36
|
-
# Handle default on empty input
|
|
37
|
-
if input.empty? && default
|
|
38
|
-
return default
|
|
39
|
-
end
|
|
40
|
-
|
|
41
|
-
# If options specified, validate input
|
|
42
|
-
if options
|
|
43
|
-
valid = options.any? { |opt| opt.downcase == input || opt.downcase.start_with?(input) }
|
|
44
|
-
if valid
|
|
45
|
-
# Return the full option that matches
|
|
46
|
-
return options.find { |opt| opt.downcase.start_with?(input) }
|
|
47
|
-
end
|
|
48
|
-
puts " Invalid option. Please choose: #{options.join('/')}"
|
|
49
|
-
else
|
|
50
|
-
return input
|
|
51
|
-
end
|
|
52
|
-
end
|
|
53
|
-
end
|
|
54
|
-
|
|
55
|
-
# Check if running in non-interactive mode
|
|
56
|
-
def self.non_interactive?
|
|
57
|
-
!$stdin.tty? || ENV['YATFA_NO_PROMPT'] == '1' || ARGV.include?('--no-prompt') || ARGV.include?('-d')
|
|
58
|
-
end
|
|
59
|
-
|
|
60
|
-
# Handle running container: prompt user for action
|
|
61
|
-
def self.handle_running_container(agent_type, container_name, config, agent_configs)
|
|
62
|
-
puts "⚠️ #{agent_type} agent is already running (#{container_name})"
|
|
63
|
-
|
|
64
|
-
# Check if container was created before last deploy
|
|
65
|
-
deploy_manifest = File.join(File.dirname(__FILE__), '..', '..', 'deploy', '.deploy-manifest.json')
|
|
66
|
-
version_file = File.join(File.dirname(__FILE__), '..', '..', 'VERSION')
|
|
67
|
-
stale = false
|
|
68
|
-
|
|
69
|
-
if File.exist?(version_file)
|
|
70
|
-
current_version = File.read(version_file).strip
|
|
71
|
-
container_version = IO.popen(["docker", "exec", container_name, "printenv", "YATFA_VERSION_NUM"], err: File::NULL, &:read).strip
|
|
72
|
-
if !container_version.empty? && container_version != current_version
|
|
73
|
-
puts " 🆕 New version deployed (container: #{container_version}, current: #{current_version})"
|
|
74
|
-
stale = true
|
|
75
|
-
end
|
|
76
|
-
elsif File.exist?(deploy_manifest)
|
|
77
|
-
deploy_time = File.mtime(deploy_manifest)
|
|
78
|
-
container_created = container_creation_time(container_name)
|
|
79
|
-
if container_created && container_created < deploy_time
|
|
80
|
-
puts " 🆕 New deploy detected (container from #{format_time(container_created)}, deploy at #{format_time(deploy_time)})"
|
|
81
|
-
stale = true
|
|
82
|
-
end
|
|
83
|
-
end
|
|
84
|
-
|
|
85
|
-
puts ""
|
|
86
|
-
|
|
87
|
-
if non_interactive?
|
|
88
|
-
puts " Running in non-interactive mode. Run 'npx yatfa #{agent_type}' to attach, or stop the container first."
|
|
89
|
-
puts " docker stop #{container_name}"
|
|
90
|
-
exit 0
|
|
91
|
-
end
|
|
92
|
-
|
|
93
|
-
default_choice = stale ? "R" : "A"
|
|
94
|
-
choice = prompt(" [A]ttach / [R]estart / [C]ancel? [#{default_choice == 'R' ? 'a/R/c' : 'A/r/c'}]: ", default: default_choice, options: %w[A R C])
|
|
95
|
-
|
|
96
|
-
case choice
|
|
97
|
-
when "A"
|
|
98
|
-
puts ""
|
|
99
|
-
attach(agent_type, config, agent_configs)
|
|
100
|
-
when "R"
|
|
101
|
-
puts ""
|
|
102
|
-
puts "🔄 Stopping existing container..."
|
|
103
|
-
system("docker", "stop", container_name, err: File::NULL, out: File::NULL)
|
|
104
|
-
system("docker", "rm", "-f", container_name, err: File::NULL, out: File::NULL)
|
|
105
|
-
return true # Continue with start
|
|
106
|
-
when "C"
|
|
107
|
-
puts " Cancelled."
|
|
108
|
-
exit 0
|
|
109
|
-
end
|
|
110
|
-
end
|
|
111
|
-
|
|
112
|
-
# Banner file is no longer generated on the host.
|
|
113
|
-
# System prompt is generated inside the container by setup-agent.rb
|
|
114
|
-
# using AGENT_TYPE and REPOSITORIES_CONTEXT env vars.
|
|
115
|
-
|
|
116
|
-
# Handle stopped container: prompt user for action
|
|
117
|
-
def self.handle_stopped_container(agent_type, container_name, config, agent_configs)
|
|
118
|
-
puts "⚠️ #{agent_type} agent container exists but is stopped (#{container_name})"
|
|
119
|
-
puts ""
|
|
120
|
-
|
|
121
|
-
if non_interactive?
|
|
122
|
-
puts " Non-interactive mode: starting existing container."
|
|
123
|
-
system("docker", "start", container_name, out: File::NULL)
|
|
124
|
-
puts "✅ Agent started in background"
|
|
125
|
-
prompt_to_attach(agent_type, container_name, config, agent_configs)
|
|
126
|
-
exit 0
|
|
127
|
-
end
|
|
128
|
-
|
|
129
|
-
choice = prompt(" [S]tart existing / [R]ecreate / [C]ancel? [S/r/c]: ", default: "S", options: %w[S R C])
|
|
130
|
-
|
|
131
|
-
case choice
|
|
132
|
-
when "S"
|
|
133
|
-
puts ""
|
|
134
|
-
puts "🔄 Starting existing container..."
|
|
135
|
-
system("docker", "start", container_name, out: File::NULL)
|
|
136
|
-
puts "✅ Agent started in background"
|
|
137
|
-
prompt_to_attach(agent_type, container_name, config, agent_configs)
|
|
138
|
-
exit 0
|
|
139
|
-
when "R"
|
|
140
|
-
puts ""
|
|
141
|
-
puts "🔄 Removing stopped container..."
|
|
142
|
-
system("docker", "rm", "-f", container_name, err: File::NULL, out: File::NULL)
|
|
143
|
-
when "C"
|
|
144
|
-
puts " Cancelled."
|
|
145
|
-
exit 0
|
|
146
|
-
end
|
|
147
|
-
end
|
|
13
|
+
extend Container
|
|
14
|
+
extend CommandBuilder
|
|
15
|
+
extend Interaction
|
|
148
16
|
|
|
149
17
|
def self.run(agent_type, config, agent_configs)
|
|
150
18
|
unless agent_configs.keys.include?(agent_type)
|
|
@@ -190,37 +58,6 @@ module YatfaAgent
|
|
|
190
58
|
end
|
|
191
59
|
end
|
|
192
60
|
|
|
193
|
-
# Prompt user to attach after starting a new agent
|
|
194
|
-
def self.prompt_to_attach(agent_type, container_name, config, agent_configs)
|
|
195
|
-
if non_interactive?
|
|
196
|
-
# Non-interactive: just print helpful info
|
|
197
|
-
puts " Logs: docker logs -f #{container_name}"
|
|
198
|
-
puts " Stop: docker stop #{container_name}"
|
|
199
|
-
puts " Attach: docker exec -it #{container_name} tmux attach -t agent"
|
|
200
|
-
return
|
|
201
|
-
end
|
|
202
|
-
|
|
203
|
-
choice = prompt(" Agent started. Attach to session? [Y/n]: ", default: "Y", options: %w[Y N])
|
|
204
|
-
|
|
205
|
-
if choice == "Y"
|
|
206
|
-
puts ""
|
|
207
|
-
# Wait briefly for container to initialize
|
|
208
|
-
print " Waiting for agent to initialize"
|
|
209
|
-
5.times do
|
|
210
|
-
print "."
|
|
211
|
-
sleep 1
|
|
212
|
-
end
|
|
213
|
-
puts ""
|
|
214
|
-
attach(agent_type, config, agent_configs)
|
|
215
|
-
else
|
|
216
|
-
puts ""
|
|
217
|
-
puts " Agent is running in background."
|
|
218
|
-
puts " Logs: docker logs -f #{container_name}"
|
|
219
|
-
puts " Stop: docker stop #{container_name}"
|
|
220
|
-
puts " Attach: docker exec -it #{container_name} tmux attach -t agent"
|
|
221
|
-
end
|
|
222
|
-
end
|
|
223
|
-
|
|
224
61
|
def self.attach(agent_type, config, agent_configs)
|
|
225
62
|
unless agent_configs.keys.include?(agent_type)
|
|
226
63
|
puts "❌ Unknown agent type: #{agent_type}"
|
|
@@ -240,7 +77,7 @@ module YatfaAgent
|
|
|
240
77
|
end
|
|
241
78
|
|
|
242
79
|
puts "📎 Attaching to #{agent_type} agent..."
|
|
243
|
-
|
|
80
|
+
|
|
244
81
|
user = detect_agent_user(container_name)
|
|
245
82
|
puts " User: #{user}"
|
|
246
83
|
|
|
@@ -282,186 +119,5 @@ module YatfaAgent
|
|
|
282
119
|
# Must run as agent user since tmux server runs under that user
|
|
283
120
|
exec("docker", "exec", "-it", "-u", user, container_name, "tmux", "attach", "-t", "agent")
|
|
284
121
|
end
|
|
285
|
-
|
|
286
|
-
private
|
|
287
|
-
|
|
288
|
-
def self.build_docker_command(container_name, agent_type, config, agent_config, agent_def, remote_version: nil)
|
|
289
|
-
docker_cmd = [
|
|
290
|
-
"docker", "run", "-d",
|
|
291
|
-
"--name", container_name,
|
|
292
|
-
"--network", "host",
|
|
293
|
-
"--restart", "unless-stopped",
|
|
294
|
-
"--tmpfs", "/rails/tmp",
|
|
295
|
-
"--tmpfs", "/rails/log"
|
|
296
|
-
]
|
|
297
|
-
|
|
298
|
-
# Inject current version for staleness detection
|
|
299
|
-
version_file = File.join(File.dirname(__FILE__), '..', '..', 'VERSION')
|
|
300
|
-
if File.exist?(version_file)
|
|
301
|
-
version = File.read(version_file).strip
|
|
302
|
-
docker_cmd += ["-e", "YATFA_VERSION_NUM=#{version}"] unless version.empty?
|
|
303
|
-
end
|
|
304
|
-
|
|
305
|
-
# Add yatfa.version label for updater daemon to detect stale containers
|
|
306
|
-
# Uses version number from remote version.json (already fetched by build_image)
|
|
307
|
-
if remote_version
|
|
308
|
-
docker_cmd += ["--label", "yatfa.version=#{remote_version}"]
|
|
309
|
-
end
|
|
310
|
-
|
|
311
|
-
# Store project root as a label so the updater daemon can find
|
|
312
|
-
# Dockerfile.sandbox and yatfa.env.rb without reverse-engineering from mounts.
|
|
313
|
-
docker_cmd += ["--label", "yatfa.project-root=#{Dir.pwd}"]
|
|
314
|
-
|
|
315
|
-
# Inject custom environment variables from config
|
|
316
|
-
# Merge global env with agent-specific env (agent-specific takes precedence)
|
|
317
|
-
merged_env = {}
|
|
318
|
-
merged_env.merge!(config["env"]) if config["env"]
|
|
319
|
-
merged_env.merge!(agent_config["env"]) if agent_config["env"]
|
|
320
|
-
|
|
321
|
-
if merged_env.any?
|
|
322
|
-
merged_env.each do |k, v|
|
|
323
|
-
docker_cmd += ["-e", "#{k}=#{v}"]
|
|
324
|
-
end
|
|
325
|
-
global_count = config["env"]&.size || 0
|
|
326
|
-
agent_count = agent_config["env"]&.size || 0
|
|
327
|
-
if agent_count > 0
|
|
328
|
-
puts "🌿 Injected #{global_count} global + #{agent_count} agent-specific env vars"
|
|
329
|
-
else
|
|
330
|
-
puts "🌿 Injected #{global_count} custom env vars from config"
|
|
331
|
-
end
|
|
332
|
-
end
|
|
333
|
-
|
|
334
|
-
if agent_type == "claw"
|
|
335
|
-
# Claw-specific Docker command setup
|
|
336
|
-
docker_cmd += [
|
|
337
|
-
"-e", "AGENT_TYPE=#{agent_type}",
|
|
338
|
-
"-e", "YATFA_VERSION=main"
|
|
339
|
-
]
|
|
340
|
-
|
|
341
|
-
# Add persistence volume for OpenClaw config and data (scoped per container)
|
|
342
|
-
docker_cmd += ["-v", "#{container_name}-openclaw-data:/home/yatfa/.openclaw"]
|
|
343
|
-
|
|
344
|
-
# Auto-inject telegram_bot_token as env var so users don't need to duplicate in env block
|
|
345
|
-
if agent_config["telegram_bot_token"]
|
|
346
|
-
docker_cmd += ["-e", "OPENCLAW_TELEGRAM_BOT_TOKEN=#{agent_config['telegram_bot_token']}"]
|
|
347
|
-
end
|
|
348
|
-
|
|
349
|
-
# Pass YATFA_URL if configured (optional for claw)
|
|
350
|
-
if config["yatfa_url"]
|
|
351
|
-
docker_cmd += ["-e", "YATFA_URL=#{config['yatfa_url']}"]
|
|
352
|
-
docker_cmd += ["-e", "YATFA_API_URL=#{config['yatfa_url']}/api/v1"]
|
|
353
|
-
end
|
|
354
|
-
|
|
355
|
-
# Pass MCP API key if configured (for future MCP integration)
|
|
356
|
-
if agent_config["mcp_api_key"]
|
|
357
|
-
docker_cmd += ["-e", "YATFA_API_KEY=#{agent_config['mcp_api_key']}"]
|
|
358
|
-
end
|
|
359
|
-
|
|
360
|
-
else
|
|
361
|
-
# Standard agent setup (worker, planner, reviewer, researcher)
|
|
362
|
-
docker_cmd += [
|
|
363
|
-
"-e", "YATFA_VERSION=main",
|
|
364
|
-
"-e", "SKILLS=#{agent_def[:skills]&.join(',')}",
|
|
365
|
-
"-e", "AGENT_TYPE=#{agent_type}",
|
|
366
|
-
"-e", "YATFA_URL=#{YatfaAgent::Config.yatfa_url(config)}",
|
|
367
|
-
"-e", "YATFA_API_KEY=#{agent_config['mcp_api_key']}"
|
|
368
|
-
]
|
|
369
|
-
|
|
370
|
-
add_github_auth!(docker_cmd, config)
|
|
371
|
-
add_git_config!(docker_cmd, config)
|
|
372
|
-
add_repository_mounts!(docker_cmd, config)
|
|
373
|
-
end
|
|
374
|
-
|
|
375
|
-
docker_cmd += [Config.image_name(config)]
|
|
376
|
-
|
|
377
|
-
docker_cmd
|
|
378
|
-
end
|
|
379
|
-
|
|
380
|
-
def self.add_github_auth!(docker_cmd, config)
|
|
381
|
-
github = config["github"] || {}
|
|
382
|
-
|
|
383
|
-
# New method: GitHub authentication is handled by the backend
|
|
384
|
-
# Agents get tokens via YATFA_API_KEY using the /github/token endpoint
|
|
385
|
-
# No GitHub credentials need to be passed into the container
|
|
386
|
-
|
|
387
|
-
# Deprecation warning for old GitHub App config
|
|
388
|
-
if github["method"] == "app" || github["app_client_id"] || github["app_installation_id"]
|
|
389
|
-
puts "⚠️ Warning: GitHub App configuration in yatfa.env.rb is deprecated"
|
|
390
|
-
puts " GitHub credentials are now managed centrally in the Rails backend"
|
|
391
|
-
puts " You can remove the 'github' section from yatfa.env.rb"
|
|
392
|
-
puts " The backend will use its own GitHub App credentials to generate tokens"
|
|
393
|
-
end
|
|
394
|
-
|
|
395
|
-
# Optional: Support GH_TOKEN fallback for development/testing
|
|
396
|
-
if github["token"]
|
|
397
|
-
docker_cmd.concat(["-e", "GH_TOKEN=#{github['token']}"])
|
|
398
|
-
puts "🔑 Using GitHub token authentication (fallback mode)"
|
|
399
|
-
else
|
|
400
|
-
puts "✅ GitHub authentication will be handled by backend (YATFA_API_KEY)"
|
|
401
|
-
end
|
|
402
|
-
end
|
|
403
|
-
|
|
404
|
-
def self.add_git_config!(docker_cmd, config)
|
|
405
|
-
return unless (git_config = config["git"])
|
|
406
|
-
|
|
407
|
-
docker_cmd.concat(["-e", "GIT_USER_NAME=#{git_config['user_name']}"]) if git_config["user_name"]
|
|
408
|
-
docker_cmd.concat(["-e", "GIT_USER_EMAIL=#{git_config['user_email']}"]) if git_config["user_email"]
|
|
409
|
-
end
|
|
410
|
-
|
|
411
|
-
def self.add_repository_mounts!(docker_cmd, config)
|
|
412
|
-
return unless config["repositories"].is_a?(Array) && config["repositories"].any?
|
|
413
|
-
|
|
414
|
-
puts "📚 Configuring #{config['repositories'].count} repositories..."
|
|
415
|
-
|
|
416
|
-
# Build REPOSITORIES_CONTEXT for sessionstart hook (format: "name:path,name:path")
|
|
417
|
-
sorted_repos = config["repositories"].sort_by { |r| r["position"] || 0 }
|
|
418
|
-
repos_context = sorted_repos.map do |repo|
|
|
419
|
-
repo_name = repo["name"]
|
|
420
|
-
sandbox_path = repo["path_in_sandbox"] || "/workspace/#{repo_name}"
|
|
421
|
-
"#{repo_name}:#{sandbox_path}"
|
|
422
|
-
end.join(",")
|
|
423
|
-
|
|
424
|
-
docker_cmd.concat(["-e", "REPOSITORIES_CONTEXT=#{repos_context}"])
|
|
425
|
-
|
|
426
|
-
# Pass repository config via environment variables for container to clone
|
|
427
|
-
repo_config_json = sorted_repos.to_json
|
|
428
|
-
docker_cmd.concat(["-e", "REPOSITORIES_CONFIG=#{repo_config_json}"])
|
|
429
|
-
|
|
430
|
-
sorted_repos.each do |repo|
|
|
431
|
-
repo_name = repo["name"]
|
|
432
|
-
sandbox_path = repo["path_in_sandbox"] || "/workspace/#{repo_name}"
|
|
433
|
-
puts " - #{repo_name}: #{sandbox_path} (will be cloned in container)"
|
|
434
|
-
end
|
|
435
|
-
end
|
|
436
|
-
|
|
437
|
-
def self.detect_agent_user(container_name)
|
|
438
|
-
# Determine the user to attach as
|
|
439
|
-
# Robust method: find the user running the agent process (tmux or bridge)
|
|
440
|
-
user = `docker exec #{container_name} ps aux | grep "[a]gent-bridge-tmux" | awk '{print $1}' | head -n 1`.strip
|
|
441
|
-
|
|
442
|
-
if user.empty?
|
|
443
|
-
user = `docker exec #{container_name} ps aux | grep "[t]mux new-session" | awk '{print $1}' | head -n 1`.strip
|
|
444
|
-
end
|
|
445
|
-
|
|
446
|
-
if user.empty?
|
|
447
|
-
# Fallback to previous heuristic
|
|
448
|
-
detected_user = `docker exec #{container_name} whoami 2>/dev/null`.strip
|
|
449
|
-
if detected_user == "root" || detected_user.empty?
|
|
450
|
-
uid = Process.uid
|
|
451
|
-
mapped_user = `docker exec #{container_name} getent passwd #{uid} | cut -d: -f1`.strip
|
|
452
|
-
user = mapped_user unless mapped_user.empty?
|
|
453
|
-
else
|
|
454
|
-
user = detected_user
|
|
455
|
-
end
|
|
456
|
-
end
|
|
457
|
-
|
|
458
|
-
if user.empty?
|
|
459
|
-
# Final Fallback
|
|
460
|
-
user = "rails"
|
|
461
|
-
puts "⚠️ Could not detect agent user, defaulting to '#{user}'"
|
|
462
|
-
end
|
|
463
|
-
|
|
464
|
-
user
|
|
465
|
-
end
|
|
466
122
|
end
|
|
467
123
|
end
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module YatfaAgent
|
|
6
|
+
# Docker command assembly.
|
|
7
|
+
# Mixed into Agent via extend — methods become available as Agent.xxx.
|
|
8
|
+
# Responsible for building the full `docker run` command with all flags, env vars, and mounts.
|
|
9
|
+
module CommandBuilder
|
|
10
|
+
def build_docker_command(container_name, agent_type, config, agent_config, agent_def, remote_version: nil)
|
|
11
|
+
docker_cmd = [
|
|
12
|
+
"docker", "run", "-d",
|
|
13
|
+
"--name", container_name,
|
|
14
|
+
"--network", "host",
|
|
15
|
+
"--restart", "unless-stopped",
|
|
16
|
+
"--tmpfs", "/rails/tmp",
|
|
17
|
+
"--tmpfs", "/rails/log"
|
|
18
|
+
]
|
|
19
|
+
|
|
20
|
+
# Inject current version for staleness detection
|
|
21
|
+
version_file = File.join(File.dirname(__FILE__), '..', '..', 'VERSION')
|
|
22
|
+
if File.exist?(version_file)
|
|
23
|
+
version = File.read(version_file).strip
|
|
24
|
+
docker_cmd += ["-e", "YATFA_VERSION_NUM=#{version}"] unless version.empty?
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# Add yatfa.version label for updater daemon to detect stale containers
|
|
28
|
+
# Uses version number from remote version.json (already fetched by build_image)
|
|
29
|
+
if remote_version
|
|
30
|
+
docker_cmd += ["--label", "yatfa.version=#{remote_version}"]
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# Store project root as a label so the updater daemon can find
|
|
34
|
+
# Dockerfile.sandbox and yatfa.env.rb without reverse-engineering from mounts.
|
|
35
|
+
docker_cmd += ["--label", "yatfa.project-root=#{Dir.pwd}"]
|
|
36
|
+
|
|
37
|
+
# Inject custom environment variables from config
|
|
38
|
+
# Merge global env with agent-specific env (agent-specific takes precedence)
|
|
39
|
+
merged_env = {}
|
|
40
|
+
merged_env.merge!(config["env"]) if config["env"]
|
|
41
|
+
merged_env.merge!(agent_config["env"]) if agent_config["env"]
|
|
42
|
+
|
|
43
|
+
if merged_env.any?
|
|
44
|
+
merged_env.each do |k, v|
|
|
45
|
+
docker_cmd += ["-e", "#{k}=#{v}"]
|
|
46
|
+
end
|
|
47
|
+
global_count = config["env"]&.size || 0
|
|
48
|
+
agent_count = agent_config["env"]&.size || 0
|
|
49
|
+
if agent_count > 0
|
|
50
|
+
puts "🌿 Injected #{global_count} global + #{agent_count} agent-specific env vars"
|
|
51
|
+
else
|
|
52
|
+
puts "🌿 Injected #{global_count} custom env vars from config"
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
if agent_type == "claw"
|
|
57
|
+
# Claw-specific Docker command setup
|
|
58
|
+
docker_cmd += [
|
|
59
|
+
"-e", "AGENT_TYPE=#{agent_type}",
|
|
60
|
+
"-e", "YATFA_VERSION=main"
|
|
61
|
+
]
|
|
62
|
+
|
|
63
|
+
# Add persistence volume for OpenClaw config and data (scoped per container)
|
|
64
|
+
docker_cmd += ["-v", "#{container_name}-openclaw-data:/home/yatfa/.openclaw"]
|
|
65
|
+
|
|
66
|
+
# Auto-inject telegram_bot_token as env var so users don't need to duplicate in env block
|
|
67
|
+
if agent_config["telegram_bot_token"]
|
|
68
|
+
docker_cmd += ["-e", "OPENCLAW_TELEGRAM_BOT_TOKEN=#{agent_config['telegram_bot_token']}"]
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# Pass YATFA_URL if configured (optional for claw)
|
|
72
|
+
if config["yatfa_url"]
|
|
73
|
+
docker_cmd += ["-e", "YATFA_URL=#{config['yatfa_url']}"]
|
|
74
|
+
docker_cmd += ["-e", "YATFA_API_URL=#{config['yatfa_url']}/api/v1"]
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# Pass MCP API key if configured (for future MCP integration)
|
|
78
|
+
if agent_config["mcp_api_key"]
|
|
79
|
+
docker_cmd += ["-e", "YATFA_API_KEY=#{agent_config['mcp_api_key']}"]
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
else
|
|
83
|
+
# Standard agent setup (worker, planner, reviewer, researcher)
|
|
84
|
+
docker_cmd += [
|
|
85
|
+
"-e", "YATFA_VERSION=main",
|
|
86
|
+
"-e", "SKILLS=#{agent_def[:skills]&.join(',')}",
|
|
87
|
+
"-e", "AGENT_TYPE=#{agent_type}",
|
|
88
|
+
"-e", "YATFA_URL=#{YatfaAgent::Config.yatfa_url(config)}",
|
|
89
|
+
"-e", "YATFA_API_KEY=#{agent_config['mcp_api_key']}"
|
|
90
|
+
]
|
|
91
|
+
|
|
92
|
+
add_github_auth!(docker_cmd, config)
|
|
93
|
+
add_git_config!(docker_cmd, config)
|
|
94
|
+
add_repository_mounts!(docker_cmd, config)
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
docker_cmd += [Config.image_name(config)]
|
|
98
|
+
|
|
99
|
+
docker_cmd
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def add_github_auth!(docker_cmd, config)
|
|
103
|
+
github = config["github"] || {}
|
|
104
|
+
|
|
105
|
+
# New method: GitHub authentication is handled by the backend
|
|
106
|
+
# Agents get tokens via YATFA_API_KEY using the /github/token endpoint
|
|
107
|
+
# No GitHub credentials need to be passed into the container
|
|
108
|
+
|
|
109
|
+
# Deprecation warning for old GitHub App config
|
|
110
|
+
if github["method"] == "app" || github["app_client_id"] || github["app_installation_id"]
|
|
111
|
+
puts "⚠️ Warning: GitHub App configuration in yatfa.env.rb is deprecated"
|
|
112
|
+
puts " GitHub credentials are now managed centrally in the Rails backend"
|
|
113
|
+
puts " You can remove the 'github' section from yatfa.env.rb"
|
|
114
|
+
puts " The backend will use its own GitHub App credentials to generate tokens"
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# Optional: Support GH_TOKEN fallback for development/testing
|
|
118
|
+
if github["token"]
|
|
119
|
+
docker_cmd.concat(["-e", "GH_TOKEN=#{github['token']}"])
|
|
120
|
+
puts "🔑 Using GitHub token authentication (fallback mode)"
|
|
121
|
+
else
|
|
122
|
+
puts "✅ GitHub authentication will be handled by backend (YATFA_API_KEY)"
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def add_git_config!(docker_cmd, config)
|
|
127
|
+
return unless (git_config = config["git"])
|
|
128
|
+
|
|
129
|
+
docker_cmd.concat(["-e", "GIT_USER_NAME=#{git_config['user_name']}"]) if git_config["user_name"]
|
|
130
|
+
docker_cmd.concat(["-e", "GIT_USER_EMAIL=#{git_config['user_email']}"]) if git_config["user_email"]
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def add_repository_mounts!(docker_cmd, config)
|
|
134
|
+
return unless config["repositories"].is_a?(Array) && config["repositories"].any?
|
|
135
|
+
|
|
136
|
+
puts "📚 Configuring #{config['repositories'].count} repositories..."
|
|
137
|
+
|
|
138
|
+
# Build REPOSITORIES_CONTEXT for sessionstart hook (format: "name:path,name:path")
|
|
139
|
+
sorted_repos = config["repositories"].sort_by { |r| r["position"] || 0 }
|
|
140
|
+
repos_context = sorted_repos.map do |repo|
|
|
141
|
+
repo_name = repo["name"]
|
|
142
|
+
sandbox_path = repo["path_in_sandbox"] || "/workspace/#{repo_name}"
|
|
143
|
+
"#{repo_name}:#{sandbox_path}"
|
|
144
|
+
end.join(",")
|
|
145
|
+
|
|
146
|
+
docker_cmd.concat(["-e", "REPOSITORIES_CONTEXT=#{repos_context}"])
|
|
147
|
+
|
|
148
|
+
# Pass repository config via environment variables for container to clone
|
|
149
|
+
repo_config_json = sorted_repos.to_json
|
|
150
|
+
docker_cmd.concat(["-e", "REPOSITORIES_CONFIG=#{repo_config_json}"])
|
|
151
|
+
|
|
152
|
+
sorted_repos.each do |repo|
|
|
153
|
+
repo_name = repo["name"]
|
|
154
|
+
sandbox_path = repo["path_in_sandbox"] || "/workspace/#{repo_name}"
|
|
155
|
+
puts " - #{repo_name}: #{sandbox_path} (will be cloned in container)"
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
end
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "time"
|
|
4
|
+
|
|
5
|
+
module YatfaAgent
|
|
6
|
+
# Container state queries and user detection.
|
|
7
|
+
# Mixed into Agent via extend — methods become available as Agent.xxx.
|
|
8
|
+
module Container
|
|
9
|
+
# Check if container is currently running
|
|
10
|
+
def container_running?(container_name)
|
|
11
|
+
result = IO.popen(["docker", "ps", "--filter", "name=^#{container_name}$", "--format", "{{.Names}}"], err: File::NULL, &:read).strip
|
|
12
|
+
!result.empty?
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
# Check if container exists (running or stopped)
|
|
16
|
+
def container_exists?(container_name)
|
|
17
|
+
result = IO.popen(["docker", "ps", "-a", "--filter", "name=^#{container_name}$", "--format", "{{.Names}}"], err: File::NULL, &:read).strip
|
|
18
|
+
!result.empty?
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# Get container creation time
|
|
22
|
+
def container_creation_time(container_name)
|
|
23
|
+
result = IO.popen(["docker", "inspect", "--format", "{{.Created}}", container_name], err: File::NULL, &:read).strip
|
|
24
|
+
return nil if result.empty?
|
|
25
|
+
Time.parse(result)
|
|
26
|
+
rescue ArgumentError
|
|
27
|
+
nil
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def format_time(t)
|
|
31
|
+
t.strftime("%H:%M")
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Detect the user running the agent process inside the container
|
|
35
|
+
def detect_agent_user(container_name)
|
|
36
|
+
# Determine the user to attach as
|
|
37
|
+
# Robust method: find the user running the agent process (tmux or bridge)
|
|
38
|
+
user = `docker exec #{container_name} ps aux | grep "[a]gent-bridge-tmux" | awk '{print $1}' | head -n 1`.strip
|
|
39
|
+
|
|
40
|
+
if user.empty?
|
|
41
|
+
user = `docker exec #{container_name} ps aux | grep "[t]mux new-session" | awk '{print $1}' | head -n 1`.strip
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
if user.empty?
|
|
45
|
+
# Fallback to previous heuristic
|
|
46
|
+
detected_user = `docker exec #{container_name} whoami 2>/dev/null`.strip
|
|
47
|
+
if detected_user == "root" || detected_user.empty?
|
|
48
|
+
uid = Process.uid
|
|
49
|
+
mapped_user = `docker exec #{container_name} getent passwd #{uid} | cut -d: -f1`.strip
|
|
50
|
+
user = mapped_user unless mapped_user.empty?
|
|
51
|
+
else
|
|
52
|
+
user = detected_user
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
if user.empty?
|
|
57
|
+
# Final Fallback
|
|
58
|
+
user = "rails"
|
|
59
|
+
puts "⚠️ Could not detect agent user, defaulting to '#{user}'"
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
user
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|