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,719 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+
5
+ module ActionAgent
6
+ # Used to open a pull request from a checkout sandbox's changes, through
7
+ # GitHub's Git Data API from the dashboard's own process.
8
+ #
9
+ # No git process ever holds the token it writes with. The sandbox backend
10
+ # only lists and reads files (SandboxOrchestrator#changed_files and
11
+ # #read_file), and the token is minted when the publish runs, limited to
12
+ # the one repository: an installation token with contents and pull request
13
+ # write, or the OAuth connection's token when its own user publishes and
14
+ # its scopes allow writing.
15
+ #
16
+ # A preview, a publish and a patch all read the changes the same way:
17
+ #
18
+ # 1. list: the backend's changed files since the checkout commit
19
+ # 2. filter: a path outside the caller's allowlist or under .github/, a
20
+ # symlink, a submodule, or a file over MAX_FILE_BYTES is refused
21
+ # 3. read: each remaining file's content now and in that commit, up to
22
+ # MAX_FILES files and READ_BUDGET_BYTES; a file past either is not
23
+ # read, and cannot be chosen until fewer files are asked for
24
+ # 4. scan: a file holding one of the sandbox's secrets, or anything shaped
25
+ # like a GitHub token, is refused
26
+ #
27
+ # A refused file is named with the reason and never published. A publish
28
+ # or a patch reads only the files it was asked for. A publish then writes
29
+ # blobs, a tree on the checkout commit's tree, a commit, and either a
30
+ # branch that did not exist and a draft pull request, or a fast-forward of
31
+ # the pull request's branch (see DraftPullRequest for its operations).
32
+ #
33
+ # The branch of a pull request always holds the checkout commit's tree with
34
+ # the files of its last publish on top, so its diff on GitHub is the diff
35
+ # the dialog showed. An update that leaves out a file published earlier
36
+ # returns that file to its content in the checkout commit.
37
+ #
38
+ # A publisher can also be given generated files: content the dashboard
39
+ # writes itself (a project's sandbox.yml, its evaluation suite) and offers
40
+ # beside the sandbox's own changes, compared with the checkout commit and
41
+ # filtered and scanned the same way.
42
+ class DraftPullRequestPublisher
43
+ # A publish, preview or patch that cannot go ahead. +code+ is a word the
44
+ # dashboard acts on:
45
+ #
46
+ # unsupported the backend or sandbox cannot be read
47
+ # not_live the sandbox is not ready or running
48
+ # unreadable the backend failed to read the changes
49
+ # nothing_selected no file was chosen
50
+ # not_publishable a chosen file is refused (see FILE_REFUSALS)
51
+ # secret a chosen file holds a secret
52
+ # changed_since_preview a chosen file differs from its preview
53
+ # too_large the chosen files exceed MAX_FILES or
54
+ # MAX_TOTAL_BYTES, or the changes asked for
55
+ # exceed what one read covers
56
+ # invalid_branch the branch name is not one git allows
57
+ # branch_exists the branch already exists on GitHub
58
+ # branch_moved the branch to update has commits the dashboard
59
+ # did not publish, or is gone
60
+ # no_credential nothing can write to the repository
61
+ # write_refused GitHub refused a write (403, 404) or the token
62
+ # github_error GitHub failed otherwise
63
+ class Refused < StandardError
64
+ attr_reader :code
65
+
66
+ def initialize(message, code:)
67
+ super(message)
68
+ @code = code
69
+ end
70
+ end
71
+
72
+ MAX_FILE_BYTES = 1024 * 1024
73
+ MAX_TOTAL_BYTES = 10 * 1024 * 1024
74
+ MAX_FILES = 300
75
+ # What one preview, publish or patch reads, counting each file's content
76
+ # now and in the checkout commit.
77
+ READ_BUDGET_BYTES = MAX_TOTAL_BYTES * 2
78
+ MAX_BRANCH_LENGTH = 200
79
+ WRITE_PERMISSIONS = { contents: "write", pull_requests: "write" }.freeze
80
+ STATUS_PERMISSIONS = { pull_requests: "read" }.freeze
81
+ PUBLISHABLE_MODES = %w[100644 100755].freeze
82
+ # Why a changed file is refused, as the dialog words it.
83
+ FILE_REFUSALS = {
84
+ "credential_directory" => "Claude configuration and credentials are never published",
85
+ "excluded" => "files under .github/ are never published",
86
+ "not_allowed" => "not in the files chosen to publish",
87
+ "symlink" => "symlinks are not published",
88
+ "submodule" => "submodules are not published",
89
+ "unsupported_mode" => "only regular files are published",
90
+ "too_large" => "larger than #{MAX_FILE_BYTES / 1024 / 1024} MB",
91
+ "unreadable" => "could not be read",
92
+ "secret" => "contains a secret of this sandbox or a GitHub token",
93
+ "local_path_gem" => "points a gem at a path outside the repository (a local engine checkout, say); " \
94
+ "use a released version or a git source",
95
+ "not_read" => "not read: a preview reads at most #{MAX_FILES} files and #{READ_BUDGET_BYTES / 1024 / 1024} MB, " \
96
+ "so limit the paths to read it"
97
+ }.freeze
98
+
99
+ # A changed file, its content now and in the checkout commit, and why it
100
+ # is refused (a FILE_REFUSALS key), or nil when it may be published.
101
+ ChangedFile = Struct.new(:path, :status, :mode, :base_mode, :size, :content, :base_content, :refusal, :generated,
102
+ keyword_init: true) do
103
+ def publishable? = refusal.nil?
104
+ def deleted? = status == "deleted"
105
+
106
+ # What the dialog previewed of this file. A publish is refused when
107
+ # the file no longer matches it.
108
+ def digest
109
+ Digest::SHA256.hexdigest("#{status}\0#{mode}\0".b + content.to_s.b)
110
+ end
111
+
112
+ # This file as SandboxPatch takes a change.
113
+ def to_change
114
+ { path: path, status: status, mode: mode, base_mode: base_mode, content: content, base_content: base_content }
115
+ end
116
+ end
117
+
118
+ # The changes of a sandbox: the commit it checked out, and its files.
119
+ Changes = Struct.new(:base_commit, :files, keyword_init: true) do
120
+ def publishable = files.select(&:publishable?)
121
+ end
122
+
123
+ # How a publish writes to the repository: through an installation
124
+ # ("app") or the OAuth connection ("oauth"); or +refusal+, why neither
125
+ # can.
126
+ Credential = Struct.new(:kind, :installation, :connection, :repository_id, :refusal, keyword_init: true) do
127
+ def available? = refusal.nil?
128
+ end
129
+
130
+ class << self
131
+ # Why +name+ cannot be a branch, or nil: the rules of
132
+ # git-check-ref-format, and at most MAX_BRANCH_LENGTH characters.
133
+ def branch_refusal(name)
134
+ return "Name the branch" unless name.is_a?(String) && name.present?
135
+ return "A branch name is at most #{MAX_BRANCH_LENGTH} characters" if name.length > MAX_BRANCH_LENGTH
136
+
137
+ valid = name.ascii_only? &&
138
+ !name.match?(%r{[\x00-\x20\x7f~^:?*\[\\]|\.\.|@\{|//|\A[/-]|/\z|\.\z|\A@\z}) &&
139
+ name.split("/").none? { |part| part.start_with?(".") || part.end_with?(".lock") }
140
+ valid ? nil : "#{name.inspect} is not a valid branch name"
141
+ end
142
+
143
+ # Reads +record+'s pull request again when it was last read at least
144
+ # DraftPullRequest::STATUS_REFRESH_INTERVAL ago, with a token that can
145
+ # read pull requests and nothing else. A failure keeps what was known.
146
+ #
147
+ # @return [DraftPullRequest] +record+
148
+ def refresh_status!(record)
149
+ return record unless record.claim_status_refresh!
150
+
151
+ client = status_client(record)
152
+ return record if client.nil?
153
+
154
+ pull = client.pull_request(record.repository, record.number)
155
+ record.update_columns(state: pull_state(pull), draft: pull["draft"] == true)
156
+ record
157
+ rescue GithubClient::Error => e
158
+ Rails.logger.warn("[ActionAgent] could not refresh pull request #{record.repository}##{record.number}: #{e.message}")
159
+ record
160
+ end
161
+
162
+ def pull_state(pull)
163
+ pull["merged"] == true || pull["merged_at"].present? ? "merged" : pull["state"].to_s
164
+ end
165
+
166
+ private
167
+
168
+ def status_client(record)
169
+ sandbox = record.sandbox_session
170
+ if record.credential_kind == "app"
171
+ installation = sandbox&.checkout_installation
172
+ repo = installation&.repository(record.repository)
173
+ return nil unless ActionAgent.github_app_configured? && repo && installation.usable?
174
+
175
+ GithubClient.new(installation.mint_token!(permissions: STATUS_PERMISSIONS, repository_ids: [ repo["id"] ]))
176
+ else
177
+ sandbox&.github_connection&.client
178
+ end
179
+ end
180
+ end
181
+
182
+ attr_reader :sandbox
183
+
184
+ # +user+ is who publishes: the OAuth connection writes only for the user
185
+ # who connected it. +generated_files+ maps a checkout path to the content
186
+ # the dashboard offers for it, in place of anything the sandbox wrote
187
+ # there.
188
+ def initialize(sandbox, user: nil, orchestrator: nil, generated_files: {})
189
+ @sandbox = sandbox
190
+ @user = user
191
+ @orchestrator = orchestrator || SandboxOrchestrator.new
192
+ @generated_files = generated_files.to_h.transform_keys(&:to_s)
193
+ end
194
+
195
+ def repository
196
+ sandbox.repository
197
+ end
198
+
199
+ # Why the sandbox's changes cannot be read now, or nil.
200
+ #
201
+ # @return [Refused, nil]
202
+ def read_refusal
203
+ return Refused.new("Pull requests are opened from checkout sandboxes only", code: "unsupported") unless sandbox.app_runtime?
204
+ unless @orchestrator.supports?(:changed_files) && @orchestrator.supports?(:read_file)
205
+ return Refused.new("The #{@orchestrator.backend_name} sandbox backend cannot read a sandbox's files", code: "unsupported")
206
+ end
207
+ unless @orchestrator.reads_checkouts?
208
+ return Refused.new("The #{@orchestrator.backend_name} sandbox backend cannot read the commit a checkout was cloned at",
209
+ code: "unsupported")
210
+ end
211
+ return nil if (sandbox.ready? || sandbox.running?) && sandbox.active?
212
+
213
+ state = sandbox.ready? || sandbox.running? ? "expired" : sandbox.status
214
+ Refused.new("The sandbox is #{state}. Publishing reads its live checkout, so start a new sandbox to publish", code: "not_live")
215
+ end
216
+
217
+ # The files the sandbox changed, and the generated files that differ
218
+ # from the checkout commit, each filtered, read and scanned. +allowlist+
219
+ # holds the patterns a path must match to be published (File.fnmatch
220
+ # patterns, where "dir/**" matches everything under dir, or a Regexp the
221
+ # whole path must match); nil allows every path. +paths+ names the only
222
+ # files to list and read; nil lists every changed file.
223
+ #
224
+ # Files are read in path order until MAX_FILES are read or
225
+ # READ_BUDGET_BYTES is spent, and those after are refused as not_read.
226
+ #
227
+ # @raise [Refused]
228
+ # @return [Changes]
229
+ def changes(allowlist: nil, paths: nil)
230
+ refusal = read_refusal
231
+ raise refusal if refusal
232
+
233
+ listing = @orchestrator.changed_files(sandbox)
234
+ base_commit = listing[:base_commit]
235
+ raise Refused.new("The sandbox did not record the commit it checked out", code: "unsupported") unless commit_id?(base_commit)
236
+
237
+ wanted = paths&.to_set
238
+ files = Array(listing[:files]).filter_map do |entry|
239
+ next if wanted && !wanted.include?(entry[:path].to_s)
240
+ next if @generated_files.key?(entry[:path].to_s)
241
+
242
+ file = ChangedFile.new(
243
+ path: entry[:path].to_s, status: entry[:status].to_s, mode: entry[:mode], base_mode: entry[:base_mode], size: entry[:size]
244
+ )
245
+ file.refusal = path_refusal(file.path, allowlist) || kind_refusal(file)
246
+ file
247
+ end
248
+
249
+ files += @generated_files.filter_map do |path, content|
250
+ next if wanted && !wanted.include?(path)
251
+
252
+ file = ChangedFile.new(path: path, mode: "100644", content: content.to_s.b, size: content.to_s.bytesize, generated: true)
253
+ file.refusal = path_refusal(path, allowlist)
254
+ file
255
+ end
256
+ files.sort_by!(&:path)
257
+
258
+ secrets = scan_values
259
+ budget = READ_BUDGET_BYTES
260
+ read = 0
261
+ files = files.filter_map do |file|
262
+ next file unless file.publishable?
263
+
264
+ if read >= MAX_FILES || budget.negative?
265
+ file.refusal = "not_read"
266
+ next file
267
+ end
268
+ read += 1
269
+ read_contents(file, secrets)&.tap { budget -= file.content.to_s.bytesize + file.base_content.to_s.bytesize }
270
+ end
271
+ Changes.new(base_commit: base_commit, files: files)
272
+ rescue Refused
273
+ raise
274
+ rescue StandardError => e
275
+ Rails.logger.warn("[ActionAgent] could not read the changes of sandbox #{sandbox.session_id}: #{e.class}: #{e.message}")
276
+ raise Refused.new("The sandbox's changes could not be read: #{e.message}", code: "unreadable")
277
+ end
278
+
279
+ # The paths of the files the sandbox changed since the checkout commit,
280
+ # listed by the backend and not read: what #changes would consider
281
+ # before the generated files are added.
282
+ #
283
+ # @raise [Refused]
284
+ # @return [Array<String>]
285
+ def changed_paths
286
+ refusal = read_refusal
287
+ raise refusal if refusal
288
+
289
+ Array(@orchestrator.changed_files(sandbox)[:files]).map { |entry| entry[:path].to_s }
290
+ rescue Refused
291
+ raise
292
+ rescue StandardError => e
293
+ Rails.logger.warn("[ActionAgent] could not list the changes of sandbox #{sandbox.session_id}: #{e.class}: #{e.message}")
294
+ raise Refused.new("The sandbox's changes could not be read: #{e.message}", code: "unreadable")
295
+ end
296
+
297
+ # What the dialog shows before a publish: every changed file with its
298
+ # refusal, and for each one that may be published its diff and digest.
299
+ def preview(allowlist: nil)
300
+ changes = self.changes(allowlist: allowlist)
301
+ files = changes.files.map do |file|
302
+ entry = {
303
+ path: file.path, status: file.status, mode: file.mode, base_mode: file.base_mode,
304
+ size: file.content&.bytesize || file.size,
305
+ refusal: file.refusal, refusal_message: file.refusal && FILE_REFUSALS[file.refusal]
306
+ }
307
+ next entry unless file.publishable?
308
+
309
+ binary = SandboxPatch.binary?(file.content) || SandboxPatch.binary?(file.base_content)
310
+ entry.merge(
311
+ binary: binary,
312
+ digest: file.digest,
313
+ diff: binary ? nil : SandboxPatch.file_diff(file.to_change).force_encoding(Encoding::UTF_8).scrub
314
+ )
315
+ end
316
+
317
+ {
318
+ repository: repository,
319
+ base_commit: changes.base_commit,
320
+ files: files,
321
+ suggested_branch: "activeagent/sandbox-#{sandbox.session_id.to_s.first(8)}",
322
+ limits: { max_file_bytes: MAX_FILE_BYTES, max_total_bytes: MAX_TOTAL_BYTES, max_files: MAX_FILES }
323
+ }
324
+ end
325
+
326
+ # The files of +changes+ that +selection+ ({ path => digest }, as the
327
+ # preview reported them) chose.
328
+ #
329
+ # @raise [Refused] when nothing is chosen, a chosen file is refused or
330
+ # changed since the preview, or the files exceed MAX_FILES or
331
+ # MAX_TOTAL_BYTES
332
+ # @return [Array<ChangedFile>]
333
+ def select!(changes, selection)
334
+ raise Refused.new("Choose at least one file to publish", code: "nothing_selected") if selection.blank?
335
+ if selection.size > MAX_FILES
336
+ raise Refused.new("#{selection.size} files are chosen; at most #{MAX_FILES} can be published at once", code: "too_large")
337
+ end
338
+
339
+ by_path = changes.files.index_by(&:path)
340
+ files = selection.map do |path, digest|
341
+ file = by_path[path]
342
+ raise Refused.new("#{path} has no changes any more. Reload the preview", code: "changed_since_preview") if file.nil?
343
+
344
+ if file.refusal == "not_read"
345
+ raise Refused.new("The chosen files are more than one publish reads (#{MAX_FILES} files, " \
346
+ "#{READ_BUDGET_BYTES / 1024 / 1024} MB). Choose fewer", code: "too_large")
347
+ end
348
+ unless file.publishable?
349
+ code = file.refusal == "secret" ? "secret" : "not_publishable"
350
+ raise Refused.new("#{path} cannot be published: #{FILE_REFUSALS[file.refusal]}", code: code)
351
+ end
352
+ raise Refused.new("#{path} changed since the preview. Reload it", code: "changed_since_preview") unless digest == file.digest
353
+
354
+ file
355
+ end
356
+ total = files.sum { |file| file.content.to_s.bytesize }
357
+ if total > MAX_TOTAL_BYTES
358
+ raise Refused.new("The chosen files hold #{total} bytes; at most #{MAX_TOTAL_BYTES} can be published at once", code: "too_large")
359
+ end
360
+
361
+ files
362
+ end
363
+
364
+ # A patch of the publishable files named by +paths+ (all of them when
365
+ # nil), for `git am` or `git apply`. Built from the backend's reads,
366
+ # with no GitHub token. +allowlist+ is #changes'.
367
+ #
368
+ # @raise [Refused] when a named file is refused or unchanged, or, without
369
+ # +paths+, when the changes are more than one read covers
370
+ # @return [String] binary-encoded
371
+ def patch(paths: nil, title: nil, body: nil, allowlist: nil)
372
+ changes = self.changes(paths: paths, allowlist: allowlist)
373
+ files = if paths.nil?
374
+ if changes.files.any? { |file| file.refusal == "not_read" }
375
+ raise Refused.new("The sandbox changed more files than one patch reads (#{MAX_FILES} files, " \
376
+ "#{READ_BUDGET_BYTES / 1024 / 1024} MB). Choose the files to download", code: "too_large")
377
+ end
378
+
379
+ changes.publishable
380
+ else
381
+ unknown = paths - changes.files.map(&:path)
382
+ raise Refused.new("#{unknown.first} has no changes in this sandbox", code: "changed_since_preview") if unknown.any?
383
+
384
+ select!(changes, changes.files.select { |file| paths.include?(file.path) }.to_h { |file| [ file.path, file.digest ] })
385
+ end
386
+ raise Refused.new("The sandbox has no changes that can be published", code: "nothing_selected") if files.empty?
387
+
388
+ SandboxPatch.format_patch(files.map(&:to_change), subject: title.presence || "Changes from a sandbox of #{repository}",
389
+ body: body, base_commit: changes.base_commit)
390
+ end
391
+
392
+ # How a publish by the user would write to the repository: the
393
+ # installation the sandbox checked out through, when the App is
394
+ # configured and the installation can write; otherwise the OAuth
395
+ # connection, when the user is the one who connected it and its scopes
396
+ # allow writing to the repository.
397
+ #
398
+ # @return [Credential]
399
+ def credential
400
+ installation = writing_installation
401
+ if installation
402
+ return Credential.new(kind: "app", installation: installation, repository_id: installation.repository(repository)["id"])
403
+ end
404
+
405
+ connection = sandbox.github_connection
406
+ if connection.nil? || connection.repository(repository).nil?
407
+ return Credential.new(refusal: "No GitHub App installation or OAuth connection of this workspace can write to #{repository}")
408
+ end
409
+
410
+ refusal = oauth_refusal(connection)
411
+ refusal ? Credential.new(refusal: refusal) : Credential.new(kind: "oauth", connection: connection)
412
+ end
413
+
414
+ # Runs +record+'s publish and records on it how it went: published,
415
+ # draft_refused, or failed with an error code (see Refused). With
416
+ # +allow_non_draft+, a pull request GitHub refuses to open as a draft is
417
+ # opened as a regular one.
418
+ #
419
+ # @return [DraftPullRequest] +record+
420
+ def publish!(record, allow_non_draft: false)
421
+ record.update!(status: "publishing", error_code: nil, error_message: nil)
422
+ refusal = read_refusal
423
+ raise refusal if refusal
424
+
425
+ credential = self.credential
426
+ raise Refused.new(credential.refusal, code: "no_credential") unless credential.available?
427
+
428
+ client = writing_client(credential)
429
+ record.update!(credential_kind: credential.kind, base_branch: record.base_branch || base_branch(client))
430
+ if record.operation.in?(%w[open_draft open_regular])
431
+ open_pull_request!(record, client, draft: record.operation == "open_draft", allow_non_draft: allow_non_draft)
432
+ else
433
+ push!(record, client)
434
+ record.operation == "create" ? open_pull_request!(record, client, draft: true, allow_non_draft: allow_non_draft) : record.update!(status: "published")
435
+ end
436
+ record
437
+ rescue Refused => e
438
+ record.update!(status: "failed", error_code: e.code, error_message: e.message)
439
+ record
440
+ rescue GithubClient::InstallationUnavailable => e
441
+ record.update!(status: "failed", error_code: "no_credential",
442
+ error_message: "#{e.message}: reinstall the GitHub App, or Check again in Settings -> Integrations")
443
+ record
444
+ rescue GithubClient::Unauthorized
445
+ record.update!(status: "failed", error_code: "no_credential",
446
+ error_message: "GitHub rejected the token. Reconnect GitHub in Settings -> Integrations")
447
+ record
448
+ rescue GithubClient::Error => e
449
+ code = e.status.in?([ 403, 404 ]) ? "write_refused" : "github_error"
450
+ record.update!(status: "failed", error_code: code, error_message: "GitHub refused the publish: #{e.message}")
451
+ record
452
+ end
453
+
454
+ private
455
+
456
+ # +file+ with its contents read and scanned, or nil when it no longer
457
+ # differs from the checkout commit. A generated file's content is its
458
+ # own, and its status follows from the checkout commit's.
459
+ def read_contents(file, secrets)
460
+ return file.tap { file.refusal = "too_large" } if file.size.to_i > MAX_FILE_BYTES
461
+
462
+ begin
463
+ if file.generated
464
+ file.base_content = @orchestrator.read_file(sandbox, file.path, base: true)
465
+ file.status = file.base_content.nil? ? "added" : "modified"
466
+ file.base_mode = file.base_content.nil? ? nil : "100644"
467
+ else
468
+ file.content = @orchestrator.read_file(sandbox, file.path) unless file.deleted?
469
+ file.base_content = @orchestrator.read_file(sandbox, file.path, base: true) unless file.status == "added"
470
+ end
471
+ rescue StandardError => e
472
+ Rails.logger.info("[ActionAgent] could not read #{file.path} from sandbox #{sandbox.session_id}: #{e.message}")
473
+ return file.tap { file.refusal = "unreadable" }
474
+ end
475
+ # Gone or reverted since it was listed.
476
+ return nil if file.deleted? ? file.base_content.nil? : file.content.nil?
477
+ return nil if file.status == "modified" && file.content == file.base_content && file.mode == (file.base_mode || file.mode)
478
+
479
+ if [ file.content, file.base_content ].any? { |content| content.to_s.bytesize > MAX_FILE_BYTES }
480
+ file.refusal = "too_large"
481
+ elsif secret?(file.content, secrets)
482
+ file.refusal = "secret"
483
+ elsif local_path_gem?(file)
484
+ file.refusal = "local_path_gem"
485
+ end
486
+ file
487
+ end
488
+
489
+ def path_refusal(path, allowlist)
490
+ return "credential_directory" if SandboxCredentialPaths.protected?(path)
491
+ return "excluded" if path.split("/").first.to_s.casecmp?(".github")
492
+ return nil if allowlist.nil?
493
+
494
+ flags = File::FNM_PATHNAME | File::FNM_DOTMATCH | File::FNM_EXTGLOB
495
+ allowed = allowlist.any? do |pattern|
496
+ pattern.is_a?(Regexp) ? pattern.match?(path) : File.fnmatch?(glob(pattern), path, flags)
497
+ end
498
+ allowed ? nil : "not_allowed"
499
+ end
500
+
501
+ # +pattern+ as File.fnmatch reads it, with a trailing "/**" matching
502
+ # everything under the directory: "app/**" -> "app/**/*".
503
+ def glob(pattern)
504
+ pattern.end_with?("/**") ? "#{pattern}/*" : pattern
505
+ end
506
+
507
+ def kind_refusal(file)
508
+ modes = [ file.mode, file.base_mode ].compact
509
+ return "symlink" if modes.include?("120000")
510
+ return "submodule" if modes.include?("160000")
511
+ return "unsupported_mode" unless file.deleted? || PUBLISHABLE_MODES.include?(file.mode)
512
+
513
+ nil
514
+ end
515
+
516
+ # The values a published file must not contain: everything the
517
+ # sandbox's own output is scrubbed of, and the OAuth connection's token.
518
+ def scan_values
519
+ values = sandbox.secret_values
520
+ values << sandbox.github_connection&.access_token
521
+ values.compact.map(&:to_s).select { |value| value.length >= SecretScrubber::MIN_SECRET_LENGTH }.map(&:b).uniq
522
+ end
523
+
524
+ # Bundler files a sandbox may rewrite with gems from its own machine: the
525
+ # :local backend's add_engine step bundles a dashboard that runs its
526
+ # engine from a path with `bundle add --path`, and that path means
527
+ # nothing in the repository.
528
+ BUNDLER_FILES = %w[Gemfile Gemfile.lock gems.rb gems.locked].freeze
529
+ # A path: option, or a lockfile PATH remote, that is absolute, in a home
530
+ # directory or climbs out of the checkout.
531
+ OUTSIDE_PATH = %r{(?:\bpath:\s*|\bpath\(\s*|^\s+remote:\s+)["']?(?:/|~|\.\./)[^"'\s,)]*}
532
+
533
+ # Whether +file+ is a Bundler file that newly points a gem outside the
534
+ # checkout. One the repository already had is left alone.
535
+ def local_path_gem?(file)
536
+ return false unless BUNDLER_FILES.include?(File.basename(file.path)) && file.content
537
+
538
+ lockfile = file.path.end_with?(".lock", ".locked")
539
+ refs = ->(content) { path_references(content.to_s.b, lockfile) }
540
+ (refs.call(file.content) - refs.call(file.base_content)).any?
541
+ end
542
+
543
+ def path_references(content, lockfile)
544
+ if lockfile
545
+ content.scan(/^PATH\n((?: .*\n?)*)/).flatten.flat_map { |block| block.scan(OUTSIDE_PATH) }
546
+ else
547
+ content.lines.reject { |line| line.lstrip.start_with?("#") }.flat_map { |line| line.scan(OUTSIDE_PATH) }
548
+ end.map(&:strip).uniq
549
+ end
550
+
551
+ def secret?(content, secrets)
552
+ return false if content.nil?
553
+
554
+ content = content.b
555
+ secrets.any? { |secret| content.include?(secret) } || content.match?(SecretScrubber::GITHUB_TOKEN)
556
+ end
557
+
558
+ def writing_installation
559
+ return nil unless ActionAgent.github_app_configured?
560
+
561
+ installation = sandbox.checkout_installation
562
+ return nil unless installation&.usable? && installation.repository(repository)
563
+
564
+ granted = installation.permissions
565
+ return installation if granted.empty?
566
+
567
+ WRITE_PERMISSIONS.all? { |name, level| granted[name.to_s] == level } ? installation : nil
568
+ end
569
+
570
+ # Why the OAuth connection cannot publish for the user, or nil. Where
571
+ # the host has a user model, only the user who connected it may; a
572
+ # connection made before the dashboard recorded that user must be
573
+ # connected again.
574
+ def oauth_refusal(connection)
575
+ if ActionAgent.user_class.present?
576
+ if connection.user_id.nil?
577
+ return "The OAuth connection does not record who connected it. Reconnect GitHub in Settings -> Integrations to publish with it"
578
+ end
579
+ unless @user.respond_to?(:id) && @user.id.to_s == connection.user_id.to_s
580
+ return "Only the user who connected GitHub (@#{connection.login}) can publish through the OAuth connection"
581
+ end
582
+ end
583
+
584
+ scopes = connection.scopes.to_s.split(/[\s,]+/).reject(&:blank?)
585
+ return nil if scopes.include?("repo")
586
+ return nil if scopes.include?("public_repo") && connection.repository(repository)["private"] == false
587
+
588
+ "The OAuth connection's scopes (#{scopes.join(', ').presence || 'none'}) do not allow writing to #{repository}. " \
589
+ "Reconnect GitHub with the repo scope"
590
+ end
591
+
592
+ def writing_client(credential)
593
+ return credential.connection.client if credential.kind == "oauth"
594
+
595
+ GithubClient.new(credential.installation.mint_token!(permissions: WRITE_PERMISSIONS, repository_ids: [ credential.repository_id ]))
596
+ rescue GithubClient::InstallationUnavailable
597
+ raise
598
+ rescue GithubClient::Error => e
599
+ raise unless e.status == 422
600
+
601
+ raise Refused.new("The GitHub App installation cannot write to #{repository}: #{e.message}", code: "write_refused")
602
+ end
603
+
604
+ # The branch the sandbox checked out, or the repository's default branch
605
+ # when it checked out a tag or a commit.
606
+ def base_branch(client)
607
+ ref = sandbox.repository_ref
608
+ return ref if ref.present? && client.branch_head(repository, ref)
609
+
610
+ repo = sandbox.checkout_installation&.repository(repository) || sandbox.github_connection&.repository(repository)
611
+ repo&.dig("default_branch").presence || "main"
612
+ end
613
+
614
+ # Writes the chosen files as a commit and points the branch at it: a new
615
+ # branch for a create, a fast-forward for an update.
616
+ def push!(record, client)
617
+ changes = self.changes(paths: record.files.map { |file| file["path"] })
618
+ unless changes.base_commit == record.base_commit
619
+ raise Refused.new("The sandbox's checkout commit is not the one previewed. Reload the preview", code: "changed_since_preview")
620
+ end
621
+
622
+ files = select!(changes, record.files.to_h { |file| [ file["path"], file["digest"] ] })
623
+ parent = expected_parent!(record, client)
624
+ base_tree = begin
625
+ client.commit_tree(record.repository, record.base_commit)
626
+ rescue GithubClient::Error => e
627
+ raise unless e.status.in?([ 404, 422 ])
628
+
629
+ raise Refused.new("GitHub has no commit #{record.base_commit}, which the sandbox checked out", code: "github_error")
630
+ end
631
+
632
+ entries = files.map do |file|
633
+ sha = file.deleted? ? nil : client.create_blob(record.repository, file.content)
634
+ { path: file.path, mode: file.mode || "100644", sha: sha }
635
+ end
636
+ tree = client.create_tree(record.repository, base_tree: base_tree, entries: entries)
637
+ commit = client.create_commit(record.repository, message: commit_message(record), tree: tree, parents: [ parent ])
638
+ point_branch!(record, client, commit)
639
+ record.update!(head_commit: commit, compare_url: record.opened? ? nil : compare_url(record))
640
+ end
641
+
642
+ # The commit the new one goes on top of: the checkout commit for a new
643
+ # branch, which must not exist yet, or the branch's head for an update,
644
+ # which must be the commit this record last published.
645
+ def expected_parent!(record, client)
646
+ head = client.branch_head(record.repository, record.branch)
647
+ if record.operation == "create"
648
+ raise Refused.new("A branch named #{record.branch} already exists on GitHub. Choose another name", code: "branch_exists") if head
649
+ return record.base_commit
650
+ end
651
+
652
+ if head.nil?
653
+ raise Refused.new("The branch #{record.branch} no longer exists on GitHub", code: "branch_moved")
654
+ elsif head != record.head_commit
655
+ raise Refused.new("The branch #{record.branch} has commits that were not published from this sandbox, so it was not updated",
656
+ code: "branch_moved")
657
+ end
658
+ head
659
+ end
660
+
661
+ def point_branch!(record, client, commit)
662
+ if record.operation == "create"
663
+ client.create_branch(record.repository, record.branch, commit)
664
+ else
665
+ client.fast_forward_branch(record.repository, record.branch, commit)
666
+ end
667
+ rescue GithubClient::Error => e
668
+ raise unless e.status == 422
669
+
670
+ if record.operation == "create"
671
+ raise Refused.new("A branch named #{record.branch} already exists on GitHub. Choose another name", code: "branch_exists")
672
+ end
673
+ raise Refused.new("The branch #{record.branch} moved while it was updated, so it was not updated", code: "branch_moved")
674
+ end
675
+
676
+ def open_pull_request!(record, client, draft:, allow_non_draft: false)
677
+ pull = client.create_pull_request(record.repository, title: record.title, body: record.body, head: record.branch,
678
+ base: record.base_branch, draft: draft)
679
+ record.update!(
680
+ status: "published", number: pull["number"], url: pull["html_url"], state: self.class.pull_state(pull),
681
+ draft: pull["draft"] == true, last_checked_at: Time.current, compare_url: nil, error_code: nil, error_message: nil
682
+ )
683
+ rescue GithubClient::Error => e
684
+ raise unless draft && draft_refused?(e)
685
+ return open_pull_request!(record, client, draft: false) if allow_non_draft
686
+
687
+ record.update!(
688
+ status: "draft_refused", compare_url: compare_url(record), error_code: "draft_refused",
689
+ error_message: "GitHub does not open draft pull requests in #{record.repository}. The branch #{record.branch} is published: " \
690
+ "open it as a regular pull request, or compare it on GitHub"
691
+ )
692
+ end
693
+
694
+ # Whether GitHub refused to open a pull request because it was a draft,
695
+ # as its validation errors say, whatever the branch names in them.
696
+ def draft_refused?(error)
697
+ return false unless error.status == 422
698
+
699
+ error.errors.any? do |detail|
700
+ detail["field"] == "draft" || detail["message"].to_s.match?(/\Adraft pull requests are not (?:supported|available|enabled)/i)
701
+ end
702
+ end
703
+
704
+ def commit_message(record)
705
+ return record.commit_message.presence || DraftPullRequest::DEFAULT_UPDATE_MESSAGE if record.operation == "update"
706
+
707
+ [ record.title, record.body.presence ].compact.join("\n\n")
708
+ end
709
+
710
+ def compare_url(record)
711
+ encode = ->(ref) { ref.split("/").map { |part| ERB::Util.url_encode(part) }.join("/") }
712
+ "#{GithubClient::WEB}/#{record.repository}/compare/#{encode.call(record.base_branch)}...#{encode.call(record.branch)}?expand=1"
713
+ end
714
+
715
+ def commit_id?(value)
716
+ value.is_a?(String) && value.match?(GithubClient::OBJECT_ID)
717
+ end
718
+ end
719
+ end