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
@@ -32,7 +32,8 @@ module ActionAgent
32
32
  end
33
33
 
34
34
  # @param agents [Array<Class>] ActiveAgent::Base subclasses
35
- # @param owner [Object] the record agents and API keys scope to
35
+ # @param owner [Object, nil] the record agents and API keys scope to; nil
36
+ # only in an install with no owner model, where agents have no owner
36
37
  # @param provider [String, Symbol, nil] defaults to the class's own
37
38
  # @param model [String, nil] defaults to the class's own
38
39
  def self.call(agents, owner:, provider: nil, model: nil)
@@ -48,7 +49,9 @@ module ActionAgent
48
49
 
49
50
  # @return [Result]
50
51
  def call
51
- return Result.new(rows: [], errors: "An owner is required: the engine scopes agents to one.") if @owner.nil?
52
+ if @owner.nil? && Agent.owner_association
53
+ return Result.new(rows: [], errors: "An owner is required: the engine scopes agents to one.")
54
+ end
52
55
 
53
56
  rows = Agent.transaction { @agents.map { |klass| upsert(klass) } }
54
57
  Result.new(rows: rows, errors: nil)
@@ -33,6 +33,14 @@ module ActionAgent
33
33
  # capabilities and the schema tools, +mcp_servers+ for services and their
34
34
  # per-server allow-lists (an entry with no +tools+ key offers everything
35
35
  # the server serves).
36
+ #
37
+ # Every tool row, and every tool a service lists, carries +approval_names+:
38
+ # the names Agent#approval_required_tools takes for it, which are the names
39
+ # the model calls it by. A capability names each function it exposes, and
40
+ # a schema or MCP tool its own name. The list is empty for a row whose
41
+ # calls the dashboard cannot hold for approval: the input tools, which ask
42
+ # on their own, and a tool the agent class declares in code, which its
43
+ # class runs.
36
44
  class AgentToolRoster
37
45
  AGENT_DEFINED = "agent_defined"
38
46
  DASHBOARD = "dashboard"
@@ -167,7 +175,8 @@ module ActionAgent
167
175
  usage_row(by_name[name]).merge(
168
176
  name: name,
169
177
  description: by_name[name]&.dig(:description),
170
- enabled: allowed.nil? || allowed.include?(name)
178
+ enabled: allowed.nil? || allowed.include?(name),
179
+ approval_names: [ name ]
171
180
  )
172
181
  end
173
182
  end
@@ -200,7 +209,8 @@ module ActionAgent
200
209
  # window may still show it being called, but that is history. A
201
210
  # tool the agent class declares in code is offered by the class.
202
211
  enabled: schema ? saved_tools.include?(name) : true,
203
- editable: schema
212
+ editable: schema,
213
+ approval_names: schema ? [ name ] : []
204
214
  )
205
215
  end
206
216
  end
@@ -222,15 +232,20 @@ module ActionAgent
222
232
  source: DASHBOARD,
223
233
  description: Agent::TOOL_DESCRIPTIONS[capability],
224
234
  enabled: saved_tools.include?(capability),
225
- editable: true
235
+ editable: true,
236
+ approval_names: capability_functions(capability) - AgentToolbox::INPUT_FUNCTIONS
226
237
  )
227
238
  end
228
239
  end
229
240
 
241
+ def capability_functions(capability)
242
+ AgentToolbox::DEFINITIONS[capability]&.map { |definition| definition[:name].to_s } || []
243
+ end
244
+
230
245
  # A capability is one checkbox over the several functions it exposes
231
246
  # ("memory" is save_memory + recall_memory), so its usage is their sum.
232
247
  def capability_usage(capability)
233
- names = (AgentToolbox::DEFINITIONS[capability]&.map { |definition| definition[:name].to_s } || []) + [ capability ]
248
+ names = capability_functions(capability) + [ capability ]
234
249
  rows = detected.select { |tool| names.include?(tool[:name]) }
235
250
  return nil if rows.empty?
236
251
 
@@ -50,6 +50,28 @@ module ActionAgent
50
50
  # Stateful: navigate changes what snapshot/click see, so these bypass
51
51
  # the toolbox result cache.
52
52
  "playwright_mcp" => [
53
+ # Not a browser action: the point where the agent stops and a person
54
+ # continues on the page it reached. AgentExecutionService answers it
55
+ # against the run's session recording (see #request_handoff there),
56
+ # which is why it has no FUNCTIONS entry.
57
+ {
58
+ name: "request_handoff",
59
+ description: "Hand the browser session to a person and stop. Call this the moment the page asks for something only its owner may give: payment details, a password, a one-time code, a consent. Pass the URL of the page you stopped on and the non-secret values you already entered, so the person can check them. After calling it, take no further browser actions; report where you stopped and what the person does next.",
60
+ parameters: {
61
+ type: "object",
62
+ properties: {
63
+ reason: { type: "string", description: "What the page is asking for, e.g. \"payment details\" or \"a login code sent by email\"" },
64
+ url: { type: "string", description: "The URL of the page the person continues on" },
65
+ form_values: {
66
+ type: "object",
67
+ description: "Values already entered, field label to value. Never a card number, password or code.",
68
+ additionalProperties: { type: "string" }
69
+ },
70
+ instructions: { type: "string", description: "One or two sentences on what the person should do next" }
71
+ },
72
+ required: [ "reason", "url" ]
73
+ }
74
+ },
53
75
  {
54
76
  name: "browser_navigate",
55
77
  description: "Open a URL in the managed browser. Returns a text snapshot of the page with element refs.",
@@ -77,6 +99,68 @@ module ActionAgent
77
99
  },
78
100
  required: [ "ref" ]
79
101
  }
102
+ },
103
+ {
104
+ name: "browser_type",
105
+ description: "Type text into an editable element from the latest snapshot: a text box, a search field. Set submit to press Enter afterwards.",
106
+ parameters: {
107
+ type: "object",
108
+ properties: {
109
+ ref: { type: "string", description: "Element ref from the snapshot" },
110
+ element: { type: "string", description: "Human-readable description of the element" },
111
+ text: { type: "string", description: "The text to type" },
112
+ submit: { type: "boolean", description: "Press Enter after typing" }
113
+ },
114
+ required: [ "ref", "text" ]
115
+ }
116
+ },
117
+ {
118
+ name: "browser_fill_form",
119
+ description: "Fill several form fields in one step. Each field names its ref from the latest snapshot, a label, its type and the value to enter; a checkbox takes true or false, a combobox the option's text.",
120
+ parameters: {
121
+ type: "object",
122
+ properties: {
123
+ fields: {
124
+ type: "array",
125
+ description: "The fields to fill",
126
+ items: {
127
+ type: "object",
128
+ properties: {
129
+ ref: { type: "string", description: "Element ref from the snapshot" },
130
+ name: { type: "string", description: "The field's label, e.g. Email" },
131
+ type: { type: "string", enum: %w[textbox checkbox radio combobox slider], description: "The field's type" },
132
+ value: { type: "string", description: "What to enter" }
133
+ },
134
+ required: [ "ref", "name", "type", "value" ]
135
+ }
136
+ }
137
+ },
138
+ required: [ "fields" ]
139
+ }
140
+ },
141
+ {
142
+ name: "browser_select_option",
143
+ description: "Choose an option in a dropdown from the latest snapshot.",
144
+ parameters: {
145
+ type: "object",
146
+ properties: {
147
+ ref: { type: "string", description: "Element ref from the snapshot" },
148
+ element: { type: "string", description: "Human-readable description of the element" },
149
+ values: { type: "array", items: { type: "string" }, description: "The option values or texts to select" }
150
+ },
151
+ required: [ "ref", "values" ]
152
+ }
153
+ },
154
+ {
155
+ name: "browser_press_key",
156
+ description: "Press a key in the browser, such as Enter, Escape, Tab or ArrowDown.",
157
+ parameters: {
158
+ type: "object",
159
+ properties: {
160
+ key: { type: "string", description: "The key to press" }
161
+ },
162
+ required: [ "key" ]
163
+ }
80
164
  }
81
165
  ],
82
166
  # Subject-bound like memory: routed by AgentExecutionService (needs the
@@ -148,6 +232,36 @@ module ActionAgent
148
232
  }
149
233
  }
150
234
  ],
235
+ # Asking a person mid-run. Each call pauses the run until the answer
236
+ # arrives (ActionAgent::InputRequest), so AgentExecutionService routes
237
+ # them, and they are NOT in FUNCTIONS below.
238
+ "ask" => [
239
+ {
240
+ name: "ask_user",
241
+ description: "Ask the user a question and wait for the answer before continuing. Pass options to have them " \
242
+ "pick one of a fixed set of answers. Use when you need information only the user has.",
243
+ parameters: {
244
+ type: "object",
245
+ properties: {
246
+ question: { type: "string", description: "The question, as the user should read it" },
247
+ options: { type: "array", items: { type: "string" }, description: "Answers to choose from; omit for a free-text answer" }
248
+ },
249
+ required: [ "question" ]
250
+ }
251
+ },
252
+ {
253
+ name: "request_approval",
254
+ description: "Ask the user to approve an action before you take it, and wait for their decision. Call it before " \
255
+ "any step with side effects the user has not already agreed to. Returns approved: true, or an error when declined.",
256
+ parameters: {
257
+ type: "object",
258
+ properties: {
259
+ action: { type: "string", description: "What you want to do, in one sentence the user can approve or decline" }
260
+ },
261
+ required: [ "action" ]
262
+ }
263
+ }
264
+ ],
151
265
  # Memory tools mirror solid_agent's HasMemory contract. They are NOT in
152
266
  # FUNCTIONS below — execution is subject-bound, so AgentExecutionService
153
267
  # routes them to the run's AgentMemory instead of this module.
@@ -179,6 +293,28 @@ module ActionAgent
179
293
  ]
180
294
  }.freeze
181
295
 
296
+ # Asks the user for a secret, such as an API key, delivered to the agent's
297
+ # server-side handler (ActionAgent::SecretRequests) and never to the
298
+ # model. Offered only to an agent with a registered handler, never
299
+ # through an agent's tools list, so it is not in DEFINITIONS.
300
+ REQUEST_SECRET_DEFINITION = {
301
+ name: "request_secret",
302
+ description: "Ask the user for a secret value, such as an API key or a password. The value is handed to the " \
303
+ "system without you ever seeing it; you receive only confirmation that it was provided.",
304
+ parameters: {
305
+ type: "object",
306
+ properties: {
307
+ name: { type: "string", description: "The secret's name, e.g. STRIPE_API_KEY" },
308
+ prompt: { type: "string", description: "What to tell the user about the value you need" }
309
+ },
310
+ required: [ "name", "prompt" ]
311
+ }
312
+ }.freeze
313
+
314
+ # The tools that pause a run to ask a person, routed by
315
+ # AgentExecutionService.
316
+ INPUT_FUNCTIONS = %w[ask_user request_approval request_secret].freeze
317
+
182
318
  # Function name => implementation method, for routing tool calls.
183
319
  FUNCTIONS = {
184
320
  "fetch_url" => :fetch_url,
@@ -188,24 +324,51 @@ module ActionAgent
188
324
  "browser_navigate" => :browser_navigate,
189
325
  "browser_snapshot" => :browser_snapshot,
190
326
  "browser_click" => :browser_click,
327
+ "browser_type" => :browser_type,
328
+ "browser_fill_form" => :browser_fill_form,
329
+ "browser_select_option" => :browser_select_option,
330
+ "browser_press_key" => :browser_press_key,
191
331
  "render_ui" => :render_ui
192
332
  }.freeze
193
333
 
194
334
  # Stateful tools whose results must never be replayed from cache — and
195
335
  # render_ui, whose result is the call itself, so there is nothing to
196
- # replay.
197
- UNCACHED_FUNCTIONS = %w[browser_navigate browser_snapshot browser_click render_ui].freeze
336
+ # replay, and the tools whose result is a person's answer.
337
+ UNCACHED_FUNCTIONS = (%w[
338
+ browser_navigate browser_snapshot browser_click browser_type browser_fill_form
339
+ browser_select_option browser_press_key render_ui
340
+ ] + INPUT_FUNCTIONS).freeze
341
+
342
+ # The group whose tools drive the process-wide browser
343
+ # (PlaywrightMCPClient), and those tools. A run against a sandbox with a
344
+ # running browser gets that browser's tools instead, and a multi-tenant
345
+ # install never offers or calls these: every tenant's runs would share
346
+ # one browser.
347
+ SHARED_BROWSER_GROUP = "playwright_mcp"
348
+ SHARED_BROWSER_FUNCTIONS = DEFINITIONS.fetch(SHARED_BROWSER_GROUP).map { |definition| definition[:name] }.freeze
198
349
 
199
350
  # Hosts browse_page may fetch — the platform's own trusted docs.
200
351
  BROWSE_ALLOWED_HOSTS = %w[docs.activeagents.ai].freeze
201
352
 
202
353
  class << self
203
354
  # Tool definitions for the subset of an agent's enabled tools that have
204
- # server-side implementations.
205
- def definitions_for(tool_names)
206
- Array(tool_names).flat_map do |name|
207
- DEFINITIONS[name.to_s] || schema_tool_definitions(name.to_s)
208
- end
355
+ # server-side implementations. The shared browser's are left out when
356
+ # +browser_attached+ (the run reaches its sandbox's browser, which
357
+ # serves tools of the same names) or when shared_browser? is false.
358
+ # Never request_secret, whatever the list names.
359
+ def definitions_for(tool_names, browser_attached: false)
360
+ names = Array(tool_names).map(&:to_s)
361
+ names -= [ SHARED_BROWSER_GROUP ] if browser_attached || !shared_browser?
362
+
363
+ names.flat_map do |name|
364
+ DEFINITIONS[name] || schema_tool_definitions(name)
365
+ end.reject { |definition| definition[:name].to_s == REQUEST_SECRET_DEFINITION[:name] }
366
+ end
367
+
368
+ # Whether runs may use the process-wide browser: never in a
369
+ # multi-tenant install.
370
+ def shared_browser?
371
+ !ActionAgent.multi_tenant?
209
372
  end
210
373
 
211
374
  def function?(name)
@@ -230,6 +393,9 @@ module ActionAgent
230
393
  # instead of re-running the side effect.
231
394
  def call(name, **kwargs)
232
395
  return { error: "Unknown tool: #{name}" } unless function?(name)
396
+ if SHARED_BROWSER_FUNCTIONS.include?(name.to_s) && !shared_browser?
397
+ return { error: "#{name} needs a browser: start the browser of the sandbox this agent runs against" }
398
+ end
233
399
 
234
400
  # Who the call is for is never one of the call's arguments: it comes
235
401
  # off here, before a tool sees them. That keeps a built-in from
@@ -354,8 +520,48 @@ module ActionAgent
354
520
  playwright_mcp("browser_snapshot", {})
355
521
  end
356
522
 
357
- def browser_click(ref:, element: nil)
358
- playwright_mcp("browser_click", { ref: ref, element: element || ref })
523
+ def browser_click(ref: nil, target: nil, element: nil, button: nil)
524
+ playwright_mcp("browser_click", browser_target(ref || target, element).merge({ button: button }.compact))
525
+ end
526
+
527
+ def browser_type(text:, ref: nil, target: nil, element: nil, submit: nil, slowly: nil)
528
+ arguments = browser_target(ref || target, element).merge({ text: text.to_s, submit: submit, slowly: slowly }.compact)
529
+ playwright_mcp("browser_type", arguments)
530
+ end
531
+
532
+ def browser_fill_form(fields:)
533
+ normalized = Array(fields).map do |field|
534
+ field = field.to_h.transform_keys(&:to_s)
535
+ {
536
+ target: (field["target"] || field["ref"]).to_s,
537
+ name: field["name"].to_s,
538
+ type: field["type"].presence || "textbox",
539
+ value: field["value"].to_s
540
+ }
541
+ end
542
+ raise ArgumentError, "every field needs a ref" if normalized.any? { |field| field[:target].blank? }
543
+
544
+ playwright_mcp("browser_fill_form", { fields: normalized })
545
+ end
546
+
547
+ def browser_select_option(values:, ref: nil, target: nil, element: nil)
548
+ playwright_mcp("browser_select_option", browser_target(ref || target, element).merge(values: Array(values).map(&:to_s)))
549
+ end
550
+
551
+ def browser_press_key(key:)
552
+ playwright_mcp("browser_press_key", { key: key.to_s })
553
+ end
554
+
555
+ # Playwright MCP addresses an element by +target+ — a ref from the
556
+ # latest snapshot, or a selector — and names it in +element+ for the
557
+ # permission prompt it may show. Earlier releases of the server called
558
+ # the ref +ref+, and that is still what the tool schemas ask the model
559
+ # for, so both spellings are accepted here and sent as the server now
560
+ # expects.
561
+ def browser_target(target, element)
562
+ raise ArgumentError, "an element ref from the snapshot is required" if target.blank?
563
+
564
+ { target: target.to_s, element: (element.presence || target).to_s }
359
565
  end
360
566
 
361
567
  SNAPSHOT_LINK = /\[Snapshot\]\(([^)]+)\)/
@@ -0,0 +1,78 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "base64"
4
+ require "json"
5
+ require "openssl"
6
+
7
+ module ActionAgent
8
+ # Used to issue the tickets that let a dashboard viewer into a sandbox
9
+ # browser's live view. The viewer's page sends the ticket as the first
10
+ # message on the live view's WebSocket, and the browser sidecar checks it
11
+ # (browser-sidecar/lib/live-ticket.mjs), so a ticket never travels in a URL.
12
+ #
13
+ # A ticket is base64url JSON claims, a dot, and the base64url HMAC-SHA256
14
+ # of that first part. The key is the HMAC-SHA256 of CONTEXT under the
15
+ # browser's token, which the sidecar was given on stdin when it started:
16
+ # a ticket opens only the browser it was issued for, and only until that
17
+ # browser stops. The sidecar accepts each ticket once, within LIFETIME.
18
+ #
19
+ # Claims:
20
+ # v 1
21
+ # sid the sandbox session
22
+ # sub the user it was issued to, as a String, or nil without one
23
+ # name their name, shown to other viewers ("held by Ada"), or nil
24
+ # mode "view" to watch, "control" to also take over
25
+ # iat when it was issued, in epoch seconds
26
+ # exp when it expires, in epoch seconds
27
+ # jti its unique id
28
+ class BrowserLiveTicket
29
+ CONTEXT = "activeagents/browser-live-ticket/v1"
30
+ MODES = %w[view control].freeze
31
+ # The sidecar refuses a ticket issued for longer than 60 seconds.
32
+ LIFETIME = 30.seconds
33
+ MAX_NAME_LENGTH = 80
34
+
35
+ # Returns { ticket:, mode:, expires_at: } for +sandbox+'s running browser.
36
+ #
37
+ # @param user [Object, nil] the viewer, whose id and name the ticket carries
38
+ # @param mode [String] one of MODES
39
+ # @raise [ArgumentError] for an unknown mode, or a sandbox whose browser
40
+ # has no token
41
+ def self.issue(sandbox, user:, mode:, now: Time.current, jti: SecureRandom.urlsafe_base64(24))
42
+ raise ArgumentError, "mode must be one of #{MODES.join(', ')}" unless MODES.include?(mode)
43
+ raise ArgumentError, "The sandbox's browser has no token" if sandbox.browser_token.blank?
44
+
45
+ issued_at = now.to_i
46
+ claims = {
47
+ v: 1,
48
+ sid: sandbox.session_id,
49
+ sub: user&.id&.to_s,
50
+ name: display_name(user),
51
+ mode: mode,
52
+ iat: issued_at,
53
+ exp: issued_at + LIFETIME.to_i,
54
+ jti: jti
55
+ }
56
+ { ticket: sign(sandbox.browser_token, claims), mode: mode, expires_at: Time.zone.at(claims[:exp]) }
57
+ end
58
+
59
+ # The ticket for +claims+, signed with the key derived from +token+.
60
+ def self.sign(token, claims)
61
+ payload = Base64.urlsafe_encode64(JSON.generate(claims), padding: false)
62
+ signature = OpenSSL::HMAC.digest("SHA256", key(token), payload)
63
+ "#{payload}.#{Base64.urlsafe_encode64(signature, padding: false)}"
64
+ end
65
+
66
+ def self.key(token)
67
+ OpenSSL::HMAC.digest("SHA256", token, CONTEXT)
68
+ end
69
+
70
+ def self.display_name(user)
71
+ return nil unless user
72
+
73
+ name = user.try(:display_name).presence || user.try(:name).presence || user.try(:email_address).presence || user.try(:email).presence
74
+ name&.to_s&.truncate(MAX_NAME_LENGTH)
75
+ end
76
+ private_class_method :key, :display_name
77
+ end
78
+ end
@@ -0,0 +1,213 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "open3"
5
+
6
+ module ActionAgent
7
+ # Used to install, find and check the browser sidecar the :local sandbox
8
+ # backend runs: the @activeagents/browser-sidecar npm package, versioned
9
+ # with this engine, and the Chromium it drives. Both live under the local
10
+ # sandbox root:
11
+ #
12
+ # .browser/
13
+ # node_modules/ the package and its dependencies
14
+ # browsers/ Chromium (PLAYWRIGHT_BROWSERS_PATH)
15
+ #
16
+ # ActionAgent.browser_sidecar_path names a checkout of the package to run
17
+ # instead, for working on the sidecar; its version is not checked.
18
+ #
19
+ # Nothing here downloads anything except #install!: a start with the
20
+ # sidecar or Chromium missing is refused with the command that installs
21
+ # them (#refusal).
22
+ class BrowserSidecar
23
+ PACKAGE = "@activeagents/browser-sidecar"
24
+ ENTRYPOINT = "bin/browser-sidecar.mjs"
25
+ MINIMUM_NODE = Gem::Version.new("20")
26
+ INSTALL_COMMAND = "bin/rails action_agent:browser:install"
27
+ CHECK_TIMEOUT = 30
28
+ INSTALL_TIMEOUT = 900
29
+
30
+ class Error < StandardError; end
31
+
32
+ # One thing a browser start needs. +fix+ says how to get it when it is
33
+ # not +ok+.
34
+ Check = Struct.new(:name, :ok, :detail, :fix, keyword_init: true) do
35
+ def message
36
+ ok ? detail : "#{detail}. #{fix}"
37
+ end
38
+ end
39
+
40
+ class << self
41
+ # @return [Pathname]
42
+ def root
43
+ ActionAgent.local_sandbox_root.join(".browser")
44
+ end
45
+
46
+ # Where Chromium is installed and looked for.
47
+ # @return [Pathname]
48
+ def browsers_path
49
+ root.join("browsers")
50
+ end
51
+
52
+ # The configured checkout, or nil to run the installed package.
53
+ # @return [Pathname, nil]
54
+ def checkout
55
+ path = ActionAgent.browser_sidecar_path.presence
56
+ path && Pathname.new(path.to_s).expand_path
57
+ end
58
+
59
+ # @return [Pathname]
60
+ def package_dir
61
+ checkout || root.join("node_modules", *PACKAGE.split("/"))
62
+ end
63
+
64
+ # The environment the sidecar runs with, on top of a sandbox's
65
+ # sanitized one.
66
+ # @return [Hash{String => String}]
67
+ def environment
68
+ { "PLAYWRIGHT_BROWSERS_PATH" => browsers_path.to_s }
69
+ end
70
+
71
+ # The command line that runs the sidecar with +args+.
72
+ # @return [Array<String>]
73
+ def command(*args)
74
+ [ ActionAgent.node_command.to_s, package_dir.join(ENTRYPOINT).to_s, *args ]
75
+ end
76
+
77
+ # Whether a sidecar reporting +version+ may run: the engine's own
78
+ # version, or any version from a configured checkout.
79
+ def acceptable_version?(version)
80
+ checkout.present? || version == ActionAgent::VERSION
81
+ end
82
+
83
+ # Node, the sidecar and Chromium, each checked. The doctor task prints
84
+ # these; a browser start refuses with the first that is not ok.
85
+ #
86
+ # @return [Array<Check>]
87
+ def checks
88
+ node = node_check
89
+ return [ node ] unless node.ok
90
+
91
+ sidecar, report = sidecar_check
92
+ return [ node, sidecar ] unless report
93
+
94
+ [ node, sidecar, chromium_check(report) ]
95
+ end
96
+
97
+ # Why a browser cannot start on this machine, or nil when it can.
98
+ # @return [String, nil]
99
+ def refusal
100
+ checks.find { |check| !check.ok }&.message
101
+ end
102
+
103
+ # Installs the sidecar (npm ci in a checkout, else the package at the
104
+ # engine's version under #root) and its Chromium, writing their output
105
+ # to +io+.
106
+ #
107
+ # @raise [Error] when a step fails
108
+ def install!(io = $stdout)
109
+ if checkout
110
+ run!(io, [ ActionAgent.npm_command.to_s, "ci" ], chdir: checkout)
111
+ else
112
+ FileUtils.mkdir_p(root)
113
+ manifest = root.join("package.json")
114
+ manifest.write(JSON.pretty_generate({ "private" => true, "description" => "The dashboard's browser sidecar" })) unless manifest.exist?
115
+ run!(io, [ ActionAgent.npm_command.to_s, "install", "--no-audit", "--no-fund", "--save-exact", "#{PACKAGE}@#{ActionAgent::VERSION}" ],
116
+ chdir: root)
117
+ end
118
+ run!(io, command("install-browser"), chdir: checkout || root)
119
+ end
120
+
121
+ private
122
+
123
+ def node_check
124
+ output, status = capture([ ActionAgent.node_command.to_s, "--version" ])
125
+ version = output.to_s[/\Av?(\d+\.\d+\.\d+)/, 1]
126
+ if status&.success? && version && Gem::Version.new(version) >= MINIMUM_NODE
127
+ return Check.new(name: "node", ok: true, detail: "Node.js #{version}")
128
+ end
129
+
130
+ Check.new(
131
+ name: "node", ok: false,
132
+ detail: version ? "Node.js #{version} is older than #{MINIMUM_NODE}" : "Node.js was not found (#{ActionAgent.node_command})",
133
+ fix: "Install Node.js #{MINIMUM_NODE} or later, or set ActionAgent.node_command"
134
+ )
135
+ end
136
+
137
+ # The sidecar check, and what its `check` command reported (nil when
138
+ # it could not run).
139
+ def sidecar_check
140
+ unless package_dir.join(ENTRYPOINT).file?
141
+ return [ Check.new(name: "sidecar", ok: false, detail: "The browser sidecar is not installed (#{package_dir})",
142
+ fix: "Run #{INSTALL_COMMAND}"), nil ]
143
+ end
144
+
145
+ output, status = capture(command("check"))
146
+ report = status&.success? ? parse_report(output) : nil
147
+ unless report
148
+ return [ Check.new(name: "sidecar", ok: false, detail: "The browser sidecar did not run: #{output.to_s.strip.lines.last&.strip}",
149
+ fix: "Run #{INSTALL_COMMAND}"), nil ]
150
+ end
151
+
152
+ version = report["version"].to_s
153
+ unless acceptable_version?(version)
154
+ return [ Check.new(name: "sidecar", ok: false,
155
+ detail: "The browser sidecar is version #{version}, and this dashboard needs #{ActionAgent::VERSION}",
156
+ fix: "Run #{INSTALL_COMMAND}"), report ]
157
+ end
158
+
159
+ [ Check.new(name: "sidecar", ok: true, detail: "Browser sidecar #{version}#{" (#{checkout})" if checkout}"), report ]
160
+ end
161
+
162
+ def chromium_check(report)
163
+ chromium = report["chromium"].is_a?(Hash) ? report["chromium"] : {}
164
+ return Check.new(name: "chromium", ok: true, detail: "Chromium at #{chromium["executable"]}") if chromium["installed"]
165
+
166
+ Check.new(name: "chromium", ok: false, detail: "Chromium is not installed (#{browsers_path})", fix: "Run #{INSTALL_COMMAND}")
167
+ end
168
+
169
+ def parse_report(output)
170
+ report = JSON.parse(output.to_s.lines.last.to_s)
171
+ report.is_a?(Hash) ? report : nil
172
+ rescue JSON::ParserError
173
+ nil
174
+ end
175
+
176
+ def process_environment
177
+ LocalSandboxBackend.sanitized_environment.merge(environment)
178
+ end
179
+
180
+ # Runs +argv+ with output captured, for at most CHECK_TIMEOUT seconds.
181
+ # Returns the output and the exit status, nil when it could not run.
182
+ def capture(argv)
183
+ Open3.popen2e(process_environment, *argv, unsetenv_others: true, pgroup: true) do |stdin, output, waiter|
184
+ stdin.close
185
+ reader = Thread.new { output.read }
186
+ unless waiter.join(CHECK_TIMEOUT)
187
+ Process.kill("KILL", -waiter.pid)
188
+ return [ "timed out after #{CHECK_TIMEOUT}s", nil ]
189
+ end
190
+ [ reader.value, waiter.value ]
191
+ end
192
+ rescue SystemCallError => e
193
+ [ e.message, nil ]
194
+ end
195
+
196
+ def run!(io, argv, chdir:)
197
+ io.puts("$ #{argv.join(' ')}")
198
+ Open3.popen2e(process_environment, *argv, chdir: chdir.to_s, unsetenv_others: true, pgroup: true) do |stdin, output, waiter|
199
+ stdin.close
200
+ reader = Thread.new { output.each_line { |line| io.write(line) } }
201
+ unless waiter.join(INSTALL_TIMEOUT)
202
+ Process.kill("KILL", -waiter.pid)
203
+ raise Error, "#{argv.first} did not finish within #{INSTALL_TIMEOUT}s"
204
+ end
205
+ reader.join
206
+ raise Error, "#{argv.join(' ')} failed (#{waiter.value.exitstatus || waiter.value.termsig})" unless waiter.value.success?
207
+ end
208
+ rescue SystemCallError => e
209
+ raise Error, "Could not run #{argv.first}: #{e.message}"
210
+ end
211
+ end
212
+ end
213
+ end