actionagent 1.8.0 → 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.
- checksums.yaml +4 -4
- data/app/assets/builds/action_agent.css +1 -1
- data/app/assets/builds/action_agent.js +76 -73
- data/app/assets/builds/action_agent_recorder.js +78 -0
- data/app/assets/builds/action_agent_replay.js +226 -0
- data/app/controllers/action_agent/api/agent_runs_controller.rb +3 -1
- data/app/controllers/action_agent/api/agents_controller.rb +1 -0
- data/app/controllers/action_agent/api/api_keys_controller.rb +12 -2
- data/app/controllers/action_agent/api/base_controller.rb +63 -2
- data/app/controllers/action_agent/api/claude_logins_controller.rb +93 -0
- data/app/controllers/action_agent/api/code_sessions_controller.rb +71 -12
- data/app/controllers/action_agent/api/dashboard_assistant_controller.rb +2 -2
- data/app/controllers/action_agent/api/draft_pull_requests_controller.rb +248 -0
- data/app/controllers/action_agent/api/evaluation_fixes_controller.rb +27 -0
- data/app/controllers/action_agent/api/evaluations_controller.rb +58 -7
- data/app/controllers/action_agent/api/explorations_controller.rb +213 -0
- data/app/controllers/action_agent/api/github_app_manifests_controller.rb +125 -0
- data/app/controllers/action_agent/api/github_connections_controller.rb +40 -2
- data/app/controllers/action_agent/api/github_installations_controller.rb +280 -0
- data/app/controllers/action_agent/api/input_requests_controller.rb +77 -0
- data/app/controllers/action_agent/api/mcp_controller.rb +46 -12
- data/app/controllers/action_agent/api/members_controller.rb +36 -0
- data/app/controllers/action_agent/api/project_explorations_controller.rb +80 -0
- data/app/controllers/action_agent/api/project_install_pull_requests_controller.rb +305 -0
- data/app/controllers/action_agent/api/project_secrets_controller.rb +96 -0
- data/app/controllers/action_agent/api/project_setup_controller.rb +151 -0
- data/app/controllers/action_agent/api/project_sign_ins_controller.rb +112 -0
- data/app/controllers/action_agent/api/projects_controller.rb +512 -0
- data/app/controllers/action_agent/api/provider_keys_controller.rb +176 -24
- data/app/controllers/action_agent/api/provider_models_controller.rb +37 -15
- data/app/controllers/action_agent/api/recording_event_ingest_controller.rb +67 -0
- data/app/controllers/action_agent/api/sandbox_browser_tickets_controller.rb +50 -0
- data/app/controllers/action_agent/api/sandbox_browsers_controller.rb +65 -0
- data/app/controllers/action_agent/api/sandboxes_controller.rb +148 -8
- data/app/controllers/action_agent/api/scenario_catalog_entries_controller.rb +188 -0
- data/app/controllers/action_agent/api/scenario_catalogs_controller.rb +302 -0
- data/app/controllers/action_agent/api/session_recordings_controller.rb +165 -34
- data/app/controllers/action_agent/api/sessions_controller.rb +111 -0
- data/app/controllers/action_agent/api/telemetry_keys_controller.rb +21 -0
- data/app/controllers/action_agent/dashboard_controller.rb +17 -6
- data/app/controllers/action_agent/session_player_controller.rb +58 -0
- data/app/controllers/concerns/action_agent/api/agent_serialization.rb +1 -0
- data/app/controllers/concerns/action_agent/api/mcp_dashboard_tools.rb +627 -20
- data/app/controllers/concerns/action_agent/api/project_browser.rb +46 -0
- data/app/controllers/concerns/action_agent/api/project_secret_authorization.rb +57 -0
- data/app/jobs/action_agent/agent_execution_job.rb +9 -39
- data/app/jobs/action_agent/agent_resume_job.rb +84 -0
- data/app/jobs/action_agent/code_session_job.rb +39 -10
- data/app/jobs/action_agent/draft_pull_request_job.rb +43 -0
- data/app/jobs/action_agent/exploration_job.rb +80 -0
- data/app/jobs/action_agent/input_request_expiry_job.rb +22 -0
- data/app/jobs/action_agent/project_evaluation_job.rb +59 -0
- data/app/jobs/action_agent/project_setup_job.rb +21 -0
- data/app/jobs/action_agent/sandbox_cleanup_job.rb +20 -3
- data/app/jobs/action_agent/sandbox_provision_job.rb +77 -10
- data/app/jobs/action_agent/sandbox_run_job.rb +9 -37
- data/app/jobs/action_agent/verify_evaluation_fix_job.rb +37 -0
- data/app/models/action_agent/agent.rb +72 -26
- data/app/models/action_agent/agent_run.rb +134 -23
- data/app/models/action_agent/agent_template.rb +35 -1
- data/app/models/action_agent/api_key.rb +14 -0
- data/app/models/action_agent/catalog_scenario.rb +75 -0
- data/app/models/action_agent/code_session.rb +21 -5
- data/app/models/action_agent/draft_pull_request.rb +131 -0
- data/app/models/action_agent/evaluation.rb +148 -0
- data/app/models/action_agent/evaluation_run.rb +143 -53
- data/app/models/action_agent/evaluation_scenario_result.rb +15 -3
- data/app/models/action_agent/exploration.rb +847 -0
- data/app/models/action_agent/github_installation.rb +159 -0
- data/app/models/action_agent/input_request.rb +291 -0
- data/app/models/action_agent/model_pricing.rb +214 -34
- data/app/models/action_agent/project.rb +789 -0
- data/app/models/action_agent/project_secret.rb +339 -0
- data/app/models/action_agent/provider_key.rb +207 -4
- data/app/models/action_agent/recording_event.rb +145 -0
- data/app/models/action_agent/recording_snapshot.rb +7 -7
- data/app/models/action_agent/sandbox_session.rb +300 -16
- data/app/models/action_agent/scenario_catalog.rb +172 -0
- data/app/models/action_agent/scenario_product.rb +57 -0
- data/app/models/action_agent/scenario_set.rb +138 -0
- data/app/models/action_agent/session_recording.rb +121 -14
- data/app/models/action_agent/telemetry_trace.rb +74 -19
- data/app/models/concerns/action_agent/ownable.rb +84 -13
- data/app/serializers/action_agent/evaluation_serializer.rb +41 -10
- data/app/serializers/action_agent/input_request_serializer.rb +47 -0
- data/app/serializers/action_agent/scenario_catalog_serializer.rb +23 -0
- data/app/serializers/action_agent/session_summary_serializer.rb +146 -0
- data/app/services/action_agent/agent_execution_service.rb +450 -72
- data/app/services/action_agent/agent_scorecard.rb +50 -21
- data/app/services/action_agent/agent_sync.rb +5 -2
- data/app/services/action_agent/agent_tool_roster.rb +19 -4
- data/app/services/action_agent/agent_toolbox.rb +215 -9
- data/app/services/action_agent/browser_live_ticket.rb +78 -0
- data/app/services/action_agent/browser_sidecar.rb +213 -0
- data/app/services/action_agent/browser_sign_in.rb +163 -0
- data/app/services/action_agent/browser_tool_redaction.rb +92 -0
- data/app/services/action_agent/claude_code_auth.rb +70 -4
- data/app/services/action_agent/codex_session_events.rb +37 -0
- data/app/services/action_agent/dashboard_assistant_service.rb +24 -10
- data/app/services/action_agent/draft_pull_request_publisher.rb +719 -0
- data/app/services/action_agent/evaluation_fix.rb +101 -0
- data/app/services/action_agent/evaluation_fix_comparison.rb +66 -0
- data/app/services/action_agent/evaluation_report_import.rb +84 -5
- data/app/services/action_agent/evaluation_run_cost.rb +609 -0
- data/app/services/action_agent/evaluation_runner_service.rb +29 -10
- data/app/services/action_agent/evaluation_standing.rb +152 -0
- data/app/services/action_agent/explorer_execution_service.rb +517 -0
- data/app/services/action_agent/github_client.rb +405 -64
- data/app/services/action_agent/live_updates.rb +31 -0
- data/app/services/action_agent/local_sandbox_backend.rb +1263 -102
- data/app/services/action_agent/local_sandbox_claude_login.rb +151 -0
- data/app/services/action_agent/mcp_client.rb +6 -2
- data/app/services/action_agent/mcp_recording_middleware.rb +104 -145
- data/app/services/action_agent/mcp_tool_dispatcher.rb +36 -13
- data/app/services/action_agent/mock_sandbox_backend.rb +3 -1
- data/app/services/action_agent/playwright_mcp_client.rb +7 -0
- data/app/services/action_agent/project_boot_fix.rb +309 -0
- data/app/services/action_agent/project_capabilities.rb +200 -0
- data/app/services/action_agent/project_install_pull_request.rb +225 -0
- data/app/services/action_agent/project_preflight.rb +178 -0
- data/app/services/action_agent/project_secret_discovery.rb +142 -0
- data/app/services/action_agent/project_setup.rb +429 -0
- data/app/services/action_agent/provider_credentials.rb +102 -0
- data/app/services/action_agent/recording_event_ingest.rb +181 -0
- data/app/services/action_agent/runtime_tool_roster.rb +52 -0
- data/app/services/action_agent/sample_data/conference_ticket.rb +516 -0
- data/app/services/action_agent/sandbox_boot_spec.rb +558 -0
- data/app/services/action_agent/sandbox_browser.rb +299 -0
- data/app/services/action_agent/sandbox_browser_driver.rb +85 -0
- data/app/services/action_agent/sandbox_credential_paths.rb +26 -0
- data/app/services/action_agent/sandbox_mail.rb +103 -0
- data/app/services/action_agent/sandbox_manifest.rb +166 -13
- data/app/services/action_agent/sandbox_orchestrator.rb +290 -23
- data/app/services/action_agent/sandbox_patch.rb +253 -0
- data/app/services/action_agent/scenario_catalog_import.rb +255 -0
- data/app/services/action_agent/scenario_catalog_repository_import.rb +81 -0
- data/app/services/action_agent/scenario_evaluation_runner.rb +121 -17
- data/app/services/action_agent/secret_scrubber.rb +33 -4
- data/app/services/action_agent/session_index.rb +270 -0
- data/app/services/action_agent/session_recording_service.rb +11 -0
- data/app/services/action_agent/session_timeline.rb +505 -0
- data/app/views/action_agent/github_app_manifests/created.html.erb +55 -0
- data/app/views/action_agent/session_player/show.html.erb +19 -0
- data/config/routes.rb +153 -2
- data/lib/action_agent/claude_login_process.rb +107 -0
- data/lib/action_agent/engine.rb +55 -5
- data/lib/action_agent/secret_requests.rb +80 -0
- data/lib/action_agent/version.rb +1 -1
- data/lib/action_agent.rb +513 -22
- data/lib/generators/action_agent/install_generator.rb +45 -5
- data/lib/generators/action_agent/templates/action_agent.rb.erb +161 -0
- data/lib/generators/action_agent/templates/add_code_session_runner.rb.erb +9 -0
- data/lib/generators/action_agent/templates/migrations/015_add_provider_key_scope.rb.erb +72 -0
- data/lib/generators/action_agent/templates/migrations/016_create_active_agent_input_requests.rb.erb +85 -0
- data/lib/generators/action_agent/templates/migrations/017_create_active_agent_recording_events.rb.erb +57 -0
- data/lib/generators/action_agent/templates/migrations/018_add_browser_to_active_agent_sandbox_sessions.rb.erb +25 -0
- data/lib/generators/action_agent/templates/migrations/019_create_active_agent_projects.rb.erb +79 -0
- data/lib/generators/action_agent/templates/migrations/020_add_github_app_installations.rb.erb +62 -0
- data/lib/generators/action_agent/templates/migrations/021_create_active_agent_explorations.rb.erb +63 -0
- data/lib/generators/action_agent/templates/migrations/023_create_active_agent_draft_pull_requests.rb.erb +75 -0
- data/lib/generators/action_agent/templates/migrations/024_add_kind_to_active_agent_project_secrets.rb.erb +14 -0
- data/lib/generators/action_agent/templates/migrations/025_create_active_agent_scenario_catalogs.rb.erb +110 -0
- data/lib/generators/action_agent/templates/migrations/026_add_evaluation_fixes_to_code_sessions.rb.erb +21 -0
- data/lib/tasks/action_agent.rake +33 -0
- data/lib/tasks/browser.rake +22 -0
- data/lib/tasks/sandbox.rake +1 -1
- data/lib/tasks/scenario_catalogs.rake +71 -0
- metadata +100 -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
|
-
|
|
47
|
-
|
|
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
|
-
#
|
|
4
|
+
# Used to record an agent's browser tool calls on a session recording.
|
|
5
5
|
#
|
|
6
|
-
#
|
|
7
|
-
#
|
|
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
|
-
#
|
|
10
|
-
#
|
|
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
|
-
#
|
|
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
|
-
"
|
|
50
|
+
"browser_drop" => "drag",
|
|
51
|
+
"browser_mouse_drag_xy" => "drag",
|
|
52
|
+
"browser_mouse_wheel" => "scroll"
|
|
36
53
|
}.freeze
|
|
37
54
|
|
|
38
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
142
|
+
recording_service&.complete!
|
|
113
143
|
end
|
|
114
144
|
|
|
115
145
|
# Fail the recording
|
|
116
146
|
def fail!(error_message = nil)
|
|
117
|
-
|
|
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
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
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
|
-
|
|
142
|
-
|
|
143
|
-
|
|
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
|
-
|
|
222
|
-
|
|
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
|
-
|
|
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
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
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.
|
|
23
|
-
#
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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,
|
|
233
|
-
# owner started (keys "sandbox:<session_id>")
|
|
234
|
-
#
|
|
235
|
-
#
|
|
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
|
|