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,151 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rbconfig"
4
+
5
+ module ActionAgent
6
+ module LocalSandboxClaudeLogin
7
+ # The config directory is outside app/, so git, manifests and publishing
8
+ # never enumerate it. The CLI alone reads and writes its credentials.
9
+ def start_claude_login(sandbox)
10
+ workspace, = checkout_workspace!(sandbox)
11
+ directory = workspace.join("claude-login")
12
+ claude_logout(sandbox)
13
+ FileUtils.mkdir_p(directory, mode: 0o700)
14
+ env = sandbox_login_environment(workspace)
15
+ script = File.expand_path("../../../lib/action_agent/claude_login_process.rb", __dir__)
16
+ timeout = (ActionAgent.claude_code_login_timeout || 300).to_f.clamp(1, 600)
17
+ pid = spawn_group(env, RbConfig.ruby, script, directory.to_s, ActionAgent.claude_code_command.to_s, timeout.to_s,
18
+ chdir: workspace, in: File::NULL, out: File::NULL, err: File::NULL)
19
+ update_state(workspace) do |state|
20
+ state["claude_login_pid"] = pid
21
+ record_process_start(state, pid)
22
+ end
23
+ Process.detach(pid)
24
+ # Until the supervisor writes its own state, the flow reads as
25
+ # starting, not as no sign-in at all. Never over a newer state.
26
+ begin
27
+ File.write(directory.join("status.json"), JSON.generate(status: "starting"), mode: "wx", perm: 0o600)
28
+ rescue Errno::EEXIST
29
+ nil
30
+ end
31
+ { status: "starting", logged_in: false, auth_method: nil }
32
+ end
33
+
34
+ def submit_claude_login_code(sandbox, code)
35
+ unless code.is_a?(String) && code.match?(/\A[^\s\x00-\x1f\x7f]{1,2048}\z/)
36
+ raise self.class::Error, "Paste the single-use code from Claude's authorization page"
37
+ end
38
+ workspace, = checkout_workspace!(sandbox)
39
+ directory = workspace.join("claude-login")
40
+ update_state(workspace) do |state|
41
+ flow = claude_login_flow(workspace)
42
+ unless flow[:status] == "awaiting_code" && !state["claude_login_submitted"]
43
+ raise self.class::Error, "This sign-in is no longer waiting for a code; start again"
44
+ end
45
+ # The one-use flag is persisted; the code itself is only ever in the
46
+ # request and the pipe. All workers serialize on the workspace lock.
47
+ state["claude_login_submitted"] = true
48
+ File.open(directory.join("code.pipe"), File::WRONLY | File::NONBLOCK | File::NOFOLLOW) do |pipe|
49
+ pipe.write("#{code}\n")
50
+ end
51
+ end
52
+ { status: "submitted", logged_in: false, auth_method: nil }
53
+ rescue Errno::ENOENT, Errno::ENXIO, Errno::EPIPE
54
+ raise self.class::Error, "The sign-in expired; start again"
55
+ end
56
+
57
+ def claude_login_status(sandbox)
58
+ workspace, app = checkout_workspace!(sandbox)
59
+ flow = claude_login_flow(workspace)
60
+ if %w[starting awaiting_code submitted].include?(flow[:status])
61
+ state = read_state(workspace)
62
+ pid = state["claude_login_pid"]
63
+ return flow.merge(logged_in: false, auth_method: nil) if pid && group_identity(pid, sandbox.session_id, state) == :ours
64
+ return { status: "expired", logged_in: false, auth_method: nil }
65
+ end
66
+ reject_subscription_overrides!(app)
67
+
68
+ output, status = capture(sandbox_login_environment(workspace),
69
+ [ ActionAgent.claude_code_command.to_s, "auth", "status", "--json" ],
70
+ chdir: app, limit: 64 * 1024, timeout: self.class::LOGIN_STATUS_TIMEOUT)
71
+ login = status&.success? ? self.class.parse_login_status(output) : self.class::LOGGED_OUT
72
+ logged_in = login[:logged_in] && login[:auth_method] == "claude.ai"
73
+ # Permission changes never read the credential. Do not follow a link.
74
+ credential = workspace.join("claude", ".credentials.json")
75
+ File.chmod(0o600, credential) if credential.file? && !credential.symlink?
76
+ { status: logged_in ? "connected" : flow[:status], logged_in: logged_in, auth_method: logged_in ? "claude.ai" : nil }
77
+ end
78
+
79
+ def claude_logout(sandbox)
80
+ workspace = workspace_for(session_id!(sandbox.session_id))
81
+ logout_claude_workspace(workspace)
82
+ true
83
+ end
84
+
85
+ private
86
+
87
+ # Project settings are untrusted, and may otherwise silently select API
88
+ # billing instead of the subscription the user explicitly chose.
89
+ def reject_subscription_overrides!(app)
90
+ %w[settings.json settings.local.json].each do |name|
91
+ path = app.join(".claude", name)
92
+ next unless path.exist? || path.symlink?
93
+ raise self.class::Error, "Claude project settings must not be symlinks" if path.symlink? || app.join(".claude").symlink?
94
+ data = JSON.parse(File.read(path, 64 * 1024))
95
+ overrides = data.fetch("env", {}).keys.grep(/\A(?:ANTHROPIC_|CLAUDE_CODE_OAUTH_TOKEN\z)/)
96
+ if data.key?("apiKeyHelper") || overrides.any?
97
+ raise self.class::Error, "Remove Claude project authentication overrides before using a subscription"
98
+ end
99
+ end
100
+ rescue JSON::ParserError, TypeError, NoMethodError
101
+ raise self.class::Error, "Claude project settings must be valid JSON without authentication overrides"
102
+ end
103
+
104
+ def claude_login_flow(workspace)
105
+ path = workspace.join("claude-login", "status.json")
106
+ return { status: "disconnected" } unless path.file? && !path.symlink?
107
+
108
+ data = JSON.parse(File.read(path, 8192))
109
+ status = data["status"].to_s
110
+ result = { status: %w[starting awaiting_code submitted completed failed expired cancelled].include?(status) ? status : "failed" }
111
+ if status == "awaiting_code" && data["authorize_url"].to_s.match?(%r{\Ahttps://(?:claude\.ai|platform\.claude\.com)/oauth/authorize\?[^\s]+\z})
112
+ result[:authorize_url] = data["authorize_url"]
113
+ end
114
+ result
115
+ rescue JSON::ParserError, Errno::ENOENT
116
+ { status: "disconnected" }
117
+ end
118
+
119
+ def sandbox_login_environment(workspace)
120
+ config = workspace.join("claude")
121
+ home = workspace.join("claude-home")
122
+ [ config, home ].each do |directory|
123
+ raise self.class::Error, "Claude configuration must be a private directory" if directory.symlink?
124
+ FileUtils.mkdir_p(directory, mode: 0o700)
125
+ File.chmod(0o700, directory)
126
+ end
127
+ self.class.sanitized_environment.merge(
128
+ "HOME" => home.to_s, "XDG_CONFIG_HOME" => home.join(".config").to_s,
129
+ "CLAUDE_CONFIG_DIR" => config.to_s, "DISABLE_AUTOUPDATER" => "1",
130
+ self.class::SESSION_ID_ENV => workspace.basename.to_s
131
+ )
132
+ end
133
+
134
+ def logout_claude_workspace(workspace)
135
+ return unless workspace.directory?
136
+
137
+ state = read_state(workspace)
138
+ pid = state["claude_login_pid"]
139
+ stop_groups([ pid ]) if pid && group_identity(pid, workspace.basename.to_s, state) == :ours
140
+ if workspace.join("claude").directory? && !workspace.join("claude").symlink?
141
+ capture(sandbox_login_environment(workspace), [ ActionAgent.claude_code_command.to_s, "auth", "logout" ],
142
+ chdir: workspace, limit: 1024, timeout: self.class::LOGIN_STATUS_TIMEOUT)
143
+ end
144
+ ensure
145
+ if workspace&.directory?
146
+ %w[claude claude-home claude-login].each { |name| FileUtils.rm_rf(workspace.join(name)) }
147
+ update_state(workspace) { |current| current.delete("claude_login_pid"); current.delete("claude_login_submitted") }
148
+ end
149
+ end
150
+ end
151
+ end
@@ -43,8 +43,12 @@ module ActionAgent
43
43
  end
44
44
 
45
45
  # Returns { text:, is_error: } — the tool result's text content.
46
- def call_tool(name, arguments = {})
47
- Rails.logger.debug("[MCPClient] call #{name} args=#{arguments.inspect[0, 200]}")
46
+ # +log_arguments+ false keeps the argument values out of the debug log,
47
+ # which then names only their keys: for a call that carries a
48
+ # credential.
49
+ def call_tool(name, arguments = {}, log_arguments: true)
50
+ logged = log_arguments ? arguments.inspect[0, 200] : "[FILTERED] #{arguments.to_h.keys.inspect}"
51
+ Rails.logger.debug("[MCPClient] call #{name} args=#{logged}")
48
52
  ensure_session!
49
53
  response = post(
50
54
  { jsonrpc: "2.0", id: next_id, method: "tools/call",
@@ -1,91 +1,121 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module ActionAgent
4
- # Middleware for recording MCP tool calls during browser automation
4
+ # Used to record an agent's browser tool calls on a session recording.
5
5
  #
6
- # This middleware intercepts Playwright MCP tool calls and records them
7
- # to a SessionRecording for playback and debugging.
6
+ # #intercept wraps one tool call. A Playwright MCP browser tool call is
7
+ # stored as an `action` RecordingEvent: the tool, its parameters with the
8
+ # typed values masked (BrowserToolRedaction) and +secrets+ scrubbed, its
9
+ # outcome and its duration. Any other tool passes straight through and is
10
+ # not recorded.
8
11
  #
9
- # Usage:
10
- # middleware = MCPRecordingMiddleware.new(session_recording: recording)
11
- #
12
- # # Process a tool call
13
- # result = middleware.intercept(tool_call) do
14
- # # Execute the actual MCP tool
15
- # mcp_client.call_tool(tool_call)
12
+ # middleware = MCPRecordingMiddleware.new(agent_run: run, secrets: -> { owner_credentials })
13
+ # result = middleware.intercept(tool_name: "browser_type", parameters: arguments) do
14
+ # mcp_client.call_tool("browser_type", arguments)
16
15
  # end
17
16
  #
17
+ # The recording is the one given, else the live recording of the sandbox
18
+ # session or agent run, created on the first browser call. Failing to
19
+ # record is logged and never changes the call's result or exception.
18
20
  class MCPRecordingMiddleware
19
- # Map of Playwright MCP tool names to our action types
21
+ # The browser tools of Playwright MCP, as the browser sidecar pins it, and
22
+ # the action type each is recorded as. Listing console messages or
23
+ # network requests is not recorded.
20
24
  PLAYWRIGHT_TOOLS = {
21
25
  "browser_navigate" => "navigate",
26
+ "browser_navigate_back" => "navigate",
27
+ "browser_navigate_forward" => "navigate",
28
+ "browser_reload" => "navigate",
29
+ "browser_tabs" => "navigate",
22
30
  "browser_click" => "click",
31
+ "browser_check" => "click",
32
+ "browser_uncheck" => "click",
33
+ "browser_mouse_click_xy" => "click",
23
34
  "browser_type" => "type",
35
+ "browser_press_sequentially" => "type",
24
36
  "browser_fill_form" => "form_fill",
25
37
  "browser_press_key" => "key_press",
38
+ "browser_keydown" => "key_press",
39
+ "browser_keyup" => "key_press",
26
40
  "browser_snapshot" => "snapshot",
27
41
  "browser_take_screenshot" => "snapshot",
28
42
  "browser_hover" => "hover",
43
+ "browser_mouse_move_xy" => "hover",
29
44
  "browser_select_option" => "select",
30
45
  "browser_file_upload" => "file_upload",
31
46
  "browser_handle_dialog" => "dialog",
32
47
  "browser_evaluate" => "evaluate",
33
48
  "browser_wait_for" => "wait",
34
49
  "browser_drag" => "drag",
35
- "browser_scroll" => "scroll"
50
+ "browser_drop" => "drag",
51
+ "browser_mouse_drag_xy" => "drag",
52
+ "browser_mouse_wheel" => "scroll"
36
53
  }.freeze
37
54
 
38
- attr_reader :recording_service
55
+ ERROR_LIMIT = 1000
56
+
57
+ def self.browser_tool?(tool_name)
58
+ PLAYWRIGHT_TOOLS.key?(tool_name.to_s)
59
+ end
39
60
 
40
- def initialize(session_recording: nil, sandbox_session: nil, agent_run: nil)
61
+ # +secrets+ is an Array of values to scrub from what is stored, or a
62
+ # callable returning one, called on the first browser call only.
63
+ def initialize(session_recording: nil, sandbox_session: nil, agent_run: nil, secrets: [])
41
64
  @recording = session_recording
42
65
  @sandbox_session = sandbox_session
43
66
  @agent_run = agent_run
44
-
45
- # Create or find recording if not provided
46
- @recording ||= find_or_create_recording
47
- @recording_service = SessionRecordingService.new(@recording) if @recording
67
+ @secrets = secrets
48
68
  end
49
69
 
50
- # Intercept an MCP tool call and record it
70
+ # Runs the block, the tool call, and returns its result. A browser tool
71
+ # call is recorded once it returns or raises.
51
72
  def intercept(tool_name:, parameters:)
52
- action_type = PLAYWRIGHT_TOOLS[tool_name]
53
-
54
- # If not a Playwright tool, just execute and return
55
- unless action_type
56
- return yield if block_given?
57
- return nil
58
- end
73
+ action_type = PLAYWRIGHT_TOOLS[tool_name.to_s]
74
+ return (yield if block_given?) unless action_type
59
75
 
60
- # Execute the tool
76
+ started_at = Time.current
61
77
  result = nil
62
- screenshot = nil
63
78
  error = nil
64
-
65
79
  begin
66
80
  result = yield if block_given?
67
-
68
- # Extract screenshot from result if present
69
- screenshot = extract_screenshot(result)
70
- rescue => e
71
- error = e.message
81
+ rescue StandardError => e
82
+ error = e
72
83
  raise
73
84
  ensure
74
- # Record the action
75
- record_action(action_type, tool_name, parameters, result, screenshot, error)
85
+ record_action_event(tool_name.to_s, action_type, parameters, result, error, started_at)
76
86
  end
77
-
78
87
  result
79
88
  end
80
89
 
90
+ # The recording this middleware writes to, or nil when there is none and
91
+ # none could be started.
92
+ def recording
93
+ return @recording if @recording || @recording_resolved
94
+
95
+ @recording_resolved = true
96
+ @recording = find_or_create_recording
97
+ rescue StandardError => e
98
+ Rails.logger.warn("[ActionAgent] could not start a session recording: #{e.class}: #{e.message}")
99
+ nil
100
+ end
101
+
102
+ # True once a recording has been found or started; never starts one.
103
+ def recording_started?
104
+ @recording.present?
105
+ end
106
+
107
+ def recording_service
108
+ @recording_service ||= recording && SessionRecordingService.new(recording)
109
+ end
110
+
81
111
  # Convenience method for recording a navigation
82
112
  def record_navigate(url:, screenshot: nil)
83
- @recording_service&.navigate(url: url, screenshot: screenshot)
113
+ recording_service&.navigate(url: url, screenshot: screenshot)
84
114
  end
85
115
 
86
116
  # Convenience method for recording a click
87
117
  def record_click(selector:, screenshot: nil, element_description: nil)
88
- @recording_service&.click(
118
+ recording_service&.click(
89
119
  selector: selector,
90
120
  screenshot: screenshot,
91
121
  metadata: { element: element_description }.compact
@@ -94,12 +124,12 @@ module ActionAgent
94
124
 
95
125
  # Convenience method for recording text input
96
126
  def record_type(selector:, text:, screenshot: nil)
97
- @recording_service&.type(selector: selector, text: text, screenshot: screenshot)
127
+ recording_service&.type(selector: selector, text: text, screenshot: screenshot)
98
128
  end
99
129
 
100
130
  # Record current page state for handoff
101
131
  def capture_for_handoff(url:, cookies: nil, local_storage: nil, form_values: nil)
102
- @recording_service&.capture_handoff_state(
132
+ recording_service&.capture_handoff_state(
103
133
  url: url,
104
134
  cookies: cookies,
105
135
  local_storage: local_storage,
@@ -109,12 +139,12 @@ module ActionAgent
109
139
 
110
140
  # Complete the recording
111
141
  def complete!
112
- @recording_service&.complete!
142
+ recording_service&.complete!
113
143
  end
114
144
 
115
145
  # Fail the recording
116
146
  def fail!(error_message = nil)
117
- @recording_service&.fail!(error_message)
147
+ recording_service&.fail!(error_message)
118
148
  end
119
149
 
120
150
  private
@@ -122,120 +152,49 @@ module ActionAgent
122
152
  def find_or_create_recording
123
153
  if @sandbox_session
124
154
  SessionRecording.recording.find_by(sandbox_session: @sandbox_session) ||
125
- SessionRecording.start!(sandbox_session: @sandbox_session)
155
+ SessionRecording.start!(sandbox_session: @sandbox_session, source: "agent")
126
156
  elsif @agent_run
127
157
  SessionRecording.recording.find_by(agent_run: @agent_run) ||
128
- SessionRecording.start!(agent_run: @agent_run)
158
+ SessionRecording.start!(agent_run: @agent_run, source: "agent")
129
159
  end
130
160
  end
131
161
 
132
- def record_action(action_type, tool_name, parameters, result, screenshot, error)
133
- return unless @recording_service
134
-
135
- metadata = {
136
- tool_name: tool_name,
137
- parameters: parameters,
138
- error: error
162
+ def record_action_event(tool_name, action_type, parameters, result, error, started_at)
163
+ finished_at = Time.current
164
+ target = recording
165
+ return unless target
166
+
167
+ arguments = (parameters || {}).to_h.deep_stringify_keys
168
+ failure = error&.message || result_error(result)
169
+ data = {
170
+ "tool_name" => tool_name,
171
+ "action_type" => action_type,
172
+ "parameters" => SecretScrubber.scrub(BrowserToolRedaction.redact_arguments(tool_name, arguments), secrets),
173
+ "status" => failure ? "error" : "done",
174
+ "error" => failure && SecretScrubber.scrub(
175
+ BrowserToolRedaction.redact_text(tool_name, failure.to_s.truncate(ERROR_LIMIT), arguments), secrets
176
+ ),
177
+ "duration_ms" => ((finished_at - started_at) * 1000).round,
178
+ "trace_id" => @agent_run&.trace_id
139
179
  }.compact
140
180
 
141
- case action_type
142
- when "navigate"
143
- @recording_service.navigate(
144
- url: parameters["url"],
145
- screenshot: screenshot
146
- )
147
- when "click"
148
- @recording_service.click(
149
- selector: parameters["ref"] || parameters["selector"],
150
- screenshot: screenshot,
151
- metadata: metadata.merge(
152
- element: parameters["element"],
153
- button: parameters["button"]
154
- ).compact
155
- )
156
- when "type"
157
- @recording_service.type(
158
- selector: parameters["ref"] || parameters["selector"],
159
- text: parameters["text"],
160
- screenshot: screenshot,
161
- metadata: metadata
162
- )
163
- when "form_fill"
164
- @recording_service.fill_form(
165
- fields: parameters["fields"] || [],
166
- screenshot: screenshot
167
- )
168
- when "key_press"
169
- @recording_service.key_press(
170
- key: parameters["key"],
171
- screenshot: screenshot
172
- )
173
- when "snapshot"
174
- # For snapshots, extract DOM if available
175
- dom = extract_dom(result)
176
- @recording_service.capture_snapshot(
177
- screenshot: screenshot,
178
- dom: dom,
179
- full_page: parameters["fullPage"]
180
- )
181
- when "hover"
182
- @recording_service.hover(
183
- selector: parameters["ref"] || parameters["selector"],
184
- screenshot: screenshot
185
- )
186
- when "select"
187
- @recording_service.select_option(
188
- selector: parameters["ref"] || parameters["selector"],
189
- values: parameters["values"] || [],
190
- screenshot: screenshot
191
- )
192
- when "file_upload"
193
- @recording_service.file_upload(
194
- paths: parameters["paths"] || [],
195
- screenshot: screenshot
196
- )
197
- when "dialog"
198
- @recording_service.dialog(
199
- accept: parameters["accept"],
200
- text: parameters["promptText"],
201
- screenshot: screenshot
202
- )
203
- when "evaluate"
204
- @recording_service.evaluate(
205
- function: parameters["function"],
206
- result: result,
207
- screenshot: screenshot
208
- )
209
- else
210
- # Generic recording for other actions
211
- @recording&.record_action!(
212
- action_type: action_type,
213
- selector: parameters["ref"] || parameters["selector"],
214
- value: parameters.to_json,
215
- screenshot: screenshot,
216
- metadata: metadata
217
- )
218
- end
181
+ target.record_server_event!(kind: "action", data: data, started_at: started_at, finished_at: finished_at)
182
+ rescue StandardError => e
183
+ Rails.logger.warn("[ActionAgent] browser action #{tool_name} not recorded: #{e.class}: #{e.message}")
219
184
  end
220
185
 
221
- def extract_screenshot(result)
222
- return nil unless result.is_a?(Hash) || result.respond_to?(:[])
186
+ # A tool that fails without raising answers with an error key.
187
+ def result_error(result)
188
+ return nil unless result.respond_to?(:key?)
223
189
 
224
- # Try different paths where screenshot data might be
225
- result["screenshot"] ||
226
- result[:screenshot] ||
227
- result.dig("data", "screenshot") ||
228
- result.dig(:data, :screenshot)
190
+ result[:error] || result["error"]
229
191
  end
230
192
 
231
- def extract_dom(result)
232
- return nil unless result.is_a?(Hash) || result.respond_to?(:[])
233
-
234
- result["dom"] ||
235
- result[:dom] ||
236
- result["snapshot"] ||
237
- result[:snapshot] ||
238
- result.dig("data", "dom")
193
+ def secrets
194
+ @resolved_secrets ||= Array(@secrets.respond_to?(:call) ? @secrets.call : @secrets)
195
+ rescue StandardError => e
196
+ Rails.logger.warn("[ActionAgent] recording secret lookup failed: #{e.class}: #{e.message}")
197
+ @resolved_secrets = []
239
198
  end
240
199
  end
241
200
  end
@@ -19,23 +19,31 @@ module ActionAgent
19
19
  # that sandbox on the agent. Such a key is treated as if the agent had it
20
20
  # enabled, and resolves exactly as a saved one does: through
21
21
  # SandboxSession.runtime_server_entry, among the agent's owner's sessions,
22
- # and only while it is live. Only runtime keys are taken; a catalog server
23
- # the agent does not enable stays out of reach.
22
+ # and only while it is live. The sandbox's browser, "browser:<session_id>",
23
+ # is reached the same way (SandboxSession.browser_server_entry), and only
24
+ # this way. Only those keys are taken; a catalog server the agent does not
25
+ # enable stays out of reach.
24
26
  class MCPToolDispatcher
25
27
  HTTP_TRANSPORTS = %w[http streamable_http sse].freeze
26
28
  class SandboxUnavailable < MCPClient::Error; end
27
29
 
28
30
  attr_reader :extra_server_keys
29
31
 
30
- # A runtime explicitly selected for this run must not silently disappear
31
- # between enqueue, discovery and a tool call.
32
+ # A runtime or browser explicitly selected for this run must not silently
33
+ # disappear between enqueue, discovery and a tool call.
32
34
  def ensure_extra_servers_live!
33
35
  extra_server_keys.each do |key|
34
- sandbox = SandboxSession.for_owner(agent.try(:owner)).find_by(session_id: key.delete_prefix(SandboxSession::RUNTIME_SERVER_PREFIX))
36
+ if SandboxSession.browser_server_key?(key)
37
+ next if SandboxSession.browser_server_entry(key, owner: agent.try(:owner))
38
+
39
+ raise SandboxUnavailable, "The browser of sandbox #{session_id_of(key)} is no longer running; " \
40
+ "start it again, or run without it"
41
+ end
42
+
43
+ sandbox = SandboxSession.for_owner(agent.try(:owner)).find_by(session_id: session_id_of(key))
35
44
  next if sandbox && (sandbox.ready? || sandbox.running?) && sandbox.runtime_server_entry
36
45
 
37
- raise SandboxUnavailable, "Sandbox #{key.delete_prefix(SandboxSession::RUNTIME_SERVER_PREFIX)} is no longer running; " \
38
- "start it again, or run without it"
46
+ raise SandboxUnavailable, "Sandbox #{session_id_of(key)} is no longer running; start it again, or run without it"
39
47
  end
40
48
  end
41
49
 
@@ -43,11 +51,17 @@ module ActionAgent
43
51
  @agent = agent
44
52
  @resolver = EvaluationToolResolver.new(agent)
45
53
  @extra_server_keys = Array(extra_server_keys).map { |key| key.to_s.strip }
46
- .select { |key| SandboxSession.runtime_server_key?(key) }.uniq
54
+ .select { |key| SandboxSession.runtime_server_key?(key) || SandboxSession.browser_server_key?(key) }.uniq
47
55
  @clients = {}
48
56
  @listed_by = {}
49
57
  end
50
58
 
59
+ # Whether this run reaches a sandbox's browser, whose tools then replace
60
+ # AgentToolbox's own browser tools.
61
+ def browser_attached?
62
+ extra_server_keys.any? { |key| SandboxSession.browser_server_key?(key) }
63
+ end
64
+
51
65
  # Whether this tool belongs to one of the agent's own reachable servers —
52
66
  # by its name, or because one of them listed it in the last
53
67
  # +tool_definitions+.
@@ -123,7 +137,8 @@ module ActionAgent
123
137
  end
124
138
  rescue MCPClient::Error => e
125
139
  if extra_server_keys.include?(key)
126
- raise SandboxUnavailable, "Cannot load tools from selected sandbox #{key.delete_prefix(SandboxSession::RUNTIME_SERVER_PREFIX)}: #{e.message}"
140
+ target = SandboxSession.browser_server_key?(key) ? "the browser of sandbox" : "selected sandbox"
141
+ raise SandboxUnavailable, "Cannot load tools from #{target} #{session_id_of(key)}: #{e.message}"
127
142
  end
128
143
  Rails.logger.warn("[MCPToolDispatcher] #{key} tools/list failed: #{e.message}")
129
144
  @discovery_errors[key] =
@@ -176,6 +191,10 @@ module ActionAgent
176
191
  key.to_s.strip.downcase
177
192
  end
178
193
 
194
+ def session_id_of(key)
195
+ key.delete_prefix(SandboxSession::RUNTIME_SERVER_PREFIX).delete_prefix(SandboxSession::BROWSER_SERVER_PREFIX)
196
+ end
197
+
179
198
  # The catalog entry for the server that serves this tool, but only when the
180
199
  # agent configured that server and the entry carries an http url. Scoping to
181
200
  # the agent's own servers is what keeps one agent's tools from reaching
@@ -229,11 +248,15 @@ module ActionAgent
229
248
  entry
230
249
  end
231
250
 
232
- # A catalog server, or the app runtime of a checkout sandbox the agent's
233
- # owner started (keys "sandbox:<session_id>"). The sandbox lookup is scoped
234
- # to the agent's owner, so naming another tenant's session resolves to
235
- # nothing.
251
+ # A catalog server, the app runtime of a checkout sandbox the agent's
252
+ # owner started (keys "sandbox:<session_id>"), or, for a key this run was
253
+ # given, that sandbox's browser ("browser:<session_id>"). The sandbox
254
+ # lookups are scoped to the agent's owner, so naming another tenant's
255
+ # session resolves to nothing.
236
256
  def catalog_entry(key)
257
+ if SandboxSession.browser_server_key?(key)
258
+ return extra_server_keys.include?(key) ? SandboxSession.browser_server_entry(key, owner: agent.try(:owner)) : nil
259
+ end
237
260
  return MCPCatalog.find(key) unless SandboxSession.runtime_server_key?(key)
238
261
 
239
262
  SandboxSession.runtime_server_entry(key, owner: agent.try(:owner))
@@ -10,7 +10,7 @@ module ActionAgent
10
10
  @sandboxes = {}
11
11
  end
12
12
 
13
- def create_sandbox(session, instance_tier: nil)
13
+ def create_sandbox(session, instance_tier: nil, boot_config: nil)
14
14
  tier = instance_tier || SandboxInstanceTier.free_tier
15
15
  name = "mock-sandbox-#{SecureRandom.hex(4)}"
16
16
 
@@ -39,6 +39,8 @@ module ActionAgent
39
39
  # Which variables would be set, never their values.
40
40
  @sandboxes[name][:environment_keys] = session.runtime_environment.keys
41
41
  @sandboxes[name][:mcp_url] = "http://127.0.0.1:8080/activeagents/mcp"
42
+ # The spec it would have booted with, secret names without values.
43
+ @sandboxes[name][:boot_config] = SandboxBootSpec.wrap(boot_config).redacted if boot_config
42
44
  end
43
45
 
44
46
  @sandboxes[name]
@@ -6,7 +6,14 @@ module ActionAgent
6
6
  class PlaywrightMCPClient < MCPClient
7
7
  DEFAULT_URL = ENV.fetch("PLAYWRIGHT_MCP_URL", "http://host.orb.internal:8931/mcp")
8
8
 
9
+ # @raise [Error] in a multi-tenant install, where one browser would be
10
+ # shared by every tenant's runs
9
11
  def self.instance
12
+ if ActionAgent.multi_tenant?
13
+ raise Error, "The shared Playwright MCP server is not used in a multi-tenant install; " \
14
+ "a run gets a browser from its sandbox"
15
+ end
16
+
10
17
  @instance ||= new
11
18
  end
12
19