actionagent 1.8.1 → 1.9.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.
Files changed (159) hide show
  1. checksums.yaml +4 -4
  2. data/app/assets/builds/action_agent.css +1 -1
  3. data/app/assets/builds/action_agent.js +76 -73
  4. data/app/assets/builds/action_agent_recorder.js +78 -0
  5. data/app/assets/builds/action_agent_replay.js +226 -0
  6. data/app/controllers/action_agent/api/agent_runs_controller.rb +3 -1
  7. data/app/controllers/action_agent/api/agents_controller.rb +1 -0
  8. data/app/controllers/action_agent/api/api_keys_controller.rb +12 -2
  9. data/app/controllers/action_agent/api/base_controller.rb +63 -2
  10. data/app/controllers/action_agent/api/claude_logins_controller.rb +93 -0
  11. data/app/controllers/action_agent/api/code_sessions_controller.rb +53 -6
  12. data/app/controllers/action_agent/api/dashboard_assistant_controller.rb +2 -2
  13. data/app/controllers/action_agent/api/draft_pull_requests_controller.rb +248 -0
  14. data/app/controllers/action_agent/api/evaluation_fixes_controller.rb +27 -0
  15. data/app/controllers/action_agent/api/evaluations_controller.rb +9 -4
  16. data/app/controllers/action_agent/api/explorations_controller.rb +213 -0
  17. data/app/controllers/action_agent/api/github_app_manifests_controller.rb +125 -0
  18. data/app/controllers/action_agent/api/github_connections_controller.rb +40 -2
  19. data/app/controllers/action_agent/api/github_installations_controller.rb +280 -0
  20. data/app/controllers/action_agent/api/input_requests_controller.rb +77 -0
  21. data/app/controllers/action_agent/api/mcp_controller.rb +46 -12
  22. data/app/controllers/action_agent/api/members_controller.rb +36 -0
  23. data/app/controllers/action_agent/api/project_explorations_controller.rb +80 -0
  24. data/app/controllers/action_agent/api/project_install_pull_requests_controller.rb +305 -0
  25. data/app/controllers/action_agent/api/project_secrets_controller.rb +96 -0
  26. data/app/controllers/action_agent/api/project_setup_controller.rb +151 -0
  27. data/app/controllers/action_agent/api/project_sign_ins_controller.rb +112 -0
  28. data/app/controllers/action_agent/api/projects_controller.rb +512 -0
  29. data/app/controllers/action_agent/api/provider_keys_controller.rb +176 -24
  30. data/app/controllers/action_agent/api/provider_models_controller.rb +37 -15
  31. data/app/controllers/action_agent/api/recording_event_ingest_controller.rb +67 -0
  32. data/app/controllers/action_agent/api/sandbox_browser_tickets_controller.rb +50 -0
  33. data/app/controllers/action_agent/api/sandbox_browsers_controller.rb +65 -0
  34. data/app/controllers/action_agent/api/sandboxes_controller.rb +140 -8
  35. data/app/controllers/action_agent/api/scenario_catalog_entries_controller.rb +188 -0
  36. data/app/controllers/action_agent/api/scenario_catalogs_controller.rb +302 -0
  37. data/app/controllers/action_agent/api/session_recordings_controller.rb +165 -34
  38. data/app/controllers/action_agent/api/sessions_controller.rb +111 -0
  39. data/app/controllers/action_agent/api/telemetry_keys_controller.rb +21 -0
  40. data/app/controllers/action_agent/dashboard_controller.rb +17 -6
  41. data/app/controllers/action_agent/session_player_controller.rb +58 -0
  42. data/app/controllers/concerns/action_agent/api/agent_serialization.rb +1 -0
  43. data/app/controllers/concerns/action_agent/api/mcp_dashboard_tools.rb +623 -18
  44. data/app/controllers/concerns/action_agent/api/project_browser.rb +46 -0
  45. data/app/controllers/concerns/action_agent/api/project_secret_authorization.rb +57 -0
  46. data/app/jobs/action_agent/agent_execution_job.rb +9 -39
  47. data/app/jobs/action_agent/agent_resume_job.rb +84 -0
  48. data/app/jobs/action_agent/code_session_job.rb +26 -2
  49. data/app/jobs/action_agent/draft_pull_request_job.rb +43 -0
  50. data/app/jobs/action_agent/exploration_job.rb +80 -0
  51. data/app/jobs/action_agent/input_request_expiry_job.rb +22 -0
  52. data/app/jobs/action_agent/project_evaluation_job.rb +59 -0
  53. data/app/jobs/action_agent/project_setup_job.rb +21 -0
  54. data/app/jobs/action_agent/sandbox_cleanup_job.rb +20 -3
  55. data/app/jobs/action_agent/sandbox_provision_job.rb +77 -10
  56. data/app/jobs/action_agent/sandbox_run_job.rb +9 -37
  57. data/app/jobs/action_agent/verify_evaluation_fix_job.rb +37 -0
  58. data/app/models/action_agent/agent.rb +39 -26
  59. data/app/models/action_agent/agent_run.rb +134 -23
  60. data/app/models/action_agent/agent_template.rb +35 -1
  61. data/app/models/action_agent/api_key.rb +14 -0
  62. data/app/models/action_agent/catalog_scenario.rb +75 -0
  63. data/app/models/action_agent/code_session.rb +16 -4
  64. data/app/models/action_agent/draft_pull_request.rb +131 -0
  65. data/app/models/action_agent/evaluation.rb +84 -0
  66. data/app/models/action_agent/exploration.rb +847 -0
  67. data/app/models/action_agent/github_installation.rb +159 -0
  68. data/app/models/action_agent/input_request.rb +291 -0
  69. data/app/models/action_agent/project.rb +789 -0
  70. data/app/models/action_agent/project_secret.rb +339 -0
  71. data/app/models/action_agent/provider_key.rb +200 -3
  72. data/app/models/action_agent/recording_event.rb +145 -0
  73. data/app/models/action_agent/recording_snapshot.rb +7 -7
  74. data/app/models/action_agent/sandbox_session.rb +297 -14
  75. data/app/models/action_agent/scenario_catalog.rb +172 -0
  76. data/app/models/action_agent/scenario_product.rb +57 -0
  77. data/app/models/action_agent/scenario_set.rb +138 -0
  78. data/app/models/action_agent/session_recording.rb +121 -14
  79. data/app/models/action_agent/telemetry_trace.rb +74 -19
  80. data/app/models/concerns/action_agent/ownable.rb +84 -13
  81. data/app/serializers/action_agent/input_request_serializer.rb +47 -0
  82. data/app/serializers/action_agent/scenario_catalog_serializer.rb +23 -0
  83. data/app/serializers/action_agent/session_summary_serializer.rb +146 -0
  84. data/app/services/action_agent/agent_execution_service.rb +450 -72
  85. data/app/services/action_agent/agent_sync.rb +5 -2
  86. data/app/services/action_agent/agent_tool_roster.rb +19 -4
  87. data/app/services/action_agent/agent_toolbox.rb +215 -9
  88. data/app/services/action_agent/browser_live_ticket.rb +78 -0
  89. data/app/services/action_agent/browser_sidecar.rb +213 -0
  90. data/app/services/action_agent/browser_sign_in.rb +163 -0
  91. data/app/services/action_agent/browser_tool_redaction.rb +92 -0
  92. data/app/services/action_agent/claude_code_auth.rb +70 -4
  93. data/app/services/action_agent/dashboard_assistant_service.rb +24 -10
  94. data/app/services/action_agent/draft_pull_request_publisher.rb +719 -0
  95. data/app/services/action_agent/evaluation_fix.rb +101 -0
  96. data/app/services/action_agent/evaluation_fix_comparison.rb +66 -0
  97. data/app/services/action_agent/evaluation_runner_service.rb +4 -5
  98. data/app/services/action_agent/evaluation_standing.rb +15 -8
  99. data/app/services/action_agent/explorer_execution_service.rb +517 -0
  100. data/app/services/action_agent/github_client.rb +405 -64
  101. data/app/services/action_agent/live_updates.rb +31 -0
  102. data/app/services/action_agent/local_sandbox_backend.rb +1218 -83
  103. data/app/services/action_agent/local_sandbox_claude_login.rb +151 -0
  104. data/app/services/action_agent/mcp_client.rb +6 -2
  105. data/app/services/action_agent/mcp_recording_middleware.rb +104 -145
  106. data/app/services/action_agent/mcp_tool_dispatcher.rb +36 -13
  107. data/app/services/action_agent/mock_sandbox_backend.rb +3 -1
  108. data/app/services/action_agent/playwright_mcp_client.rb +7 -0
  109. data/app/services/action_agent/project_boot_fix.rb +309 -0
  110. data/app/services/action_agent/project_capabilities.rb +200 -0
  111. data/app/services/action_agent/project_install_pull_request.rb +225 -0
  112. data/app/services/action_agent/project_preflight.rb +178 -0
  113. data/app/services/action_agent/project_secret_discovery.rb +142 -0
  114. data/app/services/action_agent/project_setup.rb +429 -0
  115. data/app/services/action_agent/provider_credentials.rb +102 -0
  116. data/app/services/action_agent/recording_event_ingest.rb +181 -0
  117. data/app/services/action_agent/runtime_tool_roster.rb +52 -0
  118. data/app/services/action_agent/sample_data/conference_ticket.rb +516 -0
  119. data/app/services/action_agent/sandbox_boot_spec.rb +558 -0
  120. data/app/services/action_agent/sandbox_browser.rb +299 -0
  121. data/app/services/action_agent/sandbox_browser_driver.rb +85 -0
  122. data/app/services/action_agent/sandbox_credential_paths.rb +26 -0
  123. data/app/services/action_agent/sandbox_mail.rb +103 -0
  124. data/app/services/action_agent/sandbox_manifest.rb +166 -13
  125. data/app/services/action_agent/sandbox_orchestrator.rb +276 -22
  126. data/app/services/action_agent/sandbox_patch.rb +253 -0
  127. data/app/services/action_agent/scenario_catalog_import.rb +255 -0
  128. data/app/services/action_agent/scenario_catalog_repository_import.rb +81 -0
  129. data/app/services/action_agent/scenario_evaluation_runner.rb +119 -16
  130. data/app/services/action_agent/secret_scrubber.rb +33 -4
  131. data/app/services/action_agent/session_index.rb +270 -0
  132. data/app/services/action_agent/session_recording_service.rb +11 -0
  133. data/app/services/action_agent/session_timeline.rb +505 -0
  134. data/app/views/action_agent/github_app_manifests/created.html.erb +55 -0
  135. data/app/views/action_agent/session_player/show.html.erb +19 -0
  136. data/config/routes.rb +152 -1
  137. data/lib/action_agent/claude_login_process.rb +107 -0
  138. data/lib/action_agent/engine.rb +55 -5
  139. data/lib/action_agent/secret_requests.rb +80 -0
  140. data/lib/action_agent/version.rb +1 -1
  141. data/lib/action_agent.rb +507 -22
  142. data/lib/generators/action_agent/install_generator.rb +37 -0
  143. data/lib/generators/action_agent/templates/action_agent.rb.erb +158 -0
  144. data/lib/generators/action_agent/templates/migrations/015_add_provider_key_scope.rb.erb +72 -0
  145. data/lib/generators/action_agent/templates/migrations/016_create_active_agent_input_requests.rb.erb +85 -0
  146. data/lib/generators/action_agent/templates/migrations/017_create_active_agent_recording_events.rb.erb +57 -0
  147. data/lib/generators/action_agent/templates/migrations/018_add_browser_to_active_agent_sandbox_sessions.rb.erb +25 -0
  148. data/lib/generators/action_agent/templates/migrations/019_create_active_agent_projects.rb.erb +79 -0
  149. data/lib/generators/action_agent/templates/migrations/020_add_github_app_installations.rb.erb +62 -0
  150. data/lib/generators/action_agent/templates/migrations/021_create_active_agent_explorations.rb.erb +63 -0
  151. data/lib/generators/action_agent/templates/migrations/023_create_active_agent_draft_pull_requests.rb.erb +75 -0
  152. data/lib/generators/action_agent/templates/migrations/024_add_kind_to_active_agent_project_secrets.rb.erb +14 -0
  153. data/lib/generators/action_agent/templates/migrations/025_create_active_agent_scenario_catalogs.rb.erb +110 -0
  154. data/lib/generators/action_agent/templates/migrations/026_add_evaluation_fixes_to_code_sessions.rb.erb +21 -0
  155. data/lib/tasks/action_agent.rake +33 -0
  156. data/lib/tasks/browser.rake +22 -0
  157. data/lib/tasks/sandbox.rake +1 -1
  158. data/lib/tasks/scenario_catalogs.rake +71 -0
  159. metadata +96 -2
@@ -0,0 +1,225 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActionAgent
4
+ # Used to publish what a project's bootstrap wrote into its checkout as a
5
+ # pull request that installs the engine in the repository: the install
6
+ # pull request. Publishing goes through DraftPullRequestPublisher, from the
7
+ # project's live sandbox, and takes only the paths of #allowlist:
8
+ #
9
+ # Gemfile, Gemfile.lock
10
+ # config/initializers/action_agent.rb, config/routes.rb
11
+ # config/active_agent.yml and app/agents/application_agent.rb, when the
12
+ # bootstrap generated them (the repository locked no activeagent)
13
+ # the migrations `action_agent:install` emits, by exact name after the
14
+ # timestamp (ENGINE_MIGRATIONS and the numbered templates)
15
+ # db/schema.rb or db/structure.sql
16
+ # app/agent_tools/<model>_tools.rb for every model the App assistant was
17
+ # ever given (Project#schema_tools_models), so that a file a boot
18
+ # removed can be removed on the branch too
19
+ # .activeagents/sandbox.yml and .activeagents/evals/<project>.yml
20
+ # whatever a Claude Code boot fix changed to get the boot through
21
+ # (ProjectBootFix.changed_paths_for)
22
+ #
23
+ # The last two are generated here rather than read from the checkout (see
24
+ # #generated_files): sandbox.yml holds the setup that booted the project
25
+ # and the names of its secrets, never their values, and the evaluation
26
+ # suite holds the project evaluation's enabled scenarios in the shape
27
+ # ActiveAgent::Evals::Suite.load reads. Anything else the sandbox changed,
28
+ # .github/ and any file holding one of the project's secrets is refused by
29
+ # the publisher.
30
+ #
31
+ # A publish takes every REQUIRED_PATHS file and engine migration the
32
+ # sandbox changed (#missing_required_paths): every boot of the branch
33
+ # installs nothing, so it boots only with all of them.
34
+ class ProjectInstallPullRequest
35
+ SANDBOX_CONFIG = ".activeagents/sandbox.yml"
36
+ STATIC_PATHS = [
37
+ "Gemfile", "Gemfile.lock", "config/initializers/action_agent.rb", "config/routes.rb", "db/schema.rb", "db/structure.sql",
38
+ SANDBOX_CONFIG
39
+ ].freeze
40
+ # What `active_agent:install` writes into a repository without the
41
+ # framework.
42
+ FRAMEWORK_PATHS = %w[config/active_agent.yml app/agents/application_agent.rb].freeze
43
+ # The migrations `action_agent:install` emits by name, beside its
44
+ # numbered templates (ActionAgent::InstallGenerator).
45
+ ENGINE_MIGRATIONS = %w[
46
+ create_active_agent_telemetry_traces add_agent_id_to_active_agent_telemetry_traces add_agent_releases
47
+ create_active_agent_dashboard_tables ensure_agent_release_columns create_active_agent_evaluation_scenarios
48
+ add_evaluation_report_identity add_provider_key_api_key create_active_agent_github_connections
49
+ create_active_agent_code_sessions add_code_session_runner
50
+ ].freeze
51
+ # What a boot of the branch, which installs nothing, needs to load the
52
+ # engine.
53
+ REQUIRED_PATHS = [
54
+ "Gemfile", "Gemfile.lock", "config/initializers/action_agent.rb", "config/routes.rb", "db/schema.rb", "db/structure.sql"
55
+ ].freeze
56
+ NUMBERED_MIGRATION = /\A\d{3}_(?<name>[a-z0-9_]+)\.rb\.erb\z/
57
+ NUMBERED_MIGRATIONS_PATH = File.expand_path("../../../lib/generators/action_agent/templates/migrations", __dir__)
58
+ DEFAULT_BRANCH = "activeagent/install-engine"
59
+ DEFAULT_SETUP = [ "bundle install", "bin/rails db:prepare" ].freeze
60
+
61
+ class << self
62
+ # The names of every migration `action_agent:install` emits.
63
+ def engine_migration_names
64
+ numbered = Dir.children(NUMBERED_MIGRATIONS_PATH).sort.filter_map { |file| NUMBERED_MIGRATION.match(file)&.[](:name) }
65
+ ENGINE_MIGRATIONS + numbered
66
+ rescue Errno::ENOENT
67
+ ENGINE_MIGRATIONS
68
+ end
69
+
70
+ # The publisher a queued +record+ is published with: the project's,
71
+ # with its generated files, when +record+ is a project's install pull
72
+ # request, and a plain one otherwise.
73
+ def publisher_for(record, user: nil)
74
+ project = record.sandbox_session&.project
75
+ if project && project.settings["install_pull_request_id"] == record.id
76
+ new(project, user: user).publisher(record.sandbox_session)
77
+ else
78
+ DraftPullRequestPublisher.new(record.sandbox_session, user: user)
79
+ end
80
+ end
81
+ end
82
+
83
+ attr_reader :project
84
+
85
+ def initialize(project, user: nil, orchestrator: SandboxOrchestrator.new)
86
+ @project = project
87
+ @user = user
88
+ @orchestrator = orchestrator
89
+ end
90
+
91
+ # The patterns DraftPullRequestPublisher#changes takes for the project.
92
+ #
93
+ # @return [Array<String, Regexp>]
94
+ def allowlist
95
+ paths = STATIC_PATHS.dup
96
+ paths.concat(FRAMEWORK_PATHS) if framework_generated?
97
+ paths.concat(project.schema_tools_models.map { |model| "#{SandboxBootSpec::SCHEMA_TOOLS_DIR}/#{model.underscore}_tools.rb" })
98
+ paths << evaluation_path
99
+ paths << engine_migration_pattern
100
+ paths.concat(ProjectBootFix.changed_paths_for(project).map { |path| exact_path(path) })
101
+ paths.uniq
102
+ end
103
+
104
+ # The REQUIRED_PATHS and engine migrations +publisher+'s sandbox changed
105
+ # that +paths+ leaves out.
106
+ #
107
+ # @return [Array<String>]
108
+ def missing_required_paths(publisher, paths)
109
+ chosen = paths.to_set
110
+ publisher.changed_paths.select do |path|
111
+ (REQUIRED_PATHS.include?(path) || engine_migration_pattern.match?(path)) && !chosen.include?(path)
112
+ end
113
+ end
114
+
115
+ # Where the project evaluation's suite is published.
116
+ def evaluation_path
117
+ ".activeagents/evals/#{project.name.parameterize.presence || "project-#{project.id}"}.yml"
118
+ end
119
+
120
+ # The publisher for +sandbox+ with the project's generated files.
121
+ def publisher(sandbox = project.current_sandbox_session)
122
+ DraftPullRequestPublisher.new(sandbox, user: @user, orchestrator: @orchestrator, generated_files: generated_files(sandbox))
123
+ end
124
+
125
+ # What the dashboard writes itself: sandbox.yml, and the evaluation
126
+ # suite when the project's evaluation has enabled scenarios.
127
+ #
128
+ # @return [Hash{String => String}]
129
+ def generated_files(sandbox = project.current_sandbox_session)
130
+ files = { SANDBOX_CONFIG => sandbox_config(sandbox) }
131
+ suite = evaluation_suite
132
+ files[evaluation_path] = suite if suite
133
+ files
134
+ end
135
+
136
+ # The project's sandbox.yml: the checkout's own, if it has one, with the
137
+ # setup that booted the project and the names of the project's secrets.
138
+ # The values the setup assistant set are not secrets, and stay with the
139
+ # project: a boot passes them as env.
140
+ def sandbox_config(sandbox)
141
+ data = base_sandbox_config(sandbox)
142
+ data["setup"] = setup_commands(sandbox)
143
+ names = Array(data["secrets"]).map(&:to_s) | project.secrets.ordered.reject(&:plain?).map(&:name)
144
+ data["secrets"] = names if names.any?
145
+
146
+ <<~YAML + data.to_yaml.delete_prefix("---\n")
147
+ # How an ActiveAgent checkout sandbox boots this app: the setup commands,
148
+ # and the names of the environment variables it needs. Their values are
149
+ # never written here: a sandbox is given them when it boots.
150
+ YAML
151
+ end
152
+
153
+ # The project evaluation's enabled scenarios as an evaluation suite
154
+ # (ActiveAgent::Evals::Suite), grouped as the dashboard groups them; nil
155
+ # when there are none.
156
+ def evaluation_suite
157
+ evaluation = project.evaluation
158
+ scenarios = evaluation ? evaluation.scenarios.enabled.ordered.to_a : []
159
+ return nil if scenarios.empty?
160
+
161
+ groups = scenarios.group_by { |scenario| scenario.group.presence || "default" }.map do |group, members|
162
+ { "key" => group, "name" => group, "scenarios" => members.map { |scenario| suite_entry(scenario) } }
163
+ end
164
+ document = {
165
+ "suite" => evaluation_path.delete_prefix(".activeagents/evals/").delete_suffix(".yml"),
166
+ "description" => "#{evaluation.name}: the scenarios #{project.repository}'s project evaluation replays",
167
+ "groups" => groups
168
+ }
169
+ document.to_yaml
170
+ end
171
+
172
+ private
173
+
174
+ # +path+ as a pattern File.fnmatch matches only itself.
175
+ def exact_path(path)
176
+ path.gsub(/[*?\[\]{}\\]/) { |char| "\\#{char}" }
177
+ end
178
+
179
+ def engine_migration_pattern
180
+ @engine_migration_pattern ||= begin
181
+ names = self.class.engine_migration_names.map { |name| Regexp.escape(name) }.join("|")
182
+ %r{\Adb/migrate/\d+_(?:#{names})\.rb\z}
183
+ end
184
+ end
185
+
186
+ # Whether the bootstrap wrote the framework's files: the repository's
187
+ # lock had no activeagent when the project picked it.
188
+ def framework_generated?
189
+ project.settings.dig("preflight", "activeagent").blank?
190
+ end
191
+
192
+ def suite_entry(scenario)
193
+ expect = {
194
+ "tools" => scenario.expected_tools.presence,
195
+ "contains" => scenario.expected_patterns.presence,
196
+ "not_contains" => scenario.forbidden_patterns.presence
197
+ }.compact
198
+ { "key" => scenario.key, "prompt" => scenario.prompt, "expect" => expect.presence, "notes" => scenario.notes.presence }.compact
199
+ end
200
+
201
+ def base_sandbox_config(sandbox)
202
+ content = sandbox && @orchestrator.read_file(sandbox, SANDBOX_CONFIG, base: true)
203
+ data = content ? YAML.safe_load(content.force_encoding(Encoding::UTF_8), aliases: false) : nil
204
+ data.is_a?(Hash) ? data : {}
205
+ rescue StandardError
206
+ {}
207
+ end
208
+
209
+ # The commands of the boot steps that succeeded, as the checkout's own
210
+ # sandbox.yml boot runs them: without the steps that installed the
211
+ # engine and wrote the schema tools, which the pull request carries.
212
+ def setup_commands(sandbox)
213
+ status = sandbox && @orchestrator.supports?(:boot_status) ? @orchestrator.boot_status(sandbox) : nil
214
+ succeeded = Array(status&.dig(:steps)).filter_map { |step| step[:name] if step[:status] == "succeeded" }.to_set
215
+ commands = project.boot_spec(sandbox).steps.filter_map do |step|
216
+ next if SandboxBootSpec::INSTALL_STEPS.include?(step["name"]) || SandboxBootSpec.schema_tools_step?(step["name"])
217
+
218
+ step["command"] if succeeded.include?(step["name"])
219
+ end
220
+ commands.presence || DEFAULT_SETUP.dup
221
+ rescue StandardError
222
+ DEFAULT_SETUP.dup
223
+ end
224
+ end
225
+ end
@@ -0,0 +1,178 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActionAgent
4
+ # Used to decide whether a repository can become a project before any
5
+ # sandbox exists, from files read through GitHub's contents API: its
6
+ # Gemfile.lock, .ruby-version, config/application.rb, config/database.yml
7
+ # and Gemfile. It applies the requirements a bootstrap boot's own preflight
8
+ # applies after checkout (LocalSandboxBackend), so a repository refused
9
+ # here would be refused there.
10
+ #
11
+ # Nothing of the repository runs: the lock is parsed with Bundler's parser,
12
+ # and the other files are only matched against.
13
+ #
14
+ # The files are read at the commit the ref names, and the report is kept in
15
+ # Rails.cache for that commit, so checking a repository again costs one
16
+ # GitHub call until it changes.
17
+ class ProjectPreflight
18
+ STATUSES = %w[supported bootstrap unsupported].freeze
19
+ CACHE_TTL = 1.day
20
+ SUMMARIES = {
21
+ "supported" => "Supported",
22
+ "bootstrap" => "Supported: installs the engine in the sandbox"
23
+ }.freeze
24
+ # Gems whose service a sandbox does not run, with the service's name.
25
+ SERVICE_GEMS = {
26
+ "redis" => "Redis",
27
+ "sidekiq" => "Redis (for Sidekiq)",
28
+ "resque" => "Redis (for Resque)",
29
+ "elasticsearch" => "Elasticsearch",
30
+ "elasticsearch-model" => "Elasticsearch",
31
+ "searchkick" => "Elasticsearch or OpenSearch (for Searchkick)",
32
+ "opensearch-ruby" => "OpenSearch"
33
+ }.freeze
34
+ ERB_TAG = /<%.*?%>/m
35
+
36
+ # @param client [GithubClient]
37
+ # @param repository [String] owner/name
38
+ # @param ref [String] the branch, tag or commit to read
39
+ # @return [Hash] see #call, with +ref+ as asked and +commit+, the SHA it
40
+ # named. A ref naming no commit is unsupported, and nothing is read.
41
+ def self.call(client, repository:, ref:)
42
+ commit = client.commit_sha(repository, ref)
43
+ return no_commit(repository, ref) if commit.nil?
44
+
45
+ report = Rails.cache.fetch([ name, ActionAgent::VERSION, repository, commit ], expires_in: CACHE_TTL) do
46
+ new(client, repository: repository, ref: commit).call
47
+ end
48
+ report.deep_stringify_keys.merge("ref" => ref, "commit" => commit)
49
+ end
50
+
51
+ def self.no_commit(repository, ref)
52
+ reason = "#{repository} has no commit at #{ref.presence || "its default branch"}"
53
+ {
54
+ "status" => "unsupported", "summary" => reason, "reasons" => [ reason ], "warnings" => [], "engine" => false,
55
+ "ruby" => nil, "ruby_source" => nil, "railties" => nil, "activeagent" => nil, "actionagent" => nil,
56
+ "database_adapters" => [], "services" => [], "ref" => ref, "commit" => nil, "checked_at" => Time.current.iso8601
57
+ }
58
+ end
59
+
60
+ def initialize(client, repository:, ref:)
61
+ @client = client
62
+ @repository = repository
63
+ @ref = ref
64
+ end
65
+
66
+ # @return [Hash] with string keys:
67
+ # status one of STATUSES
68
+ # summary what the picker shows: SUMMARIES' text, or the
69
+ # first reason the repository is not supported
70
+ # reasons why it is not supported (empty otherwise)
71
+ # warnings services and databases the sandbox may lack
72
+ # engine whether the Gemfile.lock locks actionagent
73
+ # ruby, ruby_source the Ruby it needs and where that was read
74
+ # railties, activeagent, actionagent the locked versions, or nil
75
+ # database_adapters the adapters config/database.yml names
76
+ # services what SERVICE_GEMS found
77
+ # ref, checked_at
78
+ def call
79
+ lock_text = read("Gemfile.lock")
80
+ facts = lock_text ? lock_facts(lock_text) : nil
81
+ ruby, ruby_source = ruby_requirement(facts)
82
+ railties = facts&.dig("gems", "railties")
83
+ reasons = refusals(lock_text, facts, ruby, ruby_source, railties)
84
+ adapters = database_adapters
85
+ services = required_services(facts)
86
+ engine = facts&.dig("gems", "actionagent").present?
87
+ status = if reasons.any? then "unsupported" elsif engine then "supported" else "bootstrap" end
88
+
89
+ {
90
+ "status" => status,
91
+ "summary" => reasons.first || SUMMARIES.fetch(status),
92
+ "reasons" => reasons,
93
+ "warnings" => warnings(adapters, services),
94
+ "engine" => engine,
95
+ "ruby" => ruby,
96
+ "ruby_source" => ruby_source,
97
+ "railties" => railties,
98
+ "activeagent" => facts&.dig("gems", "activeagent"),
99
+ "actionagent" => facts&.dig("gems", "actionagent"),
100
+ "database_adapters" => adapters,
101
+ "services" => services,
102
+ "ref" => @ref,
103
+ "checked_at" => Time.current.iso8601
104
+ }
105
+ end
106
+
107
+ private
108
+
109
+ def read(path)
110
+ @files ||= {}
111
+ return @files[path] if @files.key?(path)
112
+
113
+ @files[path] = @client.file(@repository, path, ref: @ref)
114
+ end
115
+
116
+ def refusals(lock_text, facts, ruby, ruby_source, railties)
117
+ return [ "#{@repository} has no Gemfile.lock at its root: a sandbox needs a bundled Rails app" ] if lock_text.nil?
118
+ return [ "#{@repository}'s Gemfile.lock could not be read" ] if facts.nil?
119
+
120
+ reasons = []
121
+ if ruby && below?(ruby, SandboxBootSpec::MINIMUM_RUBY)
122
+ reasons << "#{@repository} needs Ruby #{ruby} (#{ruby_source}); the engine needs Ruby #{SandboxBootSpec::MINIMUM_RUBY} or later"
123
+ end
124
+ if railties.nil?
125
+ reasons << "#{@repository}'s Gemfile.lock locks no railties: a sandbox needs a Rails app"
126
+ elsif below?(railties, SandboxBootSpec::MINIMUM_RAILTIES)
127
+ reasons << "#{@repository} locks railties #{railties}; the engine needs Rails #{SandboxBootSpec::MINIMUM_RAILTIES} or later"
128
+ end
129
+ if read("config/application.rb").nil?
130
+ reasons << "#{@repository} has no config/application.rb at its root: a sandbox needs the Rails app at the repository root"
131
+ end
132
+ reasons
133
+ end
134
+
135
+ # { "ruby" => "3.3.6" or nil, "gems" => { name => version } }, or nil
136
+ # when the lock does not parse.
137
+ def lock_facts(text)
138
+ parser = ::Bundler::LockfileParser.new(text)
139
+ gems = parser.specs.each_with_object({}) { |spec, all| all[spec.name] ||= spec.version.to_s }
140
+ { "ruby" => parser.ruby_version.to_s[/\d+\.\d+(?:\.\d+)?/], "gems" => gems }
141
+ rescue StandardError
142
+ nil
143
+ end
144
+
145
+ def ruby_requirement(facts)
146
+ return [ facts["ruby"], "Gemfile.lock" ] if facts&.dig("ruby")
147
+
148
+ version = read(".ruby-version").to_s.lines.first.to_s.strip.delete_prefix("ruby-")[/\A\d+\.\d+(?:\.\d+)?/]
149
+ version ? [ version, ".ruby-version" ] : [ nil, nil ]
150
+ end
151
+
152
+ def below?(version, minimum)
153
+ Gem::Version.correct?(version) && Gem::Version.new(version) < minimum
154
+ end
155
+
156
+ def database_adapters
157
+ text = read("config/database.yml").to_s.gsub(ERB_TAG, "")
158
+ text.scan(/^[ \t]*adapter:[ \t]*["']?([A-Za-z0-9_]+)/).flatten.uniq
159
+ end
160
+
161
+ def required_services(facts)
162
+ gemfile = read("Gemfile").to_s
163
+ named = gemfile.scan(/^\s*gem\s+["']([A-Za-z0-9_.-]+)["']/).flatten
164
+ locked = facts ? facts["gems"].keys : []
165
+ (named | locked).filter_map { |gem| SERVICE_GEMS[gem] }.uniq.sort
166
+ end
167
+
168
+ def warnings(adapters, services)
169
+ known = LocalSandboxDatabases::FILE_ADAPTERS + LocalSandboxDatabases::SERVER_ADAPTERS
170
+ unknown = adapters - known
171
+ notes = services.map { |service| "The app uses #{service}, which a sandbox does not run: features that need it may fail." }
172
+ if unknown.any?
173
+ notes << "config/database.yml names #{unknown.join(", ")}, which a local sandbox cannot give databases of its own."
174
+ end
175
+ notes
176
+ end
177
+ end
178
+ end
@@ -0,0 +1,142 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActionAgent
4
+ # Used to find the environment variables a repository expects before its
5
+ # first boot, so the project's secrets form can ask for all of them at
6
+ # once. Deterministic and model-free: the same files at the same commit give
7
+ # the same names. Read through GitHub's contents API:
8
+ #
9
+ # .env.example, .env.sample every NAME= line
10
+ # .activeagents/sandbox.yml its `secrets:` key, a list of names or a
11
+ # mapping of names to descriptions
12
+ # config/**, lib/** ENV.fetch("NAME") and ENV["NAME"] call sites
13
+ # in Ruby, YAML and ERB files, at most
14
+ # MAX_SCANNED_FILES of them in path order
15
+ #
16
+ # Variables Rails or the sandbox sets (RUNTIME_VARIABLES, and the names a
17
+ # ProjectSecret may not take) are left out.
18
+ #
19
+ # Every file is read at the commit the ref names, and only when the
20
+ # commit's file listing has it. What was found is kept in Rails.cache for
21
+ # that commit, so discovering a repository again costs one GitHub call
22
+ # until it changes.
23
+ class ProjectSecretDiscovery
24
+ ENV_FILES = %w[.env.example .env.sample].freeze
25
+ CACHE_TTL = 1.day
26
+ SANDBOX_CONFIG = LocalSandboxBackend::Config::PATH
27
+ SCANNED = %r{\A(?:config|lib)/[^\0]+\.(?:rb|ya?ml|erb)\z}
28
+ MAX_SCANNED_FILES = 40
29
+ MAX_SCANNED_BYTES = 128 * 1024
30
+ # Where a variable was found, listed up to this many places.
31
+ MAX_SOURCES = 5
32
+ ENV_FILE_LINE = /^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=/
33
+ NAME = "[A-Za-z_][A-Za-z0-9_]*"
34
+ # ENV.fetch("NAME") with no default and no block: the app cannot boot
35
+ # without it.
36
+ REQUIRED_FETCH = /ENV\.fetch\(\s*["'](#{NAME})["']\s*\)(?!\s*(?:\{|do\b))/
37
+ ANY_FETCH = /ENV\.fetch\(\s*["'](#{NAME})["']/
38
+ INDEX = /ENV\[\s*["'](#{NAME})["']\s*\]/
39
+ RUNTIME_VARIABLES = %w[
40
+ CI HOME HOSTNAME JOB_CONCURRENCY LANG PIDFILE PORT RACK_ENV RAILS_ENV RAILS_LOG_LEVEL RAILS_LOG_TO_STDOUT
41
+ RAILS_MAX_THREADS RAILS_MIN_THREADS RAILS_SERVE_STATIC_FILES SOLID_QUEUE_IN_PUMA TZ WEB_CONCURRENCY
42
+ ].freeze
43
+
44
+ # @param client [GithubClient]
45
+ # @param repository [String] owner/name
46
+ # @param ref [String] the branch, tag or commit to read
47
+ # @return [Hash] see #call, with +ref+ as asked and +commit+, the SHA it
48
+ # named
49
+ # @raise [GithubClient::NotFound] when the ref names no commit
50
+ def self.call(client, repository:, ref:)
51
+ commit = client.commit_sha(repository, ref) or
52
+ raise GithubClient::NotFound, "#{repository} has no commit at #{ref.presence || "its default branch"}"
53
+
54
+ found = Rails.cache.fetch([ name, ActionAgent::VERSION, repository, commit ], expires_in: CACHE_TTL) do
55
+ new(client, repository: repository, ref: commit).call
56
+ end
57
+ found.deep_symbolize_keys.merge(ref: ref, commit: commit)
58
+ end
59
+
60
+ def initialize(client, repository:, ref:)
61
+ @client = client
62
+ @repository = repository
63
+ @ref = ref
64
+ @found = {}
65
+ end
66
+
67
+ # @return [Hash]
68
+ # variables [{ name:, required:, sources: [String] (at most
69
+ # MAX_SOURCES), description:, organization_key: provider or
70
+ # nil }], sorted by name
71
+ # scanned the paths read for ENV call sites
72
+ # truncated whether files were left unread (more than
73
+ # MAX_SCANNED_FILES, or GitHub truncated the file listing)
74
+ def call
75
+ listing = @client.tree(@repository, ref: @ref)
76
+ listed = listing[:paths].to_set { |entry| entry[:path] }
77
+ # A truncated listing may leave out a file that is there.
78
+ present = ->(path) { listing[:truncated] || listed.include?(path) }
79
+ ENV_FILES.select(&present).each { |path| scan_env_file(path) }
80
+ scan_sandbox_config if present.call(SANDBOX_CONFIG)
81
+ candidates = listing[:paths].select { |entry| SCANNED.match?(entry[:path]) && entry[:size] <= MAX_SCANNED_BYTES }
82
+ .sort_by { |entry| entry[:path] }
83
+ scanned = candidates.first(MAX_SCANNED_FILES).map { |entry| entry[:path] }
84
+ scanned.each { |path| scan_source(path) }
85
+
86
+ {
87
+ variables: @found.values.sort_by { |variable| variable[:name] }.map do |variable|
88
+ variable.merge(sources: variable[:sources].uniq.first(MAX_SOURCES),
89
+ organization_key: ProjectSecret::ORGANIZATION_KEY_PROVIDERS[variable[:name]])
90
+ end,
91
+ scanned: scanned,
92
+ truncated: listing[:truncated] || candidates.size > MAX_SCANNED_FILES,
93
+ ref: @ref
94
+ }
95
+ end
96
+
97
+ private
98
+
99
+ def scan_env_file(path)
100
+ text = @client.file(@repository, path, ref: @ref) or return
101
+
102
+ text.each_line.with_index(1) do |line, number|
103
+ name = line[ENV_FILE_LINE, 1]
104
+ note(name, "#{path}:#{number}") if name
105
+ end
106
+ end
107
+
108
+ def scan_sandbox_config
109
+ text = @client.file(@repository, SANDBOX_CONFIG, ref: @ref) or return
110
+
111
+ data = YAML.safe_load(text, aliases: false)
112
+ entries = data.is_a?(Hash) ? data["secrets"] : nil
113
+ case entries
114
+ when Array then entries.each { |name| note(name.to_s, SANDBOX_CONFIG, required: true) }
115
+ when Hash then entries.each { |name, description| note(name.to_s, SANDBOX_CONFIG, required: true, description: description) }
116
+ end
117
+ rescue Psych::Exception
118
+ nil
119
+ end
120
+
121
+ def scan_source(path)
122
+ text = @client.file(@repository, path, ref: @ref) or return
123
+
124
+ required = text.scan(REQUIRED_FETCH).flatten.to_set
125
+ text.each_line.with_index(1) do |line, number|
126
+ (line.scan(ANY_FETCH) + line.scan(INDEX)).flatten.each do |name|
127
+ note(name, "#{path}:#{number}", required: required.include?(name))
128
+ end
129
+ end
130
+ end
131
+
132
+ def note(name, source, required: false, description: nil)
133
+ return unless SandboxBootSpec::ENV_NAME.match?(name)
134
+ return if RUNTIME_VARIABLES.include?(name) || ProjectSecret.refused_name?(name)
135
+
136
+ entry = @found[name] ||= { name: name, required: false, sources: [], description: nil }
137
+ entry[:required] ||= required
138
+ entry[:sources] << source
139
+ entry[:description] ||= description.to_s.truncate(200).presence if description.is_a?(String)
140
+ end
141
+ end
142
+ end