@specsage/cli 0.1.0

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/lib/runner.rb ADDED
@@ -0,0 +1,613 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require 'json'
5
+ require 'timeout'
6
+ require 'fileutils'
7
+
8
+ # Determine SpecSage home directory for locating resources
9
+ SPECSAGE_HOME ||= File.expand_path('..', __dir__)
10
+
11
+ require_relative 'step_client'
12
+
13
+ class Runner
14
+ NODE_IO_TIMEOUT_SECONDS = 30
15
+ NODE_SHUTDOWN_TIMEOUT_SECONDS = 45
16
+
17
+ BROWSER_ACTIONS = %w[navigate click select keypress wait scroll accept_dialog dismiss_dialog].freeze
18
+
19
+ # Pattern for safe path segment: alphanumeric, underscore, hyphen only
20
+ # Prevents directory traversal, special chars, and filesystem issues
21
+ SAFE_PATH_SEGMENT = /\A[a-zA-Z0-9_-]+\z/
22
+
23
+ # Initialize runner with scenario data from server
24
+ def initialize(scenario_data, visible: false, record: false, publisher: nil, server_run_id: nil)
25
+ @scenario = normalize_scenario_data(scenario_data)
26
+ @scenario_id = @scenario['id']
27
+ @scenario_name = @scenario['name'] || @scenario['id'] || 'unnamed'
28
+ @visible = visible
29
+ @record = record
30
+ @video_data = nil # Binary video data
31
+ @node_stdin = nil
32
+ @node_stdout = nil
33
+ @node_wait_thread = nil
34
+ @next_request_id = 1
35
+ @node_channel_poisoned = false
36
+ @publisher = publisher
37
+ @step_client = nil
38
+ @server_run_id = server_run_id
39
+ @credentials = {} # Credentials received from server { "NAME" => "value" }
40
+ @max_steps = nil # Max browser actions allowed, received from server on first step
41
+ @temp_dir = nil # Unique temp directory for this runner's video recording
42
+ end
43
+
44
+ def run
45
+ log "Starting scenario run"
46
+
47
+ raise ArgumentError, 'server_run_id is required' unless @server_run_id
48
+
49
+ @step_client = StepClient.new(
50
+ base_url: @publisher.base_url,
51
+ server_run_id: @server_run_id,
52
+ api_key: @publisher.api_key
53
+ )
54
+
55
+ start_node_process
56
+ initial_state = navigate_to_base_url
57
+
58
+ current_screenshot_base64 = initial_state[:screenshot_base64]
59
+ interactive_elements = initial_state[:elements]
60
+ interactive_elements_by_id = build_elements_by_id(interactive_elements)
61
+ previous_action = nil
62
+ action_result = nil
63
+
64
+ loop do
65
+ # Get next action from server
66
+ step_result = @step_client.submit_step(
67
+ scenario_id: @scenario_id,
68
+ screenshot_base64: current_screenshot_base64,
69
+ elements: interactive_elements,
70
+ previous_action: previous_action,
71
+ action_result: action_result
72
+ )
73
+
74
+ # Store credentials and max_steps from server on first step only
75
+ # Server sends these once at scenario start, runner caches them for the duration
76
+ if step_result[:credentials] && !step_result[:credentials].empty?
77
+ @credentials = step_result[:credentials]
78
+ end
79
+ @max_steps ||= step_result[:max_steps]
80
+
81
+ action = step_result[:action]
82
+
83
+ # Enrich action with element info for logging and storage
84
+ if action['element_id'] && interactive_elements_by_id
85
+ element = interactive_elements_by_id[action['element_id']]
86
+ if element
87
+ action['element_name'] = element_display_name(element)
88
+ action['type'] = element['type']
89
+ action['role'] = element['role']
90
+ action['visible_text'] = element['visible_text']
91
+ end
92
+ end
93
+
94
+ # Log action with element details
95
+ safe_puts " [Step #{step_result[:step_number]}] #{action['action']}: #{action.reject { |k, _| k == 'action' }.to_json}"
96
+
97
+ break if action['action'] == 'verdict'
98
+
99
+ # Skip browser execution for meta actions
100
+ unless BROWSER_ACTIONS.include?(action['action'])
101
+ previous_action = action
102
+ action_result = "Meta action: #{action['action']}"
103
+ next
104
+ end
105
+
106
+ result = execute_action(action)
107
+
108
+ # Update state from action result
109
+ # Note: screenshot may be nil if a dialog is blocking (can't screenshot while dialog is up)
110
+ # but we still need to update elements (which will contain the DIALOG pseudo-element)
111
+ if result[:screenshot_base64]
112
+ current_screenshot_base64 = result[:screenshot_base64]
113
+ end
114
+ if result[:elements]
115
+ interactive_elements = result[:elements]
116
+ interactive_elements_by_id = build_elements_by_id(interactive_elements)
117
+ end
118
+
119
+ previous_action = action
120
+ action_result = result[:result]
121
+
122
+ # Check if step count has reached max_steps limit
123
+ # This check happens AFTER executing the action, so max_steps=4 means 4 actions execute
124
+ step_number = step_result[:step_number] || 0
125
+ if @max_steps && step_number >= @max_steps
126
+ log "Step limit exceeded (#{@max_steps})."
127
+ send_client_verdict_if_needed('ERROR', "Step limit exceeded (#{@max_steps}).")
128
+ break
129
+ end
130
+
131
+ # Check if server says we should stop
132
+ break unless step_result[:continue]
133
+ end
134
+
135
+ stop_node_process
136
+ upload_video
137
+ cleanup_temp_dir
138
+ rescue StepClient::StepError => e
139
+ send_client_verdict_if_needed('ERROR', "Server error: #{e.message}")
140
+ stop_node_process
141
+ upload_video
142
+ cleanup_temp_dir
143
+ rescue StandardError => e
144
+ send_client_verdict_if_needed('ERROR', e.message)
145
+ stop_node_process
146
+ upload_video
147
+ cleanup_temp_dir
148
+ end
149
+
150
+ private
151
+
152
+ # Normalize scenario data from API format (string keys) to internal format
153
+ def normalize_scenario_data(data)
154
+ # API returns string keys, ensure consistent access
155
+ {
156
+ 'id' => data['id'] || data[:id],
157
+ 'name' => data['name'] || data[:name],
158
+ 'base_url' => data['base_url'] || data[:base_url]
159
+ }
160
+ end
161
+
162
+ def start_node_process
163
+ node_script = File.join(SPECSAGE_HOME, 'lib', 'browser.js')
164
+ raise "browser.js not found at #{node_script}" unless File.exist?(node_script)
165
+
166
+ # Create unique temp directory for this runner's video recording
167
+ if @record
168
+ @temp_dir = build_safe_temp_dir
169
+ FileUtils.mkdir_p(@temp_dir)
170
+ end
171
+
172
+ args = ['node', node_script]
173
+ args << '--visible' if @visible
174
+ args << '--record' if @record
175
+ args.push('--temp-dir', @temp_dir) if @temp_dir
176
+ @node_stdin, @node_stdout, @node_stderr, @node_wait_thread = Open3.popen3(*args)
177
+
178
+ # Wait for ready signal from browser.js
179
+ wait_for_ready_signal
180
+ end
181
+
182
+ def wait_for_ready_signal(timeout: NODE_IO_TIMEOUT_SECONDS)
183
+ response = Timeout.timeout(timeout) do
184
+ line = @node_stdout.gets
185
+ raise 'browser.js closed stdout before sending ready signal' unless line
186
+
187
+ JSON.parse(line)
188
+ end
189
+
190
+ unless response['status'] == 'ready'
191
+ raise "Expected ready signal from browser.js, got: #{response.inspect}"
192
+ end
193
+
194
+ # Drain any stderr output from browser startup (e.g., video recording status)
195
+ drain_node_stderr
196
+ rescue Timeout::Error
197
+ raise "browser.js startup timed out after #{timeout}s waiting for ready signal"
198
+ rescue JSON::ParserError => e
199
+ raise "Protocol error during startup: Invalid JSON from browser.js: #{e.message}"
200
+ end
201
+
202
+ def drain_node_stderr
203
+ return unless @node_stderr
204
+
205
+ loop do
206
+ line = @node_stderr.read_nonblock(4096)
207
+ break if line.nil? || line.empty?
208
+ line.each_line { |l| log "Node: #{l.strip}" unless l.strip.empty? }
209
+ end
210
+ rescue IO::WaitReadable, EOFError
211
+ # No more data available
212
+ end
213
+
214
+ def stop_node_process
215
+ return unless @node_stdin || @node_wait_thread
216
+
217
+ pid = @node_wait_thread&.pid
218
+
219
+ # Attempt graceful shutdown with protocol-compliant quit (skip if channel poisoned)
220
+ unless @node_channel_poisoned
221
+ begin
222
+ Timeout.timeout(NODE_SHUTDOWN_TIMEOUT_SECONDS) do
223
+ request_id = @next_request_id
224
+ request = { request_id: request_id, command: 'quit', params: {} }
225
+ @node_stdin.puts(request.to_json)
226
+ @node_stdin.flush
227
+ response_line = @node_stdout.gets
228
+ if response_line
229
+ begin
230
+ response = JSON.parse(response_line)
231
+ video_path = response&.dig('result', 'video_path')
232
+ if video_path
233
+ if File.exist?(video_path)
234
+ @video_data = File.binread(video_path)
235
+ log "Video captured: #{video_path} (#{@video_data&.bytesize} bytes)"
236
+ File.delete(video_path) rescue nil
237
+ else
238
+ log "Warning: Video path returned but file does not exist: #{video_path}"
239
+ # List contents of temp directory for debugging
240
+ if @temp_dir && Dir.exist?(@temp_dir)
241
+ files = Dir.entries(@temp_dir).reject { |f| f.start_with?('.') }
242
+ log "Temp dir contents: #{files.inspect}"
243
+ end
244
+ end
245
+ else
246
+ log "Warning: No video path in quit response (recording may not have been enabled)"
247
+ end
248
+ rescue JSON::ParserError => e
249
+ log "Warning: Failed to parse quit response: #{e.message}"
250
+ end
251
+ else
252
+ log "Warning: No response from Node process on quit"
253
+ end
254
+ end
255
+ rescue Timeout::Error => e
256
+ log "Warning: Timeout waiting for Node shutdown: #{e.message}"
257
+ rescue StandardError => e
258
+ log "Warning: Error during Node shutdown: #{e.class}: #{e.message}"
259
+ end
260
+ end
261
+
262
+ # Read any remaining stderr output from Node process for debugging
263
+ drain_node_stderr
264
+
265
+ # Close IO streams
266
+ @node_stdin&.close rescue nil
267
+ @node_stdout&.close rescue nil
268
+ @node_stderr&.close rescue nil
269
+
270
+ # Force kill the process if still running
271
+ if pid
272
+ begin
273
+ Process.kill(0, pid) # Check if process exists
274
+ Process.kill('TERM', pid)
275
+ # Wait briefly for termination
276
+ Timeout.timeout(2) { @node_wait_thread&.join }
277
+ rescue Errno::ESRCH
278
+ # Process already dead, OK
279
+ rescue Timeout::Error
280
+ # Force kill if TERM didn't work
281
+ Process.kill('KILL', pid) rescue nil
282
+ rescue StandardError
283
+ # Ignore other errors during cleanup
284
+ end
285
+ end
286
+
287
+ @node_wait_thread = nil
288
+ end
289
+
290
+ def send_to_node(command, params = {}, timeout: NODE_IO_TIMEOUT_SECONDS)
291
+ raise 'Protocol error: Node channel is poisoned, cannot send' if @node_channel_poisoned
292
+
293
+ request_id = @next_request_id
294
+ request = { request_id: request_id, command: command, params: params }
295
+
296
+ begin
297
+ response = Timeout.timeout(timeout) do
298
+ @node_stdin.puts(request.to_json)
299
+ @node_stdin.flush
300
+ response_line = @node_stdout.gets
301
+ raise 'No response from Node process' unless response_line
302
+
303
+ JSON.parse(response_line)
304
+ end
305
+ rescue Timeout::Error
306
+ poison_node_channel!
307
+ raise "Node process timed out after #{timeout}s waiting for response"
308
+ rescue JSON::ParserError => e
309
+ poison_node_channel!
310
+ raise "Protocol error: Invalid JSON from Node: #{e.message}"
311
+ end
312
+
313
+ # Validate response envelope per docs/messages.md
314
+ validate_node_response!(response, request_id)
315
+
316
+ # Increment request_id only after successful validation
317
+ @next_request_id += 1
318
+
319
+ response
320
+ end
321
+
322
+ def poison_node_channel!
323
+ @node_channel_poisoned = true
324
+ # Immediately terminate the Node process
325
+ stop_node_process
326
+ end
327
+
328
+ def validate_node_response!(response, expected_request_id)
329
+ # Check required keys
330
+ unless response.key?('request_id')
331
+ poison_node_channel!
332
+ raise 'Protocol error: missing request_id in Node response'
333
+ end
334
+
335
+ unless response.key?('ok') && response.key?('result')
336
+ poison_node_channel!
337
+ raise 'Protocol error: missing ok or result in Node response'
338
+ end
339
+
340
+ # Check request_id match (null from Node means parse error, treat as fatal)
341
+ response_id = response['request_id']
342
+ if response_id.nil? || response_id != expected_request_id
343
+ poison_node_channel!
344
+ raise "Protocol error: request_id mismatch - expected #{expected_request_id}, got #{response_id.inspect}"
345
+ end
346
+
347
+ # Check for error response
348
+ unless response['ok']
349
+ error = response['error'] || {}
350
+ error_code = error['code'] || 'UNKNOWN'
351
+ error_message = error['message'] || 'Unknown error from Node'
352
+ raise "Node error [#{error_code}]: #{error_message}"
353
+ end
354
+ end
355
+
356
+ def navigate_to_base_url
357
+ base_url = @scenario['base_url']
358
+ raise 'base_url not specified in scenario file' unless base_url
359
+
360
+ response = send_to_node('navigate', { url: base_url })
361
+ screenshot_base64 = response.dig('result', 'screenshot_base64')
362
+ elements = response.dig('result', 'elements') || []
363
+ { screenshot_base64: screenshot_base64, elements: elements }
364
+ end
365
+
366
+ def execute_action(action)
367
+ case action['action']
368
+ when 'navigate'
369
+ # Substitute credentials in URL (e.g., https://<<API_KEY>>@api.example.com)
370
+ url = action['url']
371
+ display_url = url # For logging (shows placeholders, not actual values)
372
+ url = substitute_credentials(url) if contains_credential_placeholder?(url)
373
+
374
+ response = send_to_node('navigate', { url: url })
375
+ screenshot_base64 = response.dig('result', 'screenshot_base64')
376
+ elements = response.dig('result', 'elements') || []
377
+ { result: "Navigated to #{display_url}", screenshot_base64: screenshot_base64, elements: elements }
378
+
379
+ when 'click'
380
+ element_id = action['element_id']
381
+ response = send_to_node('click_element', { element_id: element_id })
382
+ screenshot_base64 = response.dig('result', 'screenshot_base64')
383
+ elements = response.dig('result', 'elements') || []
384
+ { result: "Clicked element #{element_id}", screenshot_base64: screenshot_base64, elements: elements }
385
+
386
+ when 'select'
387
+ element_id = action['element_id']
388
+ value = action['value']
389
+ display_value = value # For logging (shows placeholders, not actual values)
390
+ value = substitute_credentials(value) if contains_credential_placeholder?(value)
391
+
392
+ response = send_to_node('select_option', { element_id: element_id, value: value })
393
+ screenshot_base64 = response.dig('result', 'screenshot_base64')
394
+ elements = response.dig('result', 'elements') || []
395
+ { result: "Selected '#{display_value}' in element #{element_id}", screenshot_base64: screenshot_base64, elements: elements }
396
+
397
+ when 'keypress'
398
+ # Substitute credential placeholders at the last moment before browser execution
399
+ # Supports inline placeholders: <<USERNAME>>@example.com, <<USER>>:<<PASS>>, etc.
400
+ keys = action['keys']
401
+ display_keys = keys # For logging (shows placeholders, not actual values)
402
+ # Skip credential substitution for special key combos like ctrl+a
403
+ keys = substitute_credentials(keys) if contains_credential_placeholder?(keys) && !special_key_combo?(keys)
404
+
405
+ response = send_to_node('keypress', { keys: keys })
406
+ screenshot_base64 = response.dig('result', 'screenshot_base64')
407
+ elements = response.dig('result', 'elements') || []
408
+ { result: "Pressed keys: #{display_keys}", screenshot_base64: screenshot_base64, elements: elements }
409
+
410
+ when 'wait'
411
+ response = send_to_node('wait', { ms: action['ms'] })
412
+ screenshot_base64 = response.dig('result', 'screenshot_base64')
413
+ elements = response.dig('result', 'elements') || []
414
+ { result: "Waited #{action['ms']}ms", screenshot_base64: screenshot_base64, elements: elements }
415
+
416
+ when 'scroll'
417
+ response = send_to_node('scroll', { direction: action['direction'] })
418
+ screenshot_base64 = response.dig('result', 'screenshot_base64')
419
+ elements = response.dig('result', 'elements') || []
420
+ { result: "Scrolled #{action['direction']}", screenshot_base64: screenshot_base64, elements: elements }
421
+
422
+ when 'accept_dialog'
423
+ value = action['value'] # Optional, for prompt dialogs
424
+ response = send_to_node('accept_dialog', { value: value })
425
+ screenshot_base64 = response.dig('result', 'screenshot_base64')
426
+ elements = response.dig('result', 'elements') || []
427
+ { result: "Accepted dialog#{value ? " with value '#{value}'" : ''}", screenshot_base64: screenshot_base64, elements: elements }
428
+
429
+ when 'dismiss_dialog'
430
+ response = send_to_node('dismiss_dialog', {})
431
+ screenshot_base64 = response.dig('result', 'screenshot_base64')
432
+ elements = response.dig('result', 'elements') || []
433
+ { result: "Dismissed dialog", screenshot_base64: screenshot_base64, elements: elements }
434
+
435
+ else
436
+ { result: "Unknown action: #{action['action']}", screenshot_base64: nil, elements: nil }
437
+ end
438
+ end
439
+
440
+ def build_elements_by_id(elements)
441
+ return {} unless elements
442
+
443
+ elements.each_with_object({}) { |e, h| h[e['id']] = e }
444
+ end
445
+
446
+ # Extract a human-readable display name from an element.
447
+ # SECURITY: Returns sanitized text safe for storage/display. The returned value
448
+ # originates from browser DOM and should never be marked html_safe in templates.
449
+ def element_display_name(element)
450
+ name = element['accessible_name'].to_s.strip
451
+ return sanitize_display_text(name) unless name.empty?
452
+
453
+ name = element['visible_text'].to_s.strip
454
+ return sanitize_display_text(name) unless name.empty?
455
+
456
+ # Fallback: type (e.g., "button", "input", "link") - trusted internal value
457
+ element['type']
458
+ end
459
+
460
+ # Sanitize untrusted display text: strip control characters, enforce max length.
461
+ # Mirrors Document::Test::Step.sanitize_display_text for use in runner context.
462
+ MAX_DISPLAY_TEXT_LENGTH = 500
463
+
464
+ def sanitize_display_text(text)
465
+ return nil if text.nil?
466
+
467
+ text.to_s
468
+ .gsub(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/, '') # Strip control chars (keep \t, \n, \r)
469
+ .strip
470
+ .slice(0, MAX_DISPLAY_TEXT_LENGTH)
471
+ end
472
+
473
+ def upload_video
474
+ unless @video_data
475
+ log "No video data to upload (recording may not have been enabled or failed)"
476
+ return
477
+ end
478
+ unless @step_client
479
+ log "Warning: No step client available for video upload"
480
+ return
481
+ end
482
+
483
+ log "Uploading video (#{@video_data.bytesize} bytes)..."
484
+ @step_client.upload_video(scenario_id: @scenario_id, video_data: @video_data)
485
+ log "Video uploaded successfully."
486
+ rescue StepClient::StepError => e
487
+ log "Warning: Failed to upload video: #{e.message}"
488
+ end
489
+
490
+ def cleanup_temp_dir
491
+ return unless @temp_dir && Dir.exist?(@temp_dir)
492
+
493
+ # Safety check: only delete if temp_dir is within expected base directory
494
+ base_tmp = File.join(SPECSAGE_HOME, 'tmp')
495
+ unless @temp_dir.start_with?(base_tmp + File::SEPARATOR)
496
+ log "Warning: Refusing to delete temp_dir outside expected location: #{@temp_dir}"
497
+ return
498
+ end
499
+
500
+ FileUtils.rm_rf(@temp_dir)
501
+ # Also clean up parent run directory if empty
502
+ run_dir = File.dirname(@temp_dir)
503
+ if Dir.exist?(run_dir) && Dir.empty?(run_dir) && run_dir.start_with?(base_tmp + File::SEPARATOR)
504
+ FileUtils.rmdir(run_dir)
505
+ end
506
+ rescue StandardError
507
+ # Ignore cleanup errors
508
+ end
509
+
510
+ # --- Safe path handling ---
511
+
512
+ # Build a safe temp directory path, validating that IDs are safe path segments
513
+ def build_safe_temp_dir
514
+ validate_path_segment!(@server_run_id, 'server_run_id')
515
+ validate_path_segment!(@scenario_id, 'scenario_id')
516
+
517
+ File.join(SPECSAGE_HOME, 'tmp', @server_run_id, @scenario_id)
518
+ end
519
+
520
+ # Validate that a string is safe to use as a filesystem path segment
521
+ # Raises if the value contains path traversal chars, slashes, or unsafe characters
522
+ def validate_path_segment!(value, name)
523
+ return if value.is_a?(String) && value.match?(SAFE_PATH_SEGMENT) && value.length <= 100
524
+
525
+ raise ArgumentError, "#{name} contains unsafe characters for filesystem path: #{value.inspect}. " \
526
+ "Expected alphanumeric, underscore, or hyphen only."
527
+ end
528
+
529
+ # Send verdict to server for client-side errors (browser crash, etc.)
530
+ # Normal PASS/FAIL verdicts from the LLM are already recorded server-side
531
+ def send_client_verdict_if_needed(status, reason)
532
+ return unless @step_client
533
+
534
+ log "Sending client verdict to server: #{status}"
535
+ @step_client.set_verdict(
536
+ scenario_id: @scenario_id,
537
+ status: status,
538
+ reason: reason
539
+ )
540
+ rescue StepClient::StepError => e
541
+ log "Warning: Failed to send verdict to server: #{e.message}"
542
+ end
543
+
544
+ # --- Logging with credential protection ---
545
+
546
+ # Log with scenario name prefix for parallel execution clarity
547
+ def log(message)
548
+ guard_log_against_credential_leak(message)
549
+ puts "[#{@scenario_name}] #{message}"
550
+ end
551
+
552
+ # Safe logging that guards against credential leaks in dev/test
553
+ def safe_puts(message)
554
+ guard_log_against_credential_leak(message)
555
+ puts "[#{@scenario_name}] #{message}"
556
+ end
557
+
558
+ # Raise if a log message contains actual credential values
559
+ # Runs in ALL environments - production is where leaks matter most
560
+ def guard_log_against_credential_leak(message)
561
+ return if @credentials.nil? || @credentials.empty?
562
+
563
+ @credentials.each do |name, secret_value|
564
+ next if secret_value.nil? || secret_value.empty?
565
+
566
+ if message.to_s.include?(secret_value)
567
+ raise "SECURITY: Credential '#{name}' value leaked into log output. " \
568
+ "Use placeholders like <<#{name}>> in logs, not actual values."
569
+ end
570
+ end
571
+ end
572
+
573
+ # --- Credential substitution ---
574
+ # Substitutes <<CREDENTIAL_NAME>> placeholders in action fields.
575
+ # Placeholders can appear anywhere in the string (inline substitution).
576
+ # Only credentials in the allowlist (from server) can be substituted.
577
+
578
+ CREDENTIAL_PLACEHOLDER_PATTERN = /<<([A-Z][A-Z0-9_]*)>>/
579
+
580
+ # Special key combinations that should not be treated as credential placeholders
581
+ SPECIAL_KEY_COMBOS = %w[ctrl+a Ctrl+A].freeze
582
+
583
+ # Check if the value is a special key combo (e.g., ctrl+a)
584
+ def special_key_combo?(value)
585
+ SPECIAL_KEY_COMBOS.include?(value)
586
+ end
587
+
588
+ # Check if the value contains any credential placeholders
589
+ def contains_credential_placeholder?(value)
590
+ return false unless value.is_a?(String)
591
+
592
+ CREDENTIAL_PLACEHOLDER_PATTERN.match?(value)
593
+ end
594
+
595
+ # Substitute all credential placeholders in a string with their actual values
596
+ # Raises if any placeholder references a credential not in the allowlist
597
+ def substitute_credentials(value)
598
+ return value unless value.is_a?(String)
599
+
600
+ value.gsub(CREDENTIAL_PLACEHOLDER_PATTERN) do |match|
601
+ name = Regexp.last_match(1)
602
+
603
+ unless @credentials.key?(name)
604
+ raise "Credential '#{name}' not allowed for this scenario. " \
605
+ "Available credentials: #{@credentials.keys.join(', ').presence || 'none'}"
606
+ end
607
+
608
+ @credentials[name]
609
+ end
610
+ end
611
+ end
612
+
613
+ require 'open3'