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
@@ -53,9 +53,12 @@ module ActionAgent
53
53
  # raised
54
54
  # - judge_provider_error: true when that lookup raised, as it does
55
55
  # when a stored key no longer decrypts
56
- # - model_providers: the providers agent runs have credentials
57
- # for, leaving out any whose credentials
58
- # cannot be read
56
+ # - model_providers: the providers scenario replays have
57
+ # credentials for, leaving out any whose
58
+ # credentials cannot be read. Resolved for
59
+ # the caller replays run as, never the
60
+ # signed-in member, whose personal keys no
61
+ # replay uses.
59
62
  def index
60
63
  scope = evaluations_scope
61
64
  scope = scope.where(agent_id: params[:agent_id]) if params[:agent_id].present?
@@ -69,7 +72,9 @@ module ActionAgent
69
72
  evaluations: evaluations.map { |evaluation| serialize(evaluation) },
70
73
  archived_count: archived_count,
71
74
  **judge_provider_fields(owner),
72
- model_providers: AgentExecutionService.available_providers(owner)
75
+ model_providers: AgentExecutionService.available_providers(
76
+ owner, actor: ScenarioEvaluationRunner.replay_actor_for(owner)
77
+ )
73
78
  }
74
79
  end
75
80
 
@@ -0,0 +1,213 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActionAgent
4
+ module Api
5
+ # Explorations and the review of their candidate scenarios: listing them,
6
+ # reading one with its candidates, submitting candidates found outside
7
+ # the dashboard, editing, rejecting and accepting candidates, and
8
+ # stopping a walk to review what it found. See Exploration.
9
+ #
10
+ # Everything is scoped to the caller's owner, and another owner's
11
+ # exploration reads as 404. Accepting writes into an evaluation, so it
12
+ # needs :replace_scenarios; storing and editing candidates changes only
13
+ # the exploration.
14
+ class ExplorationsController < BaseController
15
+ include EvaluationRunStarting
16
+
17
+ LIST_LIMIT = 50
18
+
19
+ before_action :require_owner!
20
+ before_action :set_exploration, except: [ :index, :create ]
21
+
22
+ rescue_from Exploration::InvalidCandidate, Exploration::CandidateLimitExceeded, with: :invalid_candidate
23
+ rescue_from Exploration::AcceptRefused, with: :accept_refused
24
+ rescue_from Evaluation::ScenarioLimitExceeded, with: :scenario_limit
25
+
26
+ # GET /api/explorations?project_id=&evaluation_id=
27
+ def index
28
+ scope = owned(Exploration).recent
29
+ scope = scope.where(project_id: params[:project_id].to_s) if params[:project_id].present?
30
+ scope = scope.where(evaluation_id: params[:evaluation_id].to_s) if params[:evaluation_id].present?
31
+
32
+ render json: { explorations: scope.limit(LIST_LIMIT).map(&:summary) }
33
+ end
34
+
35
+ # GET /api/explorations/:id
36
+ def show
37
+ render json: detail(@exploration)
38
+ end
39
+
40
+ # POST /api/explorations { project_id: | evaluation_id:, candidates: [...] }
41
+ # Stores candidates an agent outside the dashboard found, as a new
42
+ # exploration with source "external" that is ready for review. An
43
+ # observed agent's evaluation is refused, as the MCP facade's
44
+ # `explorations_submit` refuses it, unless a host adapter replays it.
45
+ def create
46
+ project, evaluation = submission_target
47
+ return if performed?
48
+
49
+ exploration = Exploration.build_for(project: project, evaluation: evaluation, user: current_user,
50
+ account: current_account, source: "external", status: "review", started_at: Time.current,
51
+ finished_at: Time.current)
52
+ exploration.save_with_candidates!(candidate_list)
53
+
54
+ render json: detail(exploration), status: :created
55
+ end
56
+
57
+ # PATCH /api/explorations/:id/candidates/:candidate_id
58
+ # { prompt:, group:, notes: (or rubric:), tools:, contains:, not_contains:, state: }
59
+ # An edit, a rejection (state: "rejected"), or reconsidering a
60
+ # rejected candidate (state: "proposed"); see
61
+ # Exploration#update_candidate!.
62
+ def update_candidate
63
+ candidate = @exploration.update_candidate!(params[:candidate_id], candidate_attributes(params))
64
+
65
+ render json: { candidate: candidate, exploration: @exploration.summary }
66
+ end
67
+
68
+ # POST /api/explorations/:id/accept { candidate_ids: [...], edits: { id => {...} } }
69
+ # Merges the candidates into the target evaluation (see
70
+ # Exploration#accept!). A refused candidate answers 422 with
71
+ # `problems` keyed by candidate id, and nothing is written.
72
+ #
73
+ # :replace_scenarios is asked about the evaluation the candidates
74
+ # merge into, or about the exploration when a project's accept will
75
+ # create that evaluation.
76
+ #
77
+ # With scenario_set_id, the candidates become scenarios of that set of
78
+ # one of the owner's scenario catalogs instead (see
79
+ # Exploration#accept_into_set!), and :replace_scenarios is asked about
80
+ # its catalog.
81
+ def accept
82
+ return accept_into_set if params[:scenario_set_id].present?
83
+ return unless authorize_action!(:replace_scenarios, @exploration.target_evaluation || @exploration)
84
+
85
+ merged = @exploration.accept!(params[:candidate_ids], edits: accept_edits)
86
+ evaluation = merged[:evaluation]
87
+
88
+ render json: detail(@exploration.reload).merge(
89
+ accepted: merged.slice(:added, :updated, :unchanged),
90
+ evaluation: evaluation_summary(evaluation)
91
+ )
92
+ end
93
+
94
+ # POST /api/explorations/:id/stop
95
+ # Ends a pending or running exploration and keeps what it found for
96
+ # review. 409 once it has stopped.
97
+ def stop
98
+ unless @exploration.stop!(reason: "stopped")
99
+ return render json: { error: "This exploration is not running", status: @exploration.status }, status: :conflict
100
+ end
101
+
102
+ render json: detail(@exploration.reload)
103
+ end
104
+
105
+ private
106
+
107
+ def accept_into_set
108
+ products = ScenarioProduct.where(scenario_catalog_id: owned(ScenarioCatalog).select(:id))
109
+ set = ScenarioSet.where(scenario_product_id: products.select(:id)).find(params[:scenario_set_id].to_s)
110
+ return unless authorize_action!(:replace_scenarios, set.catalog)
111
+
112
+ merged = @exploration.accept_into_set!(params[:candidate_ids], set: set, edits: accept_edits)
113
+ render json: detail(@exploration.reload).merge(
114
+ accepted: merged.slice(:added, :updated, :unchanged),
115
+ scenario_set: scenario_set_summary(set)
116
+ )
117
+ end
118
+
119
+ def scenario_set_summary(set)
120
+ catalog = set.catalog
121
+ { id: set.id, key: set.key, name: set.name, product_key: set.product.key,
122
+ catalog: { id: catalog.id, key: catalog.key, name: catalog.name } }
123
+ end
124
+
125
+ def set_exploration
126
+ @exploration = owned(Exploration).find(params[:id])
127
+ end
128
+
129
+ # [project, evaluation] the submission names, one of them nil, or nil
130
+ # after rendering why there is none.
131
+ def submission_target
132
+ if params[:project_id].present?
133
+ [ owned(Project).find(params[:project_id].to_s), nil ]
134
+ elsif params[:evaluation_id].present?
135
+ evaluation = Evaluation.joins(:agent).where(agent: owner_agents).find(params[:evaluation_id].to_s)
136
+ if unexecutable_scenario_run?(evaluation)
137
+ render json: { error: "Observed agents are read-only — duplicate this agent to create an executable copy" },
138
+ status: :unprocessable_entity
139
+ return nil
140
+ end
141
+
142
+ [ nil, evaluation ]
143
+ else
144
+ render json: { error: "Name the project_id or evaluation_id the candidates are for" }, status: :bad_request
145
+ nil
146
+ end
147
+ end
148
+
149
+ def candidate_list
150
+ list = params[:candidates]
151
+ list.is_a?(Array) ? list.map { |entry| entry.respond_to?(:to_unsafe_h) ? entry.to_unsafe_h : entry } : list
152
+ end
153
+
154
+ # The fields of a candidate edit present in +source+. Nested values
155
+ # arrive as Parameters and are read as plain data by the model.
156
+ def candidate_attributes(source)
157
+ keys = %w[prompt group notes rubric tools contains not_contains expectations state]
158
+ source = source.to_unsafe_h if source.respond_to?(:to_unsafe_h)
159
+ source.is_a?(Hash) ? source.stringify_keys.slice(*keys) : {}
160
+ end
161
+
162
+ def accept_edits
163
+ edits = params[:edits]
164
+ edits = edits.to_unsafe_h if edits.respond_to?(:to_unsafe_h)
165
+ return {} unless edits.is_a?(Hash)
166
+
167
+ edits.transform_values { |edit| candidate_attributes(edit).except("state") }
168
+ end
169
+
170
+ # The exploration with its candidates and what its review shows beside
171
+ # them: the evaluation they merge into, how many candidates to
172
+ # pre-select, and the owner's run allowance.
173
+ def detail(exploration)
174
+ evaluation = exploration.target_evaluation
175
+ usage = ActionAgent.usage_for(current_owner)
176
+ {
177
+ exploration: exploration.summary,
178
+ candidates: exploration.candidates,
179
+ project: exploration.project && { id: exploration.project.id, name: exploration.project.name },
180
+ target_agent: exploration.target_agent && { id: exploration.target_agent.id, name: exploration.target_agent.name },
181
+ evaluation: evaluation_summary(evaluation),
182
+ preselect_limit: ActionAgent.exploration_preselect_limit_for(current_owner),
183
+ runs_remaining: usage[:runs_remaining] || usage["runs_remaining"]
184
+ }
185
+ end
186
+
187
+ # What the review needs to estimate a run of the whole suite: the
188
+ # enabled scenarios, and the models each replays under.
189
+ def evaluation_summary(evaluation)
190
+ return nil if evaluation.nil?
191
+
192
+ {
193
+ id: evaluation.id,
194
+ name: evaluation.name,
195
+ enabled_scenario_count: evaluation.scenarios.enabled.count,
196
+ model_count: [ evaluation.compare_models.size, 1 ].max
197
+ }
198
+ end
199
+
200
+ def invalid_candidate(exception)
201
+ render json: { error: exception.message }, status: :unprocessable_entity
202
+ end
203
+
204
+ def accept_refused(exception)
205
+ render json: { error: exception.message, problems: exception.problems }, status: :unprocessable_entity
206
+ end
207
+
208
+ def scenario_limit(exception)
209
+ render json: { error: exception.message }, status: :unprocessable_entity
210
+ end
211
+ end
212
+ end
213
+ end
@@ -0,0 +1,125 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActionAgent
4
+ module Api
5
+ # Creating this dashboard's GitHub App from a manifest (Settings ->
6
+ # Integrations -> Create GitHub App), so a self-hoster need not register
7
+ # one by hand. Offered only when multi_tenant is off: a multi-tenant
8
+ # platform registers its App once, per environment, outside the
9
+ # dashboard.
10
+ #
11
+ # The dashboard posts the manifest #create returns to GitHub, which
12
+ # creates the App and returns to #callback with a code. #callback
13
+ # converts it and shows the App's settings and credentials on a page of
14
+ # their own, once. Nothing here stores them: the operator adds them to
15
+ # the dashboard's configuration (see ActionAgent.github_app_id).
16
+ class GithubAppManifestsController < BaseController
17
+ STATE_SESSION_KEY = :action_agent_github_app_manifest_state
18
+ # The permissions the App requests. Contents and pull requests are
19
+ # written only by an explicit publish; the organization members read
20
+ # is what an installation's admin check needs. No workflows,
21
+ # administration or secrets permission.
22
+ PERMISSIONS = { contents: "write", pull_requests: "write", metadata: "read", members: "read" }.freeze
23
+ # GitHub's limit on an App's name.
24
+ MAX_NAME_LENGTH = 34
25
+
26
+ before_action :require_owner!
27
+ before_action :require_single_tenant!
28
+ before_action :authorize_github!
29
+
30
+ # POST /api/github_app_manifest { organization: "acme" (optional) }
31
+ #
32
+ # Returns the GitHub URL to post the manifest to, with a single-use
33
+ # state, and the manifest itself, which the dashboard submits as the
34
+ # form field +manifest+.
35
+ def create
36
+ organization = params[:organization].presence
37
+ unless organization.nil? || (organization.is_a?(String) && organization.match?(GithubClient::LOGIN))
38
+ return render json: { error: "organization must be a GitHub organization login" }, status: :bad_request
39
+ end
40
+
41
+ state = SecureRandom.urlsafe_base64(32)
42
+ session[STATE_SESSION_KEY] = { "state" => state, "user_id" => current_user_id }.compact
43
+
44
+ render json: { url: GithubClient.new_app_url(state: state, organization: organization), manifest: manifest }
45
+ end
46
+
47
+ # GET /api/github_app_manifest/callback?code=...&state=...
48
+ def callback
49
+ # Single use: read and cleared before anything else can fail.
50
+ issued = session.delete(STATE_SESSION_KEY)
51
+ return redirect_to_settings(github_app: "invalid_state") unless issued_here?(issued, params[:state])
52
+ return redirect_to_settings(github_app: "missing_code") unless params[:code].is_a?(String) && params[:code].present?
53
+
54
+ @app = GithubClient.convert_manifest(params[:code])
55
+ @settings_url = "#{request.script_name}/settings?tab=integrations"
56
+
57
+ # The page carries the App's secrets: no cache keeps it, no other
58
+ # origin's script runs on it, and its links send no referrer.
59
+ response.headers["Cache-Control"] = "no-store"
60
+ response.headers["Referrer-Policy"] = "no-referrer"
61
+ response.headers["Content-Security-Policy"] =
62
+ "default-src 'none'; style-src 'unsafe-inline'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'"
63
+ render template: "action_agent/github_app_manifests/created", layout: false
64
+ rescue GithubClient::Error => e
65
+ Rails.logger.warn("[ActionAgent] GitHub App manifest conversion failed: #{e.message}")
66
+ redirect_to_settings(github_app: "manifest_error")
67
+ end
68
+
69
+ private
70
+
71
+ def manifest
72
+ base = "#{request.base_url}#{request.script_name}"
73
+
74
+ {
75
+ name: "ActiveAgent #{request.host}".first(MAX_NAME_LENGTH),
76
+ url: base.presence || request.base_url,
77
+ redirect_url: "#{base}/api/github_app_manifest/callback",
78
+ callback_urls: [ "#{base}/api/github_installations/callback" ],
79
+ request_oauth_on_install: true,
80
+ # Asks GitHub to return after an existing installation is
81
+ # reconfigured too, which is how one is linked again.
82
+ setup_on_update: true,
83
+ public: false,
84
+ default_permissions: PERMISSIONS,
85
+ default_events: []
86
+ }
87
+ end
88
+
89
+ def issued_here?(issued, given)
90
+ issued.is_a?(Hash) && issued["state"].is_a?(String) && given.is_a?(String) &&
91
+ ActiveSupport::SecurityUtils.secure_compare(issued["state"], given) &&
92
+ issued["user_id"] == current_user_id
93
+ end
94
+
95
+ def current_user_id
96
+ current_user.respond_to?(:id) ? current_user.id.to_s : nil
97
+ end
98
+
99
+ def require_single_tenant!
100
+ return unless ActionAgent.multi_tenant?
101
+ return redirect_to_settings(github_app: "manifest_unavailable") if action_name == "callback"
102
+
103
+ render json: { error: "Creating a GitHub App is not available on a multi-tenant dashboard", code: "manifest_unavailable" },
104
+ status: :not_found
105
+ end
106
+
107
+ def authorize_github!
108
+ authorize_action!(:manage_github, owned(GithubInstallation).new)
109
+ end
110
+
111
+ # The callback is a browser navigation, so a refusal returns to
112
+ # Settings, discarding the state.
113
+ def permission_denied(action)
114
+ return super unless action_name == "callback"
115
+
116
+ session.delete(STATE_SESSION_KEY)
117
+ redirect_to_settings(github_app: "forbidden")
118
+ end
119
+
120
+ def redirect_to_settings(**query)
121
+ redirect_to "#{request.script_name}/settings?#{{ tab: 'integrations' }.merge(query).to_query}"
122
+ end
123
+ end
124
+ end
125
+ end
@@ -15,19 +15,23 @@ module ActionAgent
15
15
  before_action :require_owner!
16
16
  before_action :require_github_oauth!, only: [ :connect, :callback ]
17
17
  before_action :set_connection, only: [ :repositories, :update, :destroy ]
18
+ before_action :authorize_github!, only: [ :connect, :callback, :update, :destroy ]
18
19
 
19
20
  # Later handlers win, so the subclass is registered last.
20
21
  rescue_from GithubClient::Error, with: :github_unavailable
21
22
  rescue_from GithubClient::Unauthorized, with: :github_unauthorized
22
23
 
23
- # GET /api/github_connection
24
+ # GET /api/github_connection — the OAuth connection, and the GitHub App
25
+ # path beside it under +app+: whether an App is configured, whether one
26
+ # can be created from a manifest here, and the owner's installations.
24
27
  def show
25
28
  connection = owned(GithubConnection).first
26
29
 
27
30
  render json: {
28
31
  configured: ActionAgent.github_oauth_configured?,
29
32
  connected: connection.present?,
30
- connection: connection&.as_summary
33
+ connection: connection&.as_summary,
34
+ app: github_app_status
31
35
  }
32
36
  end
33
37
 
@@ -100,6 +104,9 @@ module ActionAgent
100
104
  # A different GitHub account starts with nothing selected: the old
101
105
  # selection was checked against the other account's access.
102
106
  connection.repositories = [] if connection.persisted? && connection.github_user_id != user["id"]
107
+ # The user who authorized, also where the account owns the
108
+ # connection: only they may publish pull requests with its token.
109
+ connection.user_id = current_user.id if ActionAgent.user_class.present? && current_user.respond_to?(:id)
103
110
  connection.update!(
104
111
  access_token: grant[:access_token],
105
112
  scopes: grant[:scope],
@@ -120,6 +127,23 @@ module ActionAgent
120
127
  @connection = owned(GithubConnection).first!
121
128
  end
122
129
 
130
+ # Asks about the owner's connection, or about an unsaved one when
131
+ # #connect or #callback is about to create it.
132
+ def authorize_github!
133
+ subject = @connection || owned(GithubConnection).first || owned(GithubConnection).new
134
+ authorize_action!(:manage_github, subject)
135
+ end
136
+
137
+ # #connect and #callback are browser navigations, so a refusal returns
138
+ # to Settings like their other outcomes. The callback's state is
139
+ # discarded with it.
140
+ def permission_denied(action)
141
+ return super unless action_name.in?(%w[connect callback])
142
+
143
+ session.delete(STATE_SESSION_KEY)
144
+ redirect_to_settings(github: "forbidden")
145
+ end
146
+
123
147
  def require_github_oauth!
124
148
  return if ActionAgent.github_oauth_configured?
125
149
 
@@ -130,6 +154,20 @@ module ActionAgent
130
154
  "#{request.base_url}#{request.script_name}/api/github_connection/callback"
131
155
  end
132
156
 
157
+ def github_app_status
158
+ configured = ActionAgent.github_app_configured?
159
+
160
+ {
161
+ configured: configured,
162
+ slug: configured ? ActionAgent.github_app_slug : nil,
163
+ callback_url: "#{request.base_url}#{request.script_name}/api/github_installations/callback",
164
+ manifest_available: !ActionAgent.multi_tenant?,
165
+ # Listed only while an App is configured: without one nothing can
166
+ # mint an installation's tokens, so its repositories cannot be used.
167
+ installations: configured ? owned(GithubInstallation).order(:id).map(&:as_summary) : []
168
+ }
169
+ end
170
+
133
171
  def redirect_to_settings(**query)
134
172
  redirect_to "#{request.script_name}/settings?#{{ tab: 'integrations' }.merge(query).to_query}"
135
173
  end