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