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.
- checksums.yaml +4 -4
- data/README.md +9 -1
- data/app/assets/builds/action_agent.css +1 -1
- data/app/assets/builds/action_agent.js +61 -61
- data/app/controllers/action_agent/api/agents_controller.rb +1 -17
- data/app/controllers/action_agent/api/evaluation_reports_controller.rb +11 -7
- data/app/controllers/action_agent/api/github_app_webhooks_controller.rb +98 -0
- data/app/controllers/action_agent/api/github_installations_controller.rb +9 -1
- data/app/controllers/action_agent/api/mcp_controller.rb +5 -2
- data/app/controllers/action_agent/api/projects_controller.rb +148 -44
- data/app/controllers/action_agent/api/sandboxes_controller.rb +31 -6
- data/app/controllers/action_agent/dashboard_controller.rb +26 -0
- data/app/controllers/concerns/action_agent/api/mcp_dashboard_tools.rb +199 -24
- data/app/jobs/action_agent/github_webhook_job.rb +102 -0
- data/app/models/action_agent/github_connection.rb +21 -0
- data/app/models/action_agent/github_installation.rb +28 -1
- data/app/models/action_agent/github_webhook_delivery.rb +45 -0
- data/app/models/action_agent/provider_key.rb +8 -1
- data/app/models/action_agent/scenario_catalog.rb +8 -2
- data/app/serializers/action_agent/agent_version_serializer.rb +29 -0
- data/app/serializers/action_agent/evaluation_serializer.rb +9 -0
- data/app/services/action_agent/evaluation_report_import.rb +112 -19
- data/app/services/action_agent/github_client.rb +11 -2
- data/app/services/action_agent/local_sandbox_claude_login.rb +1 -1
- data/app/services/action_agent/project_capabilities.rb +6 -3
- data/config/routes.rb +15 -0
- data/lib/action_agent/claude_login_process.rb +1 -1
- data/lib/action_agent/version.rb +1 -1
- data/lib/action_agent.rb +15 -0
- data/lib/generators/action_agent/templates/action_agent.rb.erb +6 -0
- data/lib/generators/action_agent/templates/migrations/029_create_active_agent_github_webhook_deliveries.rb.erb +23 -0
- metadata +7 -2
|
@@ -591,23 +591,7 @@ module ActionAgent
|
|
|
591
591
|
end
|
|
592
592
|
|
|
593
593
|
def version_json(version, include_diff: false)
|
|
594
|
-
|
|
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 —
|
|
14
|
-
#
|
|
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
|
-
|
|
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:
|
|
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
|
|
199
|
-
"
|
|
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
|
|
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
|
-
|
|
59
|
-
repository = reachable_repository!(
|
|
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" =>
|
|
64
|
-
|
|
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
|
|
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
|
-
|
|
75
|
-
repository = reachable_repository!(
|
|
111
|
+
sources = github_sources! or return
|
|
112
|
+
source, repository = reachable_repository!(sources)
|
|
113
|
+
return unless source
|
|
76
114
|
|
|
77
|
-
discovery = ProjectSecretDiscovery.call(
|
|
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
|
|
91
|
-
#
|
|
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
|
-
|
|
101
|
-
repository = reachable_repository!(
|
|
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(
|
|
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 =
|
|
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
|
-
|
|
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
|
|
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:
|
|
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:
|
|
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
|
-
|
|
300
|
-
|
|
301
|
-
|
|
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: "
|
|
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
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
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,
|
|
315
|
-
#
|
|
316
|
-
#
|
|
317
|
-
|
|
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
|
-
|
|
324
|
-
|
|
389
|
+
sources.each do |source|
|
|
390
|
+
selected = source.repository(name)
|
|
391
|
+
return [ source, selected ] if selected
|
|
392
|
+
end
|
|
325
393
|
|
|
326
|
-
|
|
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
|
-
|
|
334
|
-
|
|
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
|
|
337
|
-
|
|
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
|
-
|
|
354
|
-
repository = reachable_repository!(
|
|
355
|
-
|
|
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 =
|
|
218
|
-
|
|
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,
|