yatfa 1.0.94 → 1.0.96
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/bin/install-agent.sh +1 -1
- package/lib/yatfa_agent/config.rb +37 -11
- package/lib/yatfa_agent/docker.rb +64 -12
- package/lib/yatfa_agent/setup.rb +4 -6
- package/lib/yatfa_agent/updater.rb +17 -5
- package/package.json +2 -2
- package/setup/git_credential_helper.rb +250 -0
- package/setup/repositories.rb +17 -6
- package/setup/setup-agent.rb +26 -15
- package/setup/setup-github.rb +13 -89
package/bin/install-agent.sh
CHANGED
|
@@ -101,7 +101,7 @@ YATFA_VERSION=${YATFA_VERSION:-main}
|
|
|
101
101
|
cat << 'EOF' > /usr/local/bin/setup-agent
|
|
102
102
|
#!/bin/bash
|
|
103
103
|
YATFA_VERSION=${YATFA_VERSION:-main}
|
|
104
|
-
BASE_URL="https://
|
|
104
|
+
BASE_URL="https://pub-06ce56b9a9c143118036f593f07ac1d8.r2.dev/${YATFA_VERSION}"
|
|
105
105
|
# Use local setup/ dir if mounted (dev mode), otherwise download all from remote
|
|
106
106
|
if [ -f "/tmp/setup/setup-agent.rb" ]; then
|
|
107
107
|
echo "🔧 Using local setup/ (dev mode)"
|
|
@@ -1,10 +1,15 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require "json"
|
|
4
|
+
require "digest"
|
|
4
5
|
require_relative "http"
|
|
5
6
|
|
|
6
7
|
module YatfaAgent
|
|
7
8
|
module Config
|
|
9
|
+
# Image name returned when the project's Dockerfile.sandbox is not present
|
|
10
|
+
# (e.g. image_name queried before the file is materialized, or offline).
|
|
11
|
+
DEFAULT_IMAGE_NAME = "yatfa-sandbox"
|
|
12
|
+
DOCKERFILE_SANDBOX = "Dockerfile.sandbox"
|
|
8
13
|
# Walk up the directory tree from +start_dir+ (default: Dir.pwd)
|
|
9
14
|
# looking for yatfa.env.rb. Returns the directory containing the
|
|
10
15
|
# config file, or nil if not found.
|
|
@@ -104,22 +109,27 @@ module YatfaAgent
|
|
|
104
109
|
"#{api_base.sub(/^https:\/\//, "wss://").sub(/^http:\/\//, "ws://")}/cable"
|
|
105
110
|
end
|
|
106
111
|
|
|
112
|
+
# Extract the first available agent MCP API key from the config.
|
|
113
|
+
# Returns nil when no key is configured.
|
|
114
|
+
def self.agent_api_key(config)
|
|
115
|
+
agents = config["agents"]
|
|
116
|
+
return nil unless agents.is_a?(Hash)
|
|
117
|
+
|
|
118
|
+
agents.each_value do |agent_config|
|
|
119
|
+
return agent_config["mcp_api_key"] if agent_config.is_a?(Hash) && agent_config["mcp_api_key"]
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
nil
|
|
123
|
+
end
|
|
124
|
+
|
|
107
125
|
def self.fetch_repositories!(config)
|
|
108
126
|
# Support both old and new config keys
|
|
109
127
|
api_base = api_url(config)
|
|
110
128
|
|
|
111
129
|
return unless api_base
|
|
112
130
|
|
|
113
|
-
# Use
|
|
114
|
-
mcp_api_key =
|
|
115
|
-
if config["agents"].is_a?(Hash)
|
|
116
|
-
config["agents"].each do |_, agent_config|
|
|
117
|
-
if agent_config.is_a?(Hash) && agent_config["mcp_api_key"]
|
|
118
|
-
mcp_api_key = agent_config["mcp_api_key"]
|
|
119
|
-
break
|
|
120
|
-
end
|
|
121
|
-
end
|
|
122
|
-
end
|
|
131
|
+
# Use the first available agent's MCP API key
|
|
132
|
+
mcp_api_key = agent_api_key(config)
|
|
123
133
|
|
|
124
134
|
return unless mcp_api_key
|
|
125
135
|
|
|
@@ -146,6 +156,10 @@ module YatfaAgent
|
|
|
146
156
|
project_id = agent_info["project_id"]
|
|
147
157
|
project_name = agent_info["name"] rescue "unknown"
|
|
148
158
|
|
|
159
|
+
# Expose the resolved project id so downstream steps can scope per-project
|
|
160
|
+
# resources (image tag, dockerfile_sandbox fetch) without another round-trip.
|
|
161
|
+
config["project_id"] = project_id if project_id
|
|
162
|
+
|
|
149
163
|
puts "🔑 Authenticated as #{agent_info["name"]} (project #{project_id})"
|
|
150
164
|
|
|
151
165
|
# Then fetch repositories from Rails API (scoped to our project)
|
|
@@ -176,8 +190,20 @@ module YatfaAgent
|
|
|
176
190
|
end
|
|
177
191
|
end
|
|
178
192
|
|
|
193
|
+
# Per-project Docker image tag so concurrent projects on one host don't
|
|
194
|
+
# clobber the shared "yatfa-sandbox" tag.
|
|
195
|
+
#
|
|
196
|
+
# The tag is derived from a digest of the project's Dockerfile.sandbox
|
|
197
|
+
# content: projects with an identical dockerfile share one image (no
|
|
198
|
+
# redundant rebuilds), while projects with different dockerfiles build to
|
|
199
|
+
# distinct tags and never overwrite each other. Falls back to
|
|
200
|
+
# +DEFAULT_IMAGE_NAME+ when no Dockerfile.sandbox is present (e.g. the tag
|
|
201
|
+
# is queried before the file is materialized, or while running offline).
|
|
179
202
|
def self.image_name(config)
|
|
180
|
-
|
|
203
|
+
content = File.exist?(DOCKERFILE_SANDBOX) ? File.read(DOCKERFILE_SANDBOX) : nil
|
|
204
|
+
return DEFAULT_IMAGE_NAME if content.nil? || content.empty?
|
|
205
|
+
|
|
206
|
+
"yatfa-sandbox-#{Digest::SHA256.hexdigest(content)[0, 16]}"
|
|
181
207
|
end
|
|
182
208
|
end
|
|
183
209
|
end
|
|
@@ -4,22 +4,74 @@ require "fileutils"
|
|
|
4
4
|
require "json"
|
|
5
5
|
require "time"
|
|
6
6
|
require_relative "http"
|
|
7
|
+
require_relative "config"
|
|
7
8
|
|
|
8
9
|
module YatfaAgent
|
|
9
10
|
module Docker
|
|
10
11
|
STAMP_FILE = ".yatfa-build-stamp"
|
|
11
|
-
VERSION_URL = "https://
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
12
|
+
VERSION_URL = "https://pub-06ce56b9a9c143118036f593f07ac1d8.r2.dev/main/version.json"
|
|
13
|
+
DOCKERFILE_NAME = "Dockerfile.sandbox"
|
|
14
|
+
|
|
15
|
+
# Ensure a Dockerfile.sandbox is available for the build.
|
|
16
|
+
#
|
|
17
|
+
# A local ./Dockerfile.sandbox always wins (override semantics — we never
|
|
18
|
+
# silently overwrite a file the user placed). When no local file is present,
|
|
19
|
+
# we try to materialize one from the project's active
|
|
20
|
+
# `dockerfile_sandbox/default` ProjectConfig via the API, using the agent's
|
|
21
|
+
# existing API key. If neither is available, we exit with a clear, actionable
|
|
22
|
+
# error instead of a stack trace.
|
|
23
|
+
#
|
|
24
|
+
# @param config [Hash] the loaded yatfa.env.rb config (carries yatfa_url,
|
|
25
|
+
# project_id, and the agent mcp_api_key needed to scope the request)
|
|
26
|
+
def self.check_dockerfile!(config = {})
|
|
27
|
+
return if File.exist?(DOCKERFILE_NAME)
|
|
28
|
+
|
|
29
|
+
fetched = fetch_remote_dockerfile(config)
|
|
30
|
+
if fetched
|
|
31
|
+
File.write(DOCKERFILE_NAME, fetched)
|
|
32
|
+
puts "📥 Wrote Dockerfile.sandbox from project config (dockerfile_sandbox/default)"
|
|
33
|
+
return
|
|
22
34
|
end
|
|
35
|
+
|
|
36
|
+
puts "❌ Error: Dockerfile.sandbox not found"
|
|
37
|
+
puts ""
|
|
38
|
+
puts "No local Dockerfile.sandbox and no active 'dockerfile_sandbox' project config"
|
|
39
|
+
puts "was found. Provide one of:"
|
|
40
|
+
puts " • a local ./Dockerfile.sandbox, OR"
|
|
41
|
+
puts " • a 'dockerfile_sandbox' config in your project (Project Configurations UI / API)"
|
|
42
|
+
puts ""
|
|
43
|
+
puts "See https://github.com/RoM4iK/yatfa-public/blob/main/README.md for instructions."
|
|
44
|
+
exit 1
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Fetch the project's active dockerfile_sandbox/default config content.
|
|
48
|
+
# Returns the dockerfile content [String], or nil when the project cannot
|
|
49
|
+
# be identified, the API is unreachable, or no such config is stored.
|
|
50
|
+
# Never raises — a missing remote config is a normal "fall back to error" case.
|
|
51
|
+
def self.fetch_remote_dockerfile(config)
|
|
52
|
+
api_base = Config.api_url(config)
|
|
53
|
+
project_id = config["project_id"]
|
|
54
|
+
api_key = Config.agent_api_key(config)
|
|
55
|
+
|
|
56
|
+
return nil if api_base.nil? || api_base.to_s.empty?
|
|
57
|
+
return nil if project_id.nil? || project_id.to_s.empty?
|
|
58
|
+
return nil if api_key.nil? || api_key.to_s.empty?
|
|
59
|
+
|
|
60
|
+
url = "#{api_base}/projects/#{project_id}/configs/dockerfile_sandbox/default"
|
|
61
|
+
auth_headers = { "X-API-Key" => api_key, "Accept" => "application/json" }
|
|
62
|
+
|
|
63
|
+
data = YatfaAgent::HTTP.get(url, headers: auth_headers)
|
|
64
|
+
return nil unless data.is_a?(Hash)
|
|
65
|
+
|
|
66
|
+
content = data["content"]
|
|
67
|
+
content.nil? || content.to_s.empty? ? nil : content.to_s
|
|
68
|
+
rescue YatfaAgent::HTTP::Error => e
|
|
69
|
+
# 404 just means no dockerfile_sandbox config is stored — not an error.
|
|
70
|
+
warn "⚠️ No dockerfile_sandbox config fetched (HTTP #{e.status_code})" unless e.status_code == 404
|
|
71
|
+
nil
|
|
72
|
+
rescue StandardError => e
|
|
73
|
+
warn "⚠️ Failed to fetch dockerfile_sandbox config (#{e.class}: #{e.message})"
|
|
74
|
+
nil
|
|
23
75
|
end
|
|
24
76
|
|
|
25
77
|
def self.fetch_remote_version
|
|
@@ -48,7 +100,7 @@ module YatfaAgent
|
|
|
48
100
|
end
|
|
49
101
|
|
|
50
102
|
def self.build_image(config)
|
|
51
|
-
check_dockerfile!
|
|
103
|
+
check_dockerfile!(config)
|
|
52
104
|
|
|
53
105
|
user_id = `id -u`.strip
|
|
54
106
|
group_id = `id -g`.strip
|
package/lib/yatfa_agent/setup.rb
CHANGED
|
@@ -524,12 +524,12 @@ module YatfaAgent
|
|
|
524
524
|
ARG GROUP_ID=1000
|
|
525
525
|
|
|
526
526
|
# Cache busting: only rebuilds when install-agent.sh infrastructure changes
|
|
527
|
-
ADD https://
|
|
527
|
+
ADD https://pub-06ce56b9a9c143118036f593f07ac1d8.r2.dev/${YATFA_VERSION}/version.json /tmp/yatfa_version.json
|
|
528
528
|
|
|
529
529
|
# Install YATFA agent infrastructure
|
|
530
530
|
# This layer is cached unless install-agent.sh changes (detected via version.json above)
|
|
531
531
|
# setup-agent.rb, skills, and hooks download fresh on every container startup
|
|
532
|
-
RUN curl -fsSL https://
|
|
532
|
+
RUN curl -fsSL https://pub-06ce56b9a9c143118036f593f07ac1d8.r2.dev/${YATFA_VERSION}/bin/install-agent.sh | bash
|
|
533
533
|
|
|
534
534
|
# Ensure /workspace is owned by agent user and writable
|
|
535
535
|
# The install-agent.sh script creates a user with USER_ID, so we use that ID directly
|
|
@@ -561,10 +561,8 @@ module YatfaAgent
|
|
|
561
561
|
puts " 1. Ensure 'yatfa.env.rb' is in .gitignore:"
|
|
562
562
|
puts " echo 'yatfa.env.rb' >> .gitignore"
|
|
563
563
|
puts ""
|
|
564
|
-
puts " 2.
|
|
565
|
-
puts "
|
|
566
|
-
puts ""
|
|
567
|
-
puts " 3. Start an agent:"
|
|
564
|
+
puts " 2. Start an agent (the sandbox image builds automatically on first run,"
|
|
565
|
+
puts " tagged from your Dockerfile.sandbox):"
|
|
568
566
|
if @config[:agents].key?(:worker)
|
|
569
567
|
puts " npx yatfa worker"
|
|
570
568
|
elsif @config[:agents].key?(:planner)
|
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
require "json"
|
|
4
4
|
require "time"
|
|
5
|
+
require_relative "config"
|
|
6
|
+
require_relative "docker"
|
|
5
7
|
|
|
6
8
|
module YatfaAgent
|
|
7
9
|
module Updater
|
|
@@ -130,17 +132,23 @@ module YatfaAgent
|
|
|
130
132
|
# Both Config.load and Docker.build_image call exit(1) on failure,
|
|
131
133
|
# which SystemExit catches here (not caught by rescue StandardError).
|
|
132
134
|
puts " Building fresh image..."
|
|
135
|
+
fresh_image = nil
|
|
133
136
|
begin
|
|
134
137
|
project_root = find_project_root_for_container(config)
|
|
135
138
|
if project_root && Dir.exist?(project_root)
|
|
136
139
|
Dir.chdir(project_root) do
|
|
137
140
|
docker_config = load_config_for_container(project_root)
|
|
138
141
|
Docker.build_image(docker_config)
|
|
142
|
+
# Resolve the tag while still inside the project root so the
|
|
143
|
+
# freshly built image (not the stale one baked into the old
|
|
144
|
+
# container) is used to recreate it.
|
|
145
|
+
fresh_image = Config.image_name(docker_config)
|
|
139
146
|
end
|
|
140
147
|
else
|
|
141
148
|
puts " ⚠️ Could not find project root, attempting build in cwd"
|
|
142
149
|
docker_config = Config.load
|
|
143
150
|
Docker.build_image(docker_config)
|
|
151
|
+
fresh_image = Config.image_name(docker_config)
|
|
144
152
|
end
|
|
145
153
|
rescue SystemExit => e
|
|
146
154
|
puts " ❌ Build failed (exit #{e.status}), keeping old container running"
|
|
@@ -154,7 +162,7 @@ module YatfaAgent
|
|
|
154
162
|
|
|
155
163
|
# 4. Recreate container with preserved config
|
|
156
164
|
puts " Starting new container..."
|
|
157
|
-
new_cmd = build_recreate_command(name, config, new_version)
|
|
165
|
+
new_cmd = build_recreate_command(name, config, new_version, image: fresh_image)
|
|
158
166
|
success = system(*new_cmd)
|
|
159
167
|
|
|
160
168
|
if success
|
|
@@ -262,8 +270,10 @@ module YatfaAgent
|
|
|
262
270
|
# @param name [String] container name
|
|
263
271
|
# @param config [Hash] container inspect data
|
|
264
272
|
# @param new_version [String] target infrastructure version number
|
|
273
|
+
# @param image [String, nil] freshly built image tag to run (takes
|
|
274
|
+
# precedence over the stale image baked into the old container)
|
|
265
275
|
# @return [Array<String>] docker command arguments
|
|
266
|
-
def self.build_recreate_command(name, config, new_version)
|
|
276
|
+
def self.build_recreate_command(name, config, new_version, image: nil)
|
|
267
277
|
cmd = ["docker", "run", "-d", "--name", name]
|
|
268
278
|
|
|
269
279
|
# Preserve restart policy
|
|
@@ -319,9 +329,11 @@ module YatfaAgent
|
|
|
319
329
|
entrypoint_extra = entrypoint[1..]
|
|
320
330
|
end
|
|
321
331
|
|
|
322
|
-
# Use the
|
|
323
|
-
|
|
324
|
-
|
|
332
|
+
# Use the freshly built image when provided (so an update-cycle rebuild
|
|
333
|
+
# actually takes effect); otherwise preserve the container's existing
|
|
334
|
+
# image, falling back to the default tag.
|
|
335
|
+
resolved_image = image || config.dig("Config", "Image") || Config::DEFAULT_IMAGE_NAME
|
|
336
|
+
cmd += [resolved_image]
|
|
325
337
|
|
|
326
338
|
# Use the original CMD (prepended with any extra entrypoint args)
|
|
327
339
|
original_cmd = config.dig("Config", "Cmd")
|
package/package.json
CHANGED
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
# Repo-aware GitHub App token credential helper for YATFA agent containers.
|
|
5
|
+
#
|
|
6
|
+
# Installed as /usr/local/bin/git-auth-helper and wired into git via:
|
|
7
|
+
# git config --global credential.helper '!/usr/local/bin/git-auth-helper'
|
|
8
|
+
# git config --global credential.useHttpPath true
|
|
9
|
+
#
|
|
10
|
+
# Two invocation modes:
|
|
11
|
+
#
|
|
12
|
+
# 1. git credential helper — git invokes this as `git-auth-helper <action>`
|
|
13
|
+
# (action = get|store|erase) and feeds the credential description on stdin.
|
|
14
|
+
# With useHttpPath=true, that description includes `path=owner/repo[.git]`,
|
|
15
|
+
# which lets us mint a token scoped to THAT repository's GitHub App
|
|
16
|
+
# installation. This is required for projects spanning multiple installations
|
|
17
|
+
# (e.g. repos across different orgs): a single installation token cannot
|
|
18
|
+
# clone cross-org repos. On `get`, the full credential response
|
|
19
|
+
# (protocol/host/username/password) is printed to stdout.
|
|
20
|
+
#
|
|
21
|
+
# 2. token-only mode — invoked with no recognized action (e.g. by the `gh`
|
|
22
|
+
# wrapper as `$(git-auth-helper)`). Prints just the default project token.
|
|
23
|
+
#
|
|
24
|
+
# Tokens are cached per installation (a repo->installation index plus one cache
|
|
25
|
+
# file per installation_id), so sibling repos under the same installation share
|
|
26
|
+
# a single minted token and only hit the backend once per expiry window.
|
|
27
|
+
#
|
|
28
|
+
# This file is BOTH the standalone in-container script (run via its shebang, the
|
|
29
|
+
# `if __FILE__ == $PROGRAM_NAME` guard fires) AND a require-able module for the
|
|
30
|
+
# test suite (the guard does not fire when required, exposing the unit-testable
|
|
31
|
+
# class methods).
|
|
32
|
+
|
|
33
|
+
require "json"
|
|
34
|
+
require "net/http"
|
|
35
|
+
require "uri"
|
|
36
|
+
require "time"
|
|
37
|
+
require "fileutils"
|
|
38
|
+
|
|
39
|
+
module YatfaAgent
|
|
40
|
+
module GitCredentialHelper
|
|
41
|
+
DEFAULT_CACHE_DIR = "/tmp/github-token-cache"
|
|
42
|
+
DEFAULT_HOST = "github.com"
|
|
43
|
+
DEFAULT_USERNAME = "x-access-token"
|
|
44
|
+
DEFAULT_KEY = "__default__"
|
|
45
|
+
BUFFER_SECONDS = 300 # refresh 5 minutes before the token expires
|
|
46
|
+
CREDENTIAL_ACTIONS = %w[get store erase].freeze
|
|
47
|
+
|
|
48
|
+
module_function
|
|
49
|
+
|
|
50
|
+
# Reduce a git credential `path` attribute to a normalized "owner/repo"
|
|
51
|
+
# full name, or nil when it can't be reduced to exactly owner/repo.
|
|
52
|
+
#
|
|
53
|
+
# "RoM4iK/yatfa.git" -> "RoM4iK/yatfa"
|
|
54
|
+
# "/yatfa-ai/router" -> "yatfa-ai/router"
|
|
55
|
+
# nil / "" / "info/refs" -> nil
|
|
56
|
+
def normalize_path(path)
|
|
57
|
+
return nil if path.nil?
|
|
58
|
+
|
|
59
|
+
p = path.to_s.strip
|
|
60
|
+
return nil if p.empty?
|
|
61
|
+
|
|
62
|
+
p = p.sub(/\/+\z/, "") # trailing slashes
|
|
63
|
+
p = p.sub(/\A\/+/, "") # leading slashes
|
|
64
|
+
p = p.sub(/\.git\z/i, "") # trailing .git
|
|
65
|
+
p = p.sub(/\A\/+/, "") # leading slashes again (after .git strip)
|
|
66
|
+
return nil if p.empty?
|
|
67
|
+
|
|
68
|
+
owner, repo = p.split("/", 2)
|
|
69
|
+
return nil if owner.to_s.empty? || repo.to_s.empty?
|
|
70
|
+
# Reject multi-segment paths (e.g. "org/repo/extra") — not a repo root.
|
|
71
|
+
return nil if repo.include?("/")
|
|
72
|
+
|
|
73
|
+
"#{owner}/#{repo}"
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# Parse git's newline-delimited `key=value` credential input into a Hash.
|
|
77
|
+
def parse_input(input)
|
|
78
|
+
attrs = {}
|
|
79
|
+
input.to_s.each_line do |line|
|
|
80
|
+
line = line.strip
|
|
81
|
+
next if line.empty?
|
|
82
|
+
key, value = line.split("=", 2)
|
|
83
|
+
attrs[key] = value if key && !key.empty?
|
|
84
|
+
end
|
|
85
|
+
attrs
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# Build the backend token URL, appending `?repo=<full_name>` when scoped.
|
|
89
|
+
def build_token_url(api_url, repo)
|
|
90
|
+
base = api_url.to_s.chomp("/")
|
|
91
|
+
url = "#{base}/github/token"
|
|
92
|
+
return url if repo.nil? || repo.empty?
|
|
93
|
+
"#{url}?repo=#{URI.encode_www_form_component(repo)}"
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# Default backend fetcher. Returns the parsed response Hash on HTTP 200,
|
|
97
|
+
# nil on any failure (non-2xx, network error, bad JSON). Never raises.
|
|
98
|
+
def fetch_token_data(api_url, api_key, repo)
|
|
99
|
+
uri = URI(build_token_url(api_url, repo))
|
|
100
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
101
|
+
http.use_ssl = (uri.scheme == "https")
|
|
102
|
+
http.open_timeout = 5
|
|
103
|
+
http.read_timeout = 10
|
|
104
|
+
request = Net::HTTP::Get.new(uri)
|
|
105
|
+
request["X-API-Key"] = api_key.to_s
|
|
106
|
+
request["Accept"] = "application/json"
|
|
107
|
+
response = http.request(request)
|
|
108
|
+
return nil unless response.is_a?(Net::HTTPSuccess)
|
|
109
|
+
parsed = JSON.parse(response.body)
|
|
110
|
+
parsed.is_a?(Hash) ? parsed : nil
|
|
111
|
+
rescue StandardError
|
|
112
|
+
nil
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# Resolve a usable token for `repo` (or the default project token when nil),
|
|
116
|
+
# using the per-installation cache and falling back to the default token
|
|
117
|
+
# when a repo-scoped fetch fails (older backend, unregistered repo, etc.).
|
|
118
|
+
def resolve_token(repo, cache_dir:, env:, http_fetch:)
|
|
119
|
+
api_url = env["YATFA_API_URL"]
|
|
120
|
+
api_key = env["YATFA_API_KEY"]
|
|
121
|
+
return nil if api_url.nil? || api_url.to_s.empty?
|
|
122
|
+
return nil if api_key.nil? || api_key.to_s.empty?
|
|
123
|
+
|
|
124
|
+
cached = read_cached_token(repo, cache_dir)
|
|
125
|
+
return cached if cached
|
|
126
|
+
|
|
127
|
+
data = http_fetch.call(api_url, api_key, repo)
|
|
128
|
+
# Fallback: repo-scoped fetch failed -> try the default token path.
|
|
129
|
+
data = http_fetch.call(api_url, api_key, nil) if data.nil? && !repo.nil?
|
|
130
|
+
return nil if data.nil? || data["token"].nil? || data["token"].to_s.empty?
|
|
131
|
+
|
|
132
|
+
expires_at = parse_expires_at(data["expires_at"])
|
|
133
|
+
write_cached_token(repo, data["installation_id"], data["token"], expires_at, cache_dir)
|
|
134
|
+
data["token"]
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
# Dispatch entry point. Returns a process exit code (0 success, 1 failure).
|
|
138
|
+
def run(argv, stdin: $stdin, stdout: $stdout, stderr: $stderr,
|
|
139
|
+
cache_dir: DEFAULT_CACHE_DIR, env: nil, http_fetch: nil)
|
|
140
|
+
env_hash = env || ENV.to_h
|
|
141
|
+
fetch = http_fetch || method(:fetch_token_data)
|
|
142
|
+
action = argv.first.to_s
|
|
143
|
+
|
|
144
|
+
if CREDENTIAL_ACTIONS.include?(action)
|
|
145
|
+
if action == "get"
|
|
146
|
+
attrs = parse_input(read_stdin(stdin))
|
|
147
|
+
repo = normalize_path(attrs["path"])
|
|
148
|
+
token = resolve_token(repo, cache_dir: cache_dir, env: env_hash, http_fetch: fetch)
|
|
149
|
+
if token.nil?
|
|
150
|
+
stderr.puts("git-auth-helper: failed to obtain GitHub token" + (repo ? " for #{repo}" : ""))
|
|
151
|
+
return 1
|
|
152
|
+
end
|
|
153
|
+
stdout.puts("protocol=https")
|
|
154
|
+
stdout.puts("host=#{DEFAULT_HOST}")
|
|
155
|
+
stdout.puts("username=#{DEFAULT_USERNAME}")
|
|
156
|
+
stdout.puts("password=#{token}")
|
|
157
|
+
end
|
|
158
|
+
# store/erase: no-op — we manage our own cache, not git's credential store.
|
|
159
|
+
return 0
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
# Token-only mode (e.g. the `gh` wrapper): print just the default token.
|
|
163
|
+
token = resolve_token(nil, cache_dir: cache_dir, env: env_hash, http_fetch: fetch)
|
|
164
|
+
if token.nil?
|
|
165
|
+
stderr.puts("git-auth-helper: failed to obtain GitHub token")
|
|
166
|
+
return 1
|
|
167
|
+
end
|
|
168
|
+
stdout.puts(token)
|
|
169
|
+
0
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
# -----------------------------------------------------------------
|
|
173
|
+
# Per-installation cache (repo -> installation_id index + one file per id)
|
|
174
|
+
# -----------------------------------------------------------------
|
|
175
|
+
|
|
176
|
+
def index_path(cache_dir)
|
|
177
|
+
File.join(cache_dir, "index.json")
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
def read_index(cache_dir)
|
|
181
|
+
return {} unless File.exist?(index_path(cache_dir))
|
|
182
|
+
parsed = JSON.parse(File.read(index_path(cache_dir)))
|
|
183
|
+
parsed.is_a?(Hash) ? parsed : {}
|
|
184
|
+
rescue StandardError
|
|
185
|
+
{}
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
def write_index(cache_dir, map)
|
|
189
|
+
FileUtils.mkdir_p(cache_dir)
|
|
190
|
+
File.write(index_path(cache_dir), map.to_json)
|
|
191
|
+
rescue StandardError
|
|
192
|
+
nil
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
def installation_cache_path(cache_dir, installation_id)
|
|
196
|
+
return nil if installation_id.nil?
|
|
197
|
+
id = installation_id.to_s.gsub(/[^0-9a-zA-Z_-]/, "")
|
|
198
|
+
File.join(cache_dir, "inst-#{id}.json")
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
# Look up a non-expired cached token for `repo` (or the default token).
|
|
202
|
+
def read_cached_token(repo, cache_dir)
|
|
203
|
+
map = read_index(cache_dir)
|
|
204
|
+
inst_id = repo ? map[repo] : map[DEFAULT_KEY]
|
|
205
|
+
return nil if inst_id.nil?
|
|
206
|
+
|
|
207
|
+
path = installation_cache_path(cache_dir, inst_id)
|
|
208
|
+
return nil unless path && File.exist?(path)
|
|
209
|
+
data = JSON.parse(File.read(path))
|
|
210
|
+
return nil unless data.is_a?(Hash) && data["token"] && data["expires_at"]
|
|
211
|
+
return data["token"] if Time.parse(data["expires_at"]) > Time.now + BUFFER_SECONDS
|
|
212
|
+
nil
|
|
213
|
+
rescue StandardError
|
|
214
|
+
nil
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
# Persist a token keyed by installation_id, and record the repo->id mapping.
|
|
218
|
+
def write_cached_token(repo, installation_id, token, expires_at, cache_dir)
|
|
219
|
+
path = installation_cache_path(cache_dir, installation_id)
|
|
220
|
+
if path
|
|
221
|
+
FileUtils.mkdir_p(cache_dir)
|
|
222
|
+
File.write(path, { "token" => token, "expires_at" => expires_at.iso8601 }.to_json)
|
|
223
|
+
end
|
|
224
|
+
if installation_id
|
|
225
|
+
map = read_index(cache_dir)
|
|
226
|
+
map[repo || DEFAULT_KEY] = installation_id
|
|
227
|
+
write_index(cache_dir, map)
|
|
228
|
+
end
|
|
229
|
+
rescue StandardError
|
|
230
|
+
nil
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
def parse_expires_at(value)
|
|
234
|
+
return Time.now + 3600 - BUFFER_SECONDS if value.nil? || value.to_s.empty?
|
|
235
|
+
Time.parse(value.to_s)
|
|
236
|
+
rescue StandardError
|
|
237
|
+
Time.now + 3600 - BUFFER_SECONDS
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
def read_stdin(stdin)
|
|
241
|
+
stdin.read
|
|
242
|
+
rescue StandardError
|
|
243
|
+
""
|
|
244
|
+
end
|
|
245
|
+
end
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
if __FILE__ == $PROGRAM_NAME
|
|
249
|
+
exit(YatfaAgent::GitCredentialHelper.run(ARGV))
|
|
250
|
+
end
|
package/setup/repositories.rb
CHANGED
|
@@ -54,16 +54,27 @@ def clone_repositories!
|
|
|
54
54
|
end
|
|
55
55
|
else
|
|
56
56
|
puts " ✓ #{repo_name} already exists at #{sandbox_path}"
|
|
57
|
-
# Fetch all remotes to ensure branches are up to date
|
|
57
|
+
# Fetch all remotes to ensure branches are up to date.
|
|
58
|
+
# The repo ALREADY EXISTS, so a fetch failure (transient network blip,
|
|
59
|
+
# DNS hiccup, expired creds) is NOT fatal — the agent can still work on
|
|
60
|
+
# previously-fetched code. Surface the failure honestly with a warning
|
|
61
|
+
# rather than a false "✓" (and do NOT exit, which would restart-loop).
|
|
58
62
|
Dir.chdir(sandbox_path) do
|
|
59
|
-
system("git", "fetch", "--all")
|
|
60
|
-
|
|
63
|
+
fetch_success = system("git", "fetch", "--all")
|
|
64
|
+
if fetch_success
|
|
65
|
+
puts " ✓ Fetched all remotes for #{repo_name}"
|
|
66
|
+
else
|
|
67
|
+
puts " ⚠️ git fetch for #{repo_name} failed — checkout may be stale; continuing on previously-fetched state"
|
|
68
|
+
end
|
|
61
69
|
end
|
|
62
70
|
end
|
|
63
71
|
|
|
64
|
-
# Add repository path to git safe.directory to avoid "dubious ownership" errors
|
|
65
|
-
|
|
66
|
-
|
|
72
|
+
# Add repository path to git safe.directory to avoid "dubious ownership" errors.
|
|
73
|
+
# Surface a config failure honestly rather than a false "✓" (non-fatal — the
|
|
74
|
+
# repo still works, the user may just see a "dubious ownership" warning later).
|
|
75
|
+
config_success = system("git", "config", "--global", "--add", "safe.directory", sandbox_path)
|
|
76
|
+
puts config_success ? " ✓ Added #{sandbox_path} to git safe.directory" :
|
|
77
|
+
" ⚠️ Failed to add #{sandbox_path} to git safe.directory (may see 'dubious ownership' errors)"
|
|
67
78
|
end
|
|
68
79
|
|
|
69
80
|
puts "✅ Repositories cloned"
|
package/setup/setup-agent.rb
CHANGED
|
@@ -45,7 +45,7 @@ require_relative "skills"
|
|
|
45
45
|
|
|
46
46
|
SCRIPT_DIR = File.dirname(File.expand_path(__FILE__))
|
|
47
47
|
YATFA_VERSION = ENV["YATFA_VERSION"] || "main"
|
|
48
|
-
YATFA_SCRIPTS_BASE_URL = "https://
|
|
48
|
+
YATFA_SCRIPTS_BASE_URL = "https://pub-06ce56b9a9c143118036f593f07ac1d8.r2.dev"
|
|
49
49
|
YATFA_RAW_URL = "#{YATFA_SCRIPTS_BASE_URL}/#{YATFA_VERSION}"
|
|
50
50
|
|
|
51
51
|
# Valid agent types
|
|
@@ -244,26 +244,37 @@ def setup_claude_config!
|
|
|
244
244
|
end
|
|
245
245
|
|
|
246
246
|
def setup_git_config!
|
|
247
|
-
# Configure identity if provided
|
|
247
|
+
# Configure identity if provided.
|
|
248
|
+
# Each `git config` return value is captured and reported honestly — a failed
|
|
249
|
+
# config (read-only $HOME, full disk, restricted FS) is NOT fatal, but must
|
|
250
|
+
# NOT be hidden behind an unconditional ✅. Mirrors the repositories.rb:75-77
|
|
251
|
+
# precedent (YATFA-2842). Non-fatal: setup continues so a transient/recoverable
|
|
252
|
+
# failure does not restart-loop the container.
|
|
248
253
|
if ENV["GIT_USER_NAME"] && !ENV["GIT_USER_NAME"].empty?
|
|
249
|
-
system("git", "config", "--global", "user.name", ENV["GIT_USER_NAME"])
|
|
250
|
-
puts "✅ Git user.name configured"
|
|
254
|
+
name_success = system("git", "config", "--global", "user.name", ENV["GIT_USER_NAME"])
|
|
255
|
+
puts name_success ? "✅ Git user.name configured" :
|
|
256
|
+
"⚠️ Failed to set git user.name (later commits may fail authoring)"
|
|
251
257
|
end
|
|
252
258
|
|
|
253
259
|
if ENV["GIT_USER_EMAIL"] && !ENV["GIT_USER_EMAIL"].empty?
|
|
254
|
-
system("git", "config", "--global", "user.email", ENV["GIT_USER_EMAIL"])
|
|
255
|
-
puts "✅ Git user.email configured"
|
|
260
|
+
email_success = system("git", "config", "--global", "user.email", ENV["GIT_USER_EMAIL"])
|
|
261
|
+
puts email_success ? "✅ Git user.email configured" :
|
|
262
|
+
"⚠️ Failed to set git user.email (later commits may fail authoring)"
|
|
256
263
|
end
|
|
257
264
|
|
|
258
|
-
# Force HTTPS instead of SSH to ensure our token auth works
|
|
259
|
-
# This fixes "Permission denied (publickey)" when the repo uses git@github.com remote
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
265
|
+
# Force HTTPS instead of SSH to ensure our token auth works.
|
|
266
|
+
# This fixes "Permission denied (publickey)" when the repo uses git@github.com remote.
|
|
267
|
+
# If this silently fails, SSH-remote repos are NOT rewritten to HTTPS, so the
|
|
268
|
+
# HTTPS App-token auth path never engages → high impact, must be surfaced.
|
|
269
|
+
https_success = system("git config --global url.\"https://github.com/\".insteadOf \"git@github.com:\"")
|
|
270
|
+
puts https_success ? "✅ Git configured to force HTTPS for GitHub" :
|
|
271
|
+
"⚠️ Failed to force HTTPS for GitHub (SSH-remote clones may fail with \"Permission denied (publickey)\")"
|
|
272
|
+
|
|
273
|
+
# Avoid "dubious ownership" errors in containers.
|
|
274
|
+
# Note: Individual repository paths are added in clone_repositories! after cloning.
|
|
275
|
+
safe_success = system("git", "config", "--global", "--add", "safe.directory", Dir.pwd)
|
|
276
|
+
puts safe_success ? "✅ Git configured to trust #{Dir.pwd}" :
|
|
277
|
+
"⚠️ Failed to set git safe.directory for #{Dir.pwd} (may see 'dubious ownership' errors)"
|
|
267
278
|
end
|
|
268
279
|
|
|
269
280
|
|
package/setup/setup-github.rb
CHANGED
|
@@ -29,98 +29,20 @@ def setup_github_auth!
|
|
|
29
29
|
if rails_api_url && !rails_api_url.empty? && rails_api_key && !rails_api_key.empty?
|
|
30
30
|
puts "🔐 Configuring GitHub App authentication (via backend)..."
|
|
31
31
|
|
|
32
|
-
#
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
require 'json'
|
|
36
|
-
require 'net/http'
|
|
37
|
-
require 'uri'
|
|
38
|
-
require 'time'
|
|
39
|
-
|
|
40
|
-
def get_token_from_backend
|
|
41
|
-
rails_api_url = ENV['YATFA_API_URL']
|
|
42
|
-
rails_api_key = ENV['YATFA_API_KEY']
|
|
43
|
-
|
|
44
|
-
unless rails_api_url && rails_api_key
|
|
45
|
-
STDERR.puts "❌ Error: YATFA_API_URL or YATFA_API_KEY not set"
|
|
46
|
-
exit 1
|
|
47
|
-
end
|
|
48
|
-
|
|
49
|
-
# Build GitHub token endpoint URL
|
|
50
|
-
token_url = "\#{rails_api_url.chomp('/')}/github/token"
|
|
51
|
-
|
|
52
|
-
uri = URI(token_url)
|
|
53
|
-
http = Net::HTTP.new(uri.host, uri.port)
|
|
54
|
-
http.use_ssl = uri.scheme == 'https'
|
|
55
|
-
http.open_timeout = 5
|
|
56
|
-
http.read_timeout = 10
|
|
57
|
-
|
|
58
|
-
request = Net::HTTP::Get.new(uri)
|
|
59
|
-
request['X-API-Key'] = rails_api_key
|
|
60
|
-
request['Accept'] = 'application/json'
|
|
61
|
-
|
|
62
|
-
response = http.request(request)
|
|
63
|
-
|
|
64
|
-
unless response.is_a?(Net::HTTPSuccess)
|
|
65
|
-
STDERR.puts "❌ Error: Backend API returned \#{response.code} for GitHub token"
|
|
66
|
-
exit 1
|
|
67
|
-
end
|
|
68
|
-
|
|
69
|
-
data = JSON.parse(response.body)
|
|
70
|
-
token = data['token']
|
|
71
|
-
|
|
72
|
-
unless token
|
|
73
|
-
STDERR.puts "❌ Error: No token in backend response"
|
|
74
|
-
exit 1
|
|
75
|
-
end
|
|
76
|
-
|
|
77
|
-
data # Return full response including expires_at
|
|
78
|
-
rescue Net::OpenTimeout, Net::ReadTimeout => e
|
|
79
|
-
STDERR.puts "❌ Error: Backend API timeout: \#{e.message}"
|
|
80
|
-
exit 1
|
|
81
|
-
rescue StandardError => e
|
|
82
|
-
STDERR.puts "❌ Error: \#{e.class} - \#{e.message}"
|
|
83
|
-
exit 1
|
|
84
|
-
end
|
|
85
|
-
|
|
86
|
-
def find_or_create_cached_token
|
|
87
|
-
cache_file = '/tmp/github-app-token-cache'
|
|
88
|
-
cached_token = read_cached_token(cache_file)
|
|
89
|
-
return cached_token if cached_token
|
|
90
|
-
|
|
91
|
-
token_data = get_token_from_backend
|
|
92
|
-
expires_at = token_data['expires_at'] ? Time.parse(token_data['expires_at']) : (Time.now + 3600 - 300)
|
|
93
|
-
token = token_data['token']
|
|
94
|
-
|
|
95
|
-
cache_data = {
|
|
96
|
-
token: token,
|
|
97
|
-
expires_at: expires_at.iso8601
|
|
98
|
-
}
|
|
99
|
-
File.write(cache_file, cache_data.to_json)
|
|
100
|
-
token
|
|
101
|
-
end
|
|
102
|
-
|
|
103
|
-
def read_cached_token(cache_file)
|
|
104
|
-
return nil unless File.exist?(cache_file)
|
|
105
|
-
cache = JSON.parse(File.read(cache_file))
|
|
106
|
-
return nil if cache['token'].nil? || cache['expires_at'].nil?
|
|
107
|
-
return cache['token'] if Time.parse(cache['expires_at']) > Time.now + 300
|
|
108
|
-
nil
|
|
109
|
-
rescue JSON::ParserError
|
|
110
|
-
nil
|
|
111
|
-
end
|
|
112
|
-
|
|
113
|
-
puts find_or_create_cached_token
|
|
114
|
-
RUBY
|
|
115
|
-
|
|
116
|
-
# Install helper via sudo to /usr/local/bin
|
|
32
|
+
# Install the repo-aware credential helper (setup/git_credential_helper.rb).
|
|
33
|
+
# It mints a token scoped to the requested repo's GitHub App installation,
|
|
34
|
+
# which is required for projects spanning multiple installations/orgs.
|
|
117
35
|
helper_path = "/usr/local/bin/git-auth-helper"
|
|
118
|
-
File.
|
|
36
|
+
source_path = File.join(__dir__, "git_credential_helper.rb")
|
|
37
|
+
File.write("/tmp/git-auth-helper", File.read(source_path))
|
|
119
38
|
system("sudo", "mv", "/tmp/git-auth-helper", helper_path)
|
|
120
39
|
system("sudo", "chmod", "+x", helper_path)
|
|
121
40
|
|
|
122
|
-
#
|
|
123
|
-
|
|
41
|
+
# Wire the helper into git. useHttpPath=true makes git send the repo path on
|
|
42
|
+
# stdin so the helper can request a repo-scoped token (?repo=owner/repo).
|
|
43
|
+
# Array form avoids shell interpolation of the helper path.
|
|
44
|
+
system("git", "config", "--global", "credential.helper", "!#{helper_path}")
|
|
45
|
+
system("git", "config", "--global", "credential.useHttpPath", "true")
|
|
124
46
|
|
|
125
47
|
# Configure gh CLI wrapper for auto-refresh and permission controls
|
|
126
48
|
if install_gh_wrapper!(real_gh_path: "/usr/bin/gh", with_token_refresh: true, token_helper_path: helper_path)
|
|
@@ -172,7 +94,9 @@ def install_gh_wrapper!(real_gh_path:, with_token_refresh: false, token_helper_p
|
|
|
172
94
|
|
|
173
95
|
wrapper_path = "/usr/local/bin/gh"
|
|
174
96
|
|
|
175
|
-
# Build token export line if auto-refresh is enabled
|
|
97
|
+
# Build token export line if auto-refresh is enabled.
|
|
98
|
+
# Invoked with no args, git-auth-helper runs in token-only mode and prints the
|
|
99
|
+
# default project token (cached per installation).
|
|
176
100
|
token_export = with_token_refresh ? "export GH_TOKEN=$(#{token_helper_path})\n\n" : ""
|
|
177
101
|
|
|
178
102
|
wrapper_content = <<~BASH
|