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.
@@ -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://objectstore.fra1.civo.com/yatfa/${YATFA_VERSION}"
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 to first available agent's MCP API key
114
- mcp_api_key = nil
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
- "yatfa-sandbox"
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://objectstore.fra1.civo.com/yatfa/main/version.json"
12
-
13
- def self.check_dockerfile!
14
- unless File.exist?("Dockerfile.sandbox")
15
- puts "❌ Error: Dockerfile.sandbox not found"
16
- puts ""
17
- puts "Please create Dockerfile.sandbox by copying your existing Dockerfile"
18
- puts "and adding the required agent dependencies."
19
- puts ""
20
- puts "See https://github.com/RoM4iK/yatfa-public/blob/main/README.md for instructions."
21
- exit 1
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
@@ -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://objectstore.fra1.civo.com/yatfa/${YATFA_VERSION}/version.json /tmp/yatfa_version.json
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://objectstore.fra1.civo.com/yatfa/${YATFA_VERSION}/bin/install-agent.sh | bash
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. Build the sandbox Docker image:"
565
- puts " docker build -f Dockerfile.sandbox -t yatfa-sandbox ."
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 same image
323
- image = config.dig("Config", "Image")
324
- cmd += [image || "yatfa-sandbox"]
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "yatfa",
3
- "version": "1.0.94",
3
+ "version": "1.0.96",
4
4
  "description": "YATFA - Yet Another Tool For Agents",
5
5
  "bin": "./bin/run-yatfa-agent.rb",
6
6
  "files": [
@@ -16,4 +16,4 @@
16
16
  },
17
17
  "author": "",
18
18
  "license": "ISC"
19
- }
19
+ }
@@ -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"
@@ -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://objectstore.fra1.civo.com/yatfa"
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
- system("git config --global url.\"https://github.com/\".insteadOf \"git@github.com:\"")
261
- puts "✅ Git configured to force HTTPS for GitHub"
262
-
263
- # Avoid "dubious ownership" errors in containers
264
- # Note: Individual repository paths are added in clone_repositories! after cloning
265
- system("git", "config", "--global", "--add", "safe.directory", Dir.pwd)
266
- puts " Git configured to trust #{Dir.pwd}"
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
 
@@ -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