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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "yatfa",
3
- "version": "1.0.94",
3
+ "version": "1.0.95",
4
4
  "description": "YATFA - Yet Another Tool For Agents",
5
5
  "bin": "./bin/run-yatfa-agent.rb",
6
6
  "files": [
@@ -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
@@ -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
- puts " ✓ Fetched all remotes for #{repo_name}"
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
- system("git", "config", "--global", "--add", "safe.directory", sandbox_path)
66
- puts " ✓ Added #{sandbox_path} to git safe.directory"
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"
@@ -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
- # Create helper script that calls backend API
33
- helper_content = <<~RUBY
34
- #!/usr/bin/env ruby
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.write("/tmp/git-auth-helper", helper_content)
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
- # Configure git (array form avoids shell interpolation of helper path)
123
- system("git", "config", "--global", "credential.helper", "!f() { test \"$1\" = get && echo \"protocol=https\" && echo \"host=github.com\" && echo \"username=x-access-token\" && p=$(#{helper_path}) && echo \"password=$p\"; }; f")
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