actionagent 1.9.4 → 1.9.6

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 (32) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +9 -1
  3. data/app/assets/builds/action_agent.css +1 -1
  4. data/app/assets/builds/action_agent.js +61 -61
  5. data/app/controllers/action_agent/api/agents_controller.rb +1 -17
  6. data/app/controllers/action_agent/api/evaluation_reports_controller.rb +11 -7
  7. data/app/controllers/action_agent/api/github_app_webhooks_controller.rb +98 -0
  8. data/app/controllers/action_agent/api/github_installations_controller.rb +9 -1
  9. data/app/controllers/action_agent/api/mcp_controller.rb +5 -2
  10. data/app/controllers/action_agent/api/projects_controller.rb +148 -44
  11. data/app/controllers/action_agent/api/sandboxes_controller.rb +31 -6
  12. data/app/controllers/action_agent/dashboard_controller.rb +26 -0
  13. data/app/controllers/concerns/action_agent/api/mcp_dashboard_tools.rb +199 -24
  14. data/app/jobs/action_agent/github_webhook_job.rb +102 -0
  15. data/app/models/action_agent/github_connection.rb +21 -0
  16. data/app/models/action_agent/github_installation.rb +28 -1
  17. data/app/models/action_agent/github_webhook_delivery.rb +45 -0
  18. data/app/models/action_agent/provider_key.rb +8 -1
  19. data/app/models/action_agent/scenario_catalog.rb +8 -2
  20. data/app/serializers/action_agent/agent_version_serializer.rb +29 -0
  21. data/app/serializers/action_agent/evaluation_serializer.rb +9 -0
  22. data/app/services/action_agent/evaluation_report_import.rb +112 -19
  23. data/app/services/action_agent/github_client.rb +11 -2
  24. data/app/services/action_agent/local_sandbox_claude_login.rb +1 -1
  25. data/app/services/action_agent/project_capabilities.rb +6 -3
  26. data/config/routes.rb +15 -0
  27. data/lib/action_agent/claude_login_process.rb +1 -1
  28. data/lib/action_agent/version.rb +1 -1
  29. data/lib/action_agent.rb +15 -0
  30. data/lib/generators/action_agent/templates/action_agent.rb.erb +6 -0
  31. data/lib/generators/action_agent/templates/migrations/029_create_active_agent_github_webhook_deliveries.rb.erb +23 -0
  32. metadata +7 -2
@@ -591,23 +591,7 @@ module ActionAgent
591
591
  end
592
592
 
593
593
  def version_json(version, include_diff: false)
594
- json = {
595
- id: version.id,
596
- version_number: version.version_number,
597
- change_summary: version.change_summary,
598
- created_by: version.created_by,
599
- created_at: version.created_at,
600
- is_latest: version.latest?,
601
- release: version.release?,
602
- release_digest: version.release_digest,
603
- revision: version.revision
604
- }
605
-
606
- if include_diff && version.previous
607
- json[:diff] = version.diff(version.previous)
608
- end
609
-
610
- json
594
+ AgentVersionSerializer.call(version, include_diff: include_diff)
611
595
  end
612
596
 
613
597
  def default_appearance_for(preset)
@@ -10,11 +10,14 @@ module ActionAgent
10
10
  # stored by EvaluationReportImport. Responds:
11
11
  #
12
12
  # 201 — stored; the receipt the publisher checks
13
- # 200 — an identical retry; the receipt names the stored run. Never
14
- # refused by the quota or the rate limit.
13
+ # 200 — a delivery under a run_id already stored: an identical retry,
14
+ # or progress on a running run (EvaluationReportImport); the
15
+ # receipt names the stored run and its status. Never refused by
16
+ # the quota or the rate limit.
15
17
  # 409 — this run_id already holds a different report
16
18
  # 422 — not a valid version-1 report, or an evaluation name the agent
17
- # already holds for another suite or scope
19
+ # already holds for another suite or scope. The body's
20
+ # `capabilities` lists EvaluationReportImport::CAPABILITIES.
18
21
  # 403 — storing it needs an operator first: a cap on observed agents,
19
22
  # evaluations or scenarios, or no owner for the tenant
20
23
  # 429 — a new report over the host's quota (kind :evaluation_report) or
@@ -58,14 +61,15 @@ module ActionAgent
58
61
  body = request.body.read(EvaluationReportImport::MAX_BYTES + 1).to_s
59
62
  return report_too_large if body.bytesize > EvaluationReportImport::MAX_BYTES
60
63
 
61
- run, duplicate = EvaluationReportImport.call(account: @account, payload: JSON.parse(body), admit: -> { admission_denial })
62
- ActionAgent.record_usage(@account, :evaluation_report) unless duplicate
64
+ run, duplicate, progressed = EvaluationReportImport.call(account: @account, payload: JSON.parse(body), admit: -> { admission_denial })
65
+ stored = !duplicate && !progressed
66
+ ActionAgent.record_usage(@account, :evaluation_report) if stored
63
67
 
64
- render json: receipt(run, duplicate), status: duplicate ? :ok : :created
68
+ render json: receipt(run, duplicate), status: stored ? :created : :ok
65
69
  rescue JSON::ParserError
66
70
  render json: { error: "Invalid JSON" }, status: :bad_request
67
71
  rescue EvaluationReportImport::Invalid, ActiveRecord::RecordInvalid => e
68
- render json: { error: e.message }, status: :unprocessable_entity
72
+ render json: { error: e.message, capabilities: EvaluationReportImport::CAPABILITIES }, status: :unprocessable_entity
69
73
  rescue EvaluationReportImport::Conflict => e
70
74
  render json: { error: e.message }, status: :conflict
71
75
  rescue EvaluationReportImport::Refused => e
@@ -0,0 +1,98 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "openssl"
4
+
5
+ module ActionAgent
6
+ module Api
7
+ # POST <mount>/api/github_app/webhooks: the GitHub App's webhook
8
+ # deliveries (installation, installation_repositories,
9
+ # github_app_authorization), applied by GithubWebhookJob.
10
+ #
11
+ # GitHub's signature authenticates a delivery on its own, so this is not a
12
+ # dashboard controller and sees no session, no forgery protection and none
13
+ # of the host's controller concerns. A delivery is accepted only when its
14
+ # X-Hub-Signature-256 is the HMAC-SHA256 of the raw body under
15
+ # ActionAgent.github_app_webhook_secret; with no secret configured there
16
+ # is no endpoint (404).
17
+ #
18
+ # The answer is 202 as soon as a delivery is recorded and enqueued, or
19
+ # recognized as one already recorded (GithubWebhookDelivery), or found to
20
+ # be an event the dashboard does not handle. Only a failure to enqueue
21
+ # answers otherwise, and leaves the delivery unrecorded so a redelivery
22
+ # of it is processed.
23
+ #
24
+ # The body may be JSON, or a form whose +payload+ field holds the JSON,
25
+ # as GitHub sends it when the App's content type is
26
+ # application/x-www-form-urlencoded. The signature covers the raw body
27
+ # either way.
28
+ class GithubAppWebhooksController < ActionController::API
29
+ wrap_parameters false
30
+
31
+ SIGNATURE_HEADER = "X-Hub-Signature-256"
32
+ EVENT_HEADER = "X-GitHub-Event"
33
+ DELIVERY_HEADER = "X-GitHub-Delivery"
34
+
35
+ def create
36
+ secret = ActionAgent.github_app_webhook_secret
37
+ return head(:not_found) if secret.blank?
38
+
39
+ body = request.raw_post.to_s
40
+ unless signed?(body, secret)
41
+ return render(json: { error: "Invalid or missing #{SIGNATURE_HEADER}" }, status: :unauthorized)
42
+ end
43
+
44
+ event = request.headers[EVENT_HEADER].to_s
45
+ return accepted("ignored") unless event.in?(GithubWebhookJob::EVENTS)
46
+
47
+ delivery_id = request.headers[DELIVERY_HEADER].to_s
48
+ unless delivery_id.match?(/\A[A-Za-z0-9-]{1,64}\z/)
49
+ return render(json: { error: "#{DELIVERY_HEADER} is required" }, status: :bad_request)
50
+ end
51
+
52
+ payload = parse(body)
53
+ return render(json: { error: "The delivery's body is not a JSON object" }, status: :bad_request) unless payload
54
+
55
+ job_payload = GithubWebhookJob.payload_for(event, payload)
56
+ recorded = GithubWebhookDelivery.record!(
57
+ delivery_id: delivery_id, event: event,
58
+ event_action: job_payload["action"].presence&.first(64),
59
+ installation_id: job_payload.dig("installation", "id").presence&.to_i
60
+ )
61
+ return accepted("duplicate") unless recorded
62
+
63
+ begin
64
+ GithubWebhookJob.perform_later(event, job_payload)
65
+ rescue StandardError
66
+ # Not left recorded, so a redelivery of it is processed.
67
+ GithubWebhookDelivery.forget!(delivery_id)
68
+ raise
69
+ end
70
+
71
+ accepted("queued")
72
+ end
73
+
74
+ private
75
+
76
+ # Constant time, whatever the header holds.
77
+ def signed?(body, secret)
78
+ given = request.headers[SIGNATURE_HEADER]
79
+ return false unless given.is_a?(String) && given.start_with?("sha256=")
80
+
81
+ expected = "sha256=#{OpenSSL::HMAC.hexdigest('SHA256', secret, body)}"
82
+ ActiveSupport::SecurityUtils.secure_compare(expected, given)
83
+ end
84
+
85
+ def parse(body)
86
+ body = Rack::Utils.parse_query(body)["payload"].to_s if request.media_type == "application/x-www-form-urlencoded"
87
+ data = JSON.parse(body)
88
+ data if data.is_a?(Hash)
89
+ rescue JSON::ParserError
90
+ nil
91
+ end
92
+
93
+ def accepted(status)
94
+ render json: { status: status }, status: :accepted
95
+ end
96
+ end
97
+ end
98
+ end
@@ -151,6 +151,12 @@ module ActionAgent
151
151
  record.assign_from_github(installation)
152
152
  record.save!
153
153
  "linked"
154
+ # GitHub would not say whether the user administers the organization,
155
+ # which is not the same as saying they do not.
156
+ rescue GithubClient::MembershipForbidden => e
157
+ Rails.logger.warn("[ActionAgent] GitHub App installation #{installation_id} was not linked: #{e.message}. " \
158
+ "Grant the GitHub App the organization permission Members (read).")
159
+ "members_permission"
154
160
  # Another owner linked the same installation between the lookup and
155
161
  # the save.
156
162
  rescue ActiveRecord::RecordNotUnique
@@ -163,7 +169,9 @@ module ActionAgent
163
169
  end
164
170
 
165
171
  # Whether +github_user+ administers +account+: is that user account, or
166
- # an active admin of that organization.
172
+ # an active admin of that organization. The organization check needs
173
+ # the App's organization permission Members (read): without it GitHub
174
+ # answers 403, and GithubClient::MembershipForbidden is raised.
167
175
  def administers?(github, github_user, account)
168
176
  account = {} unless account.is_a?(Hash)
169
177
 
@@ -195,8 +195,11 @@ module ActionAgent
195
195
  "failures). A run_<slug> call that pauses to ask for input returns its input request ids: " \
196
196
  "input_requests_list shows what paused runs are waiting on, and input_requests_answer answers a text or " \
197
197
  "choice request. Scenarios kept as a YAML catalog of products and sets (an .activeagents/evals file) are " \
198
- "imported with catalogs_import, listed with catalogs_list, and one set is run as an evaluation named " \
199
- "catalog/product/set with catalog_set_run (pass project_id to run against a project's running sandbox)."
198
+ "imported with catalogs_import (catalogs_preview shows what an import would change first), listed with " \
199
+ "catalogs_list, written back out with catalogs_export, and one set is run as an evaluation named " \
200
+ "catalog/product/set with catalog_set_run (pass project_id to run against a project's running sandbox). " \
201
+ "agents_versions lists an agent's versions with what each changed, and traces_export reads one trace " \
202
+ "whole, as the payload the trace ingest endpoint takes."
200
203
  end
201
204
 
202
205
  MESSAGE_INPUT_SCHEMA = {
@@ -17,16 +17,19 @@ module ActionAgent
17
17
  LOG_TAIL_BYTES = 8 * 1024
18
18
  LOG_PAGE_BYTES = 64 * 1024
19
19
  LOG_MAX_PAGE_BYTES = 1024 * 1024
20
+ # The most of a backend's error the boot status carries (see #boot_report).
21
+ BACKEND_ERROR_BYTES = 1_000
20
22
  UPDATABLE = %i[name default_ref start_url].freeze
21
23
 
22
24
  before_action :require_owner!
23
- before_action :set_project, except: [ :index, :create, :capabilities, :preflight, :discover_secrets ]
25
+ before_action :set_project, except: [ :index, :create, :capabilities, :repositories, :preflight, :discover_secrets ]
24
26
  before_action :require_execution_enabled!, only: [ :boot, :run_evaluation ]
25
27
 
26
28
  # Later handlers win, so subclasses are registered after GithubClient::Error.
27
29
  rescue_from GithubClient::Error, with: :github_unavailable
28
30
  rescue_from GithubClient::NotFound, with: :github_not_found
29
31
  rescue_from GithubClient::Unauthorized, with: :github_unauthorized
32
+ rescue_from GithubClient::InstallationUnavailable, with: :installation_unavailable
30
33
  rescue_from Project::ConfirmationRequired, with: :confirmation_required
31
34
  rescue_from Project::BootRefused, with: :boot_refused
32
35
  rescue_from SandboxOrchestrator::UnsupportedBackendError, with: :boot_refused
@@ -48,33 +51,68 @@ module ActionAgent
48
51
  render json: capabilities_check.call
49
52
  end
50
53
 
54
+ # GET /api/projects/repositories
55
+ # The New Project picker's list: the repositories the owner's GitHub
56
+ # sources reach (see #github_sources), each marked with whether it is
57
+ # selected and with the source ("app" or "oauth") a project from it
58
+ # would be read and checked out through. An installation lists every
59
+ # repository it reaches only to a caller who may manage it, and its
60
+ # selection to everyone else; the OAuth connection lists what its
61
+ # token reaches, as GET /api/github_connection/repositories does.
62
+ def repositories
63
+ sources = github_sources! or return
64
+
65
+ listed = {}
66
+ sources.each do |source|
67
+ source.repositories.each do |repo|
68
+ listed[repo["full_name"].downcase] ||= repo.merge("selected" => true, "source" => source.checkout_source)
69
+ end
70
+ end
71
+ unavailable = nil
72
+ sources.each do |source|
73
+ listing = source.is_a?(GithubConnection) || manages_github?(source) ? source.reachable_repositories : []
74
+ listing.each do |repo|
75
+ listed[repo["full_name"].downcase] ||= repo.merge("selected" => false, "source" => source.checkout_source)
76
+ end
77
+ rescue GithubClient::InstallationUnavailable => e
78
+ # Marked on the installation; the other sources still list.
79
+ unavailable = e
80
+ end
81
+ raise unavailable if unavailable && listed.empty?
82
+
83
+ render json: { repositories: listed.values }
84
+ end
85
+
51
86
  # GET /api/projects/preflight?repository=owner/name&ref=
52
87
  # Whether the repository can become a project, read through GitHub's
53
88
  # contents API before any sandbox exists (see ProjectPreflight). A
54
- # repository the connection has not selected is looked up by name,
89
+ # repository no GitHub source has selected is looked up by name,
55
90
  # which also reaches repositories past the listing's cap, and needs
56
91
  # :manage_github.
57
92
  def preflight
58
- connection = github_connection! or return
59
- repository = reachable_repository!(connection) or return
93
+ sources = github_sources! or return
94
+ source, repository = reachable_repository!(sources)
95
+ return unless source
60
96
 
61
97
  ref = ref_param || repository["default_branch"]
62
98
  render json: {
63
- repository: repository.merge("selected" => connection.repository(repository["full_name"]).present?),
64
- preflight: ProjectPreflight.call(connection.client, repository: repository["full_name"], ref: ref)
99
+ repository: repository.merge("selected" => source.repository(repository["full_name"]).present?,
100
+ "source" => source.checkout_source),
101
+ preflight: ProjectPreflight.call(source.contents_client(repository), repository: repository["full_name"], ref: ref)
65
102
  }
66
103
  end
67
104
 
68
105
  # GET /api/projects/discover_secrets?repository=owner/name&ref=
69
106
  # The environment variables the repository expects (see
70
107
  # ProjectSecretDiscovery), each marked with whether the project named by
71
- # `project_id`, if any, has it set. A repository the connection has not
108
+ # `project_id`, if any, has it set. A repository no GitHub source has
72
109
  # selected needs :manage_github, as for preflight.
73
110
  def discover_secrets
74
- connection = github_connection! or return
75
- repository = reachable_repository!(connection) or return
111
+ sources = github_sources! or return
112
+ source, repository = reachable_repository!(sources)
113
+ return unless source
76
114
 
77
- discovery = ProjectSecretDiscovery.call(connection.client, repository: repository["full_name"],
115
+ discovery = ProjectSecretDiscovery.call(source.contents_client(repository), repository: repository["full_name"],
78
116
  ref: ref_param || repository["default_branch"])
79
117
  set = params[:project_id].present? ? owned(Project).find(params[:project_id]).secrets.pluck(:name).to_set : Set.new
80
118
  discovery[:variables].each { |variable| variable[:set] = set.include?(variable[:name]) }
@@ -87,8 +125,9 @@ module ActionAgent
87
125
  #
88
126
  # Refused while a blocking capability fails, when the host's quota
89
127
  # denies a :project, and for a repository the preflight does not
90
- # support. Picking a repository the GitHub connection has not selected
91
- # selects it, which needs :manage_github.
128
+ # support. Picking a repository no GitHub source has selected selects
129
+ # it on the source it was found through (a GitHub App installation
130
+ # ahead of the OAuth connection), which needs :manage_github.
92
131
  def create
93
132
  failures = capabilities_check.blocking_failures
94
133
  if failures.any?
@@ -97,11 +136,13 @@ module ActionAgent
97
136
  end
98
137
  return if quota_denied?
99
138
 
100
- connection = github_connection! or return
101
- repository = reachable_repository!(connection) or return
139
+ sources = github_sources! or return
140
+ source, repository = reachable_repository!(sources)
141
+ return unless source
102
142
  full_name = repository["full_name"]
103
143
  ref = ref_param
104
- report = ProjectPreflight.call(connection.client, repository: full_name, ref: ref || repository["default_branch"])
144
+ report = ProjectPreflight.call(source.contents_client(repository), repository: full_name,
145
+ ref: ref || repository["default_branch"])
105
146
  if report["status"] == "unsupported"
106
147
  return render json: { error: report["summary"], code: "unsupported_repository", preflight: report },
107
148
  status: :unprocessable_entity
@@ -114,10 +155,10 @@ module ActionAgent
114
155
  return unless secret_sources_allowed!(secrets)
115
156
  return unless authorize_secrets!(project, secrets)
116
157
 
117
- selecting = connection.repository(full_name).nil?
158
+ selecting = source.repository(full_name).nil?
118
159
  Project.transaction do
119
160
  # The selection is what a project's sandbox is booted from.
120
- connection.update!(repositories: connection.repositories + [ repository ]) if selecting
161
+ source.update!(repositories: source.repositories + [ repository ]) if selecting
121
162
  project.save!
122
163
  secrets.each { |attributes| assign_secret_params(project, attributes).save! }
123
164
  project.ensure_app_assistant! unless project.engine_installed?
@@ -175,18 +216,20 @@ module ActionAgent
175
216
  # The current sandbox's boot, step by step (see
176
217
  # SandboxOrchestrator#boot_status), with the scrubbed tail of the step
177
218
  # that failed or is running, and the modes its browser can start in
178
- # (none when the backend runs no browsers).
219
+ # (none when the backend runs no browsers). When the backend cannot
220
+ # report the boot, `boot` is null and `backend_error` names the backend
221
+ # and the reason (see #boot_report).
179
222
  def boot_status
180
223
  sandbox = @project.current_sandbox_session
181
224
  orchestrator = SandboxOrchestrator.new
182
- status = sandbox && orchestrator.supports?(:boot_status) ? orchestrator.boot_status(sandbox) : nil
183
- status = SecretScrubber.scrub(status.deep_symbolize_keys, @project.scrub_values) if status.is_a?(Hash)
225
+ status, log_tail, backend_error = boot_report(orchestrator, sandbox)
184
226
 
185
227
  render json: {
186
228
  project: @project.summary,
187
229
  boot: status,
188
- log_tail: status && log_tail(orchestrator, sandbox, status),
230
+ log_tail: log_tail,
189
231
  error: sandbox&.failed? ? SecretScrubber.scrub(sandbox.error_summary, @project.scrub_values) : nil,
232
+ backend_error: backend_error,
190
233
  logs: sandbox.present? && orchestrator.supports?(:boot_log),
191
234
  confirmation: @project.local_boot_confirmation(orchestrator),
192
235
  browser_modes: browser_modes(orchestrator)
@@ -273,7 +316,7 @@ module ActionAgent
273
316
  def capabilities_check
274
317
  @capabilities_check ||= ProjectCapabilities.new(
275
318
  owner: current_owner,
276
- github_connected: owned(GithubConnection).exists?,
319
+ github_connected: github_sources.any?,
277
320
  base_url: "#{request.base_url}#{request.script_name}"
278
321
  )
279
322
  end
@@ -296,45 +339,78 @@ module ActionAgent
296
339
  performed?
297
340
  end
298
341
 
299
- def github_connection!
300
- connection = owned(GithubConnection).first
301
- return connection if connection
342
+ # Where the owner's repositories are reached through, in the order a
343
+ # repository is looked for in them: the usable GitHub App
344
+ # installations in the order they were linked, then the OAuth
345
+ # connection. It is the order SandboxSession#repository_available
346
+ # checks a project's repository out in, so a project is read through
347
+ # the source its sandbox will clone from. Installations count only
348
+ # while a GitHub App is configured, since nothing else can mint their
349
+ # tokens. Each source answers #repository, #repositories,
350
+ # #reachable_repositories, #find_repository and #contents_client.
351
+ def github_sources
352
+ @github_sources ||= begin
353
+ installations = ActionAgent.github_app_configured? ? owned(GithubInstallation).order(:id).select(&:usable?) : []
354
+ [ *installations, owned(GithubConnection).first ].compact
355
+ end
356
+ end
357
+
358
+ def github_sources!
359
+ sources = github_sources
360
+ return sources if sources.any?
302
361
 
303
- render json: { error: "Connect GitHub in Settings → Integrations first", code: "github_not_connected" },
362
+ render json: { error: "Install the GitHub App or connect GitHub in Settings → Integrations first", code: "github_not_connected" },
304
363
  status: :unprocessable_entity
305
364
  nil
306
365
  end
307
366
 
308
- # The repository +name+ names, as the connection reaches it: from its
309
- # selection, else asked of GitHub by name. Renders a 403 for a
310
- # repository outside the selection when the caller may not manage the
311
- # GitHub connection, and a 404 when GitHub finds none it can reach.
367
+ def manages_github?(source)
368
+ ActionAgent.permitted?(current_user, :manage_github, source)
369
+ end
370
+
371
+ # [source, repository] for the repository +name+ names: from the first
372
+ # source that selected it, else asked of GitHub by name through each
373
+ # source the caller may manage, in #github_sources' order. Renders a
374
+ # 403 for a repository outside every selection when the caller may
375
+ # manage none of the sources, and a 404 when GitHub finds none they
376
+ # reach.
312
377
  #
313
378
  # The connection's token can be one member's, reaching that member's
314
- # own repositories, so only the selection is shared with every member.
315
- # The 403 is decided before GitHub is asked, so it says nothing about
316
- # whether the repository exists.
317
- def reachable_repository!(connection, name = params[:repository])
379
+ # own repositories, and an installation can reach repositories a
380
+ # member cannot see on GitHub, so only the selections are shared with
381
+ # every member. The 403 is decided before GitHub is asked, so it says
382
+ # nothing about whether the repository exists.
383
+ def reachable_repository!(sources, name = params[:repository])
318
384
  unless name.is_a?(String) && Project::REPOSITORY.match?(name)
319
385
  render json: { error: "repository must be owner/name" }, status: :bad_request
320
386
  return nil
321
387
  end
322
388
 
323
- selected = connection.repository(name)
324
- return selected if selected
389
+ sources.each do |source|
390
+ selected = source.repository(name)
391
+ return [ source, selected ] if selected
392
+ end
325
393
 
326
- unless ActionAgent.permitted?(current_user, :manage_github, connection)
394
+ managed = sources.select { |source| manages_github?(source) }
395
+ if managed.empty?
327
396
  render json: { error: "#{name} is not one of the repositories selected in Settings → Integrations, and only " \
328
397
  "someone who may manage the GitHub connection can pick another",
329
398
  code: "forbidden", permission: :manage_github }, status: :forbidden
330
399
  return nil
331
400
  end
332
401
 
333
- repository = connection.client.repository(name)
334
- return repository if repository
402
+ unavailable = []
403
+ managed.each do |source|
404
+ repository = source.find_repository(name)
405
+ return [ source, repository ] if repository
406
+ rescue GithubClient::InstallationUnavailable => e
407
+ unavailable << e
408
+ end
409
+ # Every source asked turned out removed or suspended.
410
+ raise unavailable.last if unavailable.size == managed.size
335
411
 
336
- render json: { error: "GitHub found no repository #{name} that this connection can reach", code: "repository_not_found" },
337
- status: :not_found
412
+ render json: { error: "GitHub found no repository #{name} that this dashboard's GitHub access can reach",
413
+ code: "repository_not_found" }, status: :not_found
338
414
  nil
339
415
  end
340
416
 
@@ -350,9 +426,10 @@ module ActionAgent
350
426
  return nil if requested == @project.default_ref.to_s
351
427
  return nil unless authorize_secret_handover!(@project)
352
428
 
353
- connection = github_connection! or return
354
- repository = reachable_repository!(connection, @project.repository) or return
355
- report = ProjectPreflight.call(connection.client, repository: @project.repository,
429
+ sources = github_sources! or return
430
+ source, repository = reachable_repository!(sources, @project.repository)
431
+ return unless source
432
+ report = ProjectPreflight.call(source.contents_client(repository), repository: @project.repository,
356
433
  ref: requested.presence || repository["default_branch"])
357
434
  if report["status"] == "unsupported"
358
435
  render json: { error: report["summary"], code: "unsupported_repository", preflight: report },
@@ -462,6 +539,27 @@ module ActionAgent
462
539
  []
463
540
  end
464
541
 
542
+ # The backend's account of the sandbox's boot, scrubbed, with the tail
543
+ # of the step's log that goes with it: [status, log_tail, nil]. When the
544
+ # backend cannot give one (no host configured, the host unreachable, a
545
+ # container that has not written its state yet), [nil, nil, error],
546
+ # where error names the backend and the scrubbed reason. The page then
547
+ # shows the boot as unknown for that reason and keeps following it,
548
+ # instead of failing to load the project (#650). The sandbox's own
549
+ # failure, if it failed, is reported beside it as `error`.
550
+ def boot_report(orchestrator, sandbox)
551
+ return [ nil, nil, nil ] unless sandbox && orchestrator.supports?(:boot_status)
552
+
553
+ status = orchestrator.boot_status(sandbox)
554
+ status = SecretScrubber.scrub(status.deep_symbolize_keys, @project.scrub_values) if status.is_a?(Hash)
555
+ [ status, status && log_tail(orchestrator, sandbox, status), nil ]
556
+ rescue StandardError => e
557
+ Rails.logger.warn("[ActionAgent] the #{orchestrator.backend_name} sandbox backend could not report the boot of " \
558
+ "sandbox #{sandbox.session_id}: #{e.class}: #{e.message}")
559
+ [ nil, nil, { backend: orchestrator.backend_name,
560
+ message: SecretScrubber.scrub(e.message.to_s, @project.scrub_values).truncate(BACKEND_ERROR_BYTES) } ]
561
+ end
562
+
465
563
  # The last LOG_TAIL_BYTES of the step that failed, else of the one
466
564
  # running, else of the last that ran: { step:, text:, truncated: }.
467
565
  def log_tail(orchestrator, sandbox, status)
@@ -500,6 +598,12 @@ module ActionAgent
500
598
  status: :unprocessable_entity
501
599
  end
502
600
 
601
+ def installation_unavailable(exception)
602
+ state = exception.reason == :suspended ? "is suspended" : "was removed"
603
+ render json: { error: "The GitHub App installation #{state} on GitHub. Reinstall the GitHub App in Settings → Integrations.",
604
+ code: "installation_unavailable", reinstall_required: true }, status: :unprocessable_entity
605
+ end
606
+
503
607
  def github_not_found(exception)
504
608
  render json: { error: exception.message, code: "not_found" }, status: :not_found
505
609
  end
@@ -211,18 +211,19 @@ module ActionAgent
211
211
  # GET /api/sandboxes/:session_id/boot
212
212
  # How a checkout's boot went, step by step (see
213
213
  # SandboxOrchestrator#boot_status). `boot` is null when the backend
214
- # reports no steps, or holds nothing for the sandbox.
214
+ # reports no steps, or holds nothing for the sandbox; when the backend
215
+ # cannot report it at all, `backend_error` names the backend and the
216
+ # reason (see #boot_report).
215
217
  def boot
216
218
  orchestrator = SandboxOrchestrator.new
217
- status = @sandbox.app_runtime? && orchestrator.supports?(:boot_status) ? orchestrator.boot_status(@sandbox) : nil
218
- # A project's checkout booted with its secrets, which the backend may
219
- # not know to mask.
220
- status = SecretScrubber.scrub(status, @sandbox.project_scrub_values) if status
221
- render json: {
219
+ status, backend_error = boot_report(orchestrator)
220
+ payload = {
222
221
  boot: status,
223
222
  resumable: !!(status&.dig(:kept) && @sandbox.failed? && !@sandbox.past_expiry? && orchestrator.supports?(:resume_boot)),
224
223
  logs: @sandbox.app_runtime? && orchestrator.supports?(:boot_log)
225
224
  }
225
+ payload[:backend_error] = backend_error if backend_error
226
+ render json: payload
226
227
  end
227
228
 
228
229
  # GET /api/sandboxes/:session_id/boot_log?step=NAME&offset=N&limit=N
@@ -298,6 +299,30 @@ module ActionAgent
298
299
 
299
300
  private
300
301
 
302
+ # The most of a backend's error the boot status carries (see #boot_report).
303
+ BACKEND_ERROR_BYTES = 1_000
304
+
305
+ # The backend's account of the sandbox's boot, scrubbed of its
306
+ # project's secrets (a project's checkout booted with them, which the
307
+ # backend may not know to mask): [status, nil]. When the backend cannot
308
+ # give one (no host configured, the host unreachable, a container that
309
+ # has not written its state yet), [nil, error], where error names the
310
+ # backend and the scrubbed reason, so the page reports it instead of
311
+ # failing on a 500 (#650; Api::ProjectsController#boot_report is the
312
+ # same for a project).
313
+ def boot_report(orchestrator)
314
+ return [ nil, nil ] unless @sandbox.app_runtime? && orchestrator.supports?(:boot_status)
315
+
316
+ status = orchestrator.boot_status(@sandbox)
317
+ status = SecretScrubber.scrub(status, @sandbox.project_scrub_values) if status
318
+ [ status, nil ]
319
+ rescue StandardError => e
320
+ Rails.logger.warn("[ActionAgent] the #{orchestrator.backend_name} sandbox backend could not report the boot of " \
321
+ "sandbox #{@sandbox.session_id}: #{e.class}: #{e.message}")
322
+ [ nil, { backend: orchestrator.backend_name,
323
+ message: SecretScrubber.scrub(e.message.to_s, @sandbox.project_scrub_values).truncate(BACKEND_ERROR_BYTES) } ]
324
+ end
325
+
301
326
  def set_sandbox
302
327
  @sandbox = account_scoped(owned(SandboxSession)).find_by!(session_id: params[:id])
303
328
  end
@@ -24,8 +24,34 @@ module ActionAgent
24
24
  render "action_agent/dashboard/index", locals: { props: props }
25
25
  end
26
26
 
27
+ # A shareable link to an evaluation, one of its runs, or one scenario
28
+ # result of that run (/evaluations/:id/runs/:run_id/results/:result_id).
29
+ # The page is the dashboard either way; what the link names is checked
30
+ # against the caller's agents, the scope the evaluations API reads, so a
31
+ # member of the owning account opens it and anyone else gets a 404 —
32
+ # the same answer a link to nothing gets, so it says nothing about what
33
+ # another account has. The dashboard still renders, and its own request
34
+ # for the evaluation reports it unavailable. A telemetry-only install,
35
+ # with no evaluations table, has nothing to link to either.
36
+ def evaluation
37
+ linked_evaluation_records
38
+ index
39
+ rescue ActiveRecord::RecordNotFound, ActiveRecord::StatementInvalid
40
+ render "action_agent/dashboard/index", locals: { props: props }, status: :not_found
41
+ end
42
+
27
43
  private
28
44
 
45
+ # Raises RecordNotFound unless the evaluation is the caller's, the run
46
+ # is one of its runs and the result one of that run's results.
47
+ def linked_evaluation_records
48
+ evaluation = Evaluation.joins(:agent).where(agent: owner_agents).find(params[:evaluation_id])
49
+ return unless params[:run_id]
50
+
51
+ run = evaluation.evaluation_runs.find(params[:run_id])
52
+ run.scenario_results.find(params[:result_id]) if params[:result_id]
53
+ end
54
+
29
55
  def props
30
56
  {
31
57
  user: current_user_props,