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 +4 -4
- package/bin/run-yatfa-agent.rb +2 -2
- package/lib/yatfa_agent/bootstrap.rb +389 -0
- package/lib/yatfa_agent/command_builder.rb +22 -4
- package/lib/yatfa_agent/config.rb +40 -38
- package/lib/yatfa_agent/docker.rb +6 -1
- package/lib/yatfa_agent/http.rb +31 -2
- package/package.json +1 -1
- package/setup/git_credential_helper.rb +60 -2
- package/setup/http_client.rb +31 -2
- package/setup/setup-agent.rb +20 -34
- package/setup/setup-claw-agent.rb +7 -2
- package/setup/setup-github.rb +16 -18
- package/setup/url_helper.rb +86 -0
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
|
-
| `
|
|
83
|
-
| `
|
|
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 `
|
|
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
|
|
package/bin/run-yatfa-agent.rb
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
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
|
-
#
|
|
127
|
-
|
|
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
|
-
|
|
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
|
|
package/lib/yatfa_agent/http.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
@@ -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
|
-
|
|
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")
|
package/setup/http_client.rb
CHANGED
|
@@ -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
|
-
|
|
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/setup/setup-agent.rb
CHANGED
|
@@ -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
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
puts ""
|
|
65
|
-
puts "
|
|
66
|
-
puts "
|
|
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 "
|
|
69
|
-
puts " YATFA_API_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
|
|
76
|
-
#
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
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
|
|
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"] =
|
|
47
|
+
ENV["YATFA_API_URL"] = YatfaAgent::UrlHelper.with_api_suffix(ENV["YATFA_URL"])
|
|
43
48
|
end
|
|
44
49
|
|
|
45
50
|
puts "✅ Claw environment validated"
|
package/setup/setup-github.rb
CHANGED
|
@@ -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
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
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
|