yatfa 1.0.97 → 1.0.99

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/README.md CHANGED
@@ -79,9 +79,9 @@ Example `yatfa.env.rb`:
79
79
  | Variable | Required | Description |
80
80
  |----------|----------|-------------|
81
81
  | `AGENT_TYPE` | ✅ | `worker`, `planner`, `reviewer`, or `researcher` |
82
- | `YATFA_URL` | ✅ | YATFA platform URL (WebSocket and API URLs are derived from this) |
83
- | `YATFA_API_KEY` | | MCP API key (get from YATFA dashboard) |
84
- | `GH_TOKEN` | | GitHub token for git operations |
82
+ | `YATFA_API_KEY` | ✅ | MCP API key (get from YATFA dashboard) |
83
+ | `YATFA_URL` | | Yatfa platform URL (defaults to `https://yatfa.com`; `YATFA_API_URL` & `YATFA_WS_URL` are derived from this) |
84
+ | `GH_TOKEN` | | GitHub token for git operations (fallback; backend token auth is preferred) |
85
85
 
86
86
  ## Agent Types
87
87
 
@@ -199,7 +199,7 @@ YATFA_API_KEY=your_api_key_here
199
199
  AGENT_TYPE=worker
200
200
  ```
201
201
 
202
- Note: `YATFA_API_URL` and `YATFA_WS_URL` are derived from `YATFA_URL` by `setup-agent.rb`. Project is automatically determined by the API key authentication.
202
+ Note: `YATFA_URL` is optional and defaults to `https://yatfa.com` (the hosted deployment works with zero URL configuration; self-hosted installs set it to their server). `YATFA_API_URL` (`/api/v1`) and `YATFA_WS_URL` (`/cable`, scheme-adjusted) are always derived from it — you never need to set them. The git credential helper derives `YATFA_API_URL` itself, so even a bare `docker exec` shell with no other env can authenticate. Project is automatically determined by the API key.
203
203
 
204
204
  ### Agent Self-Update
205
205
 
@@ -154,12 +154,12 @@ if command == "attach"
154
154
  puts ""
155
155
  agent_type = ARGV[1]&.downcase
156
156
  abort "Usage: npx yatfa attach [agent-type]" unless agent_type
157
- config = YatfaAgent::Config.load
157
+ config = YatfaAgent::Config.load(allow_remote_fetch: true)
158
158
  YatfaAgent::Config.fetch_repositories!(config)
159
159
  YatfaAgent::Agent.attach(agent_type, config, AGENT_CONFIGS)
160
160
  else
161
161
  # Handle run commands (worker, planner, reviewer, researcher, claw)
162
- config = YatfaAgent::Config.load
162
+ config = YatfaAgent::Config.load(allow_remote_fetch: true)
163
163
  # Fetch repositories from Rails API before building
164
164
  YatfaAgent::Config.fetch_repositories!(config)
165
165
  # Note: Docker.build_image is called inside Agent.run() only when a new
@@ -0,0 +1,389 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "fileutils"
5
+ require_relative "http"
6
+
7
+ module YatfaAgent
8
+ # Single-purpose runner bootstrap (YATFA-3203 / Creation→FULL WORK phase 7b —
9
+ # runner half).
10
+ #
11
+ # Lets a runner in a fresh checkout with no local +yatfa.env.rb+ obtain one
12
+ # by pasting a single bootstrap string (+<host>#<yrt_token>+), generated by
13
+ # the server (phase 7a, YATFA-3194). The token authorizes EXACTLY one read —
14
+ # +GET <host>/api/v1/bootstrap/env_file+ — and is:
15
+ # - NOT written into the served +yatfa.env.rb+,
16
+ # - NOT exported into runtime env,
17
+ # - NOT sent anywhere except the bootstrap endpoint.
18
+ # (spec YATFA-3019 §2.3 single-purpose invariant — see the comment at
19
+ # +fetch_remote_env_file+.)
20
+ #
21
+ # The {host, token} pair is cached under +~/.yatfa/runner_bootstrap.json+
22
+ # (0600) so subsequent fresh checkouts auto-fetch without re-prompting — the
23
+ # credential is single-purpose and read-only, equivalent to +~/.npmrc+. The
24
+ # cache stores the bootstrap CREDENTIAL, never the env-file CONTENT (each
25
+ # checkout fetches fresh so rotated keys / new agents propagate).
26
+ module Bootstrap
27
+ # Raw token shape: +yrt_+ prefix + 64 hex chars (256 bits). Mirrors the
28
+ # server's +ProjectRunnerToken.well_formed?+ regex so a malformed paste is
29
+ # rejected client-side with a friendly error before any network call.
30
+ TOKEN_PATTERN = /\Ayrt_[0-9a-f]{64}\z/.freeze
31
+
32
+ # The single endpoint this credential authorizes a read against.
33
+ ENV_FILE_PATH = "/api/v1/bootstrap/env_file".freeze
34
+
35
+ # Reuses the installer's +~/.yatfa/+ directory convention (installer.rb).
36
+ # Resolved at call time (methods, not load-time constants) so the path
37
+ # tracks the current +HOME+ — keeps the cache location correct under
38
+ # env changes and lets specs isolate it via +ENV["HOME"]+.
39
+ def self.cache_dir
40
+ File.expand_path("~/.yatfa")
41
+ end
42
+
43
+ def self.cache_file
44
+ File.join(cache_dir, "runner_bootstrap.json")
45
+ end
46
+ private_class_method :cache_dir, :cache_file
47
+
48
+ # Parsed paste string: either a valid {host, token} pair or an {error}
49
+ # message. Never echoes the token in +error+. Use +ok?+ to branch.
50
+ PasteString = Struct.new(:host, :token, :error, keyword_init: true) do
51
+ def ok?
52
+ error.nil?
53
+ end
54
+ end
55
+ private_constant :PasteString
56
+
57
+ # Result of +fetch_remote_env_file+: a successful +body+ (the env-file
58
+ # Ruby source) or a classified +error+ message. Use +ok?+ to branch.
59
+ FetchResult = Struct.new(:body, :error, keyword_init: true) do
60
+ def ok?
61
+ error.nil?
62
+ end
63
+ end
64
+ private_constant :FetchResult
65
+
66
+ # Split +<host>#<token>+ on the FIRST +#+ and validate the token shape and
67
+ # host scheme. The token is a URL fragment per the server contract (phase
68
+ # 7a), so everything after the first +#+ is the token regardless of any +#+
69
+ # it may contain (it never does for a +yrt_+ token, but splitting on the
70
+ # first delimiter is the robust choice). Returns a PasteString; never
71
+ # raises. Never echoes the token in the returned error.
72
+ #
73
+ # The host MUST use +https://+ — an +http://+ host would send the bootstrap
74
+ # Bearer token in cleartext over the wire. +http://+ is allowed only for
75
+ # loopback (local dev servers).
76
+ #
77
+ # @param input [String] the pasted bootstrap string
78
+ # @return [PasteString] parsed +host+/+token+ or a friendly +error+
79
+ def self.parse_paste_string(input)
80
+ input = input.to_s.strip
81
+
82
+ unless input.include?("#")
83
+ return malformed
84
+ end
85
+
86
+ host, token = input.split("#", 2)
87
+ host = host.to_s.strip
88
+ token = token.to_s.strip
89
+
90
+ return malformed if host.empty? || !token.match?(TOKEN_PATTERN)
91
+ return insecure_host unless secure_host?(host)
92
+
93
+ PasteString.new(host: host, token: token)
94
+ end
95
+
96
+ # Fetch the env file from the bootstrap endpoint, returning a FetchResult.
97
+ # On 200 the +body+ is the env-file Ruby source (served as +text/x-ruby+);
98
+ # non-2xx, network, and invalid-body responses are classified into friendly
99
+ # +error+ messages. Never echoes the token.
100
+ #
101
+ # Single-purpose invariant (spec §2.3): the token is sent ONLY to
102
+ # +<host>/api/v1/bootstrap/env_file+ as a Bearer credential. It is not
103
+ # written anywhere, not exported into the runtime environment, and not
104
+ # reused for any other API call. The served env file carries the EXISTING
105
+ # per-agent +mcp_api_key+s unchanged — those remain the runtime +X-API-Key+
106
+ # credential. This method is the ONLY site that handles the raw token.
107
+ #
108
+ # The fetched body is validated to be non-empty Ruby that evaluates to a
109
+ # Hash BEFORE it is treated as a success, so a transient empty/HTML/malformed
110
+ # response is reported cleanly instead of crashing +Config.load+'s
111
+ # downstream +eval+ (same trust boundary: the token-authenticated yatfa
112
+ # server, which +Config.load+ evals anyway).
113
+ #
114
+ # @param host [String] yatfa base URL (e.g. +https://app.yatfa.com+)
115
+ # @param token [String] raw +yrt_++64-hex bootstrap token
116
+ # @return [FetchResult] +body+ on success, classified +error+ otherwise
117
+ def self.fetch_remote_env_file(host:, token:)
118
+ url = "#{normalize_host(host)}#{ENV_FILE_PATH}"
119
+ body = YatfaAgent::HTTP.get_raw(url, headers: { "Authorization" => "Bearer #{token}" })
120
+ unless valid_env_file_body?(body)
121
+ return FetchResult.new(error: invalid_body_error)
122
+ end
123
+ FetchResult.new(body: body)
124
+ rescue YatfaAgent::HTTP::Error => e
125
+ FetchResult.new(error: classify_http_error(e.status_code, host))
126
+ rescue StandardError => e
127
+ # Network/DNS/timeout/socket — the server was unreachable. Do not echo
128
+ # the token; surface only the host and the generic failure cause.
129
+ FetchResult.new(error: "❌ Could not reach #{normalize_host(host)}: #{e.message}")
130
+ end
131
+
132
+ # Read the cached {host, token} pair, or nil if no cache exists / is
133
+ # unreadable. Returns a +[host, token]+ Array for ergonomic destructuring.
134
+ # @return [Array(String, String), nil]
135
+ def self.read_cached_credentials
136
+ return nil unless File.exist?(cache_file)
137
+
138
+ parsed =
139
+ begin
140
+ JSON.parse(File.read(cache_file))
141
+ rescue StandardError
142
+ nil
143
+ end
144
+
145
+ return nil unless parsed.is_a?(Hash)
146
+
147
+ host = parsed["host"].to_s
148
+ token = parsed["token"].to_s
149
+ return nil if host.empty? || !token.match?(TOKEN_PATTERN)
150
+
151
+ [host, token]
152
+ end
153
+
154
+ # Persist the {host, token} pair under +~/.yatfa/runner_bootstrap.json+
155
+ # with 0600 perms — created restrictive in ONE call (File.open + chmod
156
+ # would leave a world-readable window under umask 022; this cache holds the
157
+ # raw bootstrap token). Best effort — a cache write failure must not abort
158
+ # an otherwise-successful bootstrap, so it is logged and swallowed.
159
+ # @param host [String]
160
+ # @param token [String]
161
+ # @return [Boolean] true if written, false on failure
162
+ def self.write_cached_credentials(host:, token:)
163
+ FileUtils.mkdir_p(cache_dir)
164
+ File.open(cache_file, "w", 0o600) { |f| f.write(JSON.generate("host" => host, "token" => token)) }
165
+ true
166
+ rescue StandardError => e
167
+ puts "⚠️ Could not cache bootstrap token (#{e.message}); you'll need to paste again next time."
168
+ false
169
+ end
170
+
171
+ # Remove a stale/dead cache (e.g. after a cached fetch returns 401/404 so
172
+ # the next run re-prompts instead of looping on a revoked token). Logs a
173
+ # warning if the delete fails — a stuck cache means every subsequent run
174
+ # repeats a doomed fetch, which is worth surfacing (unlike a read failure,
175
+ # which is benignly treated as "no cache").
176
+ # @return [void]
177
+ def self.clear_cached_credentials
178
+ File.delete(cache_file) if File.exist?(cache_file)
179
+ rescue StandardError => e
180
+ puts "⚠️ Could not remove stale bootstrap cache (#{e.message}); it will be retried next run."
181
+ end
182
+
183
+ # Orchestrator: obtain +yatfa.env.rb+ for a fresh checkout via the
184
+ # bootstrap token path. Writes the file to +Dir.pwd+ on success (so the
185
+ # caller's subsequent +File.read+ finds it) and caches the credential for
186
+ # future checkouts. Exits 1 with guidance on any failure, or when no
187
+ # credential is obtainable non-interactively (CI/scripts).
188
+ #
189
+ # Precedence: a local +yatfa.env.rb+ ALWAYS wins — this method is only
190
+ # reached from +Config.load+ when that file is absent and the caller opted
191
+ # into the remote-fetch path via +allow_remote_fetch:+.
192
+ #
193
+ # @return [true] on success (file written)
194
+ # @raise [SystemExit] (+exit 1+) on failure or non-interactive-no-cache
195
+ def self.bootstrap_env_file!
196
+ # 1. Try a cached credential first (prior paste in another checkout).
197
+ if (creds = read_cached_credentials)
198
+ host, token = creds
199
+ result = fetch_remote_env_file(host: host, token: token)
200
+ if result.ok?
201
+ return true if write_env_file(result.body)
202
+
203
+ exit 1 # write_env_file already printed the cause
204
+ end
205
+
206
+ # Cached credential no longer works (revoked / server rotated keys).
207
+ # Clear it so we don't keep retrying a dead token, then fall through.
208
+ puts result.error
209
+ clear_cached_credentials
210
+ end
211
+
212
+ # 2. Non-interactive + no working cache → cannot prompt a tty that
213
+ # isn't there. Mirror the existing --no-prompt contract: exit, don't
214
+ # hang. Show the manual-setup template too, so a CI box with no token
215
+ # still has the actionable copy-paste fallback it had before this path.
216
+ if non_interactive?
217
+ puts ""
218
+ puts "❌ yatfa.env.rb not found and no bootstrap token cached."
219
+ puts " Run 'npx yatfa setup' interactively once, paste a runner"
220
+ puts " bootstrap token on a tty, OR create yatfa.env.rb manually:"
221
+ puts ""
222
+ puts YatfaAgent::Bootstrap.manual_setup_instructions
223
+ exit 1
224
+ end
225
+
226
+ # 3. Interactive: prompt ONCE for the paste string.
227
+ puts ""
228
+ puts "🔧 No yatfa.env.rb found in this checkout."
229
+ puts " Paste a runner bootstrap string to fetch it"
230
+ puts " (Project Settings → Runner token):"
231
+ print "> "
232
+ input = $stdin.gets&.strip.to_s
233
+
234
+ parsed = parse_paste_string(input)
235
+ unless parsed.ok?
236
+ puts parsed.error
237
+ exit 1
238
+ end
239
+
240
+ result = fetch_remote_env_file(host: parsed.host, token: parsed.token)
241
+ unless result.ok?
242
+ puts result.error
243
+ exit 1
244
+ end
245
+
246
+ exit 1 unless write_env_file(result.body)
247
+ write_cached_credentials(host: parsed.host, token: parsed.token)
248
+ true
249
+ end
250
+
251
+ # Write the fetched env-file content to +Dir.pwd/yatfa.env.rb+ — the same
252
+ # location the setup wizard writes to, so the downstream +Config.load+
253
+ # eval path (config.rb) is unchanged. Created 0600 (the file embeds agent
254
+ # +mcp_api_key+s and +dot_env+ secrets). Returns the path on success, nil
255
+ # (after printing a clean message) if the write itself fails — a rare but
256
+ # fatal condition (read-only cwd, ENOSPC) the caller cannot recover from.
257
+ # @param content [String] env-file Ruby source
258
+ # @return [String, nil] the path written, or nil on write failure
259
+ def self.write_env_file(content)
260
+ config_file = File.join(Dir.pwd, "yatfa.env.rb")
261
+ File.open(config_file, "w", 0o600) { |f| f.write(content) }
262
+ puts "✅ Wrote yatfa.env.rb from bootstrap token (#{config_file})"
263
+ puts " Reminder: ensure it's gitignored → echo 'yatfa.env.rb' >> .gitignore"
264
+ config_file
265
+ rescue StandardError => e
266
+ puts "❌ Could not write #{config_file}: #{e.message}"
267
+ puts " Fix the directory permissions/disk and re-run."
268
+ nil
269
+ end
270
+
271
+ # The manual-setup guidance shown when +yatfa.env.rb+ is missing and the
272
+ # bootstrap path can't produce one (no tty, no cached token — e.g. CI).
273
+ # Returned as a string so both this module's non-interactive branch and
274
+ # +Config.load+'s local-only missing-file branch can +puts+ the SAME
275
+ # actionable template (Config delegates here). Lives in Bootstrap rather
276
+ # than Config because +config.rb+ requires +bootstrap.rb+ (one-way), so the
277
+ # shared helper belongs in the lower layer to avoid a back-reference.
278
+ # @return [String]
279
+ def self.manual_setup_instructions
280
+ <<~MSG
281
+ ❌ Error: yatfa.env.rb not found in current directory
282
+
283
+ Create yatfa.env.rb:
284
+ {
285
+ yatfa_url: 'https://yatfa.example.com', # Required
286
+ # ...
287
+ # Paste your stripped .env content here:
288
+ dot_env: <<~ENV
289
+ STRIPE_KEY=sk_test_...
290
+ OPENAI_KEY=sk-...
291
+ ENV
292
+ }
293
+ echo 'yatfa.env.rb' >> .gitignore
294
+ MSG
295
+ end
296
+
297
+ # Whether prompting is impossible/forbidden. Mirrors Interaction#non_interactive?
298
+ # (interaction.rb) — duplicated here so the Bootstrap module stays
299
+ # self-contained (no load-order/coupling dependency on Agent extending
300
+ # Interaction). Kept in sync with that definition by contract.
301
+ # @return [Boolean]
302
+ def self.non_interactive?
303
+ !$stdin.tty? || ENV["YATFA_NO_PROMPT"] == "1" || ARGV.include?("--no-prompt") || ARGV.include?("-d")
304
+ end
305
+
306
+ # True if +host+ may safely carry the bootstrap token: +https://+ always,
307
+ # or +http://+ only for loopback (local dev servers where TLS is unusual).
308
+ def self.secure_host?(host)
309
+ h = host.to_s.strip.downcase
310
+ return true if h.start_with?("https://")
311
+ return true if h.start_with?("http://localhost") ||
312
+ h.start_with?("http://127.0.0.1") ||
313
+ h.start_with?("http://0.0.0.0") ||
314
+ h.start_with?("http://[::1]")
315
+
316
+ false
317
+ end
318
+ private_class_method :secure_host?
319
+
320
+ # A fetched env file is valid only if it is non-empty Ruby that evaluates
321
+ # to a Hash (the shape +Config.load+ expects). Guards against an empty /
322
+ # HTML / truncated server response being written to disk and then crashing
323
+ # the downstream +eval+.
324
+ def self.valid_env_file_body?(content)
325
+ return false if content.nil? || content.to_s.strip.empty?
326
+
327
+ eval(content).is_a?(Hash)
328
+ rescue StandardError, ScriptError
329
+ # ScriptError covers SyntaxError (malformed Ruby / HTML page); this is a
330
+ # validation predicate and must NEVER raise — any eval failure means the
331
+ # body is not a valid Hash literal, so report it as invalid.
332
+ false
333
+ end
334
+ private_class_method :valid_env_file_body?
335
+
336
+ # Classify a non-2xx status code into friendly, token-free guidance.
337
+ # Status semantics mirror the server contract (bootstrap_controller.rb):
338
+ # 401 = missing/malformed/revoked, 404 = well-formed but unknown.
339
+ def self.classify_http_error(status_code, host)
340
+ case status_code
341
+ when 401
342
+ "❌ Token invalid or revoked — generate a new bootstrap string in Project Settings → Runner token."
343
+ when 404
344
+ "❌ Token not recognized by this server — generate a new bootstrap string."
345
+ else
346
+ "❌ Server at #{normalize_host(host)} returned HTTP #{status_code} — try again or generate a new bootstrap string."
347
+ end
348
+ end
349
+ private_class_method :classify_http_error
350
+
351
+ # Strip trailing slashes from the host so +<host>+/api/v1/... is well-formed
352
+ # regardless of whether the pasted host ended in +/+. NOTE: the bootstrap
353
+ # host comes from the operator's paste string (the server that ISSUED the
354
+ # token), so it is intentionally NOT routed through YatfaAgent::UrlHelper —
355
+ # an explicit +YATFA_API_URL+ override must NOT redirect the token to a
356
+ # different server than the one that minted it (that would both fail auth
357
+ # and leak the credential).
358
+ def self.normalize_host(host)
359
+ host.to_s.sub(%r{/+\z}, "")
360
+ end
361
+ private_class_method :normalize_host
362
+
363
+ # The friendly "doesn't look like a bootstrap string" error. Centralized so
364
+ # the wording is identical for every malformed-input path. Never echoes input.
365
+ def self.malformed
366
+ PasteString.new(
367
+ error: "❌ That doesn't look like a runner bootstrap string — " \
368
+ "generate one from Project Settings → Runner token."
369
+ )
370
+ end
371
+ private_class_method :malformed
372
+
373
+ # The "host must use https" error (cleartext http would leak the token).
374
+ def self.insecure_host
375
+ PasteString.new(
376
+ error: "❌ Bootstrap host must use https:// (an http:// host would " \
377
+ "send the token in cleartext). Re-copy the full bootstrap " \
378
+ "string from Project Settings → Runner token."
379
+ )
380
+ end
381
+ private_class_method :insecure_host
382
+
383
+ def self.invalid_body_error
384
+ "❌ Server returned an invalid or empty env file. " \
385
+ "Try again, or generate a new bootstrap string."
386
+ end
387
+ private_class_method :invalid_body_error
388
+ end
389
+ end
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "json"
4
+ require_relative "../../setup/url_helper"
4
5
 
5
6
  module YatfaAgent
6
7
  # Docker command assembly.
@@ -68,10 +69,12 @@ module YatfaAgent
68
69
  docker_cmd += ["-e", "OPENCLAW_TELEGRAM_BOT_TOKEN=#{agent_config['telegram_bot_token']}"]
69
70
  end
70
71
 
71
- # Pass YATFA_URL if configured (optional for claw)
72
+ # Pass YATFA_URL if configured (optional for claw). The API URL is
73
+ # derived via the shared UrlHelper so an explicit override is honored
74
+ # and the /api/v1 suffix rule stays in one place (YATFA-3018).
72
75
  if config["yatfa_url"]
73
76
  docker_cmd += ["-e", "YATFA_URL=#{config['yatfa_url']}"]
74
- docker_cmd += ["-e", "YATFA_API_URL=#{config['yatfa_url']}/api/v1"]
77
+ docker_cmd += ["-e", "YATFA_API_URL=#{YatfaAgent::UrlHelper.api_url(config)}"]
75
78
  end
76
79
 
77
80
  # Pass MCP API key if configured (for future MCP integration)
@@ -80,14 +83,29 @@ module YatfaAgent
80
83
  end
81
84
 
82
85
  else
83
- # Standard agent setup (worker, planner, reviewer, researcher)
86
+ # Standard agent setup (worker, planner, reviewer, researcher).
87
+ # Inject all three URLs as container-wide -e env (matching the claw
88
+ # branch) so any shell in the container — including `docker exec` shells
89
+ # that are not children of the setup process — sees a consistent
90
+ # YATFA_API_URL. YATFA_URL defaults to https://yatfa.com; API/WS URLs are
91
+ # derived from it via the shared UrlHelper (YATFA-3018). Previously only
92
+ # YATFA_URL + YATFA_API_KEY were injected here, leaving YATFA_API_URL
93
+ # empty outside the setup process and breaking git auth.
94
+ #
95
+ # An operator may override any URL via the env block; those overrides
96
+ # are already injected above as -e flags, so skip them here to avoid
97
+ # clobbering (the last -e flag for a key wins in docker). Derivation
98
+ # uses the effective source so env-block YATFA_URL flows into API/WS.
99
+ url_source = config.merge(merged_env)
84
100
  docker_cmd += [
85
101
  "-e", "YATFA_VERSION=main",
86
102
  "-e", "SKILLS=#{agent_def[:skills]&.join(',')}",
87
103
  "-e", "AGENT_TYPE=#{agent_type}",
88
- "-e", "YATFA_URL=#{YatfaAgent::Config.yatfa_url(config)}",
89
104
  "-e", "YATFA_API_KEY=#{agent_config['mcp_api_key']}"
90
105
  ]
106
+ docker_cmd += ["-e", "YATFA_URL=#{YatfaAgent::UrlHelper.base_url(url_source)}"] unless merged_env["YATFA_URL"]
107
+ docker_cmd += ["-e", "YATFA_API_URL=#{YatfaAgent::UrlHelper.api_url(url_source)}"] unless merged_env["YATFA_API_URL"]
108
+ docker_cmd += ["-e", "YATFA_WS_URL=#{YatfaAgent::UrlHelper.ws_url(url_source)}"] unless merged_env["YATFA_WS_URL"]
91
109
 
92
110
  add_github_auth!(docker_cmd, config)
93
111
  add_git_config!(docker_cmd, config)
@@ -3,6 +3,12 @@
3
3
  require "json"
4
4
  require "digest"
5
5
  require_relative "http"
6
+ require_relative "bootstrap"
7
+ # Single source of truth for URL→{api,ws} derivation + the hosted default.
8
+ # Loaded here (not duplicated) so the host-side launcher routes ALL URL
9
+ # derivation through YatfaAgent::UrlHelper — the same module the in-container
10
+ # setup scripts use. See YATFA-3018.
11
+ require_relative "../../setup/url_helper"
6
12
 
7
13
  module YatfaAgent
8
14
  module Config
@@ -26,24 +32,38 @@ module YatfaAgent
26
32
  nil
27
33
  end
28
34
 
29
- def self.load
35
+ # Load +yatfa.env.rb+ from the current directory and normalize it.
36
+ #
37
+ # When the file is absent and +allow_remote_fetch+ is true (the runner
38
+ # entry point opts in), the bootstrap token path runs first: it fetches the
39
+ # env file from the server via a single-purpose runner token (see
40
+ # YatfaAgent::Bootstrap) and writes it to +Dir.pwd+, then this method
41
+ # loads it as if it had been there all along. A LOCAL +yatfa.env.rb+
42
+ # always wins — the remote-fetch path only runs when one is missing.
43
+ #
44
+ # When +allow_remote_fetch+ is false (the default — tests, CI, library
45
+ # callers), +Config.load+ stays a pure local-file reader: a missing file
46
+ # prints the manual-setup guidance and exits 1, exactly as before.
47
+ #
48
+ # @param allow_remote_fetch [Boolean] enable the bootstrap token path on a
49
+ # missing file (default: false, keeps callers deterministic)
50
+ def self.load(allow_remote_fetch: false)
30
51
  config_file = File.join(Dir.pwd, "yatfa.env.rb")
31
52
 
32
53
  unless File.exist?(config_file)
33
- puts "❌ Error: yatfa.env.rb not found in current directory"
34
- puts ""
35
- puts "Create yatfa.env.rb:"
36
- puts " {"
37
- puts " yatfa_url: 'https://yatfa.example.com', # Required"
38
- puts " # ..."
39
- puts " # Paste your stripped .env content here:"
40
- puts " dot_env: <<~ENV"
41
- puts " STRIPE_KEY=sk_test_..."
42
- puts " OPENAI_KEY=sk-..."
43
- puts " ENV"
44
- puts " }"
45
- puts " echo 'yatfa.env.rb' >> .gitignore"
46
- exit 1
54
+ if allow_remote_fetch
55
+ # Fetches +yatfa.env.rb+ via a single-purpose bootstrap token and
56
+ # writes it to Dir.pwd, or exits 1 with guidance. On success the
57
+ # file now exists and the eval path below loads it unchanged.
58
+ # Local>token>error precedence holds: we only reach here when no
59
+ # local file is present.
60
+ YatfaAgent::Bootstrap.bootstrap_env_file!
61
+ else
62
+ # Shared with the bootstrap non-interactive path so both surfaces
63
+ # print the same actionable template.
64
+ puts YatfaAgent::Bootstrap.manual_setup_instructions
65
+ exit 1
66
+ end
47
67
  end
48
68
 
49
69
  puts "⚙️ Loading configuration from yatfa.env.rb"
@@ -88,27 +108,6 @@ module YatfaAgent
88
108
  JSON.parse(JSON.generate(config))
89
109
  end
90
110
 
91
- def self.yatfa_url(config)
92
- config["yatfa_url"] || config["rails_api_url"]
93
- end
94
-
95
- def self.api_url(config)
96
- # Derive API URL from yatfa_url
97
- yatfa_base = yatfa_url(config)
98
-
99
- # If yatfa_url already includes /api/v1, return as-is
100
- return yatfa_base if yatfa_base.end_with?("/api/v1") || yatfa_base.end_with?("/api/")
101
-
102
- # Otherwise, assume it's the base URL and append /api/v1
103
- "#{yatfa_base}/api/v1"
104
- end
105
-
106
- def self.ws_url(config)
107
- # Derive WebSocket URL from yatfa_url by replacing protocol and adding /cable endpoint
108
- api_base = yatfa_url(config)
109
- "#{api_base.sub(/^https:\/\//, "wss://").sub(/^http:\/\//, "ws://")}/cable"
110
- end
111
-
112
111
  # Extract the first available agent MCP API key from the config.
113
112
  # Returns nil when no key is configured.
114
113
  def self.agent_api_key(config)
@@ -123,8 +122,11 @@ module YatfaAgent
123
122
  end
124
123
 
125
124
  def self.fetch_repositories!(config)
126
- # Support both old and new config keys
127
- api_base = api_url(config)
125
+ # Derive the API URL through the shared single source of truth
126
+ # (YatfaAgent::UrlHelper) so this host-side path honors an explicit
127
+ # YATFA_API_URL override and the hosted default exactly like the
128
+ # launcher and in-container setup scripts. See YATFA-3018.
129
+ api_base = YatfaAgent::UrlHelper.api_url(config)
128
130
 
129
131
  return unless api_base
130
132
 
@@ -49,7 +49,12 @@ module YatfaAgent
49
49
  # be identified, the API is unreachable, or no such config is stored.
50
50
  # Never raises — a missing remote config is a normal "fall back to error" case.
51
51
  def self.fetch_remote_dockerfile(config)
52
- api_base = Config.api_url(config)
52
+ # Derive the API URL through the shared single source of truth
53
+ # (YatfaAgent::UrlHelper) rather than the legacy Config.api_url, which
54
+ # neither honored an explicit YATFA_API_URL override nor applied the
55
+ # hosted default and could raise NoMethodError on an unconfigured URL.
56
+ # YatfaAgent::UrlHelper is loaded transitively via config.rb. See YATFA-3018.
57
+ api_base = YatfaAgent::UrlHelper.api_url(config)
53
58
  project_id = config["project_id"]
54
59
  api_key = Config.agent_api_key(config)
55
60
 
@@ -43,6 +43,30 @@ module YatfaAgent
43
43
  handle_response(response)
44
44
  end
45
45
 
46
+ # Perform a GET request returning the RAW response body as a String on
47
+ # success (NO JSON parsing). Use this for endpoints that serve non-JSON
48
+ # bodies — e.g. the bootstrap env-file endpoint serves +text/x-ruby+
49
+ # (Ruby source), which would raise JSON::ParserError under +.get+.
50
+ #
51
+ # Raises YatfaAgent::HTTP::Error (with +status_code+) for non-2xx exactly
52
+ # like +.get+, so callers can reuse the +rescue HTTP::Error => e;
53
+ # case e.status_code+ classification pattern unchanged.
54
+ #
55
+ # @param url [String] full URL to request
56
+ # @param headers [Hash] HTTP headers (e.g. { "Authorization" => "Bearer x" })
57
+ # @param open_timeout [Integer] connection timeout in seconds (default: 5)
58
+ # @param read_timeout [Integer] response timeout in seconds (default: 10)
59
+ # @return [String] raw response body
60
+ # @raise [Error] for non-2xx HTTP responses
61
+ def self.get_raw(url, headers: {}, open_timeout: DEFAULT_OPEN_TIMEOUT, read_timeout: DEFAULT_READ_TIMEOUT)
62
+ uri = URI(url)
63
+ http = build_http(uri, open_timeout: open_timeout, read_timeout: read_timeout)
64
+ request = Net::HTTP::Get.new(uri)
65
+ headers.each { |k, v| request[k] = v }
66
+ response = http.request(request)
67
+ handle_response(response, raw: true)
68
+ end
69
+
46
70
  # Perform a POST request. Returns parsed JSON body on success.
47
71
  # Raises YatfaAgent::HTTP::Error for non-2xx responses.
48
72
  # Raises exceptions on network/timeout errors.
@@ -81,9 +105,14 @@ module YatfaAgent
81
105
  JSON.parse(body)
82
106
  end
83
107
 
84
- def handle_response(response)
108
+ # Parse a 2xx success body. By default the body is parsed as JSON (the
109
+ # contract every existing caller relies on); pass +raw: true+ to return
110
+ # the body verbatim — for non-JSON endpoints (e.g. the bootstrap env-file
111
+ # endpoint serves +text/x-ruby+). The non-2xx error path is shared so the
112
+ # +Error+ shape (with +status_code+) is identical either way.
113
+ def handle_response(response, raw: false)
85
114
  if response.is_a?(Net::HTTPSuccess)
86
- parse_json(response.body)
115
+ raw ? response.body : parse_json(response.body)
87
116
  else
88
117
  raise Error.new(
89
118
  "HTTP #{response.code} #{response.message}",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "yatfa",
3
- "version": "1.0.97",
3
+ "version": "1.0.99",
4
4
  "description": "YATFA - Yet Another Tool For Agents",
5
5
  "bin": "./bin/run-yatfa-agent.rb",
6
6
  "files": [
@@ -36,6 +36,41 @@ require "uri"
36
36
  require "time"
37
37
  require "fileutils"
38
38
 
39
+ # Self-healing URL derivation (YATFA-3018). This helper runs in isolation as
40
+ # /usr/local/bin/git-auth-helper (copied there by setup-github.rb alongside
41
+ # url_helper.rb), so it derives YATFA_API_URL from YATFA_URL itself — defaulting
42
+ # to https://yatfa.com — rather than bailing when YATFA_API_URL is empty (the
43
+ # exact failure operators hit in `docker exec` shells outside the setup
44
+ # process). Load the shared module: in-tree via require_relative (tests / setup/),
45
+ # or from the absolute install path when running as the copied binary. A
46
+ # last-resort inline mirror keeps auth working even if the sibling file is
47
+ # missing (version skew / half install) — a credential helper must never break
48
+ # on a missing file. Keep the mirror in sync with setup/url_helper.rb.
49
+ begin
50
+ require_relative "url_helper"
51
+ rescue LoadError
52
+ begin
53
+ require "/usr/local/bin/url_helper"
54
+ rescue LoadError
55
+ module YatfaAgent
56
+ module UrlHelper
57
+ DEFAULT_YATFA_URL = "https://yatfa.com"
58
+
59
+ module_function
60
+
61
+ def api_url(source = ENV)
62
+ explicit = source["YATFA_API_URL"].to_s.strip
63
+ return explicit unless explicit.empty?
64
+
65
+ base = source["YATFA_URL"].to_s.strip
66
+ base = DEFAULT_YATFA_URL if base.empty?
67
+ base.end_with?("/api/v1") || base.end_with?("/api/") ? base : "#{base}/api/v1"
68
+ end
69
+ end
70
+ end
71
+ end
72
+ end
73
+
39
74
  module YatfaAgent
40
75
  module GitCredentialHelper
41
76
  DEFAULT_CACHE_DIR = "/tmp/github-token-cache"
@@ -116,9 +151,11 @@ module YatfaAgent
116
151
  # using the per-installation cache and falling back to the default token
117
152
  # when a repo-scoped fetch fails (older backend, unregistered repo, etc.).
118
153
  def resolve_token(repo, cache_dir:, env:, http_fetch:)
119
- api_url = env["YATFA_API_URL"]
154
+ # Self-heal: derive YATFA_API_URL from YATFA_URL (default https://yatfa.com)
155
+ # when not explicitly set, so a bare `docker exec` shell with no manual
156
+ # export can still auth. An explicit YATFA_API_URL override is honored.
157
+ api_url = YatfaAgent::UrlHelper.api_url(env)
120
158
  api_key = env["YATFA_API_KEY"]
121
- return nil if api_url.nil? || api_url.to_s.empty?
122
159
  return nil if api_key.nil? || api_key.to_s.empty?
123
160
 
124
161
  cached = read_cached_token(repo, cache_dir)
@@ -134,6 +171,19 @@ module YatfaAgent
134
171
  data["token"]
135
172
  end
136
173
 
174
+ # When required config is missing, return a specific diagnostic naming the
175
+ # offending variable; otherwise nil. The API URL is self-healed (derived
176
+ # from YATFA_URL, default https://yatfa.com), so the only hard requirement
177
+ # is the YATFA_API_KEY secret — previously this surfaced as the opaque
178
+ # "failed to obtain GitHub token" (YATFA-3018).
179
+ def missing_config_message(env_hash, repo: nil)
180
+ api_key = env_hash["YATFA_API_KEY"]
181
+ return nil unless api_key.nil? || api_key.to_s.empty?
182
+
183
+ repo_suffix = repo ? " for #{repo}" : ""
184
+ "git-auth-helper: YATFA_API_KEY is not set — cannot mint a GitHub token#{repo_suffix}"
185
+ end
186
+
137
187
  # Dispatch entry point. Returns a process exit code (0 success, 1 failure).
138
188
  def run(argv, stdin: $stdin, stdout: $stdout, stderr: $stderr,
139
189
  cache_dir: DEFAULT_CACHE_DIR, env: nil, http_fetch: nil)
@@ -145,6 +195,10 @@ module YatfaAgent
145
195
  if action == "get"
146
196
  attrs = parse_input(read_stdin(stdin))
147
197
  repo = normalize_path(attrs["path"])
198
+ if (msg = missing_config_message(env_hash, repo: repo))
199
+ stderr.puts(msg)
200
+ return 1
201
+ end
148
202
  token = resolve_token(repo, cache_dir: cache_dir, env: env_hash, http_fetch: fetch)
149
203
  if token.nil?
150
204
  stderr.puts("git-auth-helper: failed to obtain GitHub token" + (repo ? " for #{repo}" : ""))
@@ -160,6 +214,10 @@ module YatfaAgent
160
214
  end
161
215
 
162
216
  # Token-only mode (e.g. the `gh` wrapper): print just the default token.
217
+ if (msg = missing_config_message(env_hash))
218
+ stderr.puts(msg)
219
+ return 1
220
+ end
163
221
  token = resolve_token(nil, cache_dir: cache_dir, env: env_hash, http_fetch: fetch)
164
222
  if token.nil?
165
223
  stderr.puts("git-auth-helper: failed to obtain GitHub token")
@@ -43,6 +43,30 @@ module YatfaAgent
43
43
  handle_response(response)
44
44
  end
45
45
 
46
+ # Perform a GET request returning the RAW response body as a String on
47
+ # success (NO JSON parsing). Use this for endpoints that serve non-JSON
48
+ # bodies — e.g. the bootstrap env-file endpoint serves +text/x-ruby+
49
+ # (Ruby source), which would raise JSON::ParserError under +.get+.
50
+ #
51
+ # Raises YatfaAgent::HTTP::Error (with +status_code+) for non-2xx exactly
52
+ # like +.get+, so callers can reuse the +rescue HTTP::Error => e;
53
+ # case e.status_code+ classification pattern unchanged.
54
+ #
55
+ # @param url [String] full URL to request
56
+ # @param headers [Hash] HTTP headers (e.g. { "Authorization" => "Bearer x" })
57
+ # @param open_timeout [Integer] connection timeout in seconds (default: 5)
58
+ # @param read_timeout [Integer] response timeout in seconds (default: 10)
59
+ # @return [String] raw response body
60
+ # @raise [Error] for non-2xx HTTP responses
61
+ def self.get_raw(url, headers: {}, open_timeout: DEFAULT_OPEN_TIMEOUT, read_timeout: DEFAULT_READ_TIMEOUT)
62
+ uri = URI(url)
63
+ http = build_http(uri, open_timeout: open_timeout, read_timeout: read_timeout)
64
+ request = Net::HTTP::Get.new(uri)
65
+ headers.each { |k, v| request[k] = v }
66
+ response = http.request(request)
67
+ handle_response(response, raw: true)
68
+ end
69
+
46
70
  # Perform a POST request. Returns parsed JSON body on success.
47
71
  # Raises YatfaAgent::HTTP::Error for non-2xx responses.
48
72
  # Raises exceptions on network/timeout errors.
@@ -81,9 +105,14 @@ module YatfaAgent
81
105
  JSON.parse(body)
82
106
  end
83
107
 
84
- def handle_response(response)
108
+ # Parse a 2xx success body. By default the body is parsed as JSON (the
109
+ # contract every existing caller relies on); pass +raw: true+ to return
110
+ # the body verbatim — for non-JSON endpoints (e.g. the bootstrap env-file
111
+ # endpoint serves +text/x-ruby+). The non-2xx error path is shared so the
112
+ # +Error+ shape (with +status_code+) is identical either way.
113
+ def handle_response(response, raw: false)
85
114
  if response.is_a?(Net::HTTPSuccess)
86
- parse_json(response.body)
115
+ raw ? response.body : parse_json(response.body)
87
116
  else
88
117
  raise Error.new(
89
118
  "HTTP #{response.code} #{response.message}",
@@ -36,6 +36,7 @@ require "time"
36
36
  require "socket"
37
37
  require "timeout"
38
38
  require_relative "http_client"
39
+ require_relative "url_helper"
39
40
  require_relative "setup-github"
40
41
  require_relative "repositories"
41
42
  require_relative "agent_bridge"
@@ -52,47 +53,32 @@ YATFA_RAW_URL = "#{YATFA_SCRIPTS_BASE_URL}/#{YATFA_VERSION}"
52
53
  VALID_AGENT_TYPES = %w[planner worker reviewer researcher claw]
53
54
 
54
55
  def check_env!
55
- agent_type = ENV["AGENT_TYPE"]
56
-
57
56
  # Note: claw agents have a separate entry point in setup-claw-agent.rb
58
57
  # which calls check_env_claw! directly. This method only handles standard agent types.
59
58
 
60
- required = %w[YATFA_URL]
61
- missing = required.select { |var| ENV[var].to_s.empty? }
62
- unless missing.empty?
63
- puts "❌ Missing environment variables: #{missing.join(', ')}"
64
- puts ""
65
- puts "Required:"
66
- puts " YATFA_URL - Yatfa platform URL (https://yatfa.example.com)"
59
+ # YATFA_API_KEY is the single required secret — the agent cannot authenticate
60
+ # to the backend or mint GitHub tokens without it. Fail fast with a specific
61
+ # message rather than starting a half-working agent (YATFA-3018).
62
+ if ENV["YATFA_API_KEY"].to_s.empty?
63
+ puts "❌ Missing required environment variable: YATFA_API_KEY"
64
+ puts " YATFA_API_KEY is the MCP API key for this agent (from the Yatfa dashboard)."
65
+ puts " The agent needs it to authenticate to the backend and to mint GitHub tokens."
67
66
  puts ""
68
- puts "Optional:"
69
- puts " YATFA_API_URL - API URL for MCP tools (derived from YATFA_URL)"
70
- puts " YATFA_API_KEY - MCP API key"
71
- puts " GH_TOKEN - GitHub token"
67
+ puts "YATFA_URL is optional (defaults to https://yatfa.com);"
68
+ puts " YATFA_API_URL / YATFA_WS_URL are derived from it."
72
69
  exit 1
73
70
  end
74
71
 
75
- # Derive YATFA_API_URL and YATFA_WS_URL from YATFA_URL if not set
76
- # IMPORTANT: Always use original YATFA_URL for derivations, not modified values
77
- yatfa_url = ENV["YATFA_URL"]
78
-
79
- # Derive YATFA_API_URL: append /api/v1 if not already present
80
- unless ENV["YATFA_API_URL"]
81
- if yatfa_url.end_with?("/api/v1") || yatfa_url.end_with?("/api/")
82
- ENV["YATFA_API_URL"] = yatfa_url
83
- else
84
- ENV["YATFA_API_URL"] = "#{yatfa_url}/api/v1"
85
- end
86
- puts "📡 Derived YATFA_API_URL: #{ENV['YATFA_API_URL']}"
87
- end
88
-
89
- # Derive YATFA_WS_URL: replace protocol with ws/wss and add /cable endpoint
90
- # IMPORTANT: Use original yatfa_url before any modifications
91
- original_yatfa_url = ENV["YATFA_URL"]
92
- unless ENV["YATFA_WS_URL"]
93
- ENV["YATFA_WS_URL"] = "#{original_yatfa_url.sub(/^https:\/\//, 'wss://').sub(/^http:\/\//, 'ws://')}/cable"
94
- puts "📡 Derived YATFA_WS_URL: #{ENV['YATFA_WS_URL']}"
95
- end
72
+ # Derive the three URLs via the shared UrlHelper (YATFA-3018). YATFA_URL
73
+ # defaults to https://yatfa.com when unset; YATFA_API_URL (/api/v1) and
74
+ # YATFA_WS_URL (scheme-swapped /cable) are derived from it. Explicit
75
+ # overrides for any of the three are honored (backwards compatible).
76
+ ENV["YATFA_URL"] = YatfaAgent::UrlHelper.base_url
77
+ ENV["YATFA_API_URL"] = YatfaAgent::UrlHelper.api_url
78
+ ENV["YATFA_WS_URL"] = YatfaAgent::UrlHelper.ws_url
79
+ puts "📡 YATFA_URL: #{ENV['YATFA_URL']}"
80
+ puts "📡 YATFA_API_URL: #{ENV['YATFA_API_URL']}"
81
+ puts "📡 YATFA_WS_URL: #{ENV['YATFA_WS_URL']}"
96
82
 
97
83
  agent_type = ENV["AGENT_TYPE"]
98
84
  if agent_type && !VALID_AGENT_TYPES.include?(agent_type)
@@ -14,6 +14,9 @@
14
14
  # - fetch_llm_credentials! is defined by the parent setup-agent.rb
15
15
  # - Standard library requires (json, fileutils, pathname, etc.) are loaded
16
16
  # by setup-agent.rb before this file is required
17
+ # - YatfaAgent::UrlHelper is provided by setup/url_helper.rb
18
+
19
+ require_relative "url_helper" unless defined?(YatfaAgent::UrlHelper)
17
20
 
18
21
  def check_env_claw!
19
22
  required = %w[OPENCLAW_TELEGRAM_BOT_TOKEN]
@@ -37,9 +40,11 @@ def check_env_claw!
37
40
  exit 1
38
41
  end
39
42
 
40
- # Derive YATFA_API_URL from YATFA_URL if set (optional for claw)
43
+ # Derive YATFA_API_URL from YATFA_URL via the shared helper (optional for
44
+ # claw — yatfa integration is opt-in, so only when YATFA_URL is set). The
45
+ # /api/v1 suffix rule lives in one place (YATFA-3018).
41
46
  if ENV["YATFA_URL"] && !ENV["YATFA_API_URL"]
42
- ENV["YATFA_API_URL"] = "#{ENV['YATFA_URL']}/api/v1"
47
+ ENV["YATFA_API_URL"] = YatfaAgent::UrlHelper.with_api_suffix(ENV["YATFA_URL"])
43
48
  end
44
49
 
45
50
  puts "✅ Claw environment validated"
@@ -4,26 +4,14 @@
4
4
  # Extracted from setup-agent.rb for modularity
5
5
 
6
6
  def setup_github_auth!
7
- rails_api_url = ENV["YATFA_API_URL"]
8
7
  rails_api_key = ENV["YATFA_API_KEY"]
9
8
 
10
- # Derive YATFA_API_URL from YATFA_URL if not set
11
- unless rails_api_url && !rails_api_url.empty?
12
- yatfa_url = ENV["YATFA_URL"]
13
- if yatfa_url && !yatfa_url.empty?
14
- rails_api_url = "#{yatfa_url}/api/v1"
15
- ENV["YATFA_API_URL"] = rails_api_url
16
- end
17
- end
18
-
19
- # Validate YATFA_API_URL format before proceeding
20
- if rails_api_url && !rails_api_url.empty? && !rails_api_url.include?('/api/v1')
21
- puts "⚠️ Warning: YATFA_API_URL must include /api/v1 path"
22
- puts " Current: #{rails_api_url}"
23
- puts " Expected: https://example.com/api/v1"
24
- puts " Falling back to alternative authentication..."
25
- rails_api_url = nil # Clear it to fall through to other methods
26
- end
9
+ # Derive YATFA_API_URL via the shared UrlHelper (YATFA-3018): honors an
10
+ # explicit override, else derives /api/v1 from YATFA_URL (default
11
+ # https://yatfa.com). check_env! already set this for standard agents, but
12
+ # re-derive defensively so a re-run / partial flow is consistent.
13
+ ENV["YATFA_API_URL"] = YatfaAgent::UrlHelper.api_url
14
+ rails_api_url = ENV["YATFA_API_URL"]
27
15
 
28
16
  # New method: Get token from Rails backend (preferred)
29
17
  if rails_api_url && !rails_api_url.empty? && rails_api_key && !rails_api_key.empty?
@@ -38,6 +26,16 @@ def setup_github_auth!
38
26
  system("sudo", "mv", "/tmp/git-auth-helper", helper_path)
39
27
  system("sudo", "chmod", "+x", helper_path)
40
28
 
29
+ # Install the shared URL helper alongside the credential helper. The helper
30
+ # runs in isolation as /usr/local/bin/git-auth-helper and loads it via
31
+ # `require_relative "url_helper"` to self-heal YATFA_API_URL from YATFA_URL
32
+ # (default https://yatfa.com) in bare `docker exec` shells (YATFA-3018).
33
+ url_helper_src = File.join(__dir__, "url_helper.rb")
34
+ if File.exist?(url_helper_src)
35
+ File.write("/tmp/url_helper", File.read(url_helper_src))
36
+ system("sudo", "mv", "/tmp/url_helper", "/usr/local/bin/url_helper.rb")
37
+ end
38
+
41
39
  # Wire the helper into git. useHttpPath=true makes git send the repo path on
42
40
  # stdin so the helper can request a repo-scoped token (?repo=owner/repo).
43
41
  # Array form avoids shell interpolation of the helper path.
@@ -0,0 +1,86 @@
1
+ # frozen_string_literal: true
2
+
3
+ # YATFA URL derivation — the single source of truth.
4
+ #
5
+ # `YATFA_URL` is the only URL variable an operator ever configures. The API URL
6
+ # (`<base>/api/v1`) and the WebSocket URL (`<scheme-adjusted base>/cable`) are
7
+ # ALWAYS derived from it. Both the host-side launcher (lib/yatfa_agent/) and the
8
+ # in-container setup scripts (setup/*) load this module so the derivation lives
9
+ # in exactly one place.
10
+ #
11
+ # Rules (see YATFA-3018):
12
+ # 1. `YATFA_URL` defaults to `https://yatfa.com` when unset/blank, so the
13
+ # hosted deployment works with zero URL configuration.
14
+ # 2. `YATFA_API_URL` / `YATFA_WS_URL`, when explicitly set, are honored as
15
+ # overrides (backwards compatible) — never re-derived.
16
+ # 3. The API URL appends `/api/v1` unless the base already carries an `/api`
17
+ # path; the WS URL swaps the scheme (http→ws, https→wss) and appends
18
+ # `/cable`.
19
+ #
20
+ # Every method takes an optional +source+ — any hash-like object (ENV, or a
21
+ # string-keyed config hash) — so the same logic serves the launcher (config
22
+ # hash) and the container scripts (ENV).
23
+
24
+ module YatfaAgent
25
+ module UrlHelper
26
+ DEFAULT_YATFA_URL = "https://yatfa.com"
27
+ API_SUFFIX = "/api/v1"
28
+ WS_SUFFIX = "/cable"
29
+
30
+ module_function
31
+
32
+ # Resolve the base platform URL, applying the hosted default when unset.
33
+ # Reads +YATFA_URL+ (ENV convention) then +yatfa_url+ / +rails_api_url+
34
+ # (config-hash convention).
35
+ def base_url(source = ENV)
36
+ value = source_value(source, "YATFA_URL") ||
37
+ source_value(source, "yatfa_url") ||
38
+ source_value(source, "rails_api_url")
39
+ value = value.to_s.strip
40
+ value.empty? ? DEFAULT_YATFA_URL : value
41
+ end
42
+
43
+ # Derive the API URL. An explicit +YATFA_API_URL+ override wins; otherwise
44
+ # the suffix is appended to the resolved base URL.
45
+ def api_url(source = ENV)
46
+ explicit = source_value(source, "YATFA_API_URL").to_s.strip
47
+ return explicit unless explicit.empty?
48
+
49
+ with_api_suffix(base_url(source))
50
+ end
51
+
52
+ # Derive the WebSocket URL. An explicit +YATFA_WS_URL+ override wins;
53
+ # otherwise the base scheme is swapped (http→ws, https→wss) and +/cable+
54
+ # is appended.
55
+ def ws_url(source = ENV)
56
+ explicit = source_value(source, "YATFA_WS_URL").to_s.strip
57
+ return explicit unless explicit.empty?
58
+
59
+ "#{to_ws_scheme(base_url(source))}#{WS_SUFFIX}"
60
+ end
61
+
62
+ # Append +/api/v1+ to +base+ unless it already ends with +/api/v1+ or
63
+ # +/api/+. Shared with YatfaAgent::Config so the suffix rule is defined once.
64
+ def with_api_suffix(base)
65
+ base = base.to_s
66
+ return base if base.end_with?("/api/v1") || base.end_with?("/api/")
67
+
68
+ "#{base}#{API_SUFFIX}"
69
+ end
70
+
71
+ # Swap the scheme of +base+ to its WebSocket equivalent. Already-ws schemes
72
+ # are left untouched.
73
+ def to_ws_scheme(base)
74
+ base.to_s.sub(/\Ahttps:\/\//, "wss://").sub(/\Ahttp:\/\//, "ws://")
75
+ end
76
+
77
+ # Safely read +key+ from a hash-like +source+ (ENV or a config Hash).
78
+ def source_value(source, key)
79
+ return nil unless source.respond_to?(:[])
80
+
81
+ source[key]
82
+ rescue StandardError
83
+ nil
84
+ end
85
+ end
86
+ end