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
@@ -2,29 +2,57 @@
2
2
 
3
3
  module ActionAgent
4
4
  # The runtime manifest: how a booted checkout tells the sandbox backend
5
- # where its MCP facade answers and which bearer token opens it.
5
+ # where its MCP facade answers and which bearer token opens it, and which
6
+ # of the app's models an assistant could be given tools over.
6
7
  #
7
- # { "mcp_path": "/activeagents/mcp", "mcp_token": "aa_..." }
8
+ # { "mcp_path": "/activeagents/mcp", "mcp_token": "aa_...",
9
+ # "models": [{ "name": "Reservation", "table": "reservations",
10
+ # "columns": [{ "name": "status", "type": "string" }] }] }
8
11
  #
9
12
  # The checked-out app writes it with `bin/rails action_agent:sandbox:manifest`
10
13
  # (it mounts this engine, so the task ships with it), and a backend reads it
11
14
  # back with .parse. Both halves live here so they cannot drift.
15
+ #
16
+ # Writing it also mirrors the checkout's agent classes into its dashboard
17
+ # (AgentSync), so the facade serves their run_<slug> tools to the key it
18
+ # names. The key and the agents each belong to the only record of the class
19
+ # their model is owned through, when there is exactly one (see
20
+ # .sandbox_owner), and to nobody in an app with no owner model.
12
21
  module SandboxManifest
13
22
  # Where the manifest task writes, when set; stdout otherwise.
14
23
  PATH_ENV = "ACTION_AGENT_SANDBOX_MANIFEST"
24
+ MAX_MODELS = 200
25
+ MAX_COLUMNS = 150
26
+ # The namespaces of models an app gets from Rails and these gems rather
27
+ # than writes itself.
28
+ FRAMEWORK_NAMESPACES = %w[ActionAgent:: SolidAgent:: ActiveAgent:: ActiveStorage:: ActionText:: ActionMailbox:: ActiveRecord::].freeze
15
29
  # The dashboard API key the manifest hands out, created once per checkout.
16
30
  KEY_NAME = "Checkout sandbox runtime"
31
+ # Column names that look like they hold a credential:
32
+ # ActiveAgent::SchemaTools::SECRET_COLUMNS, or the same pattern under a
33
+ # framework release that predates the constant, which an app can bundle
34
+ # beside this engine.
35
+ SECRET_COLUMNS =
36
+ if defined?(ActiveAgent::SchemaTools) && ActiveAgent::SchemaTools.const_defined?(:SECRET_COLUMNS, false)
37
+ ActiveAgent::SchemaTools::SECRET_COLUMNS
38
+ else
39
+ /password|digest|token|secret|api_key|otp|encrypted|ssn/i
40
+ end
17
41
 
18
42
  class Error < StandardError; end
19
43
 
20
44
  module_function
21
45
 
22
46
  # Builds the manifest inside the booted app: the engine's MCP path under
23
- # wherever the app mounts it, and an API key for the MCP facade.
47
+ # wherever the app mounts it, and an API key for the MCP facade. Also
48
+ # syncs the app's agent classes. A class that cannot be synced is
49
+ # reported on stderr, and the manifest is written regardless.
24
50
  #
25
51
  # @param routes [ActionDispatch::Routing::RouteSet] the app's routes
52
+ # @param agent_classes [Array<Class>, nil] what to sync; nil for
53
+ # .checkout_agent_classes
26
54
  # @return [Hash{String => String}]
27
- def generate(routes: Rails.application.routes)
55
+ def generate(routes: Rails.application.routes, agent_classes: nil)
28
56
  # Rails 8 loads routes lazily in development and test; the mount is
29
57
  # invisible until they are.
30
58
  Rails.application.reload_routes_unless_loaded if Rails.application.respond_to?(:reload_routes_unless_loaded)
@@ -32,21 +60,132 @@ module ActionAgent
32
60
  mount = ActiveAgent::Telemetry::Configuration.new.mount_path_in(routes)
33
61
  raise Error, "ActionAgent::Engine is not mounted in this app's routes" if mount.nil?
34
62
 
35
- { "mcp_path" => "#{mount}/mcp", "mcp_token" => api_key.token }
63
+ key = api_key(sandbox_owner(ActionAgent::ApiKey))
64
+ sync_agents(agent_classes || checkout_agent_classes, sandbox_owner(ActionAgent::Agent))
65
+ { "mcp_path" => "#{mount}/mcp", "mcp_token" => key.token, "models" => app_models }
66
+ end
67
+
68
+ # The app's own models under app/models that have a table, each with its
69
+ # columns but +id+ and those that look like secrets (SECRET_COLUMNS), at
70
+ # most MAX_MODELS models of MAX_COLUMNS columns. Empty when they cannot
71
+ # be loaded, which is reported on stderr.
72
+ #
73
+ # @return [Array<Hash>] [{ "name" =>, "table" =>, "columns" => [{ "name" =>, "type" => }] }]
74
+ def app_models(root = Rails.root.join("app", "models"))
75
+ return [] unless defined?(ActiveRecord::Base) && root.directory?
76
+
77
+ begin
78
+ Rails.autoloaders.main.eager_load_dir(root.to_s)
79
+ rescue StandardError, ScriptError => e
80
+ warn "[ActionAgent] sandbox manifest: could not load app/models: #{e.class}: #{e.message}"
81
+ end
82
+
83
+ roots = [ root.to_s, File.realpath(root) ].uniq.map { |dir| "#{dir.chomp("/")}/" }
84
+ models = ActiveRecord::Base.descendants.select { |klass| app_model?(klass, roots) }.sort_by(&:name).first(MAX_MODELS)
85
+ models.filter_map do |klass|
86
+ columns = klass.columns.reject { |column| column.name == "id" || SECRET_COLUMNS.match?(column.name) }
87
+ { "name" => klass.name, "table" => klass.table_name,
88
+ "columns" => columns.first(MAX_COLUMNS).map { |column| { "name" => column.name, "type" => column.type.to_s } } }
89
+ rescue StandardError => e
90
+ warn "[ActionAgent] sandbox manifest: could not read #{klass.name}'s columns: #{e.class}: #{e.message}"
91
+ nil
92
+ end
93
+ end
94
+
95
+ def app_model?(klass, roots)
96
+ return false if klass.name.blank? || klass.abstract_class? || klass.name.include?("HABTM_")
97
+ return false if FRAMEWORK_NAMESPACES.any? { |namespace| klass.name.start_with?(namespace) }
98
+
99
+ file, = Object.const_source_location(klass.name)
100
+ file && roots.any? { |dir| file.start_with?(dir) || File.realpath(file).start_with?(dir) } && klass.table_exists?
101
+ rescue NameError, SystemCallError, ActiveRecord::ActiveRecordError
102
+ false
36
103
  end
37
104
 
38
105
  # The checkout's own dashboard API key for the facade. Reused across
39
- # runs so a re-run manifest does not mint a key per boot. A host that
40
- # owns keys by account or user gets a key with no owner, which reaches
41
- # no agents over MCP — the manifest says so rather than failing.
42
- def api_key
43
- ActionAgent::ApiKey.find_or_create_by!(name: KEY_NAME)
106
+ # runs so a re-run manifest does not mint a key per boot. A key without
107
+ # an owner takes +owner+ when there is one. In an app that owns keys by
108
+ # account or user and has no single owner, the key has none and reaches
109
+ # no agents over MCP.
110
+ def api_key(owner = nil)
111
+ key = ActionAgent::ApiKey.find_or_initialize_by(name: KEY_NAME)
112
+ key.owner = owner if owner && key.owner.nil?
113
+ key.save! if key.new_record? || key.changed?
114
+ key
115
+ end
116
+
117
+ # The record the checkout's +model+ rows belong to: the only one of the
118
+ # class +model+ is owned through. The API key and the agents can be owned
119
+ # through different classes (an account and a user), so each is resolved
120
+ # on its own. Nil when the app configures no owner class for +model+, and
121
+ # when that class has no record or several, since a sandbox cannot tell
122
+ # whose it would be.
123
+ #
124
+ # @param model [Class] an Ownable model
125
+ # @return [ActiveRecord::Base, nil]
126
+ def sandbox_owner(model)
127
+ owner_class = owner_class_for(model)
128
+ return nil if owner_class.nil?
129
+
130
+ owners = owner_class.limit(2).to_a
131
+ owners.first if owners.one?
132
+ end
133
+
134
+ def owner_class_for(model)
135
+ association = model.owner_association
136
+ association && ActionAgent.public_send(Ownable::CLASS_FOR.fetch(association)).safe_constantize
44
137
  end
45
138
 
46
- # Reads a manifest a checkout wrote.
139
+ # The app's own agent classes: those defined under app/agents, except
140
+ # ApplicationAgent and abstract ones.
141
+ #
142
+ # @param root [Pathname] where the app keeps its agents
143
+ # @return [Array<Class>]
144
+ def checkout_agent_classes(root = Rails.root.join("app", "agents"))
145
+ return [] unless defined?(ActiveAgent::Base) && root.directory?
146
+
147
+ loader = Rails.autoloaders.main
148
+ Dir[root.join("**", "*.rb").to_s].sort.each do |path|
149
+ loader.load_file(path)
150
+ rescue StandardError, ScriptError => e
151
+ warn "[ActionAgent] sandbox manifest: could not load #{path}: #{e.class}: #{e.message}"
152
+ end
153
+
154
+ roots = [ root.to_s, File.realpath(root) ].uniq.map { |dir| "#{dir.chomp("/")}/" }
155
+ ActiveAgent::Base.descendants.select do |klass|
156
+ next false if klass.name.blank? || klass.abstract? || klass.name == "ApplicationAgent"
157
+
158
+ file, = Object.const_source_location(klass.name)
159
+ file && roots.any? { |dir| file.start_with?(dir) || File.realpath(file).start_with?(dir) }
160
+ rescue NameError, SystemCallError
161
+ false
162
+ end.sort_by(&:name)
163
+ end
164
+
165
+ # Syncs each class on its own, so one that cannot be synced (a provider
166
+ # gem the checkout lacks, say) leaves the rest synced.
167
+ def sync_agents(classes, owner)
168
+ if owner.nil? && ActionAgent::Agent.owner_association
169
+ warn "[ActionAgent] sandbox manifest: no agents synced: the app owns agents by " \
170
+ "#{owner_class_for(ActionAgent::Agent)&.name || "an owner model"} and has no single owner for a sandbox to use"
171
+ return
172
+ end
173
+
174
+ classes.each do |klass|
175
+ result = ActionAgent::AgentSync.call([ klass ], owner: owner)
176
+ warn "[ActionAgent] sandbox manifest: could not sync #{klass.name}: #{result.errors}" unless result.success?
177
+ result.skipped.each { |row| warn "[ActionAgent] sandbox manifest: skipped #{row.skipped}" }
178
+ rescue StandardError => e
179
+ warn "[ActionAgent] sandbox manifest: could not sync #{klass.name}: #{e.class}: #{e.message}"
180
+ end
181
+ end
182
+
183
+ # Reads a manifest a checkout wrote. Its "models" are kept as far as they
184
+ # have the shape .app_models gives them: an entry that does not is
185
+ # dropped, and a manifest written before the list existed has none.
47
186
  #
48
187
  # @param json [String]
49
- # @return [Hash{String => String}] with "mcp_path" and "mcp_token"
188
+ # @return [Hash] with "mcp_path", "mcp_token" and "models"
50
189
  def parse(json)
51
190
  data = JSON.parse(json.to_s)
52
191
  raise Error, "the manifest is not a JSON object" unless data.is_a?(Hash)
@@ -59,9 +198,23 @@ module ActionAgent
59
198
  token = data["mcp_token"]
60
199
  raise Error, "the manifest's mcp_token is not a string" unless token.nil? || token.is_a?(String)
61
200
 
62
- { "mcp_path" => path, "mcp_token" => token }
201
+ { "mcp_path" => path, "mcp_token" => token, "models" => parse_models(data["models"]) }
63
202
  rescue JSON::ParserError => e
64
203
  raise Error, "the manifest is not JSON (#{e.message.truncate(120)})"
65
204
  end
205
+
206
+ def parse_models(value)
207
+ Array(value).first(MAX_MODELS).filter_map do |entry|
208
+ next unless entry.is_a?(Hash) && SandboxBootSpec::MODEL_NAME.match?(entry["name"].to_s)
209
+
210
+ columns = Array(entry["columns"]).first(MAX_COLUMNS).filter_map do |column|
211
+ next unless column.is_a?(Hash) && SandboxBootSpec::COLUMN_NAME.match?(column["name"].to_s)
212
+ next if SECRET_COLUMNS.match?(column["name"])
213
+
214
+ { "name" => column["name"], "type" => column["type"].to_s.truncate(32) }
215
+ end
216
+ { "name" => entry["name"], "table" => entry["table"].to_s.truncate(128), "columns" => columns }
217
+ end
218
+ end
66
219
  end
67
220
  end
@@ -47,10 +47,31 @@ module ActionAgent
47
47
  # Claude Code sessions inside an app_runtime checkout. Optional: a
48
48
  # backend without them simply cannot run sessions (see #supports?).
49
49
  code_session: %i[run_code_session],
50
- cancel_code_session: %i[cancel_code_session]
50
+ cancel_code_session: %i[cancel_code_session],
51
+ # Optional too, each documented on the orchestrator method of the same
52
+ # name. A backend implements a verb by defining a public method with
53
+ # that name and signature.
54
+ changed_files: %i[changed_files],
55
+ read_file: %i[read_file],
56
+ start_browser: %i[start_browser],
57
+ stop_browser: %i[stop_browser],
58
+ resume_boot: %i[resume_boot],
59
+ boot_status: %i[boot_status],
60
+ boot_log: %i[boot_log],
61
+ start_claude_login: %i[start_claude_login],
62
+ submit_claude_login_code: %i[submit_claude_login_code],
63
+ claude_login_status: %i[claude_login_status],
64
+ claude_logout: %i[claude_logout],
65
+ refresh_runtime: %i[refresh_runtime]
51
66
  }.freeze
52
67
 
68
+ # What #start_browser may be asked for: a browser with no window, or one
69
+ # a person can watch where the backend can show one.
70
+ BROWSER_MODES = %i[headless headed].freeze
71
+
53
72
  class UnsupportedBackendError < StandardError; end
73
+ # A host-registered backend failed a sign-in verb (see #login_call).
74
+ class BackendError < StandardError; end
54
75
 
55
76
  # All backend names available in this install.
56
77
  def self.backends
@@ -85,38 +106,45 @@ module ActionAgent
85
106
 
86
107
  attr_reader :backend_name
87
108
 
109
+ # Login adapters return only flow state / the CLI authorize URL. No
110
+ # credential, terminal output or echoed code may cross this boundary.
111
+ # Whatever a host-registered backend raises comes out as BackendError,
112
+ # which callers rescue along with the local backend's own errors.
113
+ def start_claude_login(sandbox) = login_call { @backend.public_send(adapter_method(:start_claude_login), sandbox) }
114
+ def submit_claude_login_code(sandbox, code) = login_call { @backend.public_send(adapter_method(:submit_claude_login_code), sandbox, code) }
115
+ def claude_login_status(sandbox) = login_call { @backend.public_send(adapter_method(:claude_login_status), sandbox) }
116
+ def claude_logout(sandbox) = login_call { @backend.public_send(adapter_method(:claude_logout), sandbox) }
117
+ def refresh_runtime(sandbox) = @backend.public_send(adapter_method(:refresh_runtime), sandbox)
118
+
88
119
  # Create a new sandbox for the given session
89
120
  #
90
121
  # @param sandbox_session [SandboxSession] The session to create a sandbox for
91
122
  # @param instance_tier [String, Symbol, SandboxInstanceTier] Optional instance tier
123
+ # @param boot_config [SandboxBootSpec, Hash, nil] how to boot the checkout
124
+ # instead of its own .activeagents/sandbox.yml, handed to the backend as
125
+ # SandboxBootSpec#to_h. A backend whose create method takes no
126
+ # boot_config: keyword boots as it always has; one that must apply
127
+ # (apply "always") is refused there instead.
92
128
  # @return [Hash] Sandbox details including ID/name and URL
93
- def create_sandbox(sandbox_session, instance_tier: nil)
129
+ # @raise [UnsupportedBackendError] for a spec that must apply and a backend
130
+ # that cannot take one
131
+ # @raise [SandboxBootSpec::Invalid] for a malformed spec
132
+ def create_sandbox(sandbox_session, instance_tier: nil, boot_config: nil)
94
133
  # Resolve tier
95
134
  tier = resolve_tier(instance_tier)
135
+ spec = SandboxBootSpec.wrap(boot_config)
96
136
 
97
137
  method = adapter_method(:create)
98
- result = if accepts_instance_tier?(method)
99
- @backend.public_send(method, sandbox_session, instance_tier: tier)
100
- else
101
- @backend.public_send(method, sandbox_session)
138
+ options = {}
139
+ options[:instance_tier] = tier if accepts_instance_tier?(method)
140
+ if spec && accepts_keyword?(method, :boot_config)
141
+ options[:boot_config] = spec.to_h
142
+ elsif spec && !spec.without_engine_only?
143
+ raise UnsupportedBackendError, "The #{backend_name} sandbox backend cannot boot a checkout from a boot spec"
102
144
  end
145
+ result = @backend.public_send(method, sandbox_session, **options)
103
146
 
104
- # Normalize response format across backends
105
- {
106
- sandbox_id: result[:container_name] || result[:pod_name] || result[:job_name],
107
- url: result[:url],
108
- ip: result[:container_ip] || result[:pod_ip],
109
- backend: @backend_name,
110
- instance_tier: result[:instance_tier] || tier&.id,
111
- resources: result[:resources],
112
- hourly_cost: result[:hourly_cost] || tier&.hourly_cost&.to_f,
113
- created_at: result[:created_at] || Time.current,
114
- # An app_runtime sandbox's backend clones sandbox_session.checkout_spec,
115
- # boots the app, and reports where its MCP facade answers (and the
116
- # bearer token it expects) so agents can use the checkout's tools.
117
- mcp_url: result[:mcp_url],
118
- mcp_token: result[:mcp_token]
119
- }
147
+ normalize_created(result, tier)
120
148
  end
121
149
 
122
150
  # List available instance tiers
@@ -191,6 +219,30 @@ module ActionAgent
191
219
  ADAPTER_METHODS.fetch(verb).any? { |m| @backend.respond_to?(m) }
192
220
  end
193
221
 
222
+ # Whether the backend's create method takes a boot spec (see
223
+ # #create_sandbox's boot_config:).
224
+ def accepts_boot_config?
225
+ accepts_keyword?(adapter_method(:create), :boot_config)
226
+ end
227
+
228
+ # Whether the backend can list a checkout's changes and read each file
229
+ # now and in the commit the checkout was cloned at: #changed_files, and
230
+ # #read_file with +base+.
231
+ def reads_checkouts?
232
+ supports?(:changed_files) && reads_checkout_commit?
233
+ end
234
+
235
+ # Whether the backend's read_file takes +base:+. A backend written before
236
+ # +base:+ existed defines read_file(session, path) and cannot be asked
237
+ # for the commit cloned at.
238
+ def reads_checkout_commit?
239
+ return false unless supports?(:read_file)
240
+
241
+ @backend.method(adapter_method(:read_file)).parameters.any? do |type, name|
242
+ type == :keyrest || type == :rest || (type.in?(%i[key keyreq]) && name == :base)
243
+ end
244
+ end
245
+
194
246
  # Existing backends predate runner selection and support Claude only.
195
247
  # A backend must explicitly advertise Codex before accepting its keys.
196
248
  def supports_code_runner?(runner)
@@ -212,6 +264,12 @@ module ActionAgent
212
264
  end
213
265
  refusal = ClaudeCodeAuth.backend_refusal(self) unless runner == "codex"
214
266
  raise UnsupportedBackendError, refusal if refusal
267
+ if runner == "claude_code" && code_session.try(:credential_mode) == "sandbox_login"
268
+ unless sandbox_session.claude_login_user_id.present? && sandbox_session.claude_login_user_id == code_session.user_id &&
269
+ claude_login_status(sandbox_session).slice(:logged_in, :auth_method) == { logged_in: true, auth_method: "claude.ai" }
270
+ raise UnsupportedBackendError, "This user's Claude subscription is no longer signed in to this sandbox"
271
+ end
272
+ end
215
273
 
216
274
  @backend.public_send(adapter_method(:code_session), sandbox_session, code_session, &on_event)
217
275
  end
@@ -221,6 +279,156 @@ module ActionAgent
221
279
  @backend.public_send(adapter_method(:cancel_code_session), sandbox_session, code_session)
222
280
  end
223
281
 
282
+ # The files +sandbox_session+'s checkout has changed since it was cloned,
283
+ # read without running anything the checkout controls (no git hooks,
284
+ # filters or configuration from the checkout).
285
+ #
286
+ # @return [Hash] { base_commit:, files: }
287
+ # base_commit: the commit the checkout was cloned at
288
+ # files: one Hash per changed path, each
289
+ # path: relative to the checkout root
290
+ # status: "added", "modified" or "deleted"
291
+ # mode: "100644", "100755", "120000" (a symlink) or "160000" (a
292
+ # submodule, or a nested repository); nil when deleted
293
+ # base_mode: the mode in the commit cloned at, where the backend
294
+ # knows it (optional); nil when added
295
+ # size: the bytes in the working tree, where the backend knows them
296
+ # (optional); nil when deleted
297
+ # Files the repository ignores are not listed.
298
+ # @raise [UnsupportedBackendError] when the backend does not implement it
299
+ def changed_files(sandbox_session)
300
+ @backend.public_send(adapter_method(:changed_files), sandbox_session)
301
+ end
302
+
303
+ # The current content of +path+ in +sandbox_session+'s checkout, or with
304
+ # +base+ its content in the commit the checkout was cloned at. A symlink
305
+ # is read as its target path, never followed.
306
+ #
307
+ # A backend implements this as read_file(sandbox_session, path, base:),
308
+ # and is passed +base+ only when it is true.
309
+ #
310
+ # @param path [String] relative to the checkout root
311
+ # @return [String, nil] the bytes, binary-encoded; nil when nothing is
312
+ # there
313
+ # @raise [ArgumentError] when +path+ is absolute or climbs out of the
314
+ # checkout, before the backend is asked
315
+ # @raise [UnsupportedBackendError] when the backend does not implement
316
+ # it, or is asked for +base+ and its read_file takes no +base:+ (see
317
+ # #reads_checkout_commit?)
318
+ def read_file(sandbox_session, path, base: false)
319
+ unless checkout_relative?(path)
320
+ raise ArgumentError, "#{path.inspect} is not a path inside the checkout"
321
+ end
322
+
323
+ method = adapter_method(:read_file)
324
+ if base
325
+ unless reads_checkout_commit?
326
+ raise UnsupportedBackendError, "The #{backend_name} sandbox backend's read_file takes no base:, " \
327
+ "so it cannot read the commit a checkout was cloned at"
328
+ end
329
+
330
+ @backend.public_send(method, sandbox_session, path, base: true)
331
+ else
332
+ @backend.public_send(method, sandbox_session, path)
333
+ end
334
+ end
335
+
336
+ # Starts a browser for +sandbox_session+, one per sandbox, and returns how
337
+ # an agent reaches it over MCP. The backend reads the rest of what it
338
+ # needs from sandbox_session.browser_launch (see SandboxSession): the
339
+ # token the browser's MCP endpoint is to expect, the app it may open,
340
+ # optional tool groups, when to stop, and where to post its recording.
341
+ #
342
+ # @param mode [Symbol] one of BROWSER_MODES
343
+ # @return [Hash] at least { mcp_url:, mcp_token: }: where the browser's
344
+ # MCP server answers, and the bearer token it expects (nil for none);
345
+ # optionally live_url:, where a person can watch it
346
+ # @raise [ArgumentError] for a mode outside BROWSER_MODES
347
+ # @raise [UnsupportedBackendError] when the backend does not implement it,
348
+ # or cannot run a browser in +mode+ (see #browser_modes)
349
+ def start_browser(sandbox_session, mode: :headless)
350
+ raise ArgumentError, "Unknown browser mode #{mode.inspect}" unless BROWSER_MODES.include?(mode)
351
+
352
+ method = adapter_method(:start_browser)
353
+ unless browser_modes.include?(mode)
354
+ raise UnsupportedBackendError, "The #{backend_name} sandbox backend cannot show a browser window; start the browser headless"
355
+ end
356
+
357
+ @backend.public_send(method, sandbox_session, mode: mode)
358
+ end
359
+
360
+ # The BROWSER_MODES the backend can start a browser in: what its own
361
+ # #browser_modes answers, or every mode for a backend that does not say.
362
+ # Empty when it cannot start a browser at all.
363
+ #
364
+ # @return [Array<Symbol>]
365
+ def browser_modes
366
+ return [] unless supports?(:start_browser)
367
+ return BROWSER_MODES.dup unless @backend.respond_to?(:browser_modes)
368
+
369
+ BROWSER_MODES & Array(@backend.browser_modes).map(&:to_sym)
370
+ end
371
+
372
+ # Stops +sandbox_session+'s browser. True, also when none was running.
373
+ #
374
+ # @raise [UnsupportedBackendError] when the backend does not implement it
375
+ def stop_browser(sandbox_session)
376
+ @backend.public_send(adapter_method(:stop_browser), sandbox_session)
377
+ end
378
+
379
+ # Continues a boot of +sandbox_session+ that failed and kept its
380
+ # workspace, re-running from the step named +from+ (nil for the step that
381
+ # failed) with the session's current settings.
382
+ #
383
+ # @param boot_config [SandboxBootSpec, Hash, nil] the spec to continue
384
+ # with, for updated env or secrets; nil for the one the boot started
385
+ # with. Passed only to a backend whose method takes the keyword.
386
+ # @return [Hash] what #create_sandbox returns
387
+ # @raise [UnsupportedBackendError] when the backend does not implement it
388
+ def resume_boot(sandbox_session, from:, boot_config: nil)
389
+ method = adapter_method(:resume_boot)
390
+ spec = SandboxBootSpec.wrap(boot_config)
391
+ options = { from: from }
392
+ options[:boot_config] = spec.to_h if spec && accepts_keyword?(method, :boot_config)
393
+ normalize_created(@backend.public_send(method, sandbox_session, **options), nil)
394
+ end
395
+
396
+ # How +sandbox_session+'s boot went, step by step, while the backend
397
+ # still holds it.
398
+ #
399
+ # @return [Hash, nil] nil when the backend holds nothing for the session;
400
+ # otherwise
401
+ # mode: "config" (the checkout's sandbox.yml) or "spec"
402
+ # kind: the spec's kind, "bootstrap" or "custom"
403
+ # failed_step: the name of the step that failed, or nil
404
+ # kept: whether a failed boot's workspace was kept for
405
+ # #resume_boot
406
+ # resumable_steps: optional, the step names #resume_boot accepts as
407
+ # `from`
408
+ # steps: [{ name:, status:, started_at:, finished_at:,
409
+ # duration_ms:, detail: }], status one of "pending",
410
+ # "running", "succeeded", "failed" or "skipped"
411
+ # @raise [UnsupportedBackendError] when the backend does not implement it
412
+ def boot_status(sandbox_session)
413
+ @backend.public_send(adapter_method(:boot_status), sandbox_session)
414
+ end
415
+
416
+ # One page of a boot step's log, scrubbed of the session's secrets and
417
+ # of +secrets+. Pages end at a line break where they can, so a value is
418
+ # never split between two of them.
419
+ #
420
+ # @param step [String] a step name #boot_status lists
421
+ # @param offset [Integer] the byte offset to read from
422
+ # @param limit [Integer] the most bytes to read
423
+ # @return [Hash, nil] { step:, offset:, next_offset:, size:, eof:, text: },
424
+ # or nil when the step has no log
425
+ # @raise [UnsupportedBackendError] when the backend does not implement it
426
+ def boot_log(sandbox_session, step:, offset: 0, limit: nil, secrets: [])
427
+ options = { step: step, offset: offset, secrets: secrets }
428
+ options[:limit] = limit if limit
429
+ @backend.public_send(adapter_method(:boot_log), sandbox_session, **options)
430
+ end
431
+
224
432
  # Check if the backend is healthy
225
433
  #
226
434
  # @return [Boolean] true if backend is reachable
@@ -248,6 +456,17 @@ module ActionAgent
248
456
 
249
457
  private
250
458
 
459
+ # Runs a sign-in verb, turning a host-registered backend's own errors
460
+ # into BackendError. The message is kept for the log; the sign-in
461
+ # endpoints answer with a fixed one.
462
+ def login_call
463
+ yield
464
+ rescue UnsupportedBackendError, LocalSandboxBackend::Error
465
+ raise
466
+ rescue StandardError => e
467
+ raise BackendError, e.message
468
+ end
469
+
251
470
  # The backend's method for +verb+, or a clear error naming what it
252
471
  # would have to implement.
253
472
  def adapter_method(verb)
@@ -256,10 +475,45 @@ module ActionAgent
256
475
  "#{@backend.class} implements none of #{ADAPTER_METHODS.fetch(verb).join(', ')}")
257
476
  end
258
477
 
478
+ # The backend's create result in one shape whichever backend produced it.
479
+ def normalize_created(result, tier)
480
+ {
481
+ sandbox_id: result[:container_name] || result[:pod_name] || result[:job_name],
482
+ url: result[:url],
483
+ ip: result[:container_ip] || result[:pod_ip],
484
+ backend: @backend_name,
485
+ instance_tier: result[:instance_tier] || tier&.id,
486
+ resources: result[:resources],
487
+ hourly_cost: result[:hourly_cost] || tier&.hourly_cost&.to_f,
488
+ created_at: result[:created_at] || Time.current,
489
+ # An app_runtime sandbox's backend clones sandbox_session.checkout_spec,
490
+ # boots the app, and reports where its MCP facade answers (and the
491
+ # bearer token it expects) so agents can use the checkout's tools.
492
+ mcp_url: result[:mcp_url],
493
+ mcp_token: result[:mcp_token],
494
+ # The models its manifest lists (SandboxManifest.app_models), when the
495
+ # backend reports them.
496
+ app_models: result[:app_models]
497
+ }
498
+ end
499
+
500
+ # Whether +path+ names something inside a checkout: relative, and never
501
+ # climbing out through "..".
502
+ def checkout_relative?(path)
503
+ return false unless path.is_a?(String) && path.present? && !path.include?("\0")
504
+
505
+ pathname = Pathname.new(path)
506
+ pathname.relative? && pathname.each_filename.none?("..")
507
+ end
508
+
259
509
  def accepts_instance_tier?(method)
260
510
  @backend.method(method).parameters.any? { |_type, name| name == :instance_tier }
261
511
  end
262
512
 
513
+ def accepts_keyword?(method, keyword)
514
+ @backend.method(method).parameters.any? { |type, name| name == keyword && %i[key keyreq].include?(type) }
515
+ end
516
+
263
517
  def resolve_tier(tier_param)
264
518
  return nil unless tier_param
265
519