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,558 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "shellwords"
4
+
5
+ module ActionAgent
6
+ # How a sandbox backend boots one checkout, handed to it as data
7
+ # (SandboxOrchestrator#create_sandbox(session, boot_config:)). For that
8
+ # boot it replaces the checkout's .activeagents/sandbox.yml. #to_h is plain
9
+ # JSON, so a backend that boots somewhere else (a container's own boot
10
+ # script) reads the same thing:
11
+ #
12
+ # kind "bootstrap" (built by .bootstrap) or "custom"
13
+ # apply "always", or "without_engine": only when the checkout
14
+ # has a Gemfile.lock that locks no actionagent and its
15
+ # sandbox.yml names no manifest. Otherwise the checkout
16
+ # boots as it would without a spec, with the spec's env
17
+ # and secrets added to its sandbox.yml env and the
18
+ # spec's `always` steps run after that file's setup.
19
+ # preflight refuse, before any repository command runs, a
20
+ # checkout without a Gemfile.lock or a
21
+ # config/application.rb at its root, or one locking Ruby
22
+ # or railties older than the engine supports
23
+ # steps [{ name, command, timeout, unless_locked?, if_task?,
24
+ # always? }], run in order, each with a log of its own.
25
+ # A step with unless_locked is skipped when the
26
+ # checkout's Gemfile.lock, as checked out, locks that
27
+ # gem; one with if_task when the app defines no such
28
+ # Rake task. A step with always runs whether the spec
29
+ # applies or not, so it cannot have unless_locked.
30
+ # env environment for the steps, manifest and start
31
+ # secrets environment too, masked in every log and message the
32
+ # backend produces and never written down
33
+ # manifest, start { command, timeout }, as sandbox.yml's keys of the
34
+ # same name
35
+ # start_url a path on the booted app that must not answer 5xx once
36
+ # its MCP facade answers
37
+ # keep_on_failure keep a failed boot's workspace and databases, so it
38
+ # can be resumed from the step that failed
39
+ # timeout seconds for the whole boot, checkout included; a
40
+ # step's own timeout bounds it within that
41
+ # engine where the gems were taken from (see .engine_gems)
42
+ #
43
+ # Secrets travel in memory only. #redacted is what may be written down: the
44
+ # secrets' names without their values.
45
+ class SandboxBootSpec
46
+ class Invalid < ArgumentError; end
47
+
48
+ KINDS = %w[bootstrap custom].freeze
49
+ APPLY_MODES = %w[always without_engine].freeze
50
+ STEP_NAME = /\A[a-z][a-z0-9_]{0,39}\z/
51
+ # Names the backend gives the parts of a boot that are not spec steps.
52
+ RESERVED_STEP_NAMES = %w[checkout preflight setup manifest start server].freeze
53
+ ENV_NAME = /\A[A-Za-z_][A-Za-z0-9_]*\z/
54
+ GEM_NAME = /\A[A-Za-z0-9][A-Za-z0-9._-]{0,99}\z/
55
+ TASK_NAME = /\A[A-Za-z0-9_][A-Za-z0-9_:.-]{0,99}\z/
56
+ # Set by the backend, or changing how Ruby, Bundler, Node or git load
57
+ # code: a secret is handed to the checkout's code, never to the loader.
58
+ REFUSED_SECRET_NAME = /
59
+ \A(?:PORT|DATABASE_URL|RUBYOPT|RUBYLIB|LD_PRELOAD|PATH|NODE_OPTIONS)\z |
60
+ _DATABASE_URL\z | \AACTION_AGENT_SANDBOX_ | \ADYLD_ | \ABUNDLE_ | \AGIT_
61
+ /x
62
+ MAX_STEPS = 30
63
+ MAX_COMMAND_LENGTH = 4096
64
+ MAX_START_URL_LENGTH = 2048
65
+ MAX_TIMEOUT = 6 * 3600
66
+ DEFAULT_STEP_TIMEOUT = 600
67
+ # A bootstrap installs a bundle, runs two generators and every engine
68
+ # migration before the app's own setup: more than a configured app needs.
69
+ BOOTSTRAP_TIMEOUT = 1800
70
+ MINIMUM_RUBY = Gem::Version.new("3.2")
71
+ MINIMUM_RAILTIES = Gem::Version.new("7.2")
72
+ ASSET_TASKS = %w[javascript:build css:build tailwindcss:build].freeze
73
+ CREDENTIALED_URL = %r{[a-z][a-z0-9+.-]*://[^/\s@]+@}i
74
+ # What a sandbox request may ask of a checkout's boot (see
75
+ # .request_options): bootstrap one whose Gemfile.lock lacks the engine,
76
+ # bootstrap it whatever its lock says, or boot it as its sandbox.yml says.
77
+ BOOTSTRAP_MODES = %w[auto always never].freeze
78
+ # The steps that install the engine, which a checkout that bundles it
79
+ # already (a project's install pull request branch) does not run.
80
+ INSTALL_STEPS = %w[bundle_config add_framework add_engine install_framework install_engine].freeze
81
+ # A schema tools choice (see .schema_tools_steps).
82
+ MODEL_NAME = /\A[A-Z][A-Za-z0-9]{0,99}(?:::[A-Z][A-Za-z0-9]{0,99}){0,4}\z/
83
+ COLUMN_NAME = /\A[a-z_][a-z0-9_]{0,62}\z/
84
+ MAX_SCHEMA_TOOL_MODELS = 50
85
+ MAX_SCHEMA_TOOL_COLUMNS = 100
86
+ # Leaves room within MAX_STEPS for every step of a bootstrap's own.
87
+ MAX_SCHEMA_TOOL_STEPS = 10
88
+ SCHEMA_TOOLS_STEP = /\Aschema_tools(?:_\d+)?\z/
89
+ SCHEMA_TOOLS_DIR = "app/agent_tools"
90
+ # ActiveAgent::SchemaTools::MANAGED_MARKER, spelled out because the
91
+ # checkout's framework writes it and this dashboard's own framework can
92
+ # predate the constant.
93
+ SCHEMA_TOOLS_MARKER = "Managed by the ActiveAgent dashboard"
94
+
95
+ attr_reader :kind, :apply, :steps, :env, :secrets, :manifest, :start, :start_url, :timeout, :engine,
96
+ :secret_names
97
+
98
+ class << self
99
+ # A spec from +value+: a spec, a Hash in #to_h's shape (string or
100
+ # symbol keys), or nil for none.
101
+ #
102
+ # @raise [Invalid] when +value+ is not a valid spec
103
+ def wrap(value)
104
+ case value
105
+ when nil then nil
106
+ when self then value
107
+ when Hash then new(value)
108
+ else raise Invalid, "a boot spec is a Hash, not #{value.class}"
109
+ end
110
+ end
111
+
112
+ # The spec that installs the engine into a checkout that does not
113
+ # bundle it: the same version as this dashboard's, or the git revision
114
+ # or path this dashboard bundles it from (see .engine_gems).
115
+ #
116
+ # @param apply [String] "always", or "without_engine" for only a
117
+ # checkout without the engine
118
+ # @raise [Invalid] for options no spec can hold, and when the engine's
119
+ # gems come from a git URL that carries credentials
120
+ #
121
+ # @param steps [Array<Hash>] more steps, run after db_prepare
122
+ def bootstrap(apply: "always", start_url: "/", keep_on_failure: false, env: {}, secrets: {}, timeout: BOOTSTRAP_TIMEOUT,
123
+ engine: engine_gems, steps: [])
124
+ new(
125
+ "kind" => "bootstrap",
126
+ "apply" => apply,
127
+ "preflight" => true,
128
+ "steps" => bootstrap_steps(engine) + steps,
129
+ "env" => env,
130
+ "secrets" => secrets,
131
+ "manifest" => { "command" => "bin/rails action_agent:sandbox:manifest", "timeout" => 300 },
132
+ "start" => { "command" => "bin/rails server -b 127.0.0.1 -p $PORT", "timeout" => 300 },
133
+ "start_url" => start_url,
134
+ "keep_on_failure" => keep_on_failure,
135
+ "timeout" => timeout,
136
+ "engine" => engine
137
+ )
138
+ end
139
+
140
+ # The spec for a checkout that bundles the engine already because a
141
+ # bootstrap's changes were published to its branch: the bootstrap's
142
+ # steps without the INSTALL_STEPS, whatever the checkout's
143
+ # sandbox.yml says.
144
+ #
145
+ # @param steps [Array<Hash>] more steps, run after db_prepare
146
+ def installed(start_url: "/", keep_on_failure: false, env: {}, secrets: {}, steps: [])
147
+ new(
148
+ "kind" => "custom",
149
+ "apply" => "always",
150
+ "steps" => [ step("bundle_install", "bundle install", 900), *asset_steps, step("db_prepare", "bin/rails db:prepare", 900),
151
+ *steps ],
152
+ "env" => env,
153
+ "secrets" => secrets,
154
+ "manifest" => { "command" => "bin/rails action_agent:sandbox:manifest", "timeout" => 300 },
155
+ "start" => { "command" => "bin/rails server -b 127.0.0.1 -p $PORT", "timeout" => 300 },
156
+ "start_url" => start_url,
157
+ "keep_on_failure" => keep_on_failure,
158
+ "timeout" => BOOTSTRAP_TIMEOUT
159
+ )
160
+ end
161
+
162
+ # The steps that write the schema tools in +choices+ after db_prepare,
163
+ # which a checkout booting from its own sandbox.yml runs too (they are
164
+ # `always` steps):
165
+ #
166
+ # [{ "model" => "Reservation", "filterable" => ["status"], "returns" => ["status", "starts_at"] }]
167
+ #
168
+ # The first command removes every app/agent_tools file headed by
169
+ # SCHEMA_TOOLS_MARKER, so a model that is no longer chosen loses its
170
+ # tools. Each chosen model's file is then written by
171
+ # `active_agent:schema_tools --managed`, exposing only the columns
172
+ # chosen, except where a file the dashboard did not write is already
173
+ # there: that file is left as it is. The commands are packed into as
174
+ # few steps as MAX_COMMAND_LENGTH allows, named schema_tools,
175
+ # schema_tools_2 and so on.
176
+ #
177
+ # nil gives no steps. An empty list gives the step that only removes.
178
+ #
179
+ # @raise [Invalid] for a model or column name that is not one, a column
180
+ # that looks like a secret, more than MAX_SCHEMA_TOOL_MODELS models,
181
+ # or choices that need more than MAX_SCHEMA_TOOL_STEPS steps
182
+ def schema_tools_steps(choices)
183
+ return [] if choices.nil?
184
+
185
+ choices = Array(choices)
186
+ raise Invalid, "at most #{MAX_SCHEMA_TOOL_MODELS} models can have schema tools" if choices.size > MAX_SCHEMA_TOOL_MODELS
187
+
188
+ commands = [ remove_managed_tools_command, *choices.map { |choice| schema_tools_command(choice.to_h.stringify_keys) } ]
189
+ packed = commands.each_with_object([]) do |command, chunks|
190
+ if chunks.last && chunks.last.length + command.length + 4 <= MAX_COMMAND_LENGTH
191
+ chunks.last << " && " << command
192
+ else
193
+ chunks << command.dup
194
+ end
195
+ end
196
+ if packed.size > MAX_SCHEMA_TOOL_STEPS
197
+ raise Invalid, "the chosen models and columns need more than #{MAX_SCHEMA_TOOL_STEPS} boot steps: choose fewer"
198
+ end
199
+
200
+ packed.each_with_index.map do |command, index|
201
+ step(index.zero? ? "schema_tools" : "schema_tools_#{index + 1}", command, 900, always: true)
202
+ end
203
+ end
204
+
205
+ # Whether +name+ is one of the steps .schema_tools_steps names.
206
+ def schema_tools_step?(name)
207
+ SCHEMA_TOOLS_STEP.match?(name.to_s)
208
+ end
209
+
210
+ # A sandbox request's boot options, checked and in the shape a job
211
+ # argument carries them. They hold no secrets.
212
+ #
213
+ # @param bootstrap [String, Boolean, nil] "auto" (the default), "always"
214
+ # or "never"; true and false read as "always" and "never"
215
+ # @param start_url [String, nil] a path on the app, "/" when blank
216
+ # @param keep_on_failure [Boolean, String, nil]
217
+ # @return [Hash] { "bootstrap" =>, "start_url" =>, "keep_on_failure" => }
218
+ # @raise [Invalid]
219
+ def request_options(bootstrap: nil, start_url: nil, keep_on_failure: nil)
220
+ mode =
221
+ case bootstrap
222
+ when nil, "" then "auto"
223
+ when true, "true" then "always"
224
+ when false, "false" then "never"
225
+ else bootstrap.to_s
226
+ end
227
+ raise Invalid, "`bootstrap` must be one of #{BOOTSTRAP_MODES.join(", ")}" unless BOOTSTRAP_MODES.include?(mode)
228
+
229
+ url = start_url.presence || "/"
230
+ raise Invalid, "`start_url` must be a path on the app, such as /" unless url.is_a?(String) && start_url_path?(url)
231
+
232
+ { "bootstrap" => mode, "start_url" => url, "keep_on_failure" => ActiveModel::Type::Boolean.new.cast(keep_on_failure) == true }
233
+ end
234
+
235
+ # The spec .request_options ask for: nil for "never", and for "auto" a
236
+ # bootstrap that applies only to a checkout without the engine.
237
+ #
238
+ # @raise [Invalid] as .bootstrap does
239
+ def for_request(options)
240
+ options = request_options(**options.to_h.symbolize_keys.slice(:bootstrap, :start_url, :keep_on_failure))
241
+ return nil if options["bootstrap"] == "never"
242
+
243
+ bootstrap(apply: options["bootstrap"] == "always" ? "always" : "without_engine",
244
+ start_url: options["start_url"], keep_on_failure: options["keep_on_failure"])
245
+ end
246
+
247
+ def start_url_path?(url)
248
+ url.start_with?("/") && !url.start_with?("//") && url.length <= MAX_START_URL_LENGTH && url.match?(/\A[[:graph:]]+\z/)
249
+ end
250
+
251
+ # Where a bootstrap takes activeagent and actionagent from: wherever
252
+ # this dashboard's own bundle has them. A gem from rubygems.org is
253
+ # added at this process's version (`~>`), one from git at the same
254
+ # revision, and one from a path at the same path, which only a backend
255
+ # on this machine can reach.
256
+ #
257
+ # { "activeagent" => { "source" => "rubygems", "version" => "1.9.0" },
258
+ # "actionagent" => { "source" => "path", "path" => "/src/activeagent/actionagent" } }
259
+ #
260
+ # @param locked_gems [Bundler::LockfileParser, nil] the dashboard's lock;
261
+ # nil when it runs without Bundler
262
+ # @raise [Invalid] for a git URL with credentials in it, which would end
263
+ # up in the checkout's Gemfile and logs
264
+ def engine_gems(locked_gems = default_locked_gems)
265
+ specs = locked_gems.respond_to?(:specs) ? locked_gems.specs.to_a : []
266
+ { "activeagent" => ActiveAgent::VERSION, "actionagent" => ActionAgent::VERSION }.to_h do |name, version|
267
+ [ name, gem_source(name, specs.find { |spec| spec.name == name }&.source, version) ]
268
+ end
269
+ end
270
+
271
+ private
272
+
273
+ def default_locked_gems
274
+ defined?(::Bundler) ? ::Bundler.locked_gems : nil
275
+ rescue StandardError
276
+ nil
277
+ end
278
+
279
+ def gem_source(name, source, version)
280
+ if defined?(::Bundler::Source::Git) && source.is_a?(::Bundler::Source::Git)
281
+ uri = source.uri.to_s
282
+ if CREDENTIALED_URL.match?(uri)
283
+ raise Invalid, "the dashboard bundles #{name} from a git URL with credentials in it, which a sandbox " \
284
+ "would write into the checkout's Gemfile"
285
+ end
286
+
287
+ { "source" => "git", "uri" => uri, "ref" => source.revision.to_s }
288
+ elsif defined?(::Bundler::Source::Path) && source.is_a?(::Bundler::Source::Path)
289
+ { "source" => "path", "path" => source.expanded_original_path.to_s }
290
+ else
291
+ { "source" => "rubygems", "version" => version }
292
+ end
293
+ end
294
+
295
+ def bootstrap_steps(engine)
296
+ framework = engine.fetch("activeagent")
297
+ steps = [
298
+ step("bundle_config", "bundle config set --local frozen false", 60),
299
+ step("bundle_install", "bundle install", 900)
300
+ ]
301
+ # From rubygems.org the engine brings the framework with it. From git
302
+ # or a path the framework must come from the same place.
303
+ if framework["source"] != "rubygems"
304
+ steps << step("add_framework", add_gem("activeagent", framework, skip_install: true), 300, unless_locked: "activeagent")
305
+ end
306
+ steps << step("add_engine", add_gem("actionagent", engine.fetch("actionagent")), 900, unless_locked: "actionagent")
307
+ # --skip: run with no terminal, Thor would take end of input as
308
+ # "overwrite" for every file that already exists.
309
+ steps << step("install_framework", "bin/rails generate active_agent:install --skip", 300, unless_locked: "activeagent")
310
+ steps << step("install_engine", "bin/rails generate action_agent:install --skip", 300, unless_locked: "actionagent")
311
+ steps.concat(asset_steps)
312
+ steps << step("db_prepare", "bin/rails db:prepare", 900)
313
+ end
314
+
315
+ def asset_steps
316
+ ASSET_TASKS.map { |task| step(task.tr(":", "_"), "bin/rails #{task}", 600, if_task: task) }
317
+ end
318
+
319
+ def remove_managed_tools_command
320
+ find = [ "find", SCHEMA_TOOLS_DIR, "-type", "f", "-name", "*_tools.rb", "-exec", "grep", "-qF", SCHEMA_TOOLS_MARKER, "{}", ";",
321
+ "-exec", "rm", "-f", "{}", ";" ]
322
+ "[ ! -d #{SCHEMA_TOOLS_DIR} ] || #{find.shelljoin}"
323
+ end
324
+
325
+ # Writes +choice+'s file unless one is there: after
326
+ # remove_managed_tools_command, only a file the dashboard did not
327
+ # write can be.
328
+ def schema_tools_command(choice)
329
+ model = choice["model"].to_s
330
+ raise Invalid, "#{model.truncate(60).inspect} is not a model name" unless MODEL_NAME.match?(model)
331
+
332
+ path = "#{SCHEMA_TOOLS_DIR}/#{model.underscore}_tools.rb"
333
+ words = [ "bin/rails", "generate", "active_agent:schema_tools", model, "--force", "--managed" ]
334
+ %w[filterable returns].each do |option|
335
+ columns = schema_tool_columns(choice[option], model, option)
336
+ words.push("--#{option}", *columns) if columns.any?
337
+ end
338
+ skip = [ "echo", "#{path} was not written by the dashboard, so it is left as it is" ].shelljoin
339
+ command = "if [ -e #{path} ]; then #{skip}; else #{words.shelljoin}; fi"
340
+ if command.length > MAX_COMMAND_LENGTH
341
+ raise Invalid, "#{model} has more columns chosen than one boot step can hold: choose fewer"
342
+ end
343
+
344
+ command
345
+ end
346
+
347
+ def schema_tool_columns(value, model, option)
348
+ columns = Array(value).map(&:to_s).uniq
349
+ raise Invalid, "#{model} has more than #{MAX_SCHEMA_TOOL_COLUMNS} #{option} columns" if columns.size > MAX_SCHEMA_TOOL_COLUMNS
350
+
351
+ columns.each do |column|
352
+ raise Invalid, "#{column.truncate(60).inspect} is not a column name" unless COLUMN_NAME.match?(column)
353
+ if SandboxManifest::SECRET_COLUMNS.match?(column)
354
+ raise Invalid, "#{model}.#{column} looks like it holds a secret, so no tool may read it"
355
+ end
356
+ end
357
+ columns
358
+ end
359
+
360
+ def step(name, command, timeout, **conditions)
361
+ { "name" => name, "command" => command, "timeout" => timeout, **conditions.transform_keys(&:to_s) }
362
+ end
363
+
364
+ def add_gem(name, source, skip_install: false)
365
+ words = [ "bundle add #{name}" ]
366
+ words <<
367
+ case source["source"]
368
+ when "git" then "--git #{Shellwords.escape(source.fetch("uri"))} --ref #{Shellwords.escape(source.fetch("ref"))}"
369
+ when "path" then "--path #{Shellwords.escape(source.fetch("path"))}"
370
+ else %(--version "~> #{source.fetch("version")}")
371
+ end
372
+ words << "--skip-install" if skip_install
373
+ words.join(" ")
374
+ end
375
+ end
376
+
377
+ # @param data [Hash] #to_h's shape
378
+ # @raise [Invalid]
379
+ def initialize(data)
380
+ raise Invalid, "a boot spec is a Hash, not #{data.class}" unless data.is_a?(Hash)
381
+
382
+ data = data.deep_stringify_keys
383
+ @kind = one_of(data.fetch("kind", "custom"), KINDS, "kind")
384
+ @apply = one_of(data.fetch("apply", "always"), APPLY_MODES, "apply")
385
+ @preflight = data["preflight"] == true
386
+ @steps = parse_steps(data["steps"])
387
+ @env = parse_env(data["env"], "env")
388
+ @secrets = parse_env(data["secrets"], "secrets")
389
+ refused = @secrets.keys.grep(REFUSED_SECRET_NAME)
390
+ raise Invalid, "secrets may not set #{refused.join(", ")}" if refused.any?
391
+
392
+ @secret_names = (Array(data["secret_names"]).map(&:to_s) | @secrets.keys).freeze
393
+ @manifest = parse_command(data["manifest"], "manifest", "bin/rails action_agent:sandbox:manifest")
394
+ @start = parse_command(data["start"], "start", "bin/rails server -b 127.0.0.1 -p $PORT")
395
+ @start_url = parse_start_url(data.fetch("start_url", "/"))
396
+ @keep_on_failure = data["keep_on_failure"] == true
397
+ @timeout = parse_timeout(data.fetch("timeout", @kind == "bootstrap" ? BOOTSTRAP_TIMEOUT : DEFAULT_STEP_TIMEOUT), "timeout")
398
+ if @kind == "bootstrap" && @timeout < BOOTSTRAP_TIMEOUT
399
+ raise Invalid, "a bootstrap boot needs a timeout of at least #{BOOTSTRAP_TIMEOUT}s, not #{@timeout}s"
400
+ end
401
+
402
+ @engine = data["engine"].is_a?(Hash) ? data["engine"] : nil
403
+ freeze
404
+ end
405
+
406
+ def preflight?
407
+ @preflight
408
+ end
409
+
410
+ def keep_on_failure?
411
+ @keep_on_failure
412
+ end
413
+
414
+ def bootstrap?
415
+ @kind == "bootstrap"
416
+ end
417
+
418
+ # Whether the spec applies only to a checkout without the engine.
419
+ def without_engine_only?
420
+ @apply == "without_engine"
421
+ end
422
+
423
+ # The steps a checkout the spec does not apply to runs after its
424
+ # sandbox.yml setup.
425
+ def always_steps
426
+ @steps.select { |step| step["always"] }
427
+ end
428
+
429
+ # Secret names the spec names without carrying their values: a
430
+ # #redacted spec read back.
431
+ def missing_secrets
432
+ @secret_names - @secrets.keys
433
+ end
434
+
435
+ # The environment the steps, manifest and start add.
436
+ def step_environment
437
+ @env.merge(@secrets)
438
+ end
439
+
440
+ # What scrubbing masks.
441
+ def secret_values
442
+ @secrets.values
443
+ end
444
+
445
+ # The whole spec, secrets included: for a backend, in memory.
446
+ def to_h
447
+ {
448
+ "kind" => @kind, "apply" => @apply, "preflight" => @preflight,
449
+ "steps" => @steps.map(&:dup), "env" => @env.dup, "secrets" => @secrets.dup,
450
+ "manifest" => @manifest.dup, "start" => @start.dup, "start_url" => @start_url,
451
+ "keep_on_failure" => @keep_on_failure, "timeout" => @timeout, "engine" => @engine
452
+ }.compact
453
+ end
454
+
455
+ # #to_h without the secrets' values: what may be stored or logged.
456
+ def redacted
457
+ to_h.except("secrets").merge("secret_names" => @secret_names.dup)
458
+ end
459
+
460
+ private
461
+
462
+ def one_of(value, allowed, key)
463
+ value = value.to_s
464
+ raise Invalid, "`#{key}` must be one of #{allowed.join(", ")}, not #{value.inspect}" unless allowed.include?(value)
465
+
466
+ value
467
+ end
468
+
469
+ def parse_steps(value)
470
+ return [].freeze if value.nil?
471
+ raise Invalid, "`steps` must be a list" unless value.is_a?(Array)
472
+ raise Invalid, "`steps` may hold at most #{MAX_STEPS} steps" if value.size > MAX_STEPS
473
+
474
+ steps = value.map { |entry| parse_step(entry) }
475
+ duplicate = steps.map { |step| step["name"] }.tally.find { |_name, count| count > 1 }&.first
476
+ raise Invalid, "step names must be unique (#{duplicate} is not)" if duplicate
477
+
478
+ steps.each(&:freeze).freeze
479
+ end
480
+
481
+ def parse_step(entry)
482
+ raise Invalid, "each step must be a mapping" unless entry.is_a?(Hash)
483
+
484
+ name = entry["name"].to_s
485
+ unless STEP_NAME.match?(name) && !RESERVED_STEP_NAMES.include?(name)
486
+ raise Invalid, "#{entry["name"].inspect} is not a step name (lowercase, digits and _; not #{RESERVED_STEP_NAMES.join(", ")})"
487
+ end
488
+
489
+ step = { "name" => name, "command" => command!(entry["command"], "step #{name}"),
490
+ "timeout" => parse_timeout(entry.fetch("timeout", DEFAULT_STEP_TIMEOUT), "step #{name}'s timeout") }
491
+ if entry.key?("unless_locked")
492
+ raise Invalid, "step #{name}'s unless_locked must name a gem" unless GEM_NAME.match?(entry["unless_locked"].to_s)
493
+
494
+ step["unless_locked"] = entry["unless_locked"].to_s
495
+ end
496
+ if entry.key?("if_task")
497
+ raise Invalid, "step #{name}'s if_task must name a Rake task" unless TASK_NAME.match?(entry["if_task"].to_s)
498
+
499
+ step["if_task"] = entry["if_task"].to_s
500
+ end
501
+ if entry.key?("always")
502
+ raise Invalid, "step #{name}'s always must be true or false" unless [ true, false ].include?(entry["always"])
503
+ if entry["always"] && step.key?("unless_locked")
504
+ raise Invalid, "step #{name} runs always, so it cannot be skipped by what the checkout locks"
505
+ end
506
+
507
+ step["always"] = true if entry["always"]
508
+ end
509
+ step
510
+ end
511
+
512
+ def parse_command(value, key, default)
513
+ return { "command" => default, "timeout" => DEFAULT_STEP_TIMEOUT }.freeze if value.nil?
514
+
515
+ value = { "command" => value } if value.is_a?(String)
516
+ raise Invalid, "`#{key}` must be a command" unless value.is_a?(Hash)
517
+
518
+ { "command" => command!(value["command"], key),
519
+ "timeout" => parse_timeout(value.fetch("timeout", DEFAULT_STEP_TIMEOUT), "#{key}'s timeout") }.freeze
520
+ end
521
+
522
+ def command!(value, what)
523
+ unless value.is_a?(String) && value.strip.present? && value.length <= MAX_COMMAND_LENGTH && !value.include?("\0")
524
+ raise Invalid, "#{what} needs a command (a string of at most #{MAX_COMMAND_LENGTH} characters)"
525
+ end
526
+
527
+ value
528
+ end
529
+
530
+ def parse_timeout(value, what)
531
+ seconds = Integer(value, exception: false) if value.is_a?(Integer) || value.is_a?(String)
532
+ raise Invalid, "#{what} must be a number of seconds from 1 to #{MAX_TIMEOUT}" unless seconds&.between?(1, MAX_TIMEOUT)
533
+
534
+ seconds
535
+ end
536
+
537
+ def parse_env(value, key)
538
+ return {}.freeze if value.nil?
539
+ raise Invalid, "`#{key}` must map variable names to strings" unless value.is_a?(Hash)
540
+
541
+ value.to_h do |name, setting|
542
+ scalar = setting.is_a?(String) || setting.is_a?(Numeric) || setting == true || setting == false
543
+ unless scalar && ENV_NAME.match?(name.to_s) && !setting.to_s.include?("\0")
544
+ raise Invalid, "`#{key}` must map variable names to strings (#{name.inspect} does not)"
545
+ end
546
+
547
+ [ name.to_s, setting.to_s ]
548
+ end.freeze
549
+ end
550
+
551
+ def parse_start_url(value)
552
+ url = value.to_s
553
+ raise Invalid, "`start_url` must be a path on the app, such as /" unless self.class.start_url_path?(url)
554
+
555
+ url
556
+ end
557
+ end
558
+ end