actionagent 1.8.1 → 1.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (159) hide show
  1. checksums.yaml +4 -4
  2. data/app/assets/builds/action_agent.css +1 -1
  3. data/app/assets/builds/action_agent.js +76 -73
  4. data/app/assets/builds/action_agent_recorder.js +78 -0
  5. data/app/assets/builds/action_agent_replay.js +226 -0
  6. data/app/controllers/action_agent/api/agent_runs_controller.rb +3 -1
  7. data/app/controllers/action_agent/api/agents_controller.rb +1 -0
  8. data/app/controllers/action_agent/api/api_keys_controller.rb +12 -2
  9. data/app/controllers/action_agent/api/base_controller.rb +63 -2
  10. data/app/controllers/action_agent/api/claude_logins_controller.rb +93 -0
  11. data/app/controllers/action_agent/api/code_sessions_controller.rb +53 -6
  12. data/app/controllers/action_agent/api/dashboard_assistant_controller.rb +2 -2
  13. data/app/controllers/action_agent/api/draft_pull_requests_controller.rb +248 -0
  14. data/app/controllers/action_agent/api/evaluation_fixes_controller.rb +27 -0
  15. data/app/controllers/action_agent/api/evaluations_controller.rb +9 -4
  16. data/app/controllers/action_agent/api/explorations_controller.rb +213 -0
  17. data/app/controllers/action_agent/api/github_app_manifests_controller.rb +125 -0
  18. data/app/controllers/action_agent/api/github_connections_controller.rb +40 -2
  19. data/app/controllers/action_agent/api/github_installations_controller.rb +280 -0
  20. data/app/controllers/action_agent/api/input_requests_controller.rb +77 -0
  21. data/app/controllers/action_agent/api/mcp_controller.rb +46 -12
  22. data/app/controllers/action_agent/api/members_controller.rb +36 -0
  23. data/app/controllers/action_agent/api/project_explorations_controller.rb +80 -0
  24. data/app/controllers/action_agent/api/project_install_pull_requests_controller.rb +305 -0
  25. data/app/controllers/action_agent/api/project_secrets_controller.rb +96 -0
  26. data/app/controllers/action_agent/api/project_setup_controller.rb +151 -0
  27. data/app/controllers/action_agent/api/project_sign_ins_controller.rb +112 -0
  28. data/app/controllers/action_agent/api/projects_controller.rb +512 -0
  29. data/app/controllers/action_agent/api/provider_keys_controller.rb +176 -24
  30. data/app/controllers/action_agent/api/provider_models_controller.rb +37 -15
  31. data/app/controllers/action_agent/api/recording_event_ingest_controller.rb +67 -0
  32. data/app/controllers/action_agent/api/sandbox_browser_tickets_controller.rb +50 -0
  33. data/app/controllers/action_agent/api/sandbox_browsers_controller.rb +65 -0
  34. data/app/controllers/action_agent/api/sandboxes_controller.rb +140 -8
  35. data/app/controllers/action_agent/api/scenario_catalog_entries_controller.rb +188 -0
  36. data/app/controllers/action_agent/api/scenario_catalogs_controller.rb +302 -0
  37. data/app/controllers/action_agent/api/session_recordings_controller.rb +165 -34
  38. data/app/controllers/action_agent/api/sessions_controller.rb +111 -0
  39. data/app/controllers/action_agent/api/telemetry_keys_controller.rb +21 -0
  40. data/app/controllers/action_agent/dashboard_controller.rb +17 -6
  41. data/app/controllers/action_agent/session_player_controller.rb +58 -0
  42. data/app/controllers/concerns/action_agent/api/agent_serialization.rb +1 -0
  43. data/app/controllers/concerns/action_agent/api/mcp_dashboard_tools.rb +623 -18
  44. data/app/controllers/concerns/action_agent/api/project_browser.rb +46 -0
  45. data/app/controllers/concerns/action_agent/api/project_secret_authorization.rb +57 -0
  46. data/app/jobs/action_agent/agent_execution_job.rb +9 -39
  47. data/app/jobs/action_agent/agent_resume_job.rb +84 -0
  48. data/app/jobs/action_agent/code_session_job.rb +26 -2
  49. data/app/jobs/action_agent/draft_pull_request_job.rb +43 -0
  50. data/app/jobs/action_agent/exploration_job.rb +80 -0
  51. data/app/jobs/action_agent/input_request_expiry_job.rb +22 -0
  52. data/app/jobs/action_agent/project_evaluation_job.rb +59 -0
  53. data/app/jobs/action_agent/project_setup_job.rb +21 -0
  54. data/app/jobs/action_agent/sandbox_cleanup_job.rb +20 -3
  55. data/app/jobs/action_agent/sandbox_provision_job.rb +77 -10
  56. data/app/jobs/action_agent/sandbox_run_job.rb +9 -37
  57. data/app/jobs/action_agent/verify_evaluation_fix_job.rb +37 -0
  58. data/app/models/action_agent/agent.rb +39 -26
  59. data/app/models/action_agent/agent_run.rb +134 -23
  60. data/app/models/action_agent/agent_template.rb +35 -1
  61. data/app/models/action_agent/api_key.rb +14 -0
  62. data/app/models/action_agent/catalog_scenario.rb +75 -0
  63. data/app/models/action_agent/code_session.rb +16 -4
  64. data/app/models/action_agent/draft_pull_request.rb +131 -0
  65. data/app/models/action_agent/evaluation.rb +84 -0
  66. data/app/models/action_agent/exploration.rb +847 -0
  67. data/app/models/action_agent/github_installation.rb +159 -0
  68. data/app/models/action_agent/input_request.rb +291 -0
  69. data/app/models/action_agent/project.rb +789 -0
  70. data/app/models/action_agent/project_secret.rb +339 -0
  71. data/app/models/action_agent/provider_key.rb +200 -3
  72. data/app/models/action_agent/recording_event.rb +145 -0
  73. data/app/models/action_agent/recording_snapshot.rb +7 -7
  74. data/app/models/action_agent/sandbox_session.rb +297 -14
  75. data/app/models/action_agent/scenario_catalog.rb +172 -0
  76. data/app/models/action_agent/scenario_product.rb +57 -0
  77. data/app/models/action_agent/scenario_set.rb +138 -0
  78. data/app/models/action_agent/session_recording.rb +121 -14
  79. data/app/models/action_agent/telemetry_trace.rb +74 -19
  80. data/app/models/concerns/action_agent/ownable.rb +84 -13
  81. data/app/serializers/action_agent/input_request_serializer.rb +47 -0
  82. data/app/serializers/action_agent/scenario_catalog_serializer.rb +23 -0
  83. data/app/serializers/action_agent/session_summary_serializer.rb +146 -0
  84. data/app/services/action_agent/agent_execution_service.rb +450 -72
  85. data/app/services/action_agent/agent_sync.rb +5 -2
  86. data/app/services/action_agent/agent_tool_roster.rb +19 -4
  87. data/app/services/action_agent/agent_toolbox.rb +215 -9
  88. data/app/services/action_agent/browser_live_ticket.rb +78 -0
  89. data/app/services/action_agent/browser_sidecar.rb +213 -0
  90. data/app/services/action_agent/browser_sign_in.rb +163 -0
  91. data/app/services/action_agent/browser_tool_redaction.rb +92 -0
  92. data/app/services/action_agent/claude_code_auth.rb +70 -4
  93. data/app/services/action_agent/dashboard_assistant_service.rb +24 -10
  94. data/app/services/action_agent/draft_pull_request_publisher.rb +719 -0
  95. data/app/services/action_agent/evaluation_fix.rb +101 -0
  96. data/app/services/action_agent/evaluation_fix_comparison.rb +66 -0
  97. data/app/services/action_agent/evaluation_runner_service.rb +4 -5
  98. data/app/services/action_agent/evaluation_standing.rb +15 -8
  99. data/app/services/action_agent/explorer_execution_service.rb +517 -0
  100. data/app/services/action_agent/github_client.rb +405 -64
  101. data/app/services/action_agent/live_updates.rb +31 -0
  102. data/app/services/action_agent/local_sandbox_backend.rb +1218 -83
  103. data/app/services/action_agent/local_sandbox_claude_login.rb +151 -0
  104. data/app/services/action_agent/mcp_client.rb +6 -2
  105. data/app/services/action_agent/mcp_recording_middleware.rb +104 -145
  106. data/app/services/action_agent/mcp_tool_dispatcher.rb +36 -13
  107. data/app/services/action_agent/mock_sandbox_backend.rb +3 -1
  108. data/app/services/action_agent/playwright_mcp_client.rb +7 -0
  109. data/app/services/action_agent/project_boot_fix.rb +309 -0
  110. data/app/services/action_agent/project_capabilities.rb +200 -0
  111. data/app/services/action_agent/project_install_pull_request.rb +225 -0
  112. data/app/services/action_agent/project_preflight.rb +178 -0
  113. data/app/services/action_agent/project_secret_discovery.rb +142 -0
  114. data/app/services/action_agent/project_setup.rb +429 -0
  115. data/app/services/action_agent/provider_credentials.rb +102 -0
  116. data/app/services/action_agent/recording_event_ingest.rb +181 -0
  117. data/app/services/action_agent/runtime_tool_roster.rb +52 -0
  118. data/app/services/action_agent/sample_data/conference_ticket.rb +516 -0
  119. data/app/services/action_agent/sandbox_boot_spec.rb +558 -0
  120. data/app/services/action_agent/sandbox_browser.rb +299 -0
  121. data/app/services/action_agent/sandbox_browser_driver.rb +85 -0
  122. data/app/services/action_agent/sandbox_credential_paths.rb +26 -0
  123. data/app/services/action_agent/sandbox_mail.rb +103 -0
  124. data/app/services/action_agent/sandbox_manifest.rb +166 -13
  125. data/app/services/action_agent/sandbox_orchestrator.rb +276 -22
  126. data/app/services/action_agent/sandbox_patch.rb +253 -0
  127. data/app/services/action_agent/scenario_catalog_import.rb +255 -0
  128. data/app/services/action_agent/scenario_catalog_repository_import.rb +81 -0
  129. data/app/services/action_agent/scenario_evaluation_runner.rb +119 -16
  130. data/app/services/action_agent/secret_scrubber.rb +33 -4
  131. data/app/services/action_agent/session_index.rb +270 -0
  132. data/app/services/action_agent/session_recording_service.rb +11 -0
  133. data/app/services/action_agent/session_timeline.rb +505 -0
  134. data/app/views/action_agent/github_app_manifests/created.html.erb +55 -0
  135. data/app/views/action_agent/session_player/show.html.erb +19 -0
  136. data/config/routes.rb +152 -1
  137. data/lib/action_agent/claude_login_process.rb +107 -0
  138. data/lib/action_agent/engine.rb +55 -5
  139. data/lib/action_agent/secret_requests.rb +80 -0
  140. data/lib/action_agent/version.rb +1 -1
  141. data/lib/action_agent.rb +507 -22
  142. data/lib/generators/action_agent/install_generator.rb +37 -0
  143. data/lib/generators/action_agent/templates/action_agent.rb.erb +158 -0
  144. data/lib/generators/action_agent/templates/migrations/015_add_provider_key_scope.rb.erb +72 -0
  145. data/lib/generators/action_agent/templates/migrations/016_create_active_agent_input_requests.rb.erb +85 -0
  146. data/lib/generators/action_agent/templates/migrations/017_create_active_agent_recording_events.rb.erb +57 -0
  147. data/lib/generators/action_agent/templates/migrations/018_add_browser_to_active_agent_sandbox_sessions.rb.erb +25 -0
  148. data/lib/generators/action_agent/templates/migrations/019_create_active_agent_projects.rb.erb +79 -0
  149. data/lib/generators/action_agent/templates/migrations/020_add_github_app_installations.rb.erb +62 -0
  150. data/lib/generators/action_agent/templates/migrations/021_create_active_agent_explorations.rb.erb +63 -0
  151. data/lib/generators/action_agent/templates/migrations/023_create_active_agent_draft_pull_requests.rb.erb +75 -0
  152. data/lib/generators/action_agent/templates/migrations/024_add_kind_to_active_agent_project_secrets.rb.erb +14 -0
  153. data/lib/generators/action_agent/templates/migrations/025_create_active_agent_scenario_catalogs.rb.erb +110 -0
  154. data/lib/generators/action_agent/templates/migrations/026_add_evaluation_fixes_to_code_sessions.rb.erb +21 -0
  155. data/lib/tasks/action_agent.rake +33 -0
  156. data/lib/tasks/browser.rake +22 -0
  157. data/lib/tasks/sandbox.rake +1 -1
  158. data/lib/tasks/scenario_catalogs.rake +71 -0
  159. metadata +96 -2
@@ -0,0 +1,305 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActionAgent
4
+ module Api
5
+ # The project's install pull request (see ProjectInstallPullRequest): its
6
+ # status, the preview of exactly what it would publish, publishing it and
7
+ # updating its branch later, and the patch to download where publishing
8
+ # is not available.
9
+ #
10
+ # Everything is read from the project's live sandbox. A request made
11
+ # while none is live boots one (Project#ensure_sandbox!) and answers 202,
12
+ # to be asked again once it serves. Publishing is an explicit request
13
+ # that asks ActionAgent.permission_checker about :publish_pull_request,
14
+ # as DraftPullRequestJob asks again before it writes.
15
+ class ProjectInstallPullRequestsController < BaseController
16
+ before_action :require_owner!
17
+ before_action :set_project
18
+
19
+ rescue_from DraftPullRequestPublisher::Refused, with: :refused
20
+ rescue_from Project::ConfirmationRequired, with: :confirmation_required
21
+ rescue_from Project::BootRefused, SandboxOrchestrator::UnsupportedBackendError, with: :boot_refused
22
+
23
+ # GET /api/projects/:project_id/install_pull_request
24
+ # The install pull request, its state read again from GitHub at most
25
+ # once a minute. Once GitHub reports it merged, the project counts as
26
+ # installed (Project#install_merged!).
27
+ def show
28
+ record = @project.install_pull_request
29
+ if record
30
+ DraftPullRequest.where(id: record.id).fail_stalled!
31
+ record.reload
32
+ DraftPullRequestPublisher.refresh_status!(record) unless record.in_progress?
33
+ @project.install_merged! if record.state == "merged"
34
+ end
35
+
36
+ render json: { pull_request: record&.summary, publishing: publishing_status, allowlist: allowlist_json,
37
+ project: @project.reload.summary }
38
+ end
39
+
40
+ # POST /api/projects/:project_id/install_pull_request/preview
41
+ # Every file the sandbox changed or the project generates, each with why
42
+ # it is refused or with its exact diff.
43
+ def preview
44
+ return unless live_sandbox!
45
+
46
+ preview = install.publisher(@sandbox).preview(allowlist: install.allowlist)
47
+ preview[:suggested_branch] = ProjectInstallPullRequest::DEFAULT_BRANCH
48
+ render json: { preview: preview, publishing: publishing_status }
49
+ end
50
+
51
+ # POST /api/projects/:project_id/install_pull_request
52
+ #
53
+ # { title:, body:, branch:, files: [{ path:, digest: }] }
54
+ # opens the install pull request as a draft, with the files
55
+ # { update: true, files:, message: }
56
+ # publishes the files as a new commit on its branch, from the
57
+ # project's sandbox now, which may be a later one than the
58
+ # sandbox it was opened from
59
+ # { open: true } or { regular: true }
60
+ # opens a draft, or a regular, pull request for its branch when
61
+ # the branch was published without one
62
+ #
63
+ # Answers 202 with the queued record.
64
+ def create
65
+ return unless live_sandbox!
66
+ return open_branch(regular: boolean_param(:regular)) if boolean_param(:open) || boolean_param(:regular)
67
+
68
+ on_branch = false
69
+ if boolean_param(:update)
70
+ record = update_record or return
71
+ on_branch = record.new_record? && @sandbox.repository_ref == record.branch
72
+ else
73
+ return unless openable!
74
+
75
+ record = @sandbox.draft_pull_requests.new(
76
+ operation: "create", repository: @sandbox.repository, branch: string_param(:branch).to_s.strip.presence ||
77
+ ProjectInstallPullRequest::DEFAULT_BRANCH,
78
+ title: string_param(:title).to_s.strip.presence || "Install ActiveAgent", body: string_param(:body)
79
+ )
80
+ refusal = DraftPullRequestPublisher.branch_refusal(record.branch)
81
+ raise DraftPullRequestPublisher::Refused.new(refusal, code: "invalid_branch") if refusal
82
+ end
83
+ assign_owner(record)
84
+ return unless authorize_action!(:publish_pull_request, record)
85
+
86
+ queue(record, on_branch: on_branch)
87
+ end
88
+
89
+ # GET /api/projects/:project_id/install_pull_request/patch?paths[]=…
90
+ # The files named, or every publishable one, each within the project's
91
+ # allowlist.
92
+ def patch
93
+ return unless live_sandbox!
94
+
95
+ paths = params[:paths]
96
+ paths = nil unless paths.is_a?(Array) && paths.all? { |path| path.is_a?(String) }
97
+ paths ||= install.publisher(@sandbox).changes(allowlist: install.allowlist).publishable.map(&:path)
98
+ data = install.publisher(@sandbox).patch(paths: paths, title: string_param(:title) || "Install ActiveAgent", allowlist: install.allowlist)
99
+ send_data data, type: "text/x-diff", disposition: "attachment", filename: "#{@project.repository.tr('/', '-')}-install.patch"
100
+ end
101
+
102
+ private
103
+
104
+ def set_project
105
+ @project = owned(Project).find(params[:project_id])
106
+ end
107
+
108
+ def install
109
+ @install ||= ProjectInstallPullRequest.new(@project, user: current_user)
110
+ end
111
+
112
+ # The project's sandbox when it serves; otherwise boots one, renders
113
+ # 202 and answers false.
114
+ def live_sandbox!
115
+ sandbox = @project.current_sandbox_session
116
+ if sandbox && (sandbox.ready? || sandbox.running?) && sandbox.active?
117
+ @sandbox = sandbox
118
+ return true
119
+ end
120
+
121
+ unless ActionAgent.execution_enabled?
122
+ render json: { error: "The project's sandbox is not running, and agent execution is disabled on this dashboard" },
123
+ status: :forbidden
124
+ return false
125
+ end
126
+ enforce_execution_quota!
127
+ return false if performed?
128
+
129
+ before = @project.current_sandbox_session_id
130
+ booting = @project.ensure_sandbox!(confirm: ActiveModel::Type::Boolean.new.cast(params[:confirm]) == true, confirmed_by: current_user)
131
+ record_execution_usage if booting.id != before
132
+ render json: { booting: true, code: "sandbox_booting", project: @project.reload.summary,
133
+ error: "The project's sandbox is booting. Publishing reads its checkout, so ask again once it is ready" },
134
+ status: :accepted
135
+ false
136
+ end
137
+
138
+ # Whether a new install pull request may be opened: not while one is
139
+ # open, or its branch is published without one.
140
+ def openable!
141
+ current = @project.install_pull_request
142
+ return true if current.nil? || (current.head_commit.blank? && !current.in_progress?) || current.state.in?(%w[closed merged])
143
+
144
+ error = if current.branch_only?
145
+ "The install branch is published without a pull request. Open a pull request for it instead"
146
+ else
147
+ "The project has an install pull request already. Update it instead"
148
+ end
149
+ render json: { error: error, code: "already_opened", pull_request: current.summary }, status: :conflict
150
+ false
151
+ end
152
+
153
+ # The record an update publishes: the install pull request itself when
154
+ # the sandbox it was published from is the one live now, and otherwise
155
+ # a record of the live sandbox for the same pull request. A sandbox of
156
+ # the pull request's branch starts from the commit it checked out, so
157
+ # the update goes on top of the branch as that sandbox saw it.
158
+ def update_record
159
+ current = @project.install_pull_request
160
+ unless current&.opened? && current.state.in?([ nil, "open" ])
161
+ render json: { error: "The project has no open install pull request to update", code: "not_opened" }, status: :unprocessable_entity
162
+ return nil
163
+ end
164
+
165
+ message = string_param(:message).to_s.strip.presence || DraftPullRequest::DEFAULT_UPDATE_MESSAGE
166
+ return current.tap { current.assign_attributes(operation: "update", commit_message: message) } if current.sandbox_session_id == @sandbox.id
167
+
168
+ @sandbox.draft_pull_requests.new(
169
+ current.attributes.slice(*%w[repository base_branch branch title body number url state draft head_commit compare_url])
170
+ .merge("operation" => "update", "commit_message" => message, "status" => "queued")
171
+ )
172
+ end
173
+
174
+ # Opens a pull request for the install branch, which was published
175
+ # without one.
176
+ def open_branch(regular:)
177
+ record = @project.install_pull_request
178
+ unless record&.branch_only? && !record.in_progress?
179
+ return render json: { error: "The install branch is not waiting to be opened as a pull request", code: "nothing_published" },
180
+ status: :unprocessable_entity
181
+ end
182
+
183
+ record.assign_attributes(operation: regular ? "open_regular" : "open_draft")
184
+ assign_owner(record)
185
+ return unless authorize_action!(:publish_pull_request, record)
186
+
187
+ queue(record, files: false)
188
+ end
189
+
190
+ # Checks the files against the preview and queues the publish, one at a
191
+ # time per sandbox, then makes the record the project's install pull
192
+ # request. A choice that leaves out a file the branch's boots need
193
+ # (ProjectInstallPullRequest#missing_required_paths) is refused.
194
+ # +on_branch+ is a record of a sandbox that checked out the pull
195
+ # request's branch, whose checkout commit is the head it updates.
196
+ def queue(record, on_branch: false, files: true)
197
+ publisher = install.publisher(@sandbox)
198
+ refusal = publisher.read_refusal
199
+ raise refusal if refusal
200
+
201
+ credential = publisher.credential
202
+ unless credential.available?
203
+ return render json: { error: credential.refusal, code: "no_credential", patch_available: true }, status: :unprocessable_entity
204
+ end
205
+
206
+ if files
207
+ selection = selection_param
208
+ missing = install.missing_required_paths(publisher, selection.keys)
209
+ if missing.any?
210
+ raise DraftPullRequestPublisher::Refused.new("Every boot of the branch needs #{missing.to_sentence}, which the sandbox " \
211
+ "changed: choose #{missing.one? ? "it" : "them"} too", code: "incomplete_install")
212
+ end
213
+
214
+ changes = publisher.changes(allowlist: install.allowlist, paths: selection.keys)
215
+ chosen = publisher.select!(changes, selection)
216
+ record.base_commit = changes.base_commit
217
+ record.head_commit = changes.base_commit if on_branch
218
+ record.files = chosen.map { |file| { "path" => file.path, "status" => file.status, "mode" => file.mode, "digest" => file.digest } }
219
+ end
220
+ record.assign_attributes(status: "queued", credential_kind: credential.kind, error_code: nil, error_message: nil)
221
+ return render json: { error: record.errors.full_messages.to_sentence }, status: :unprocessable_entity unless record.valid?
222
+
223
+ busy = nil
224
+ @sandbox.with_lock do
225
+ @sandbox.draft_pull_requests.fail_stalled!
226
+ busy = @sandbox.draft_pull_requests.where(status: %w[queued publishing]).first
227
+ unless busy
228
+ record.save!
229
+ @project.update!(settings: @project.settings.merge("install_pull_request_id" => record.id))
230
+ end
231
+ end
232
+ if busy
233
+ return render json: { error: "A publish from this sandbox is already #{busy.status}", pull_request: busy.summary }, status: :conflict
234
+ end
235
+
236
+ DraftPullRequestJob.perform_later(record.id)
237
+ render json: { pull_request: record.summary, project: @project.reload.summary }, status: :accepted
238
+ end
239
+
240
+ def assign_owner(record)
241
+ record.user_id = current_user.id if ActionAgent.user_class.present? && current_user.respond_to?(:id)
242
+ record.account_id = @sandbox.account_id if @sandbox.has_attribute?(:account_id) && @sandbox.account_id
243
+ end
244
+
245
+ def publishing_status
246
+ sandbox = @project.current_sandbox_session
247
+ live = sandbox && (sandbox.ready? || sandbox.running?) && sandbox.active?
248
+ return { supported: true, available: false, live: false, refusal: "The project's sandbox is not running", patch_available: false } unless live
249
+
250
+ publisher = install.publisher(sandbox)
251
+ refusal = publisher.read_refusal
252
+ credential = refusal ? nil : publisher.credential
253
+ {
254
+ supported: refusal&.code != "unsupported",
255
+ available: refusal.nil? && credential.available?,
256
+ live: true,
257
+ refusal: refusal&.message || credential&.refusal,
258
+ refusal_code: refusal&.code || (credential&.available? == false ? "no_credential" : nil),
259
+ credential: credential&.kind,
260
+ patch_available: refusal.nil?
261
+ }
262
+ end
263
+
264
+ # The allowlist as the dialog shows it: paths, and a description of the
265
+ # migration pattern.
266
+ def allowlist_json
267
+ install.allowlist.map do |entry|
268
+ entry.is_a?(Regexp) ? "db/migrate/<timestamp>_<a migration action_agent:install emits>.rb" : entry
269
+ end
270
+ end
271
+
272
+ def selection_param
273
+ files = params[:files]
274
+ files = files.map { |file| file.respond_to?(:permit) ? file.permit(:path, :digest).to_h : file } if files.is_a?(Array)
275
+ unless files.is_a?(Array) && files.all? { |file| file.is_a?(Hash) && file["path"].is_a?(String) && file["digest"].is_a?(String) }
276
+ raise DraftPullRequestPublisher::Refused.new("files must be a list of { path, digest } from the preview", code: "nothing_selected")
277
+ end
278
+
279
+ files.to_h { |file| [ file["path"], file["digest"] ] }
280
+ end
281
+
282
+ def string_param(name)
283
+ value = params[name]
284
+ value.is_a?(String) ? value : nil
285
+ end
286
+
287
+ def boolean_param(name)
288
+ ActiveModel::Type::Boolean.new.cast(params[name]) == true
289
+ end
290
+
291
+ def refused(error)
292
+ status = error.code == "changed_since_preview" ? :conflict : :unprocessable_entity
293
+ render json: { error: error.message, code: error.code }, status: status
294
+ end
295
+
296
+ def confirmation_required(exception)
297
+ render json: { error: exception.message, code: "confirmation_required", confirmation: exception.message }, status: :conflict
298
+ end
299
+
300
+ def boot_refused(exception)
301
+ render json: { error: exception.message, code: "boot_refused" }, status: :unprocessable_entity
302
+ end
303
+ end
304
+ end
305
+ end
@@ -0,0 +1,96 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActionAgent
4
+ module Api
5
+ # A project's secrets (the Environment tab): names, sources and who set
6
+ # them, never values. Setting, replacing and removing one needs
7
+ # :manage_project_secrets, and using the organization's provider key
8
+ # :manage_credentials as well (see ProjectSecretAuthorization). A name
9
+ # the sandbox sets itself, one that changes how code is loaded, or one
10
+ # the project's sign-in keeps is refused with 422 (see ProjectSecret), and
11
+ # so is a source a person cannot give
12
+ # (ProjectSecretAuthorization#secret_sources_allowed!).
13
+ class ProjectSecretsController < BaseController
14
+ include ProjectSecretAuthorization
15
+
16
+ before_action :require_owner!
17
+ before_action :set_project
18
+
19
+ # GET /api/projects/:project_id/secrets
20
+ def index
21
+ render json: { secrets: summaries }
22
+ end
23
+
24
+ # PUT /api/projects/:project_id/secrets
25
+ # { secrets: [{ name:, value: } | { name:, source: "organization_key", consent: true }] }
26
+ # Sets every listed secret, replacing those that exist, or none of them
27
+ # when one is refused. Each saved secret comes back with the warnings
28
+ # its value raises (ProjectSecret.warnings_for).
29
+ def upsert
30
+ entries = params[:secrets]
31
+ unless entries.is_a?(Array) && entries.all? { |entry| entry.respond_to?(:permit) }
32
+ return render json: { error: "secrets must be a list of { name:, value: }" }, status: :bad_request
33
+ end
34
+
35
+ return unless secret_sources_allowed!(entries)
36
+
37
+ records = entries.map { |entry| assign(entry) }
38
+ return unless records.all? { |record| authorize_secret!(record) }
39
+
40
+ ProjectSecret.transaction { records.each(&:save!) }
41
+ render json: { secrets: summaries, saved: records.map { |record| saved_json(record) } }
42
+ end
43
+
44
+ # PUT /api/projects/:project_id/secrets/:name { value: } or
45
+ # { source: "organization_key", consent: true }
46
+ def update
47
+ return unless secret_sources_allowed!([ params ])
48
+
49
+ record = assign(params)
50
+ return unless authorize_secret!(record)
51
+
52
+ record.save!
53
+ render json: { secret: saved_json(record) }
54
+ end
55
+
56
+ # DELETE /api/projects/:project_id/secrets/:name
57
+ def destroy
58
+ record = @project.secrets.find_by!(name: params[:name])
59
+ return unless authorize_action!(:manage_project_secrets, record)
60
+
61
+ record.destroy!
62
+ head :no_content
63
+ end
64
+
65
+ private
66
+
67
+ def set_project
68
+ @project = owned(Project).find(params[:project_id])
69
+ end
70
+
71
+ def assign(entry)
72
+ attributes = entry.slice(:name, :value, :source, :consent).permit(:name, :value, :source, :consent).to_h
73
+ @project.assign_secret(
74
+ name: attributes["name"], value: attributes["value"], source: attributes["source"],
75
+ consent: ActiveModel::Type::Boolean.new.cast(attributes["consent"]) == true, set_by: current_user
76
+ )
77
+ end
78
+
79
+ def summaries
80
+ records = @project.secrets.reload.ordered.to_a
81
+ setters = setters_for(records)
82
+ records.map { |record| record.as_summary(setters) }
83
+ end
84
+
85
+ def saved_json(record)
86
+ record.as_summary(setters_for([ record ])).merge(warnings: record.warnings)
87
+ end
88
+
89
+ def setters_for(records)
90
+ ids = records.filter_map(&:set_by_id).uniq
91
+ user_class = ActionAgent.user_class&.safe_constantize
92
+ ids.empty? || user_class.nil? ? {} : user_class.where(id: ids).index_by(&:id)
93
+ end
94
+ end
95
+ end
96
+ end
@@ -0,0 +1,151 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActionAgent
4
+ module Api
5
+ # Getting a project's sandbox to boot and choosing what its App assistant
6
+ # may read:
7
+ #
8
+ # - starting the setup assistant on a failed boot (see ProjectSetup), or
9
+ # Claude Code in its checkout (see ProjectBootFix)
10
+ # - the requests for input the project's agents are waiting on, which
11
+ # the Project page answers through the input requests API
12
+ # - the app's models as a boot listed them, and the models and columns
13
+ # the App assistant gets schema tools over
14
+ class ProjectSetupController < BaseController
15
+ include ProjectSecretAuthorization
16
+
17
+ before_action :require_owner!
18
+ before_action :set_project
19
+ before_action :require_execution_enabled!, only: [ :start, :fix_with_claude_code ]
20
+
21
+ rescue_from Project::ConfirmationRequired, with: :confirmation_required
22
+ rescue_from Project::BootRefused, SandboxOrchestrator::UnsupportedBackendError, with: :boot_refused
23
+
24
+ # POST /api/projects/:project_id/setup
25
+ # Starts the setup assistant on the project's failed boot. Its tools
26
+ # set the project's environment, so this needs :manage_project_secrets.
27
+ def start
28
+ return unless authorize_action!(:manage_project_secrets, secret_subject)
29
+
30
+ enforce_execution_quota!
31
+ return if performed?
32
+
33
+ run = ProjectSetup.start!(@project, trigger: "requested")
34
+ record_execution_usage
35
+ render json: { project: @project.reload.summary, run: { id: run.id, status: run.status, agent_id: run.agent_id } },
36
+ status: :accepted
37
+ rescue ProjectSetup::Unavailable => e
38
+ render json: { error: e.message, code: "setup_unavailable" }, status: :conflict
39
+ end
40
+
41
+ # POST /api/projects/:project_id/setup/claude_code
42
+ # Starts a Claude Code session in the failed boot's checkout to fix the
43
+ # step that failed; the boot resumes once it changed something. It
44
+ # runs on the caller's Claude Code, as a session started from the
45
+ # sandbox's page would.
46
+ def fix_with_claude_code
47
+ enforce_execution_quota!
48
+ return if performed?
49
+
50
+ session = ProjectBootFix.start!(@project, trigger: "requested", user_id: current_user.respond_to?(:id) ? current_user.id : @project.user_id)
51
+ record_execution_usage
52
+ render json: { project: @project.reload.summary, code_session: session.summary }, status: :accepted
53
+ rescue ProjectBootFix::Unavailable => e
54
+ render json: { error: e.message, code: "claude_code_unavailable" }, status: :conflict
55
+ end
56
+
57
+ # PATCH /api/projects/:project_id/setup { auto: false }
58
+ # Whether a failed boot starts Claude Code or the setup assistant on its
59
+ # own.
60
+ def update
61
+ return unless authorize_action!(:manage_project_secrets, secret_subject)
62
+
63
+ @project.update_setup_settings!("auto" => ActiveModel::Type::Boolean.new.cast(params[:auto]) != false)
64
+ render json: { project: @project.summary }
65
+ end
66
+
67
+ # GET /api/projects/:project_id/input_requests
68
+ # The requests pending on runs of the project's setup assistant and of
69
+ # the agent it evaluates, newest first, as GET /api/input_requests
70
+ # lists them. Overdue ones are expired first.
71
+ def input_requests
72
+ InputRequest.expire_overdue!(@project.input_requests)
73
+ requests = @project.pending_input_requests.for_listing.includes(:subject).recent.limit(InputRequestsController::LIST_LIMIT)
74
+ render json: { input_requests: requests.map { |request| InputRequestSerializer.call(request) } }
75
+ end
76
+
77
+ # GET /api/projects/:project_id/app_models
78
+ # The app's models and columns as the last boot's manifest listed them,
79
+ # with the choices made so far. 409 before a boot listed any.
80
+ def app_models
81
+ models = @project.app_models
82
+ if models.nil?
83
+ return render json: { error: "Boot the project first: a boot lists the app's models", code: "not_listed" }, status: :conflict
84
+ end
85
+
86
+ render json: { models: models, schema_tools: @project.schema_tools }
87
+ end
88
+
89
+ # PUT /api/projects/:project_id/schema_tools
90
+ # { schema_tools: [{ model:, filterable: [], returns: [] }], apply: true }
91
+ # Stores the models and columns the App assistant may read. Every boot
92
+ # after this writes their tools; with `apply`, a running sandbox is
93
+ # replaced by a new boot now, so the assistant's tools follow at once.
94
+ def schema_tools
95
+ unless @project.app_assistant?
96
+ return render json: { error: "Only a project evaluated with the App assistant chooses what it may read", code: "not_app_assistant" },
97
+ status: :conflict
98
+ end
99
+
100
+ choices = params[:schema_tools]
101
+ unless choices.is_a?(Array) && choices.all? { |choice| choice.respond_to?(:permit) }
102
+ return render json: { error: "schema_tools must be a list of { model:, filterable:, returns: }" }, status: :bad_request
103
+ end
104
+
105
+ @project.choose_schema_tools!(choices.map { |choice| choice.permit(:model, filterable: [], returns: []).to_h })
106
+ rebooted = ActiveModel::Type::Boolean.new.cast(params[:apply]) == true && reboot!
107
+ return if performed?
108
+
109
+ render json: { project: @project.reload.summary, rebooted: rebooted == true }
110
+ rescue SandboxBootSpec::Invalid => e
111
+ render json: { error: e.message, code: "invalid_schema_tools" }, status: :unprocessable_entity
112
+ end
113
+
114
+ private
115
+
116
+ def set_project
117
+ @project = owned(Project).find(params[:project_id])
118
+ end
119
+
120
+ def secret_subject
121
+ ProjectSecret.new(project: @project, account_id: @project.account_id, user_id: @project.user_id)
122
+ end
123
+
124
+ # Stops the current sandbox and boots a new one from the project's
125
+ # spec, as POST /api/projects/:id/boot boots one. Answers whether a
126
+ # boot started.
127
+ def reboot!
128
+ unless ActionAgent.execution_enabled?
129
+ render json: { error: "Agent execution is disabled on this dashboard" }, status: :forbidden
130
+ return false
131
+ end
132
+ enforce_execution_quota!
133
+ return false if performed?
134
+
135
+ current = @project.current_sandbox_session
136
+ current.expire! if current && !current.expired?
137
+ @project.ensure_sandbox!(confirm: ActiveModel::Type::Boolean.new.cast(params[:confirm]) == true, confirmed_by: current_user)
138
+ record_execution_usage
139
+ true
140
+ end
141
+
142
+ def confirmation_required(exception)
143
+ render json: { error: exception.message, code: "confirmation_required", confirmation: exception.message }, status: :conflict
144
+ end
145
+
146
+ def boot_refused(exception)
147
+ render json: { error: exception.message, code: "boot_refused" }, status: :unprocessable_entity
148
+ end
149
+ end
150
+ end
151
+ end
@@ -0,0 +1,112 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActionAgent
4
+ module Api
5
+ # The test account step: how the project's browser signs in to the app,
6
+ # kept as sign-in ProjectSecrets that never reach the sandbox's
7
+ # environment or a model.
8
+ #
9
+ # GET /api/projects/:project_id/sign_in what is set, never the password
10
+ # PUT /api/projects/:project_id/sign_in { login_url, login, password,
11
+ # login_field, password_field, submit_field }
12
+ # POST /api/projects/:project_id/sign_in/check signs the browser in with them
13
+ # POST /api/projects/:project_id/sign_in/save_browser keeps the running browser's sign-in
14
+ # DELETE /api/projects/:project_id/sign_in no sign-in
15
+ #
16
+ # Changing them needs :manage_project_secrets, asked about the secret.
17
+ # A check answers BrowserSignIn's status, "unsupported" when the login
18
+ # page has no password field.
19
+ class ProjectSignInsController < BaseController
20
+ include ProjectBrowser
21
+
22
+ before_action :require_owner!
23
+ before_action :set_project
24
+ before_action :require_execution_enabled!, only: [ :check ]
25
+
26
+ def show
27
+ render json: { sign_in: summary }
28
+ end
29
+
30
+ def update
31
+ secret = @project.assign_sign_in(credential_params, set_by: current_user)
32
+ return unless authorize_action!(:manage_project_secrets, secret)
33
+
34
+ if secret.save
35
+ render json: { sign_in: summary, warnings: secret.warnings }
36
+ else
37
+ render json: { error: secret.errors.full_messages.to_sentence }, status: :unprocessable_entity
38
+ end
39
+ end
40
+
41
+ def destroy
42
+ secrets = @project.secrets.where(kind: %w[sign_in storage_state]).to_a
43
+ return unless secrets.all? { |secret| authorize_action!(:manage_project_secrets, secret) }
44
+
45
+ secrets.each(&:destroy!)
46
+ render json: { sign_in: summary }
47
+ end
48
+
49
+ def check
50
+ secret = @project.secrets.sign_in.find_by(name: Project::SIGN_IN_SECRET)
51
+ unless secret
52
+ return render json: { error: "Enter the test account's login and password first", code: "no_sign_in" },
53
+ status: :unprocessable_entity
54
+ end
55
+
56
+ sandbox = ready_project_sandbox!(@project) or return
57
+ sandbox, = ensure_project_browser!(@project, sandbox)
58
+ return if performed?
59
+
60
+ render json: { result: BrowserSignIn.call(sandbox, secret).to_h, sign_in: summary }
61
+ end
62
+
63
+ def save_browser
64
+ sandbox = @project.current_sandbox_session
65
+ unless sandbox&.browser_running?
66
+ return render json: { error: "Start the project's browser and sign in to the app there first", code: "browser_not_running" },
67
+ status: :conflict
68
+ end
69
+
70
+ state = SandboxBrowser.storage_state(sandbox)
71
+ secret = @project.assign_storage_state(state, set_by: current_user)
72
+ return unless authorize_action!(:manage_project_secrets, secret)
73
+
74
+ if secret.save
75
+ render json: { sign_in: summary }
76
+ else
77
+ render json: { error: secret.errors.full_messages.to_sentence }, status: :unprocessable_entity
78
+ end
79
+ rescue SandboxBrowser::Error => e
80
+ render json: { error: e.message, code: "browser_unavailable" }, status: :unprocessable_entity
81
+ end
82
+
83
+ private
84
+
85
+ def set_project
86
+ @project = owned(Project).find(params[:project_id])
87
+ end
88
+
89
+ def credential_params
90
+ params.permit(*ProjectSecret::SIGN_IN_FIELDS).to_h
91
+ end
92
+
93
+ def summary
94
+ credentials = @project.secrets.sign_in.find_by(name: Project::SIGN_IN_SECRET)
95
+ state = @project.secrets.storage_state.find_by(name: Project::STORAGE_STATE_SECRET)
96
+ fields = credentials&.sign_in_credentials || {}
97
+ {
98
+ credentials: credentials && {
99
+ secret_ref: credentials.name,
100
+ login_url: fields["login_url"],
101
+ login: fields["login"],
102
+ password_set: fields["password"].present?,
103
+ fields: fields.slice("login_field", "password_field", "submit_field"),
104
+ updated_at: credentials.updated_at&.iso8601
105
+ },
106
+ storage_state: state && { saved: true, updated_at: state.updated_at&.iso8601 },
107
+ browser_running: @project.current_sandbox_session&.browser_running? || false
108
+ }
109
+ end
110
+ end
111
+ end
112
+ end