actionagent 1.8.1 → 1.9.1
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/feedback_ingest_controller.rb +34 -0
- data/app/controllers/action_agent/api/feedbacks_controller.rb +40 -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/interaction_feedback.rb +24 -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 +155 -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/generators/action_agent/templates/migrations/027_create_active_agent_interaction_feedbacks.rb.erb +17 -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
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "digest"
|
|
3
4
|
require "io/wait"
|
|
4
5
|
require "net/http"
|
|
5
6
|
require "open3"
|
|
@@ -21,10 +22,12 @@ module ActionAgent
|
|
|
21
22
|
# state.json { pid, port, started_at, code_sessions: { "<id>" => pid } },
|
|
22
23
|
# plus the commit checked out, the boot step running
|
|
23
24
|
# (step_pid), when each recorded process started, cancels
|
|
24
|
-
# sent and cancels that found nothing to stop yet,
|
|
25
|
-
# whether a terminate is under way
|
|
25
|
+
# sent and cancels that found nothing to stop yet,
|
|
26
|
+
# whether a terminate is under way, and the boot's steps
|
|
27
|
+
# and how each went (boot; see #boot_status)
|
|
26
28
|
# state.lock what changes to state.json are serialized on
|
|
27
|
-
# logs/ checkout, setup, manifest, server and claude-<id> logs
|
|
29
|
+
# logs/ checkout, setup, manifest, server and claude-<id> logs,
|
|
30
|
+
# and with a boot spec preflight and one per spec step
|
|
28
31
|
# claude/ CLAUDE_CONFIG_DIR for Claude Code sessions (with
|
|
29
32
|
# ActionAgent.claude_code_auth = :api_key; with
|
|
30
33
|
# :local_login they use the user's own configuration)
|
|
@@ -36,7 +39,8 @@ module ActionAgent
|
|
|
36
39
|
# it is only ever changed under an exclusive lock, and replaced whole (see
|
|
37
40
|
# #update_state) so a crash never leaves it half written.
|
|
38
41
|
#
|
|
39
|
-
# How a checkout boots is up to its .activeagents/sandbox.yml (see Config)
|
|
42
|
+
# How a checkout boots is up to its .activeagents/sandbox.yml (see Config),
|
|
43
|
+
# or to the boot spec the engine hands create_sandbox (see SandboxBootSpec).
|
|
40
44
|
# Every process starts from a sanitized copy of the dashboard's environment
|
|
41
45
|
# (see .sanitized_environment), so a checkout never sees the dashboard's
|
|
42
46
|
# database or secrets. The GitHub token reaches only the fetch, and the
|
|
@@ -44,6 +48,7 @@ module ActionAgent
|
|
|
44
48
|
# ActionAgent.claude_code_auth = :local_login no credential is passed at
|
|
45
49
|
# all: Claude Code runs on the machine's own login.
|
|
46
50
|
class LocalSandboxBackend
|
|
51
|
+
include LocalSandboxClaudeLogin
|
|
47
52
|
class Error < RuntimeError; end
|
|
48
53
|
|
|
49
54
|
HANDLE_PREFIX = "local-"
|
|
@@ -65,6 +70,19 @@ module ActionAgent
|
|
|
65
70
|
STOP_GRACE = 10
|
|
66
71
|
LOG_TAIL_LINES = 20
|
|
67
72
|
LOG_TAIL_BYTES = 64 * 1024
|
|
73
|
+
# A boot step's output is scrubbed a line at a time on its way to the
|
|
74
|
+
# log; a longer line is cut into pieces of this size.
|
|
75
|
+
LOG_LINE_BYTES = 64 * 1024
|
|
76
|
+
# #boot_log pages.
|
|
77
|
+
LOG_PAGE_BYTES = 64 * 1024
|
|
78
|
+
MAX_LOG_PAGE_BYTES = 1024 * 1024
|
|
79
|
+
LOG_NAME = /\A[a-z][a-z0-9_]{0,39}\z/
|
|
80
|
+
# Listing the checkout's Rake tasks for a step's if_task.
|
|
81
|
+
TASK_LIST_TIMEOUT = 120
|
|
82
|
+
TASK_LIST_BYTES = 1024 * 1024
|
|
83
|
+
# How long one GET of a boot spec's start_url may take: a development
|
|
84
|
+
# server compiles on its first request.
|
|
85
|
+
START_URL_READ_TIMEOUT = 60
|
|
68
86
|
|
|
69
87
|
# Bounds on the git commands run in a checkout (rev-parse after the
|
|
70
88
|
# fetch, add and diff after a session) and on the diff a Claude Code
|
|
@@ -72,6 +90,22 @@ module ActionAgent
|
|
|
72
90
|
# truncation notice still shows.
|
|
73
91
|
GIT_TIMEOUT = 60
|
|
74
92
|
MAX_DIFF_BYTES = 1_000_000
|
|
93
|
+
# Bounds on what #changed_files lists and #read_file returns.
|
|
94
|
+
MAX_LISTING_BYTES = 4 * 1024 * 1024
|
|
95
|
+
MAX_CHANGED_FILES = 5_000
|
|
96
|
+
MAX_READ_BYTES = 1024 * 1024
|
|
97
|
+
# The verified trees one backend keeps, by object id (see #tree_entries).
|
|
98
|
+
MAX_CACHED_TREES = 256
|
|
99
|
+
# The environment of the git commands that read a checkout's changes.
|
|
100
|
+
# They take no lock on the index, read every path as a literal name
|
|
101
|
+
# rather than a pathspec, and ignore replace refs, which the checkout
|
|
102
|
+
# could have added to make its commit read as something else.
|
|
103
|
+
READ_ONLY_GIT_ENVIRONMENT = {
|
|
104
|
+
"GIT_OPTIONAL_LOCKS" => "0",
|
|
105
|
+
"GIT_LITERAL_PATHSPECS" => "1",
|
|
106
|
+
"GIT_NO_REPLACE_OBJECTS" => "1",
|
|
107
|
+
"GIT_TERMINAL_PROMPT" => "0"
|
|
108
|
+
}.freeze
|
|
75
109
|
# How long a cancel that found no Claude Code process is remembered. The
|
|
76
110
|
# session it names starts within seconds of being marked running, or
|
|
77
111
|
# never; this only keeps old ones from piling up in state.json.
|
|
@@ -255,6 +289,21 @@ module ActionAgent
|
|
|
255
289
|
end
|
|
256
290
|
end
|
|
257
291
|
|
|
292
|
+
# What a boot runs once the checkout is there: the steps of the
|
|
293
|
+
# checkout's sandbox.yml (mode "config") or of a boot spec (mode "spec"),
|
|
294
|
+
# then manifest, then start. +label+ is what a failure message calls the
|
|
295
|
+
# step, +log+ the file under logs/ its output goes to, and +skip+ why it
|
|
296
|
+
# will not run, when that is known before the boot starts.
|
|
297
|
+
BootStep = Struct.new(:name, :command, :timeout, :log, :label, :if_task, :skip, keyword_init: true)
|
|
298
|
+
BootPlan = Struct.new(:mode, :spec, :steps, :manifest, :start, :env, :start_url, :keep_on_failure, :offset,
|
|
299
|
+
keyword_init: true) do
|
|
300
|
+
def entries
|
|
301
|
+
[ *steps, manifest, start ]
|
|
302
|
+
end
|
|
303
|
+
end
|
|
304
|
+
# What a step's block returns when the step had nothing to do.
|
|
305
|
+
Skip = Struct.new(:reason)
|
|
306
|
+
|
|
258
307
|
class << self
|
|
259
308
|
# The environment every sandbox process starts from: the dashboard's
|
|
260
309
|
# own, as it was before Bundler set it up, minus the dashboard's
|
|
@@ -362,16 +411,18 @@ module ActionAgent
|
|
|
362
411
|
end
|
|
363
412
|
end
|
|
364
413
|
|
|
365
|
-
# Clones the session's checkout, boots it as sandbox.yml says
|
|
366
|
-
# until its MCP
|
|
414
|
+
# Clones the session's checkout, boots it as sandbox.yml says (or as
|
|
415
|
+
# +boot_config+ says, see SandboxBootSpec), and waits until its MCP
|
|
416
|
+
# facade answers.
|
|
367
417
|
#
|
|
368
418
|
# @return [Hash] the handle (container_name), url, mcp_url and mcp_token
|
|
369
|
-
def create_sandbox(session, instance_tier: nil)
|
|
419
|
+
def create_sandbox(session, instance_tier: nil, boot_config: nil)
|
|
370
420
|
ensure_enabled!
|
|
371
421
|
unless session.app_runtime?
|
|
372
422
|
raise Error, "The local sandbox backend only boots app_runtime checkouts, not #{session.sandbox_type} sandboxes"
|
|
373
423
|
end
|
|
374
424
|
|
|
425
|
+
boot_spec = boot_spec!(boot_config)
|
|
375
426
|
spec = session.checkout_spec
|
|
376
427
|
raise Error, "Sandbox #{session.session_id} has no checkout to boot (is GitHub still connected?)" if spec.blank?
|
|
377
428
|
|
|
@@ -382,7 +433,105 @@ module ActionAgent
|
|
|
382
433
|
raise Error, "Sandbox #{session_id} still has processes from an earlier boot that could not be stopped; " \
|
|
383
434
|
"see the dashboard log"
|
|
384
435
|
end
|
|
385
|
-
boot(session_id, spec, [ spec[:token], *session.runtime_environment.values ])
|
|
436
|
+
boot(session_id, spec, [ spec[:token], *session.runtime_environment.values, *boot_spec&.secret_values ], boot_spec)
|
|
437
|
+
end
|
|
438
|
+
|
|
439
|
+
# Continues a boot that failed and kept its workspace (a boot spec with
|
|
440
|
+
# keep_on_failure): re-runs it from the step named +from+, or from the
|
|
441
|
+
# step that failed when +from+ is nil, on the same checkout and
|
|
442
|
+
# databases. Nothing is cloned again and no earlier step runs again.
|
|
443
|
+
# +boot_config+ replaces the spec the boot started with, for new env or
|
|
444
|
+
# secrets; without one the recorded spec is used, which holds no secret
|
|
445
|
+
# values, so a boot whose spec had secrets needs it passed again.
|
|
446
|
+
#
|
|
447
|
+
# @return [Hash] what #create_sandbox returns
|
|
448
|
+
def resume_boot(session, from:, boot_config: nil)
|
|
449
|
+
ensure_enabled!
|
|
450
|
+
session_id = session_id!(session.session_id)
|
|
451
|
+
workspace = workspace_for(session_id)
|
|
452
|
+
boot_state = read_state(workspace)["boot"]
|
|
453
|
+
unless boot_state.is_a?(Hash) && boot_state["kept"] == true
|
|
454
|
+
raise Error, "Sandbox #{session_id} has no failed boot kept to resume: start it again"
|
|
455
|
+
end
|
|
456
|
+
|
|
457
|
+
spec = boot_spec!(boot_config) || boot_spec!(boot_state["spec"])
|
|
458
|
+
raise Error, "Sandbox #{session_id} was not booted from a boot spec, so it cannot be resumed" if spec.nil?
|
|
459
|
+
if spec.missing_secrets.any?
|
|
460
|
+
raise Error, "Resuming sandbox #{session_id} needs its boot spec again: the values of " \
|
|
461
|
+
"#{spec.missing_secrets.join(", ")} are never kept"
|
|
462
|
+
end
|
|
463
|
+
|
|
464
|
+
plan = spec_plan(spec, Array(boot_state["locked_gems"]).to_set)
|
|
465
|
+
start_at = resume_position(plan, from.presence || boot_state["failed_step"])
|
|
466
|
+
unless claim_kept_boot(workspace)
|
|
467
|
+
raise Error, "Sandbox #{session_id} has no failed boot kept to resume: it is being resumed, or was stopped"
|
|
468
|
+
end
|
|
469
|
+
|
|
470
|
+
checkout = begin
|
|
471
|
+
session.checkout_spec
|
|
472
|
+
rescue StandardError
|
|
473
|
+
nil
|
|
474
|
+
end
|
|
475
|
+
secrets = [ checkout&.dig(:token), *session.runtime_environment.values, *spec.secret_values ]
|
|
476
|
+
resume(workspace, plan, start_at, secrets)
|
|
477
|
+
end
|
|
478
|
+
|
|
479
|
+
# How the sandbox's boot went, step by step (see
|
|
480
|
+
# SandboxOrchestrator#boot_status); nil when it has no workspace.
|
|
481
|
+
def boot_status(session)
|
|
482
|
+
session_id = session_id!(session.session_id)
|
|
483
|
+
boot_state = read_state(workspace_for(session_id))["boot"]
|
|
484
|
+
return nil unless boot_state.is_a?(Hash) && boot_state["steps"].is_a?(Array)
|
|
485
|
+
|
|
486
|
+
secrets = session_secrets(session)
|
|
487
|
+
{
|
|
488
|
+
mode: boot_state["mode"],
|
|
489
|
+
kind: boot_state["kind"],
|
|
490
|
+
failed_step: boot_state["failed_step"],
|
|
491
|
+
kept: boot_state["kept"] == true,
|
|
492
|
+
resumable_steps: resumable_step_names(boot_state),
|
|
493
|
+
steps: boot_state["steps"].filter_map do |step|
|
|
494
|
+
next unless step.is_a?(Hash)
|
|
495
|
+
|
|
496
|
+
{
|
|
497
|
+
name: step["name"], status: step["status"], started_at: step["started_at"], finished_at: step["finished_at"],
|
|
498
|
+
duration_ms: step["duration_ms"] || running_for(step["started_at"]),
|
|
499
|
+
detail: step["detail"] && SecretScrubber.scrub(step["detail"].to_s, secrets)
|
|
500
|
+
}
|
|
501
|
+
end
|
|
502
|
+
}
|
|
503
|
+
end
|
|
504
|
+
|
|
505
|
+
# One page of a boot step's log (see SandboxOrchestrator#boot_log).
|
|
506
|
+
def boot_log(session, step:, offset: 0, limit: LOG_PAGE_BYTES, secrets: [])
|
|
507
|
+
session_id = session_id!(session.session_id)
|
|
508
|
+
workspace = workspace_for(session_id)
|
|
509
|
+
boot_state = read_state(workspace)["boot"]
|
|
510
|
+
steps = boot_state.is_a?(Hash) && boot_state["steps"].is_a?(Array) ? boot_state["steps"] : []
|
|
511
|
+
entry = steps.find { |candidate| candidate.is_a?(Hash) && candidate["name"] == step.to_s }
|
|
512
|
+
return nil unless entry && LOG_NAME.match?(entry["log"].to_s)
|
|
513
|
+
|
|
514
|
+
path = log_path(workspace, entry["log"])
|
|
515
|
+
return nil unless path.file?
|
|
516
|
+
|
|
517
|
+
size = path.size
|
|
518
|
+
offset = Integer(offset, exception: false).to_i.clamp(0, size)
|
|
519
|
+
limit = Integer(limit, exception: false).to_i.clamp(1, MAX_LOG_PAGE_BYTES)
|
|
520
|
+
data = File.open(path, "rb") do |file|
|
|
521
|
+
file.seek(offset)
|
|
522
|
+
file.read(limit).to_s
|
|
523
|
+
end
|
|
524
|
+
# Ends on a line break when the page stops short of the end, so a
|
|
525
|
+
# value the scrubber masks is never split across two pages.
|
|
526
|
+
if offset + data.bytesize < size && (newline = data.rindex("\n"))
|
|
527
|
+
data = data.byteslice(0, newline + 1)
|
|
528
|
+
end
|
|
529
|
+
next_offset = offset + data.bytesize
|
|
530
|
+
|
|
531
|
+
{
|
|
532
|
+
step: entry["name"], offset: offset, next_offset: next_offset, size: size, eof: next_offset >= size,
|
|
533
|
+
text: SecretScrubber.scrub(data.force_encoding(Encoding::UTF_8).scrub, session_secrets(session) + Array(secrets))
|
|
534
|
+
}
|
|
386
535
|
end
|
|
387
536
|
|
|
388
537
|
# A sandbox's handle follows from its session id, so one whose boot was
|
|
@@ -440,6 +589,45 @@ module ActionAgent
|
|
|
440
589
|
%w[claude_code codex]
|
|
441
590
|
end
|
|
442
591
|
|
|
592
|
+
# Restart only the app runtime; keep the checkout, databases and login.
|
|
593
|
+
# The fresh manifest and readiness probe prove the changed source has
|
|
594
|
+
# loaded before the dashboard queues verification. The restart gets the
|
|
595
|
+
# time a boot gets. If the app does not come back, the sandbox is failed
|
|
596
|
+
# rather than left ready with nothing listening.
|
|
597
|
+
def refresh_runtime(sandbox)
|
|
598
|
+
stopped = false
|
|
599
|
+
workspace, app = checkout_workspace!(sandbox)
|
|
600
|
+
state = read_state(workspace)
|
|
601
|
+
raise Error, "The sandbox is stopping" if state["terminating"]
|
|
602
|
+
spec = sandbox.project&.boot_spec(sandbox)
|
|
603
|
+
plan = if state.dig("boot", "mode") == "spec" && spec
|
|
604
|
+
spec_plan(spec, Array(state.dig("boot", "locked_gems")).to_set)
|
|
605
|
+
else
|
|
606
|
+
config_plan(Config.load(app), secrets: spec&.step_environment || {})
|
|
607
|
+
end
|
|
608
|
+
env = boot_environment(workspace, (state["database_env"] || {}).transform_values(&:to_s), plan)
|
|
609
|
+
port = state.fetch("port")
|
|
610
|
+
env["PORT"] = port.to_s
|
|
611
|
+
secrets = sandbox_secrets(sandbox, {})
|
|
612
|
+
reset_boot(ActionAgent.local_sandbox_boot_timeout)
|
|
613
|
+
deadline = deadline_after(@boot_timeout)
|
|
614
|
+
pid = state["pid"]
|
|
615
|
+
stop_groups([ pid ]) if pid && group_identity(pid, sandbox.session_id, state) == :ours
|
|
616
|
+
stopped = true
|
|
617
|
+
manifest = run_manifest!(workspace, plan.manifest, env, deadline, secrets)
|
|
618
|
+
server_pid, waiter = start_server(workspace, plan.start.command, env, port)
|
|
619
|
+
wait_until_ready!(workspace, port, manifest, server_pid, waiter, deadline, secrets, step: plan.start, boot_deadline: deadline)
|
|
620
|
+
sandbox.update!(runtime_mcp_url: "http://127.0.0.1:#{port}#{manifest['mcp_path']}", runtime_mcp_token: manifest["mcp_token"])
|
|
621
|
+
true
|
|
622
|
+
rescue StandardError => e
|
|
623
|
+
stop_groups([ server_pid ]) if server_pid
|
|
624
|
+
if stopped
|
|
625
|
+
message = SecretScrubber.scrub("The app did not restart after the change: #{e.message}", secrets || [])
|
|
626
|
+
sandbox.update!(status: :failed, error_message: message.truncate(1000))
|
|
627
|
+
end
|
|
628
|
+
raise
|
|
629
|
+
end
|
|
630
|
+
|
|
443
631
|
# Runs a coding agent headless in the sandbox's checkout, yielding each
|
|
444
632
|
# stream-json event (a Hash, already scrubbed of the sandbox's secrets)
|
|
445
633
|
# as it arrives.
|
|
@@ -455,7 +643,13 @@ module ActionAgent
|
|
|
455
643
|
runner = code_session.try(:runner) || "claude_code"
|
|
456
644
|
raise Error, "Unsupported code runner: #{runner}" unless code_runners.include?(runner)
|
|
457
645
|
|
|
458
|
-
|
|
646
|
+
subscription = runner == "claude_code" && code_session.try(:credential_mode) == "sandbox_login"
|
|
647
|
+
if subscription
|
|
648
|
+
unless sandbox.claude_login_user_id.present? && sandbox.claude_login_user_id == code_session.user_id && claude_login_status(sandbox)[:logged_in]
|
|
649
|
+
raise Error, "Sign in to this sandbox with your own Claude subscription before starting a session"
|
|
650
|
+
end
|
|
651
|
+
end
|
|
652
|
+
credentials = subscription ? {} : session_credentials(sandbox, runner: runner)
|
|
459
653
|
secrets = sandbox_secrets(sandbox, credentials)
|
|
460
654
|
argv = runner == "codex" ? codex_argv(code_session) : claude_argv(code_session)
|
|
461
655
|
database_env = read_state(workspace)["database_env"]
|
|
@@ -473,12 +667,17 @@ module ActionAgent
|
|
|
473
667
|
# own, in the workspace. With the machine's own login it must use the
|
|
474
668
|
# user's: that is where `claude /login` left the credentials (HOME,
|
|
475
669
|
# which the sanitized environment keeps, or the keychain).
|
|
476
|
-
if
|
|
670
|
+
if subscription
|
|
671
|
+
env = env.merge(sandbox_login_environment(workspace))
|
|
672
|
+
forbidden = env.keys.grep(/\A(?:ANTHROPIC_|CLAUDE_CODE_OAUTH_TOKEN\z)/)
|
|
673
|
+
raise Error, "Subscription sessions cannot contain Anthropic credential environment variables" if forbidden.any?
|
|
674
|
+
elsif runner == "codex"
|
|
477
675
|
env["CODEX_HOME"] = workspace.join("codex").to_s
|
|
478
676
|
FileUtils.mkdir_p(workspace.join("codex"), mode: 0o700)
|
|
479
677
|
elsif !ClaudeCodeAuth.local_login?
|
|
480
|
-
|
|
481
|
-
|
|
678
|
+
config = workspace.join(ClaudeCodeAuth.sandbox_login? ? "claude-api" : "claude")
|
|
679
|
+
env["CLAUDE_CONFIG_DIR"] = config.to_s
|
|
680
|
+
FileUtils.mkdir_p(config, mode: 0o700)
|
|
482
681
|
end
|
|
483
682
|
|
|
484
683
|
run_claude(workspace, code_session, argv, env, secrets, runner: runner, &on_event)
|
|
@@ -522,8 +721,177 @@ module ActionAgent
|
|
|
522
721
|
true
|
|
523
722
|
end
|
|
524
723
|
|
|
724
|
+
# The paths the sandbox's checkout changed since it was fetched: what
|
|
725
|
+
# `git diff` reports against the commit checked out, and the untracked
|
|
726
|
+
# files the repository does not ignore. Git runs as #capture_diff runs
|
|
727
|
+
# it, with no credential in its environment, and not at all when the
|
|
728
|
+
# checkout's git config defines filter drivers. It does not look inside
|
|
729
|
+
# submodules, whose own configuration could define filters, so a
|
|
730
|
+
# submodule is listed only when the commit it points at changed. Each
|
|
731
|
+
# path's mode and size come from lstat of the working tree, so a symlink
|
|
732
|
+
# is reported as one (120000) and a nested repository as a submodule
|
|
733
|
+
# (160000). Sockets, pipes and devices, which git does not track, are
|
|
734
|
+
# left out.
|
|
735
|
+
#
|
|
736
|
+
# @return [Hash] { base_commit:, files: [{ path:, status:, mode:, base_mode:, size: }] },
|
|
737
|
+
# as SandboxOrchestrator#changed_files describes
|
|
738
|
+
# @raise [Error] when the checkout is gone, recorded no commit, defines
|
|
739
|
+
# filter drivers, or changed more than MAX_CHANGED_FILES paths
|
|
740
|
+
def changed_files(sandbox)
|
|
741
|
+
workspace, app = checkout_workspace!(sandbox)
|
|
742
|
+
base = recorded_checkout_commit!(workspace)
|
|
743
|
+
env = read_only_git_environment
|
|
744
|
+
git = git_command(app)
|
|
745
|
+
if filter_drivers?(app, env, git)
|
|
746
|
+
raise Error, "The checkout's git config defines filter drivers, which would run commands, so its changes are not read"
|
|
747
|
+
end
|
|
748
|
+
|
|
749
|
+
diff = git_listing!(env, [ *git, "diff", "--raw", "-z", "--no-renames", "--no-abbrev", "--no-ext-diff", "--no-textconv",
|
|
750
|
+
"--ignore-submodules=dirty", base, "--" ], app)
|
|
751
|
+
untracked = git_listing!(env, [ *git, "ls-files", "-z", "--others", "--exclude-standard" ], app)
|
|
752
|
+
|
|
753
|
+
# ":<base mode> <mode> <base object> <object> <status>" per path.
|
|
754
|
+
base_modes = {}
|
|
755
|
+
listed = []
|
|
756
|
+
diff.each_slice(2) do |header, path|
|
|
757
|
+
next if path.nil?
|
|
758
|
+
|
|
759
|
+
fields = header.split(" ")
|
|
760
|
+
base_modes[path] = fields.first.delete_prefix(":") unless fields.last == "A"
|
|
761
|
+
listed << path
|
|
762
|
+
end
|
|
763
|
+
# A nested repository is listed as its directory, with a trailing slash.
|
|
764
|
+
listed.concat(untracked.map { |path| path.delete_suffix("/") })
|
|
765
|
+
listed.uniq!
|
|
766
|
+
raise Error, "The checkout changed more than #{MAX_CHANGED_FILES} files" if listed.size > MAX_CHANGED_FILES
|
|
767
|
+
|
|
768
|
+
files = listed.filter_map do |path|
|
|
769
|
+
next if SandboxCredentialPaths.protected?(path)
|
|
770
|
+
next unless checkout_path?(path)
|
|
771
|
+
|
|
772
|
+
base_mode = base_modes[path]
|
|
773
|
+
stat = working_tree_stat(app, path)
|
|
774
|
+
next { path: path, status: "deleted", mode: nil, base_mode: base_mode, size: nil } if stat.nil? && base_mode
|
|
775
|
+
next if stat.nil?
|
|
776
|
+
|
|
777
|
+
mode = mode_for(stat) or next
|
|
778
|
+
{
|
|
779
|
+
path: path, status: base_mode ? "modified" : "added", mode: mode, base_mode: base_mode,
|
|
780
|
+
size: stat.directory? ? nil : stat.size
|
|
781
|
+
}
|
|
782
|
+
end
|
|
783
|
+
|
|
784
|
+
{ base_commit: base, files: files.sort_by { |file| file[:path] } }
|
|
785
|
+
end
|
|
786
|
+
|
|
787
|
+
# +path+'s content in the sandbox's checkout, or with +base+ in the
|
|
788
|
+
# commit the checkout was fetched at. Nil when nothing is there.
|
|
789
|
+
#
|
|
790
|
+
# The working tree is read with lstat, one path component at a time, and
|
|
791
|
+
# never through a symlink: a symlink reads as its target path, and a path
|
|
792
|
+
# whose parent is a symlink or a file reads as nothing. The commit is
|
|
793
|
+
# read one object at a time with `git cat-file`, which runs no filters,
|
|
794
|
+
# and each object must hash to its id (see #read_committed_file).
|
|
795
|
+
#
|
|
796
|
+
# @raise [Error] for a directory, a submodule, a special file, more than
|
|
797
|
+
# MAX_READ_BYTES, or an object of the commit that does not hash to its
|
|
798
|
+
# id
|
|
799
|
+
# @return [String, nil] binary-encoded bytes
|
|
800
|
+
def read_file(sandbox, path, base: false)
|
|
801
|
+
raise Error, "Claude configuration is never read through the checkout API" if SandboxCredentialPaths.protected?(path)
|
|
802
|
+
workspace, app = checkout_workspace!(sandbox)
|
|
803
|
+
raise Error, "#{path.inspect} is not a path inside the checkout" unless checkout_path?(path)
|
|
804
|
+
|
|
805
|
+
base ? read_committed_file(workspace, app, path) : read_working_file(app, path)
|
|
806
|
+
end
|
|
807
|
+
|
|
808
|
+
# The modes #start_browser runs a browser in on this machine: headless
|
|
809
|
+
# always, and headed where there is a display to show a window on.
|
|
810
|
+
#
|
|
811
|
+
# @return [Array<Symbol>]
|
|
812
|
+
def browser_modes
|
|
813
|
+
display = RUBY_PLATFORM.include?("darwin") || ENV["DISPLAY"].present? || ENV["WAYLAND_DISPLAY"].present?
|
|
814
|
+
display ? %i[headless headed] : %i[headless]
|
|
815
|
+
end
|
|
816
|
+
|
|
817
|
+
# Starts the browser sidecar (BrowserSidecar) for +sandbox+, pointed at
|
|
818
|
+
# its app as sandbox.browser_launch says, and waits until it listens. It
|
|
819
|
+
# runs in a process group of its own, recorded in state.json beside the
|
|
820
|
+
# app's, so terminate stops it with the sandbox. Its configuration,
|
|
821
|
+
# tokens included, goes in on stdin. Any browser the sandbox already had
|
|
822
|
+
# is stopped first.
|
|
823
|
+
#
|
|
824
|
+
# @return [Hash] { mcp_url:, mcp_token: }, and live_url: when the launch
|
|
825
|
+
# asked for a live view
|
|
826
|
+
def start_browser(sandbox, mode: :headless)
|
|
827
|
+
ensure_enabled!
|
|
828
|
+
session_id = session_id!(sandbox.session_id)
|
|
829
|
+
workspace = workspace_for(session_id)
|
|
830
|
+
raise Error, "Sandbox #{session_id} has no local checkout: start the sandbox again" unless workspace.join("app").directory?
|
|
831
|
+
unless browser_modes.include?(mode.to_sym)
|
|
832
|
+
raise Error, "This machine has no display to show a browser window on; start the browser headless"
|
|
833
|
+
end
|
|
834
|
+
|
|
835
|
+
launch = sandbox.browser_launch
|
|
836
|
+
raise Error, "Sandbox #{session_id} was given no browser settings" unless launch.is_a?(Hash) && launch[:token].present?
|
|
837
|
+
|
|
838
|
+
refusal = BrowserSidecar.refusal
|
|
839
|
+
raise Error, refusal if refusal
|
|
840
|
+
raise Error, "The sandbox's earlier browser could not be stopped" unless stop_browser(sandbox)
|
|
841
|
+
|
|
842
|
+
run_browser(workspace, session_id, mode.to_s, launch)
|
|
843
|
+
end
|
|
844
|
+
|
|
845
|
+
# Stops +sandbox+'s browser sidecar and removes its directory. True, also
|
|
846
|
+
# when none was running; false when its process group is still alive and
|
|
847
|
+
# could not be stopped, or not told apart from an unrelated process, in
|
|
848
|
+
# which case state.json keeps it for terminate.
|
|
849
|
+
def stop_browser(sandbox)
|
|
850
|
+
session_id = session_id!(sandbox.session_id)
|
|
851
|
+
workspace = workspace_for(session_id)
|
|
852
|
+
state = read_state(workspace)
|
|
853
|
+
browser = state["browser"]
|
|
854
|
+
return true unless browser.is_a?(Hash)
|
|
855
|
+
|
|
856
|
+
pid = browser["pid"]
|
|
857
|
+
identity = signalable?(pid) ? group_identity(pid, session_id, state) : :stranger
|
|
858
|
+
stop_groups([ pid ]) if identity == :ours
|
|
859
|
+
return false if identity != :stranger && group_alive?(pid)
|
|
860
|
+
|
|
861
|
+
forget_browser(workspace, browser)
|
|
862
|
+
true
|
|
863
|
+
end
|
|
864
|
+
|
|
525
865
|
private
|
|
526
866
|
|
|
867
|
+
def boot_spec!(value)
|
|
868
|
+
SandboxBootSpec.wrap(value)
|
|
869
|
+
rescue SandboxBootSpec::Invalid => e
|
|
870
|
+
raise Error, "Sandbox boot spec is invalid: #{e.message}"
|
|
871
|
+
end
|
|
872
|
+
|
|
873
|
+
# The secrets a sandbox's stored output is scrubbed of when it is read
|
|
874
|
+
# back: its checkout token, the credentials its sessions get, and the
|
|
875
|
+
# secrets of the project it was booted for.
|
|
876
|
+
def session_secrets(session)
|
|
877
|
+
token = begin
|
|
878
|
+
session.checkout_spec&.dig(:token)
|
|
879
|
+
rescue StandardError
|
|
880
|
+
nil
|
|
881
|
+
end
|
|
882
|
+
[ token, *session.runtime_environment.values, *Array(session.try(:project_scrub_values)) ].compact.map(&:to_s)
|
|
883
|
+
end
|
|
884
|
+
|
|
885
|
+
# Milliseconds since +started_at+ (an ISO 8601 time), for a step still
|
|
886
|
+
# running; nil without one.
|
|
887
|
+
def running_for(started_at)
|
|
888
|
+
return nil unless started_at.is_a?(String)
|
|
889
|
+
|
|
890
|
+
((Time.current - Time.iso8601(started_at)) * 1000).round
|
|
891
|
+
rescue ArgumentError
|
|
892
|
+
nil
|
|
893
|
+
end
|
|
894
|
+
|
|
527
895
|
def ensure_enabled!
|
|
528
896
|
return if ActionAgent.local_sandboxes_enabled?
|
|
529
897
|
|
|
@@ -531,28 +899,314 @@ module ActionAgent
|
|
|
531
899
|
"set ActionAgent.local_sandboxes_enabled = true to allow them"
|
|
532
900
|
end
|
|
533
901
|
|
|
902
|
+
# --- Reading changes ----------------------------------------------------
|
|
903
|
+
|
|
904
|
+
# The workspace and checkout of +sandbox+, which must exist.
|
|
905
|
+
def checkout_workspace!(sandbox)
|
|
906
|
+
ensure_enabled!
|
|
907
|
+
workspace = workspace_for(session_id!(sandbox.session_id))
|
|
908
|
+
app = workspace.join("app")
|
|
909
|
+
raise Error, "Sandbox #{sandbox.session_id} has no local checkout" unless app.directory?
|
|
910
|
+
|
|
911
|
+
[ workspace, app ]
|
|
912
|
+
end
|
|
913
|
+
|
|
914
|
+
def recorded_checkout_commit!(workspace)
|
|
915
|
+
commit = read_state(workspace)["checkout_commit"]
|
|
916
|
+
return commit if commit.is_a?(String) && COMMIT_ID.match?(commit)
|
|
917
|
+
|
|
918
|
+
raise Error, "The sandbox did not record the commit it checked out"
|
|
919
|
+
end
|
|
920
|
+
|
|
921
|
+
def read_only_git_environment
|
|
922
|
+
self.class.sanitized_environment.merge(READ_ONLY_GIT_ENVIRONMENT)
|
|
923
|
+
end
|
|
924
|
+
|
|
925
|
+
# A filter driver in the checkout's git config (which a session could
|
|
926
|
+
# have written) runs its command whenever git reads the working tree, as
|
|
927
|
+
# the dashboard's user.
|
|
928
|
+
def filter_drivers?(app, env, git)
|
|
929
|
+
drivers, = capture(env, [ *git, "config", "--local", "--includes", "--name-only", "--get-regexp", "^filter\\." ],
|
|
930
|
+
chdir: app, limit: 64 * 1024, timeout: GIT_TIMEOUT)
|
|
931
|
+
drivers.to_s.strip.present?
|
|
932
|
+
end
|
|
933
|
+
|
|
934
|
+
# The NUL-separated fields +argv+ prints.
|
|
935
|
+
def git_listing!(env, argv, app)
|
|
936
|
+
output, status = capture(env, argv, chdir: app, limit: MAX_LISTING_BYTES, timeout: GIT_TIMEOUT)
|
|
937
|
+
raise Error, "The checkout's changes are too many to list" if output.bytesize >= MAX_LISTING_BYTES
|
|
938
|
+
raise Error, "git could not list the checkout's changes (#{status ? describe(status) : 'timed out'})" unless status&.success?
|
|
939
|
+
|
|
940
|
+
output.force_encoding(Encoding::UTF_8).split("\0").reject(&:empty?)
|
|
941
|
+
end
|
|
942
|
+
|
|
943
|
+
# Relative, inside the checkout, and outside its .git directory.
|
|
944
|
+
def checkout_path?(path)
|
|
945
|
+
return false unless path.is_a?(String) && path.valid_encoding? && path.present? && !path.include?("\0")
|
|
946
|
+
return false if path.start_with?("/")
|
|
947
|
+
|
|
948
|
+
parts = path.split("/")
|
|
949
|
+
parts.none? { |part| part.empty? || part == "." || part == ".." } && !parts.first.casecmp?(".git")
|
|
950
|
+
end
|
|
951
|
+
|
|
952
|
+
# lstat of +path+ under +app+, or nil when nothing is there, which
|
|
953
|
+
# includes a path one of whose parents is a symlink or a file.
|
|
954
|
+
def working_tree_stat(app, path)
|
|
955
|
+
*parents, name = path.split("/")
|
|
956
|
+
directory = app
|
|
957
|
+
parents.each do |part|
|
|
958
|
+
directory = directory.join(part)
|
|
959
|
+
return nil unless File.lstat(directory).directory?
|
|
960
|
+
end
|
|
961
|
+
File.lstat(directory.join(name))
|
|
962
|
+
rescue Errno::ENOENT, Errno::ENOTDIR
|
|
963
|
+
nil
|
|
964
|
+
end
|
|
965
|
+
|
|
966
|
+
# git's mode for what +stat+ describes, or nil for a socket, a pipe or a
|
|
967
|
+
# device.
|
|
968
|
+
def mode_for(stat)
|
|
969
|
+
if stat.symlink? then "120000"
|
|
970
|
+
elsif stat.directory? then "160000"
|
|
971
|
+
elsif stat.file? then stat.mode.anybits?(0o111) ? "100755" : "100644"
|
|
972
|
+
end
|
|
973
|
+
end
|
|
974
|
+
|
|
975
|
+
def read_working_file(app, path)
|
|
976
|
+
stat = working_tree_stat(app, path)
|
|
977
|
+
return nil if stat.nil?
|
|
978
|
+
|
|
979
|
+
full = app.join(path)
|
|
980
|
+
return File.readlink(full).b if stat.symlink?
|
|
981
|
+
raise Error, "#{path} is a submodule or a directory, not a file" if stat.directory?
|
|
982
|
+
raise Error, "#{path} is not a regular file" unless stat.file?
|
|
983
|
+
raise Error, "#{path} is larger than #{MAX_READ_BYTES} bytes" if stat.size > MAX_READ_BYTES
|
|
984
|
+
|
|
985
|
+
File.open(full, File::RDONLY | File::NOFOLLOW) do |file|
|
|
986
|
+
# Whatever replaced the file since the lstat was not what was listed.
|
|
987
|
+
opened = file.stat
|
|
988
|
+
raise Error, "#{path} changed while it was read" unless opened.file? && opened.ino == stat.ino && opened.dev == stat.dev
|
|
989
|
+
raise Error, "#{path} is not inside the checkout" unless File.realpath(full).start_with?("#{File.realpath(app)}/")
|
|
990
|
+
|
|
991
|
+
content = file.read(MAX_READ_BYTES + 1) || String.new
|
|
992
|
+
raise Error, "#{path} is larger than #{MAX_READ_BYTES} bytes" if content.bytesize > MAX_READ_BYTES
|
|
993
|
+
|
|
994
|
+
content.b
|
|
995
|
+
end
|
|
996
|
+
rescue Errno::ELOOP
|
|
997
|
+
raise Error, "#{path} changed while it was read"
|
|
998
|
+
rescue Errno::ENOENT, Errno::ENOTDIR
|
|
999
|
+
nil
|
|
1000
|
+
end
|
|
1001
|
+
|
|
1002
|
+
# +path+ in the checkout commit, walked from the commit through each
|
|
1003
|
+
# tree to the blob. Git does not check that an object it reads hashes to
|
|
1004
|
+
# its id, and the checkout's object store is the sandbox's to rewrite, so
|
|
1005
|
+
# every object is checked here: the content is the commit's, not
|
|
1006
|
+
# whatever the sandbox wrote in its place.
|
|
1007
|
+
def read_committed_file(workspace, app, path)
|
|
1008
|
+
commit = recorded_checkout_commit!(workspace)
|
|
1009
|
+
commit_object = read_object(app, commit, "commit", limit: MAX_LISTING_BYTES)
|
|
1010
|
+
tree = commit_object[/\Atree (\h+)\n/, 1] or raise Error, "The checked-out commit #{commit} names no tree"
|
|
1011
|
+
|
|
1012
|
+
*parents, name = path.split("/")
|
|
1013
|
+
parents.each do |part|
|
|
1014
|
+
entry = tree_entries(app, tree)[part]
|
|
1015
|
+
# Not there, or a file or submodule where a directory would be.
|
|
1016
|
+
return nil unless entry && entry[:mode] == "40000"
|
|
1017
|
+
|
|
1018
|
+
tree = entry[:id]
|
|
1019
|
+
end
|
|
1020
|
+
entry = tree_entries(app, tree)[name]
|
|
1021
|
+
return nil if entry.nil?
|
|
1022
|
+
raise Error, "#{path} is a submodule in the checked-out commit" if entry[:mode] == "160000"
|
|
1023
|
+
raise Error, "#{path} is a directory in the checked-out commit" if entry[:mode] == "40000"
|
|
1024
|
+
|
|
1025
|
+
read_object(app, entry[:id], "blob", limit: MAX_READ_BYTES, label: path)
|
|
1026
|
+
end
|
|
1027
|
+
|
|
1028
|
+
# The entries of tree +id+, { name => { mode:, id: } }. Kept by id once
|
|
1029
|
+
# checked, since a checked object never changes.
|
|
1030
|
+
def tree_entries(app, id)
|
|
1031
|
+
@verified_trees ||= {}
|
|
1032
|
+
@verified_trees.fetch(id) do
|
|
1033
|
+
@verified_trees.clear if @verified_trees.size >= MAX_CACHED_TREES
|
|
1034
|
+
@verified_trees[id] = parse_tree(read_object(app, id, "tree", limit: MAX_LISTING_BYTES), id)
|
|
1035
|
+
end
|
|
1036
|
+
end
|
|
1037
|
+
|
|
1038
|
+
# A raw tree object: "<mode> <name>\0<id as bytes>" per entry.
|
|
1039
|
+
def parse_tree(raw, id)
|
|
1040
|
+
id_bytes = id.length / 2
|
|
1041
|
+
entries = {}
|
|
1042
|
+
offset = 0
|
|
1043
|
+
while offset < raw.bytesize
|
|
1044
|
+
space = raw.index(" ".b, offset)
|
|
1045
|
+
nul = space && raw.index("\0".b, space)
|
|
1046
|
+
raise Error, "The checkout's tree #{id} could not be read" unless nul && nul + 1 + id_bytes <= raw.bytesize
|
|
1047
|
+
|
|
1048
|
+
name = raw.byteslice(space + 1, nul - space - 1).force_encoding(Encoding::UTF_8)
|
|
1049
|
+
entries[name] = { mode: raw.byteslice(offset, space - offset), id: raw.byteslice(nul + 1, id_bytes).unpack1("H*") }
|
|
1050
|
+
offset = nul + 1 + id_bytes
|
|
1051
|
+
end
|
|
1052
|
+
entries
|
|
1053
|
+
end
|
|
1054
|
+
|
|
1055
|
+
# The content of object +id+ as a +type+ ("commit", "tree" or "blob"),
|
|
1056
|
+
# refused unless it hashes to +id+. +label+ names it in errors.
|
|
1057
|
+
def read_object(app, id, type, limit:, label: nil)
|
|
1058
|
+
label ||= "The checkout's #{type} #{id}"
|
|
1059
|
+
raise Error, "#{label} has no valid object id" unless id.is_a?(String) && COMMIT_ID.match?(id)
|
|
1060
|
+
|
|
1061
|
+
content, status = capture(read_only_git_environment, [ *git_command(app), "cat-file", type, id ],
|
|
1062
|
+
chdir: app, limit: limit + 1, timeout: GIT_TIMEOUT)
|
|
1063
|
+
raise Error, "#{label} is larger than #{limit} bytes" if content.bytesize > limit
|
|
1064
|
+
raise Error, "git could not read #{label} from the checked-out commit" unless status&.success?
|
|
1065
|
+
|
|
1066
|
+
digest = id.length == 64 ? Digest::SHA256 : Digest::SHA1
|
|
1067
|
+
unless digest.hexdigest("#{type} #{content.bytesize}\0".b + content) == id
|
|
1068
|
+
raise Error, "#{label} in the checkout does not match the checked-out commit, so it is not read"
|
|
1069
|
+
end
|
|
1070
|
+
|
|
1071
|
+
content.b
|
|
1072
|
+
end
|
|
1073
|
+
|
|
534
1074
|
# --- Boot -------------------------------------------------------------
|
|
535
1075
|
|
|
536
|
-
def boot(session_id,
|
|
1076
|
+
def boot(session_id, checkout, secrets, boot_spec = nil)
|
|
537
1077
|
workspace = workspace_for(session_id)
|
|
538
|
-
|
|
1078
|
+
started = monotonic
|
|
1079
|
+
# A spec only for checkouts without the engine may not apply, which is
|
|
1080
|
+
# known once the checkout is there: until then, and when it does not,
|
|
1081
|
+
# the boot has the configured limit.
|
|
1082
|
+
reset_boot(boot_spec && !boot_spec.without_engine_only? ? spec_boot_timeout(boot_spec) : ActionAgent.local_sandbox_boot_timeout)
|
|
1083
|
+
deadline = deadline_after(@boot_timeout)
|
|
539
1084
|
booted = false
|
|
540
|
-
|
|
1085
|
+
keep = false
|
|
541
1086
|
|
|
542
1087
|
prepare_workspace(workspace)
|
|
543
|
-
|
|
1088
|
+
update_state(workspace) do |state|
|
|
1089
|
+
state["boot"] = { "steps" => [ { "name" => "checkout", "log" => "checkout", "status" => "pending" } ], "kept" => false }
|
|
1090
|
+
end
|
|
1091
|
+
boot_step(workspace, 0) { checkout!(workspace, checkout, secrets, deadline) }
|
|
544
1092
|
|
|
545
1093
|
app = workspace.join("app")
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
1094
|
+
plan = plan_boot(workspace, app, checkout, boot_spec)
|
|
1095
|
+
if plan.spec && spec_boot_timeout(plan.spec) != @boot_timeout
|
|
1096
|
+
@boot_timeout = spec_boot_timeout(plan.spec)
|
|
1097
|
+
deadline = started + @boot_timeout
|
|
1098
|
+
end
|
|
1099
|
+
databases = assign_databases(workspace, app, plan.env, checkout)
|
|
1100
|
+
# Only from here on does a kept workspace hold all a resume needs.
|
|
1101
|
+
keep = plan.keep_on_failure
|
|
1102
|
+
result = run_plan!(workspace, plan, boot_environment(workspace, databases, plan), 0, deadline, secrets)
|
|
1103
|
+
booted = true
|
|
1104
|
+
result
|
|
1105
|
+
rescue Error
|
|
1106
|
+
raise
|
|
1107
|
+
rescue StandardError => e
|
|
1108
|
+
raise Error, SecretScrubber.scrub("Sandbox boot failed: #{e.class.name}: #{e.message}", secrets)
|
|
1109
|
+
ensure
|
|
1110
|
+
finish_failed_boot(workspace, keep) unless booted
|
|
1111
|
+
end
|
|
1112
|
+
|
|
1113
|
+
# Takes a kept boot for one resume, under the state.json lock, so two
|
|
1114
|
+
# resumes never run in one workspace. False when there is none to take,
|
|
1115
|
+
# including while a terminate is removing the workspace.
|
|
1116
|
+
def claim_kept_boot(workspace)
|
|
1117
|
+
claimed = false
|
|
1118
|
+
update_state(workspace, create: false) do |state|
|
|
1119
|
+
boot_state = state["boot"]
|
|
1120
|
+
next if state["terminating"]
|
|
1121
|
+
next unless boot_state.is_a?(Hash) && boot_state["kept"] == true
|
|
1122
|
+
|
|
1123
|
+
boot_state["kept"] = false
|
|
1124
|
+
claimed = true
|
|
1125
|
+
end
|
|
1126
|
+
claimed
|
|
1127
|
+
rescue Errno::ENOENT, Errno::ENOTDIR
|
|
1128
|
+
false
|
|
1129
|
+
end
|
|
1130
|
+
|
|
1131
|
+
# Runs a kept boot's +plan+ again from its entry +start_at+.
|
|
1132
|
+
def resume(workspace, plan, start_at, secrets)
|
|
1133
|
+
reset_boot(spec_boot_timeout(plan.spec))
|
|
1134
|
+
deadline = deadline_after(@boot_timeout)
|
|
1135
|
+
booted = false
|
|
1136
|
+
keep = plan.keep_on_failure
|
|
1137
|
+
|
|
1138
|
+
database_env = read_state(workspace)["database_env"]
|
|
1139
|
+
databases = database_env.is_a?(Hash) ? database_env.transform_values(&:to_s) : {}
|
|
1140
|
+
record_resumed_plan(workspace, plan, start_at)
|
|
1141
|
+
result = run_plan!(workspace, plan, boot_environment(workspace, databases, plan), start_at, deadline, secrets)
|
|
1142
|
+
booted = true
|
|
1143
|
+
result
|
|
1144
|
+
rescue Error
|
|
1145
|
+
raise
|
|
1146
|
+
rescue StandardError => e
|
|
1147
|
+
raise Error, SecretScrubber.scrub("Sandbox boot failed: #{e.class.name}: #{e.message}", secrets)
|
|
1148
|
+
ensure
|
|
1149
|
+
finish_failed_boot(workspace, keep) unless booted
|
|
1150
|
+
end
|
|
1151
|
+
|
|
1152
|
+
# The limit on a boot from +spec+. A bootstrap does more than the app's
|
|
1153
|
+
# own boot from its sandbox.yml, so it never gets less time than
|
|
1154
|
+
# `local_sandbox_boot_timeout` gives that boot.
|
|
1155
|
+
def spec_boot_timeout(spec)
|
|
1156
|
+
spec.kind == "bootstrap" ? [ spec.timeout, ActionAgent.local_sandbox_boot_timeout ].max : spec.timeout
|
|
1157
|
+
end
|
|
1158
|
+
|
|
1159
|
+
# What a boot remembers while it runs, which a backend instance holds for
|
|
1160
|
+
# one call: the limit on the whole boot, the server it started and the
|
|
1161
|
+
# checkout's Rake tasks once listed.
|
|
1162
|
+
def reset_boot(timeout)
|
|
1163
|
+
@boot_timeout = timeout
|
|
1164
|
+
@server_pid = nil
|
|
1165
|
+
@app_tasks = nil
|
|
1166
|
+
end
|
|
1167
|
+
|
|
1168
|
+
# A failed boot leaves nothing running. Unless it is kept, it leaves
|
|
1169
|
+
# nothing on disk either: no handle was reported, so nothing would ever
|
|
1170
|
+
# reap it. The error carries the failing step's log tail.
|
|
1171
|
+
def finish_failed_boot(workspace, keep)
|
|
1172
|
+
stop_groups([ @server_pid ].compact)
|
|
1173
|
+
if keep && workspace.directory?
|
|
1174
|
+
update_state(workspace, create: false) { |state| state_hash(state, "boot")["kept"] = true }
|
|
1175
|
+
else
|
|
1176
|
+
# A setup that got as far as db:prepare created the databases.
|
|
1177
|
+
drop_databases(workspace)
|
|
1178
|
+
remove_workspace(workspace)
|
|
1179
|
+
end
|
|
1180
|
+
rescue SystemCallError => e
|
|
1181
|
+
Rails.logger.warn("[ActionAgent] sandbox #{workspace.basename}: could not finish a failed boot: #{e.message}")
|
|
1182
|
+
end
|
|
1183
|
+
|
|
1184
|
+
def boot_environment(workspace, databases, plan)
|
|
1185
|
+
self.class.sanitized_environment.merge(databases).merge(plan.env).merge(
|
|
549
1186
|
# Merged after the file's env, so a checkout cannot move them.
|
|
550
1187
|
SandboxManifest::PATH_ENV => workspace.join("runtime.json").to_s,
|
|
551
|
-
SESSION_ID_ENV =>
|
|
1188
|
+
SESSION_ID_ENV => workspace.basename.to_s,
|
|
1189
|
+
SandboxMail::DIRECTORY_ENV => SandboxMail::DIRECTORY
|
|
552
1190
|
)
|
|
1191
|
+
end
|
|
553
1192
|
|
|
554
|
-
|
|
555
|
-
|
|
1193
|
+
# Runs +plan+'s entries from +start_at+ on, records each in state.json as
|
|
1194
|
+
# it goes, and returns what #create_sandbox does.
|
|
1195
|
+
def run_plan!(workspace, plan, env, start_at, deadline, secrets)
|
|
1196
|
+
app = workspace.join("app")
|
|
1197
|
+
plan.steps.each_with_index do |step, index|
|
|
1198
|
+
next if index < start_at
|
|
1199
|
+
|
|
1200
|
+
boot_step(workspace, plan.offset + index) do
|
|
1201
|
+
reason = step.skip || missing_task(workspace, step, env, deadline, secrets)
|
|
1202
|
+
if reason
|
|
1203
|
+
File.open(log_path(workspace, step.log), "a") { |file| file.puts("# skipped: #{reason}") }
|
|
1204
|
+
next Skip.new(reason)
|
|
1205
|
+
end
|
|
1206
|
+
|
|
1207
|
+
run_step!(workspace, step.label, step.command, env: env, chdir: app, deadline: deadline, secrets: secrets,
|
|
1208
|
+
timeout: step.timeout, log: step.log)
|
|
1209
|
+
end
|
|
556
1210
|
end
|
|
557
1211
|
|
|
558
1212
|
# Picked after setup, which can take minutes, so that the port is
|
|
@@ -561,33 +1215,305 @@ module ActionAgent
|
|
|
561
1215
|
# of the server's own process group.
|
|
562
1216
|
port = free_port
|
|
563
1217
|
env = env.merge("PORT" => port.to_s)
|
|
564
|
-
|
|
1218
|
+
manifest_index = plan.steps.size
|
|
1219
|
+
manifest = if start_at > manifest_index
|
|
1220
|
+
read_manifest!(workspace, plan.manifest, secrets)
|
|
1221
|
+
else
|
|
1222
|
+
boot_step(workspace, plan.offset + manifest_index) { run_manifest!(workspace, plan.manifest, env, deadline, secrets) }
|
|
1223
|
+
end
|
|
565
1224
|
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
1225
|
+
boot_step(workspace, plan.offset + manifest_index + 1) do
|
|
1226
|
+
server_pid, waiter = start_server(workspace, plan.start.command, env, port)
|
|
1227
|
+
start_deadline = step_deadline(plan.start, deadline)
|
|
1228
|
+
wait_until_ready!(workspace, port, manifest, server_pid, waiter, start_deadline, secrets, step: plan.start, boot_deadline: deadline)
|
|
1229
|
+
probe_start_url!(workspace, port, plan.start_url, waiter, start_deadline, secrets, step: plan.start, boot_deadline: deadline) if plan.start_url
|
|
1230
|
+
end
|
|
569
1231
|
|
|
570
1232
|
{
|
|
571
|
-
container_name: "#{HANDLE_PREFIX}#{
|
|
1233
|
+
container_name: "#{HANDLE_PREFIX}#{workspace.basename}",
|
|
572
1234
|
url: "http://127.0.0.1:#{port}",
|
|
573
1235
|
container_ip: "127.0.0.1",
|
|
574
1236
|
mcp_url: "http://127.0.0.1:#{port}#{manifest["mcp_path"]}",
|
|
575
1237
|
mcp_token: manifest["mcp_token"],
|
|
1238
|
+
app_models: manifest["models"],
|
|
576
1239
|
created_at: Time.current
|
|
577
1240
|
}
|
|
578
|
-
|
|
579
|
-
|
|
1241
|
+
end
|
|
1242
|
+
|
|
1243
|
+
# Records the boot's entry at +position+ in state.json as running, then
|
|
1244
|
+
# as succeeded, skipped (the block returned a Skip) or failed, and names
|
|
1245
|
+
# a failed one as the boot's failed_step. Returns the block's value.
|
|
1246
|
+
def boot_step(workspace, position)
|
|
1247
|
+
update_boot_step(workspace, position, "status" => "running", "started_at" => Time.current.iso8601(3),
|
|
1248
|
+
"finished_at" => nil, "duration_ms" => nil, "detail" => nil)
|
|
1249
|
+
started = monotonic
|
|
1250
|
+
outcome = nil
|
|
1251
|
+
finished = false
|
|
1252
|
+
error = nil
|
|
1253
|
+
begin
|
|
1254
|
+
outcome = yield
|
|
1255
|
+
finished = true
|
|
1256
|
+
rescue StandardError => e
|
|
1257
|
+
error = e
|
|
1258
|
+
raise
|
|
1259
|
+
ensure
|
|
1260
|
+
result =
|
|
1261
|
+
if !finished then { "status" => "failed", "detail" => error.is_a?(Error) ? error.message.lines.first.to_s.strip : nil }
|
|
1262
|
+
elsif outcome.is_a?(Skip) then { "status" => "skipped", "detail" => outcome.reason }
|
|
1263
|
+
else { "status" => "succeeded" }
|
|
1264
|
+
end
|
|
1265
|
+
update_boot_step(workspace, position,
|
|
1266
|
+
result.merge("finished_at" => Time.current.iso8601(3), "duration_ms" => ((monotonic - started) * 1000).round))
|
|
1267
|
+
end
|
|
1268
|
+
outcome
|
|
1269
|
+
end
|
|
1270
|
+
|
|
1271
|
+
def update_boot_step(workspace, position, attributes)
|
|
1272
|
+
update_state(workspace, create: false) do |state|
|
|
1273
|
+
boot_state = state_hash(state, "boot")
|
|
1274
|
+
step = Array(boot_state["steps"])[position]
|
|
1275
|
+
next unless step.is_a?(Hash)
|
|
1276
|
+
|
|
1277
|
+
step.merge!(attributes)
|
|
1278
|
+
boot_state["failed_step"] = step["name"] if attributes["status"] == "failed"
|
|
1279
|
+
end
|
|
1280
|
+
rescue SystemCallError
|
|
1281
|
+
# The workspace is gone: nothing left to record it in.
|
|
1282
|
+
end
|
|
1283
|
+
|
|
1284
|
+
# Decides how the checkout boots, records the plan in state.json, and
|
|
1285
|
+
# returns it. A boot spec that applies runs its preflight here, before
|
|
1286
|
+
# any of the checkout's own commands.
|
|
1287
|
+
def plan_boot(workspace, app, checkout, boot_spec)
|
|
1288
|
+
facts = nil
|
|
1289
|
+
if boot_spec && spec_applies?(boot_spec, app)
|
|
1290
|
+
if boot_spec.preflight?
|
|
1291
|
+
position = append_boot_steps(workspace, [ { "name" => "preflight", "log" => "preflight", "status" => "pending" } ])
|
|
1292
|
+
facts = boot_step(workspace, position) { preflight!(workspace, app, checkout) }
|
|
1293
|
+
else
|
|
1294
|
+
facts = lock_facts(app.join("Gemfile.lock")) if app.join("Gemfile.lock").file?
|
|
1295
|
+
end
|
|
1296
|
+
plan = spec_plan(boot_spec, (facts&.dig("gems") || {}).keys.to_set)
|
|
1297
|
+
else
|
|
1298
|
+
plan = config_plan(Config.load(app), secrets: boot_spec&.step_environment || {}, steps: boot_spec&.always_steps || [])
|
|
1299
|
+
end
|
|
1300
|
+
|
|
1301
|
+
update_state(workspace) do |state|
|
|
1302
|
+
boot_state = state_hash(state, "boot")
|
|
1303
|
+
boot_state["mode"] = plan.mode
|
|
1304
|
+
if plan.spec
|
|
1305
|
+
boot_state["kind"] = plan.spec.kind
|
|
1306
|
+
boot_state["spec"] = plan.spec.redacted
|
|
1307
|
+
boot_state["locked_gems"] = (facts&.dig("gems") || {}).keys.sort
|
|
1308
|
+
boot_state["lock"] = facts&.except("gems")
|
|
1309
|
+
end
|
|
1310
|
+
steps = boot_state["steps"] = Array(boot_state["steps"])
|
|
1311
|
+
plan.offset = boot_state["plan_offset"] = steps.size
|
|
1312
|
+
steps.concat(plan.entries.map { |step| { "name" => step.name, "log" => step.log, "status" => "pending" } })
|
|
1313
|
+
end
|
|
1314
|
+
plan
|
|
1315
|
+
end
|
|
1316
|
+
|
|
1317
|
+
# Appends +entries+ to the boot's steps; returns the first one's position.
|
|
1318
|
+
def append_boot_steps(workspace, entries)
|
|
1319
|
+
position = nil
|
|
1320
|
+
update_state(workspace) do |state|
|
|
1321
|
+
steps = state_hash(state, "boot")["steps"] = Array(state_hash(state, "boot")["steps"])
|
|
1322
|
+
position = steps.size
|
|
1323
|
+
steps.concat(entries)
|
|
1324
|
+
end
|
|
1325
|
+
position
|
|
1326
|
+
end
|
|
1327
|
+
|
|
1328
|
+
# Rewrites the boot's steps for a resume from +start_at+: the entries
|
|
1329
|
+
# before it keep how they went, the rest are pending again.
|
|
1330
|
+
def record_resumed_plan(workspace, plan, start_at)
|
|
1331
|
+
update_state(workspace, create: false) do |state|
|
|
1332
|
+
boot_state = state_hash(state, "boot")
|
|
1333
|
+
steps = Array(boot_state["steps"])
|
|
1334
|
+
offset = Integer(boot_state["plan_offset"], exception: false) || steps.size
|
|
1335
|
+
earlier = steps.drop(offset).select { |step| step.is_a?(Hash) }.index_by { |step| step["name"] }
|
|
1336
|
+
resumed_from = plan.entries[start_at].name
|
|
1337
|
+
entries = plan.entries.each_with_index.map do |step, index|
|
|
1338
|
+
if index < start_at
|
|
1339
|
+
earlier[step.name] || { "name" => step.name, "log" => step.log, "status" => "skipped", "detail" => "not run: resumed from #{resumed_from}" }
|
|
1340
|
+
else
|
|
1341
|
+
{ "name" => step.name, "log" => step.log, "status" => "pending" }
|
|
1342
|
+
end
|
|
1343
|
+
end
|
|
1344
|
+
plan.offset = offset
|
|
1345
|
+
boot_state.merge!("steps" => steps.first(offset) + entries, "failed_step" => nil, "kept" => false,
|
|
1346
|
+
"spec" => plan.spec.redacted, "plan_offset" => offset)
|
|
1347
|
+
end
|
|
1348
|
+
end
|
|
1349
|
+
|
|
1350
|
+
def resume_position(plan, name)
|
|
1351
|
+
index = plan.entries.index { |step| step.name == name.to_s }
|
|
1352
|
+
return index if index
|
|
1353
|
+
|
|
1354
|
+
raise Error, "#{name.inspect} is not a step this boot can resume from (#{plan.entries.map(&:name).join(", ")})"
|
|
1355
|
+
end
|
|
1356
|
+
|
|
1357
|
+
# The steps #resume_position accepts: a spec boot's own, which follow
|
|
1358
|
+
# the checkout and preflight steps. None for a boot from the checkout's
|
|
1359
|
+
# sandbox.yml, which cannot be resumed.
|
|
1360
|
+
def resumable_step_names(boot_state)
|
|
1361
|
+
offset = Integer(boot_state["plan_offset"], exception: false)
|
|
1362
|
+
return [] unless boot_state["mode"] == "spec" && offset
|
|
1363
|
+
|
|
1364
|
+
boot_state["steps"].drop(offset).filter_map { |step| step["name"] if step.is_a?(Hash) }
|
|
1365
|
+
end
|
|
1366
|
+
|
|
1367
|
+
# +secrets+ are the env and secrets of a boot spec that does not apply to
|
|
1368
|
+
# the checkout, and +steps+ its `always` steps: it boots as its
|
|
1369
|
+
# sandbox.yml says, with them added to that file's env, and the steps
|
|
1370
|
+
# run after that file's setup.
|
|
1371
|
+
def config_plan(config, secrets: {}, steps: [])
|
|
1372
|
+
extra = steps.map do |entry|
|
|
1373
|
+
BootStep.new(name: entry["name"], command: entry["command"], timeout: entry["timeout"], log: entry["name"], label: entry["name"],
|
|
1374
|
+
if_task: entry["if_task"])
|
|
1375
|
+
end
|
|
1376
|
+
BootPlan.new(
|
|
1377
|
+
mode: "config", spec: nil,
|
|
1378
|
+
steps: config.setup.map { |command| BootStep.new(name: "setup", command: command, log: "setup", label: "setup") } + extra,
|
|
1379
|
+
manifest: BootStep.new(name: "manifest", command: config.manifest, log: "manifest", label: "manifest"),
|
|
1380
|
+
start: BootStep.new(name: "start", command: config.start, log: "server", label: "server"),
|
|
1381
|
+
env: config.env.merge(secrets), start_url: nil, keep_on_failure: false
|
|
1382
|
+
)
|
|
1383
|
+
end
|
|
1384
|
+
|
|
1385
|
+
# +locked+ holds the names of the gems the checkout's Gemfile.lock locked
|
|
1386
|
+
# as it was checked out, before any step changed it.
|
|
1387
|
+
def spec_plan(spec, locked)
|
|
1388
|
+
steps = spec.steps.map do |entry|
|
|
1389
|
+
gem = entry["unless_locked"]
|
|
1390
|
+
BootStep.new(
|
|
1391
|
+
name: entry["name"], command: entry["command"], timeout: entry["timeout"], log: entry["name"], label: entry["name"],
|
|
1392
|
+
if_task: entry["if_task"], skip: (gem && locked.include?(gem) ? "the checkout already locks #{gem}" : nil)
|
|
1393
|
+
)
|
|
1394
|
+
end
|
|
1395
|
+
BootPlan.new(
|
|
1396
|
+
mode: "spec", spec: spec, steps: steps,
|
|
1397
|
+
manifest: BootStep.new(name: "manifest", command: spec.manifest["command"], timeout: spec.manifest["timeout"],
|
|
1398
|
+
log: "manifest", label: "manifest"),
|
|
1399
|
+
start: BootStep.new(name: "start", command: spec.start["command"], timeout: spec.start["timeout"], log: "server",
|
|
1400
|
+
label: "start"),
|
|
1401
|
+
env: spec.step_environment, start_url: spec.start_url, keep_on_failure: spec.keep_on_failure?
|
|
1402
|
+
)
|
|
1403
|
+
end
|
|
1404
|
+
|
|
1405
|
+
# Whether +spec+ boots the checkout in +app+. One for checkouts without
|
|
1406
|
+
# the engine leaves alone a checkout that bundles it, one that says how
|
|
1407
|
+
# it serves its manifest itself, and anything without a Gemfile.lock.
|
|
1408
|
+
def spec_applies?(spec, app)
|
|
1409
|
+
return true unless spec.without_engine_only?
|
|
1410
|
+
|
|
1411
|
+
lock = app.join("Gemfile.lock")
|
|
1412
|
+
return false unless lock.file?
|
|
1413
|
+
return false if lock.read.match?(/^ {4}actionagent \(/)
|
|
1414
|
+
|
|
1415
|
+
file = app.join(Config::PATH)
|
|
1416
|
+
data = file.file? ? YAML.safe_load(file.read, aliases: false) : nil
|
|
1417
|
+
!(data.is_a?(Hash) && data.key?("manifest"))
|
|
1418
|
+
rescue Psych::Exception
|
|
1419
|
+
# Malformed: booting as configured reports what is wrong with it.
|
|
1420
|
+
false
|
|
1421
|
+
end
|
|
1422
|
+
|
|
1423
|
+
# Refuses, before any of the checkout's commands runs, a checkout the
|
|
1424
|
+
# engine cannot be installed into. Returns what the checkout's
|
|
1425
|
+
# Gemfile.lock locks (see #lock_facts).
|
|
1426
|
+
def preflight!(workspace, app, checkout)
|
|
1427
|
+
repository = checkout[:repository].presence || "The checkout"
|
|
1428
|
+
log = log_path(workspace, "preflight")
|
|
1429
|
+
lock = app.join("Gemfile.lock")
|
|
1430
|
+
refuse = ->(problem) { fail_step!("preflight", problem, log, []) }
|
|
1431
|
+
|
|
1432
|
+
refuse.call("#{repository} has no Gemfile.lock at its root: a bootstrap boot needs a bundled Rails app") unless lock.file?
|
|
1433
|
+
facts = lock_facts(lock)
|
|
1434
|
+
ruby, ruby_source = facts["ruby"] ? [ facts["ruby"], "Gemfile.lock" ] : [ ruby_version_file(app), ".ruby-version" ]
|
|
1435
|
+
railties = facts.dig("gems", "railties")
|
|
1436
|
+
File.open(log, "a") do |file|
|
|
1437
|
+
file.puts("Ruby #{ruby || "unpinned"}, railties #{railties || "not locked"}, " \
|
|
1438
|
+
"activeagent #{facts.dig("gems", "activeagent") || "not locked"}, actionagent #{facts.dig("gems", "actionagent") || "not locked"}")
|
|
1439
|
+
end
|
|
1440
|
+
|
|
1441
|
+
if ruby && version_below?(ruby, SandboxBootSpec::MINIMUM_RUBY)
|
|
1442
|
+
refuse.call("#{repository} needs Ruby #{ruby} (#{ruby_source}); the engine needs Ruby #{SandboxBootSpec::MINIMUM_RUBY} or later")
|
|
1443
|
+
end
|
|
1444
|
+
refuse.call("#{repository}'s Gemfile.lock locks no railties: a bootstrap boot needs a Rails app") if railties.nil?
|
|
1445
|
+
if version_below?(railties, SandboxBootSpec::MINIMUM_RAILTIES)
|
|
1446
|
+
refuse.call("#{repository} locks railties #{railties}; the engine needs Rails #{SandboxBootSpec::MINIMUM_RAILTIES} or later")
|
|
1447
|
+
end
|
|
1448
|
+
unless app.join("config", "application.rb").file?
|
|
1449
|
+
refuse.call("#{repository} has no config/application.rb at its root: a bootstrap boot needs the Rails app at the repository root")
|
|
1450
|
+
end
|
|
1451
|
+
|
|
1452
|
+
facts.merge("ruby" => ruby, "railties" => railties,
|
|
1453
|
+
"activeagent" => facts.dig("gems", "activeagent"), "actionagent" => facts.dig("gems", "actionagent"))
|
|
1454
|
+
end
|
|
1455
|
+
|
|
1456
|
+
# What a Gemfile.lock locks: { "ruby" => "3.3.6" or nil, "gems" => { name => version } }.
|
|
1457
|
+
# Read in this process with Bundler's parser, which only parses.
|
|
1458
|
+
def lock_facts(path)
|
|
1459
|
+
parser = ::Bundler::LockfileParser.new(path.read)
|
|
1460
|
+
gems = parser.specs.each_with_object({}) { |spec, all| all[spec.name] ||= spec.version.to_s }
|
|
1461
|
+
{ "ruby" => parser.ruby_version.to_s[/\d+\.\d+(?:\.\d+)?/], "gems" => gems }
|
|
580
1462
|
rescue StandardError => e
|
|
581
|
-
raise Error,
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
1463
|
+
raise Error, "Sandbox preflight failed: the checkout's Gemfile.lock could not be read (#{e.message.lines.first.to_s.strip.truncate(200)})"
|
|
1464
|
+
end
|
|
1465
|
+
|
|
1466
|
+
def ruby_version_file(app)
|
|
1467
|
+
file = app.join(".ruby-version")
|
|
1468
|
+
file.file? ? file.read.lines.first.to_s.strip.delete_prefix("ruby-")[/\A\d+\.\d+(?:\.\d+)?/] : nil
|
|
1469
|
+
end
|
|
1470
|
+
|
|
1471
|
+
def version_below?(version, minimum)
|
|
1472
|
+
Gem::Version.correct?(version) && Gem::Version.new(version) < minimum
|
|
1473
|
+
end
|
|
1474
|
+
|
|
1475
|
+
# Why +step+ is skipped because the checkout defines no Rake task of its
|
|
1476
|
+
# if_task's name; nil when it has none, or the task exists.
|
|
1477
|
+
def missing_task(workspace, step, env, deadline, secrets)
|
|
1478
|
+
return nil unless step.if_task
|
|
1479
|
+
|
|
1480
|
+
app_tasks(workspace, step, env, deadline, secrets).include?(step.if_task) ? nil : "the app defines no #{step.if_task} task"
|
|
1481
|
+
end
|
|
1482
|
+
|
|
1483
|
+
# The checkout's Rake tasks, as `bin/rails -T -A` lists them, once per
|
|
1484
|
+
# boot: the first step that needs them runs it, after the steps before
|
|
1485
|
+
# it installed the bundle.
|
|
1486
|
+
def app_tasks(workspace, step, env, deadline, secrets)
|
|
1487
|
+
return @app_tasks if @app_tasks
|
|
1488
|
+
|
|
1489
|
+
log = log_path(workspace, step.log)
|
|
1490
|
+
File.open(log, "a") { |file| file.puts("$ bin/rails -T -A") }
|
|
1491
|
+
timeout = [ time_left(step_deadline(step, deadline)) || TASK_LIST_TIMEOUT, TASK_LIST_TIMEOUT ].min
|
|
1492
|
+
output, status = capture(env, [ "bin/rails", "-T", "-A" ], chdir: workspace.join("app"), limit: TASK_LIST_BYTES,
|
|
1493
|
+
timeout: [ timeout, 1 ].max, err: [ :child, :out ])
|
|
1494
|
+
unless status&.success?
|
|
1495
|
+
File.open(log, "a") { |file| file.write(SecretScrubber.scrub(output.force_encoding(Encoding::UTF_8).scrub.last(LOG_TAIL_BYTES), secrets)) }
|
|
1496
|
+
fail_step!(step.label, "could not list the app's Rake tasks: `bin/rails -T -A` #{status ? "exited with #{describe(status)}" : "did not finish"}",
|
|
1497
|
+
log, secrets)
|
|
1498
|
+
end
|
|
1499
|
+
|
|
1500
|
+
@app_tasks = output.force_encoding(Encoding::UTF_8).scrub.lines.filter_map { |line| line[/\A\S+\s+([^\s\[#]+)/, 1] }.to_set
|
|
1501
|
+
rescue SystemCallError => e
|
|
1502
|
+
fail_step!(step.label, "could not list the app's Rake tasks: #{e.message}", log, secrets)
|
|
1503
|
+
end
|
|
1504
|
+
|
|
1505
|
+
# When +step+ must be done by: its own timeout or the boot's, whichever
|
|
1506
|
+
# comes first.
|
|
1507
|
+
def step_deadline(step, deadline)
|
|
1508
|
+
step.timeout ? [ deadline, deadline_after(step.timeout) ].min : deadline
|
|
1509
|
+
end
|
|
1510
|
+
|
|
1511
|
+
# How a timeout message names the limit that ran out.
|
|
1512
|
+
def limit_reached(step, step_deadline, deadline)
|
|
1513
|
+
if step&.timeout && step_deadline < deadline
|
|
1514
|
+
"within its #{step.timeout}s timeout"
|
|
1515
|
+
else
|
|
1516
|
+
"within the boot timeout (#{@boot_timeout}s)"
|
|
591
1517
|
end
|
|
592
1518
|
end
|
|
593
1519
|
|
|
@@ -596,9 +1522,9 @@ module ActionAgent
|
|
|
596
1522
|
# or after a restart, drops what is recorded there. Claude Code sessions
|
|
597
1523
|
# get them too, so a `bin/rails db:migrate` a session runs lands in the
|
|
598
1524
|
# sandbox's database rather than the developer's.
|
|
599
|
-
def assign_databases(workspace, app,
|
|
1525
|
+
def assign_databases(workspace, app, overrides, spec)
|
|
600
1526
|
plan = LocalSandboxDatabases.plan(
|
|
601
|
-
app: app, workspace: workspace, session_id: workspace.basename.to_s, overrides:
|
|
1527
|
+
app: app, workspace: workspace, session_id: workspace.basename.to_s, overrides: overrides,
|
|
602
1528
|
fallback_name: spec[:repository].to_s.split("/").last
|
|
603
1529
|
)
|
|
604
1530
|
if plan.notes.any?
|
|
@@ -632,11 +1558,17 @@ module ActionAgent
|
|
|
632
1558
|
app = workspace.join("app")
|
|
633
1559
|
return unless app.join("bin", "rails").file?
|
|
634
1560
|
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
1561
|
+
boot_state = state["boot"]
|
|
1562
|
+
file_env =
|
|
1563
|
+
if boot_state.is_a?(Hash) && boot_state["mode"] == "spec"
|
|
1564
|
+
boot_state.dig("spec", "env").is_a?(Hash) ? boot_state.dig("spec", "env") : {}
|
|
1565
|
+
else
|
|
1566
|
+
begin
|
|
1567
|
+
Config.load(app).env
|
|
1568
|
+
rescue Error
|
|
1569
|
+
{}
|
|
1570
|
+
end
|
|
1571
|
+
end
|
|
640
1572
|
env = self.class.sanitized_environment.merge(file_env).merge(database_env.transform_values(&:to_s)).merge(
|
|
641
1573
|
SESSION_ID_ENV => workspace.basename.to_s,
|
|
642
1574
|
"RAILS_ENV" => state.fetch("database_rails_env"),
|
|
@@ -692,13 +1624,22 @@ module ActionAgent
|
|
|
692
1624
|
update_state(workspace) { |state| state["checkout_commit"] = commit } if status&.success? && COMMIT_ID.match?(commit)
|
|
693
1625
|
end
|
|
694
1626
|
|
|
695
|
-
def run_manifest!(workspace,
|
|
1627
|
+
def run_manifest!(workspace, step, env, deadline, secrets)
|
|
696
1628
|
path = workspace.join("runtime.json")
|
|
697
1629
|
FileUtils.rm_f(path)
|
|
698
|
-
run_step!(workspace,
|
|
1630
|
+
run_step!(workspace, step.label, step.command, env: env, chdir: workspace.join("app"), deadline: deadline, secrets: secrets,
|
|
1631
|
+
timeout: step.timeout, log: step.log)
|
|
699
1632
|
|
|
700
|
-
log = log_path(workspace,
|
|
701
|
-
fail_step!(
|
|
1633
|
+
log = log_path(workspace, step.log)
|
|
1634
|
+
fail_step!(step.label, "`#{step.command}` wrote nothing to $#{SandboxManifest::PATH_ENV}", log, secrets) unless path.file?
|
|
1635
|
+
|
|
1636
|
+
read_manifest!(workspace, step, secrets)
|
|
1637
|
+
end
|
|
1638
|
+
|
|
1639
|
+
def read_manifest!(workspace, step, secrets)
|
|
1640
|
+
path = workspace.join("runtime.json")
|
|
1641
|
+
log = log_path(workspace, step.log)
|
|
1642
|
+
fail_step!(step.label, "there is no manifest to resume from: resume from manifest", log, secrets) unless path.file?
|
|
702
1643
|
|
|
703
1644
|
# Written by the checkout's command, with its umask: the MCP token in
|
|
704
1645
|
# it is for the dashboard alone.
|
|
@@ -707,48 +1648,78 @@ module ActionAgent
|
|
|
707
1648
|
URI.parse("http://127.0.0.1#{manifest["mcp_path"]}")
|
|
708
1649
|
manifest
|
|
709
1650
|
rescue SandboxManifest::Error, URI::InvalidURIError => e
|
|
710
|
-
fail_step!(
|
|
1651
|
+
fail_step!(step.label, e.message, log, secrets)
|
|
711
1652
|
end
|
|
712
1653
|
|
|
713
1654
|
# Runs one boot command to completion in its own process group, with its
|
|
714
|
-
# output in logs/<
|
|
715
|
-
#
|
|
716
|
-
#
|
|
1655
|
+
# output in logs/<log>.log, scrubbed of +secrets+ a line at a time on the
|
|
1656
|
+
# way there. Anything it left running in that group is stopped too: a
|
|
1657
|
+
# setup command is not a way to start services the sandbox never records
|
|
1658
|
+
# (that is what `start` is for). +step+ names it in a failure, and
|
|
1659
|
+
# +timeout+ bounds it within +deadline+.
|
|
717
1660
|
#
|
|
718
|
-
# The group is stopped however the wait ends:
|
|
1661
|
+
# The group is stopped however the wait ends: a deadline, or any
|
|
719
1662
|
# exception at all (a worker shutting down raises into this thread with
|
|
720
1663
|
# Thread#raise, which is no StandardError). While it runs its pid is in
|
|
721
1664
|
# state.json as step_pid, so a terminate after this process itself died
|
|
722
1665
|
# (a crashed dashboard, a killed worker) stops a hung `bundle install`
|
|
723
1666
|
# too; nothing else would, the deadline having died with this process.
|
|
724
|
-
def run_step!(workspace, step, command, env:, chdir:, deadline:, secrets:, label: command)
|
|
725
|
-
log = log_path(workspace,
|
|
726
|
-
File.open(log, "a") { |file| file.puts("$ #{label}") }
|
|
1667
|
+
def run_step!(workspace, step, command, env:, chdir:, deadline:, secrets:, label: command, timeout: nil, log: step)
|
|
1668
|
+
log = log_path(workspace, log)
|
|
1669
|
+
File.open(log, "a") { |file| file.puts("$ #{SecretScrubber.scrub(label, secrets)}") }
|
|
1670
|
+
until_time = timeout ? [ deadline, deadline_after(timeout) ].min : deadline
|
|
727
1671
|
|
|
728
1672
|
pid = nil
|
|
729
1673
|
waiter = nil
|
|
730
1674
|
finished = false
|
|
1675
|
+
reader, writer = IO.pipe
|
|
1676
|
+
copier = nil
|
|
731
1677
|
# Deferred until the group is recorded or stopped, so an interrupt
|
|
732
1678
|
# never lands between the spawn and the ensure that stops it.
|
|
733
1679
|
Thread.handle_interrupt(Object => :never) do
|
|
734
|
-
|
|
1680
|
+
begin
|
|
1681
|
+
pid = spawn_group(env, "sh", "-c", command, chdir: chdir, in: File::NULL, out: writer, err: [ :child, :out ])
|
|
1682
|
+
ensure
|
|
1683
|
+
writer.close
|
|
1684
|
+
end
|
|
735
1685
|
begin
|
|
736
1686
|
Thread.handle_interrupt(Object => :immediate) do
|
|
737
1687
|
record_step(workspace, pid)
|
|
1688
|
+
# Started once the step is recorded: until then the group's
|
|
1689
|
+
# output waits in the pipe.
|
|
1690
|
+
copier = background { copy_output(reader, log, secrets) }
|
|
738
1691
|
waiter = Process.detach(pid)
|
|
739
|
-
finished = waiter.join(time_left(
|
|
1692
|
+
finished = waiter.join(time_left(until_time))
|
|
740
1693
|
end
|
|
741
1694
|
ensure
|
|
742
1695
|
stop_groups([ pid ], grace: finished ? 1 : stop_grace)
|
|
743
1696
|
forget_step(workspace, pid)
|
|
1697
|
+
# What the group wrote last is in the log before its tail is read.
|
|
1698
|
+
copier&.join(OUTPUT_DRAIN_GRACE)
|
|
744
1699
|
end
|
|
745
1700
|
end
|
|
746
1701
|
|
|
747
1702
|
unless finished
|
|
748
|
-
|
|
1703
|
+
limit = timeout && until_time < deadline ? "within its #{timeout}s timeout" : "within the boot timeout (#{@boot_timeout || ActionAgent.local_sandbox_boot_timeout}s)"
|
|
1704
|
+
fail_step!(step, "`#{label}` did not finish #{limit}", log, secrets)
|
|
749
1705
|
end
|
|
750
1706
|
status = waiter.value
|
|
751
1707
|
fail_step!(step, "`#{label}` exited with #{describe(status)}", log, secrets) unless status.success?
|
|
1708
|
+
ensure
|
|
1709
|
+
reader&.close unless reader&.closed?
|
|
1710
|
+
writer&.close unless writer&.closed?
|
|
1711
|
+
end
|
|
1712
|
+
|
|
1713
|
+
# Appends what a boot step prints to +log+, a line at a time, scrubbed.
|
|
1714
|
+
def copy_output(io, log, secrets)
|
|
1715
|
+
File.open(log, "ab") do |file|
|
|
1716
|
+
io.each_line(LOG_LINE_BYTES) do |line|
|
|
1717
|
+
file.write(SecretScrubber.scrub(line.force_encoding(Encoding::UTF_8).scrub, secrets))
|
|
1718
|
+
file.flush
|
|
1719
|
+
end
|
|
1720
|
+
end
|
|
1721
|
+
rescue IOError
|
|
1722
|
+
# Closed under us once the step is over.
|
|
752
1723
|
end
|
|
753
1724
|
|
|
754
1725
|
def record_step(workspace, pid)
|
|
@@ -769,12 +1740,16 @@ module ActionAgent
|
|
|
769
1740
|
# The workspace is gone: nothing left to forget it in.
|
|
770
1741
|
end
|
|
771
1742
|
|
|
772
|
-
|
|
1743
|
+
# The server's output goes to logs/server.log directly, unscrubbed: it
|
|
1744
|
+
# outlives this process, and a pipe would end with it. #log_tail and
|
|
1745
|
+
# #boot_log scrub it when they read it.
|
|
1746
|
+
def start_server(workspace, command, env, port)
|
|
773
1747
|
log = log_path(workspace, "server")
|
|
774
|
-
File.open(log, "a") { |file| file.puts("$ #{
|
|
1748
|
+
File.open(log, "a") { |file| file.puts("$ #{command}") }
|
|
775
1749
|
|
|
776
|
-
pid = spawn_group(env, "sh", "-c",
|
|
1750
|
+
pid = spawn_group(env, "sh", "-c", command,
|
|
777
1751
|
chdir: workspace.join("app"), in: File::NULL, out: [ log.to_s, "a" ], err: [ :child, :out ])
|
|
1752
|
+
@server_pid = pid
|
|
778
1753
|
# Reaped by this thread for as long as the dashboard lives; after a
|
|
779
1754
|
# restart the recorded pid is all that is left, hence state.json.
|
|
780
1755
|
waiter = Process.detach(pid)
|
|
@@ -798,7 +1773,7 @@ module ActionAgent
|
|
|
798
1773
|
# server this sandbox started. The port was free when it was picked, but
|
|
799
1774
|
# nothing held it after: another process can bind it first, and then
|
|
800
1775
|
# answers in the server's place — and would be handed the MCP token.
|
|
801
|
-
def wait_until_ready!(workspace, port, manifest, server_pid, waiter, deadline, secrets)
|
|
1776
|
+
def wait_until_ready!(workspace, port, manifest, server_pid, waiter, deadline, secrets, step:, boot_deadline: deadline)
|
|
802
1777
|
log = log_path(workspace, "server")
|
|
803
1778
|
mcp_path = manifest["mcp_path"]
|
|
804
1779
|
uri = URI.parse("http://127.0.0.1:#{port}#{mcp_path}")
|
|
@@ -808,7 +1783,7 @@ module ActionAgent
|
|
|
808
1783
|
loop do
|
|
809
1784
|
unless waiter.alive?
|
|
810
1785
|
taken = foreign ? " (another process was listening on port #{port})" : ""
|
|
811
|
-
fail_step!(
|
|
1786
|
+
fail_step!(step.label, "the server exited with #{describe(waiter.value)} before it answered on port #{port}#{taken}",
|
|
812
1787
|
log, secrets)
|
|
813
1788
|
end
|
|
814
1789
|
|
|
@@ -826,13 +1801,38 @@ module ActionAgent
|
|
|
826
1801
|
elsif last_status then " (last answer: #{last_status})"
|
|
827
1802
|
else ""
|
|
828
1803
|
end
|
|
829
|
-
fail_step!(
|
|
830
|
-
"
|
|
1804
|
+
fail_step!(step.label, "the server did not answer #{mcp_path} on port #{port} " \
|
|
1805
|
+
"#{limit_reached(step, deadline, boot_deadline)}#{answered}", log, secrets)
|
|
831
1806
|
end
|
|
832
1807
|
sleep POLL_INTERVAL
|
|
833
1808
|
end
|
|
834
1809
|
end
|
|
835
1810
|
|
|
1811
|
+
# Fails the start step when +start_url+ on the booted server answers 5xx,
|
|
1812
|
+
# or never answers. Asked once the MCP facade answered, so the server is
|
|
1813
|
+
# the sandbox's own; anything else (a redirect to sign in, a 404) passes.
|
|
1814
|
+
def probe_start_url!(workspace, port, start_url, waiter, deadline, secrets, step:, boot_deadline: deadline)
|
|
1815
|
+
log = log_path(workspace, "server")
|
|
1816
|
+
uri = URI.parse("http://127.0.0.1:#{port}#{start_url}")
|
|
1817
|
+
loop do
|
|
1818
|
+
read_timeout = [ START_URL_READ_TIMEOUT, time_left(deadline) || START_URL_READ_TIMEOUT ].min
|
|
1819
|
+
status = probe(uri, accept: "text/html,*/*", read_timeout: [ read_timeout, 1 ].max)
|
|
1820
|
+
if status
|
|
1821
|
+
fail_step!(step.label, "GET #{start_url} answered #{status}", log, secrets) if status >= 500
|
|
1822
|
+
return status
|
|
1823
|
+
end
|
|
1824
|
+
unless waiter.alive?
|
|
1825
|
+
fail_step!(step.label, "the server exited with #{describe(waiter.value)} before it answered GET #{start_url}", log, secrets)
|
|
1826
|
+
end
|
|
1827
|
+
if monotonic >= deadline
|
|
1828
|
+
fail_step!(step.label, "GET #{start_url} got no answer #{limit_reached(step, deadline, boot_deadline)}", log, secrets)
|
|
1829
|
+
end
|
|
1830
|
+
sleep POLL_INTERVAL
|
|
1831
|
+
end
|
|
1832
|
+
rescue URI::InvalidURIError
|
|
1833
|
+
fail_step!(step.label, "#{start_url} is not a path this backend can request", log, secrets)
|
|
1834
|
+
end
|
|
1835
|
+
|
|
836
1836
|
# Whether what listens on +uri+'s port is the sandbox's server (process
|
|
837
1837
|
# group +pgid+). Asked of the system where it can say (/proc on Linux,
|
|
838
1838
|
# lsof elsewhere); the token is never sent before. Where it cannot say,
|
|
@@ -938,14 +1938,14 @@ module ActionAgent
|
|
|
938
1938
|
nil
|
|
939
1939
|
end
|
|
940
1940
|
|
|
941
|
-
# The HTTP status a GET on
|
|
942
|
-
#
|
|
943
|
-
def probe(uri)
|
|
1941
|
+
# The HTTP status a GET on +uri+ answers with, or nil while nothing
|
|
1942
|
+
# answers.
|
|
1943
|
+
def probe(uri, accept: "application/json", read_timeout: 2)
|
|
944
1944
|
# No proxy: the dashboard's HTTP(S)_PROXY does not know this loopback.
|
|
945
1945
|
http = Net::HTTP.new(uri.host, uri.port, nil)
|
|
946
1946
|
http.open_timeout = 1
|
|
947
|
-
http.read_timeout =
|
|
948
|
-
http.start { |connection| connection.request(Net::HTTP::Get.new(uri, "Accept" =>
|
|
1947
|
+
http.read_timeout = read_timeout
|
|
1948
|
+
http.start { |connection| connection.request(Net::HTTP::Get.new(uri, "Accept" => accept)).code.to_i }
|
|
949
1949
|
rescue SystemCallError, IOError, Timeout::Error, Net::HTTPBadResponse
|
|
950
1950
|
nil
|
|
951
1951
|
end
|
|
@@ -1002,6 +2002,11 @@ module ActionAgent
|
|
|
1002
2002
|
# that would prompt is denied instead of stalling the session.
|
|
1003
2003
|
argv += [ "--permission-prompts", "none" ] if permission_prompts_supported?(command)
|
|
1004
2004
|
argv += [ "--max-turns", ActionAgent.claude_code_max_turns.to_i.to_s ] if ActionAgent.claude_code_max_turns.present?
|
|
2005
|
+
# A boot fix may run the few commands it needs to re-resolve the
|
|
2006
|
+
# checkout's dependencies (ProjectBootFix::BUNDLER_COMMANDS); every
|
|
2007
|
+
# other session runs none.
|
|
2008
|
+
allowed = ProjectBootFix.allowed_tools_for(code_session)
|
|
2009
|
+
argv += [ "--allowedTools", allowed.join(",") ] if allowed.any?
|
|
1005
2010
|
|
|
1006
2011
|
if (model = code_session.model.presence)
|
|
1007
2012
|
# One argv entry, but one that must not read as a flag.
|
|
@@ -1252,20 +2257,17 @@ module ActionAgent
|
|
|
1252
2257
|
app = workspace.join("app")
|
|
1253
2258
|
env = self.class.sanitized_environment
|
|
1254
2259
|
git = git_command(app)
|
|
1255
|
-
#
|
|
1256
|
-
|
|
1257
|
-
# dashboard's user. Rather than run it, report no diff.
|
|
1258
|
-
drivers, = capture(env, [ *git, "config", "--local", "--includes", "--name-only", "--get-regexp", "^filter\\." ],
|
|
1259
|
-
chdir: app, limit: 64 * 1024, timeout: GIT_TIMEOUT)
|
|
1260
|
-
if drivers.to_s.strip.present?
|
|
2260
|
+
# `git add` and `git diff` would run a filter driver's command.
|
|
2261
|
+
if filter_drivers?(app, env, git)
|
|
1261
2262
|
return "(diff not recorded: the checkout's git config defines filter drivers, which would run commands)"
|
|
1262
2263
|
end
|
|
1263
|
-
|
|
2264
|
+
exclusions = SandboxCredentialPaths.pathspecs
|
|
2265
|
+
capture(env, [ *git, "add", "--intent-to-add", "--all", "--", ".", *exclusions ], chdir: app, limit: 64 * 1024, timeout: GIT_TIMEOUT)
|
|
1264
2266
|
|
|
1265
2267
|
base = read_state(workspace)["checkout_commit"]
|
|
1266
2268
|
base = "HEAD" unless base.is_a?(String) && COMMIT_ID.match?(base)
|
|
1267
2269
|
diff = ->(commit) do
|
|
1268
|
-
capture(env, [ *git, "diff", "--no-color", "--no-ext-diff", "--no-textconv", commit, "--" ],
|
|
2270
|
+
capture(env, [ *git, "diff", "--no-color", "--no-ext-diff", "--no-textconv", "--ignore-submodules=dirty", commit, "--", ".", *exclusions ],
|
|
1269
2271
|
chdir: app, limit: MAX_DIFF_BYTES, timeout: GIT_TIMEOUT)
|
|
1270
2272
|
end
|
|
1271
2273
|
output, status = diff.call(base)
|
|
@@ -1300,13 +2302,16 @@ module ActionAgent
|
|
|
1300
2302
|
credentials
|
|
1301
2303
|
end
|
|
1302
2304
|
|
|
2305
|
+
# What a code session's output is scrubbed of. The project's secrets are
|
|
2306
|
+
# among them although the session never gets them: the checkout's own
|
|
2307
|
+
# files and processes can still show them.
|
|
1303
2308
|
def sandbox_secrets(sandbox, credentials)
|
|
1304
2309
|
token = begin
|
|
1305
2310
|
sandbox.checkout_spec&.dig(:token)
|
|
1306
2311
|
rescue StandardError
|
|
1307
2312
|
nil
|
|
1308
2313
|
end
|
|
1309
|
-
[ token, *credentials.values ].compact.map(&:to_s)
|
|
2314
|
+
[ token, *credentials.values, *Array(sandbox.try(:project_scrub_values)) ].compact.map(&:to_s)
|
|
1310
2315
|
end
|
|
1311
2316
|
|
|
1312
2317
|
# Drops the session's pid, and the cancels it may have left.
|
|
@@ -1325,6 +2330,133 @@ module ActionAgent
|
|
|
1325
2330
|
status.exitstatus || (status.termsig ? 128 + status.termsig : 1)
|
|
1326
2331
|
end
|
|
1327
2332
|
|
|
2333
|
+
# --- Browser ------------------------------------------------------------
|
|
2334
|
+
|
|
2335
|
+
BROWSER_DIR = /\Abrowser-\h+\z/
|
|
2336
|
+
|
|
2337
|
+
# Spawns the sidecar in a directory of its own under the workspace, hands
|
|
2338
|
+
# it its configuration and waits for the line it prints once it listens.
|
|
2339
|
+
def run_browser(workspace, session_id, mode, launch)
|
|
2340
|
+
dir = workspace.join("browser-#{SecureRandom.hex(4)}")
|
|
2341
|
+
FileUtils.mkdir_p(dir, mode: 0o700)
|
|
2342
|
+
secrets = [ launch[:token], launch.dig(:recording, :token) ].compact
|
|
2343
|
+
env = self.class.sanitized_environment.merge(BrowserSidecar.environment).merge(SESSION_ID_ENV => session_id)
|
|
2344
|
+
log = log_path(workspace, "browser")
|
|
2345
|
+
FileUtils.mkdir_p(log.dirname)
|
|
2346
|
+
stdin_read, stdin_write = IO.pipe
|
|
2347
|
+
stdout_read, stdout_write = IO.pipe
|
|
2348
|
+
|
|
2349
|
+
begin
|
|
2350
|
+
pid = spawn_group(env, *BrowserSidecar.command("serve"), chdir: dir, in: stdin_read, out: stdout_write, err: [ log.to_s, "a" ])
|
|
2351
|
+
rescue SystemCallError => e
|
|
2352
|
+
raise Error, "Could not start the browser sidecar (#{ActionAgent.node_command}): #{e.message}"
|
|
2353
|
+
ensure
|
|
2354
|
+
[ stdin_read, stdout_write ].each(&:close)
|
|
2355
|
+
end
|
|
2356
|
+
Process.detach(pid)
|
|
2357
|
+
browser = { "pid" => pid, "dir" => dir.basename.to_s, "mode" => mode }
|
|
2358
|
+
started = false
|
|
2359
|
+
|
|
2360
|
+
begin
|
|
2361
|
+
terminating = nil
|
|
2362
|
+
update_state(workspace, create: false) do |state|
|
|
2363
|
+
state["browser"] = browser
|
|
2364
|
+
record_process_start(state, pid)
|
|
2365
|
+
terminating = state["terminating"]
|
|
2366
|
+
end
|
|
2367
|
+
raise Error, "The sandbox is being stopped, so its browser did not start" if terminating
|
|
2368
|
+
|
|
2369
|
+
begin
|
|
2370
|
+
stdin_write.write(JSON.generate(browser_config(launch, mode, dir)))
|
|
2371
|
+
stdin_write.close
|
|
2372
|
+
rescue Errno::EPIPE
|
|
2373
|
+
# It exited before reading its configuration; its log says why.
|
|
2374
|
+
end
|
|
2375
|
+
ready = await_browser_ready(stdout_read, log, secrets)
|
|
2376
|
+
unless BrowserSidecar.acceptable_version?(ready["version"].to_s)
|
|
2377
|
+
raise Error, "The browser sidecar is version #{ready["version"]}, and this dashboard needs #{ActionAgent::VERSION}; " \
|
|
2378
|
+
"run #{BrowserSidecar::INSTALL_COMMAND}"
|
|
2379
|
+
end
|
|
2380
|
+
|
|
2381
|
+
port = Integer(ready["port"])
|
|
2382
|
+
update_state(workspace, create: false) { |state| state["browser"]["port"] = port if state["browser"].is_a?(Hash) }
|
|
2383
|
+
started = true
|
|
2384
|
+
{ mcp_url: "http://127.0.0.1:#{port}/mcp", mcp_token: launch[:token], live_url: ("ws://127.0.0.1:#{port}/live" if launch[:live]) }.compact
|
|
2385
|
+
rescue Errno::ENOENT
|
|
2386
|
+
raise Error, "Sandbox #{session_id} was stopped while its browser started"
|
|
2387
|
+
ensure
|
|
2388
|
+
[ stdin_write, stdout_read ].each { |io| io.close unless io.closed? }
|
|
2389
|
+
unless started
|
|
2390
|
+
stop_groups([ pid ], grace: 1)
|
|
2391
|
+
forget_browser(workspace, browser)
|
|
2392
|
+
end
|
|
2393
|
+
end
|
|
2394
|
+
end
|
|
2395
|
+
|
|
2396
|
+
def browser_config(launch, mode, dir)
|
|
2397
|
+
recording = launch[:recording]
|
|
2398
|
+
{
|
|
2399
|
+
token: launch[:token],
|
|
2400
|
+
app_url: launch[:app_url],
|
|
2401
|
+
mode: mode,
|
|
2402
|
+
capabilities: Array(launch[:capabilities]).map(&:to_s),
|
|
2403
|
+
host: "127.0.0.1",
|
|
2404
|
+
port: 0,
|
|
2405
|
+
workdir: dir.to_s,
|
|
2406
|
+
stop_at: launch[:stop_at] && (launch[:stop_at].to_f * 1000).floor,
|
|
2407
|
+
recording: recording && recording.slice(:url, :token, :batch_events, :batch_bytes),
|
|
2408
|
+
live: launch[:live] && launch[:live].slice(:session_id, :origins),
|
|
2409
|
+
storage_state: launch[:storage_state]
|
|
2410
|
+
}.compact
|
|
2411
|
+
end
|
|
2412
|
+
|
|
2413
|
+
# The JSON line the sidecar prints once it listens. Raises with the tail
|
|
2414
|
+
# of its log when it exits first or takes longer than
|
|
2415
|
+
# ActionAgent.browser_start_timeout.
|
|
2416
|
+
def await_browser_ready(io, log, secrets)
|
|
2417
|
+
deadline = deadline_after(ActionAgent.browser_start_timeout)
|
|
2418
|
+
line = String.new(encoding: Encoding::BINARY)
|
|
2419
|
+
until line.include?("\n")
|
|
2420
|
+
left = time_left(deadline)
|
|
2421
|
+
unless left.nil? || left.positive?
|
|
2422
|
+
raise Error, "The browser did not start within #{ActionAgent.browser_start_timeout}s#{browser_log_tail(log, secrets)}"
|
|
2423
|
+
end
|
|
2424
|
+
next unless io.wait_readable([ left || POLL_INTERVAL, POLL_INTERVAL ].min)
|
|
2425
|
+
|
|
2426
|
+
chunk = io.read_nonblock(4096, exception: false)
|
|
2427
|
+
raise Error, "The browser sidecar exited before it started#{browser_log_tail(log, secrets)}" if chunk.nil?
|
|
2428
|
+
|
|
2429
|
+
line << chunk unless chunk == :wait_readable
|
|
2430
|
+
end
|
|
2431
|
+
|
|
2432
|
+
ready = JSON.parse(line.lines.first)
|
|
2433
|
+
raise Error, "The browser sidecar answered #{line.lines.first.strip.inspect}" unless ready.is_a?(Hash) && ready["ready"]
|
|
2434
|
+
|
|
2435
|
+
ready
|
|
2436
|
+
rescue JSON::ParserError
|
|
2437
|
+
raise Error, "The browser sidecar answered #{line.lines.first.to_s.strip.truncate(200).inspect}"
|
|
2438
|
+
end
|
|
2439
|
+
|
|
2440
|
+
def browser_log_tail(log, secrets)
|
|
2441
|
+
tail = log_tail(log, secrets)
|
|
2442
|
+
tail.present? ? ":\n#{tail}" : ""
|
|
2443
|
+
end
|
|
2444
|
+
|
|
2445
|
+
# Removes the browser's directory and its entry in state.json, if the
|
|
2446
|
+
# entry is still +browser+'s.
|
|
2447
|
+
def forget_browser(workspace, browser)
|
|
2448
|
+
dir = browser["dir"].to_s
|
|
2449
|
+
FileUtils.rm_rf(workspace.join(dir)) if BROWSER_DIR.match?(dir)
|
|
2450
|
+
update_state(workspace, create: false) do |state|
|
|
2451
|
+
next unless state["browser"].is_a?(Hash) && state["browser"]["pid"] == browser["pid"]
|
|
2452
|
+
|
|
2453
|
+
state.delete("browser")
|
|
2454
|
+
state_hash(state, "process_starts").delete(browser["pid"].to_s)
|
|
2455
|
+
end
|
|
2456
|
+
rescue Errno::ENOENT
|
|
2457
|
+
# Terminated meanwhile: the workspace, and its state, are gone.
|
|
2458
|
+
end
|
|
2459
|
+
|
|
1328
2460
|
# --- Processes ----------------------------------------------------------
|
|
1329
2461
|
|
|
1330
2462
|
# Every sandbox process leads its own process group, so stopping one
|
|
@@ -1517,11 +2649,14 @@ module ActionAgent
|
|
|
1517
2649
|
{}
|
|
1518
2650
|
end
|
|
1519
2651
|
sessions = state["code_sessions"].is_a?(Hash) ? state["code_sessions"].values : []
|
|
1520
|
-
|
|
2652
|
+
browser = state["browser"]["pid"] if state["browser"].is_a?(Hash)
|
|
2653
|
+
recorded = [ state["pid"], state["step_pid"], browser, *sessions ].uniq.select { |pid| signalable?(pid) }
|
|
1521
2654
|
identities = recorded.index_with { |pid| group_identity(pid, session_id, state) }
|
|
1522
2655
|
stop_groups(recorded.select { |pid| identities[pid] == :ours })
|
|
1523
2656
|
stop_escaped(session_id)
|
|
1524
2657
|
|
|
2658
|
+
logout_claude_workspace(workspace) if state["claude_login_pid"] || ClaudeCodeAuth.sandbox_login?
|
|
2659
|
+
|
|
1525
2660
|
left = recorded.select { |pid| identities[pid] != :stranger && group_alive?(pid) }
|
|
1526
2661
|
if left.any?
|
|
1527
2662
|
Rails.logger.error("[ActionAgent] sandbox #{session_id}: process groups #{left.join(", ")} are still alive and " \
|