yatfa 1.0.94 → 1.0.95
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/package.json +1 -1
- package/setup/git_credential_helper.rb +250 -0
- package/setup/repositories.rb +17 -6
- package/setup/setup-github.rb +13 -89
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-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
|