actionagent 1.8.0 → 1.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (168) hide show
  1. checksums.yaml +4 -4
  2. data/app/assets/builds/action_agent.css +1 -1
  3. data/app/assets/builds/action_agent.js +76 -73
  4. data/app/assets/builds/action_agent_recorder.js +78 -0
  5. data/app/assets/builds/action_agent_replay.js +226 -0
  6. data/app/controllers/action_agent/api/agent_runs_controller.rb +3 -1
  7. data/app/controllers/action_agent/api/agents_controller.rb +1 -0
  8. data/app/controllers/action_agent/api/api_keys_controller.rb +12 -2
  9. data/app/controllers/action_agent/api/base_controller.rb +63 -2
  10. data/app/controllers/action_agent/api/claude_logins_controller.rb +93 -0
  11. data/app/controllers/action_agent/api/code_sessions_controller.rb +71 -12
  12. data/app/controllers/action_agent/api/dashboard_assistant_controller.rb +2 -2
  13. data/app/controllers/action_agent/api/draft_pull_requests_controller.rb +248 -0
  14. data/app/controllers/action_agent/api/evaluation_fixes_controller.rb +27 -0
  15. data/app/controllers/action_agent/api/evaluations_controller.rb +58 -7
  16. data/app/controllers/action_agent/api/explorations_controller.rb +213 -0
  17. data/app/controllers/action_agent/api/github_app_manifests_controller.rb +125 -0
  18. data/app/controllers/action_agent/api/github_connections_controller.rb +40 -2
  19. data/app/controllers/action_agent/api/github_installations_controller.rb +280 -0
  20. data/app/controllers/action_agent/api/input_requests_controller.rb +77 -0
  21. data/app/controllers/action_agent/api/mcp_controller.rb +46 -12
  22. data/app/controllers/action_agent/api/members_controller.rb +36 -0
  23. data/app/controllers/action_agent/api/project_explorations_controller.rb +80 -0
  24. data/app/controllers/action_agent/api/project_install_pull_requests_controller.rb +305 -0
  25. data/app/controllers/action_agent/api/project_secrets_controller.rb +96 -0
  26. data/app/controllers/action_agent/api/project_setup_controller.rb +151 -0
  27. data/app/controllers/action_agent/api/project_sign_ins_controller.rb +112 -0
  28. data/app/controllers/action_agent/api/projects_controller.rb +512 -0
  29. data/app/controllers/action_agent/api/provider_keys_controller.rb +176 -24
  30. data/app/controllers/action_agent/api/provider_models_controller.rb +37 -15
  31. data/app/controllers/action_agent/api/recording_event_ingest_controller.rb +67 -0
  32. data/app/controllers/action_agent/api/sandbox_browser_tickets_controller.rb +50 -0
  33. data/app/controllers/action_agent/api/sandbox_browsers_controller.rb +65 -0
  34. data/app/controllers/action_agent/api/sandboxes_controller.rb +148 -8
  35. data/app/controllers/action_agent/api/scenario_catalog_entries_controller.rb +188 -0
  36. data/app/controllers/action_agent/api/scenario_catalogs_controller.rb +302 -0
  37. data/app/controllers/action_agent/api/session_recordings_controller.rb +165 -34
  38. data/app/controllers/action_agent/api/sessions_controller.rb +111 -0
  39. data/app/controllers/action_agent/api/telemetry_keys_controller.rb +21 -0
  40. data/app/controllers/action_agent/dashboard_controller.rb +17 -6
  41. data/app/controllers/action_agent/session_player_controller.rb +58 -0
  42. data/app/controllers/concerns/action_agent/api/agent_serialization.rb +1 -0
  43. data/app/controllers/concerns/action_agent/api/mcp_dashboard_tools.rb +627 -20
  44. data/app/controllers/concerns/action_agent/api/project_browser.rb +46 -0
  45. data/app/controllers/concerns/action_agent/api/project_secret_authorization.rb +57 -0
  46. data/app/jobs/action_agent/agent_execution_job.rb +9 -39
  47. data/app/jobs/action_agent/agent_resume_job.rb +84 -0
  48. data/app/jobs/action_agent/code_session_job.rb +39 -10
  49. data/app/jobs/action_agent/draft_pull_request_job.rb +43 -0
  50. data/app/jobs/action_agent/exploration_job.rb +80 -0
  51. data/app/jobs/action_agent/input_request_expiry_job.rb +22 -0
  52. data/app/jobs/action_agent/project_evaluation_job.rb +59 -0
  53. data/app/jobs/action_agent/project_setup_job.rb +21 -0
  54. data/app/jobs/action_agent/sandbox_cleanup_job.rb +20 -3
  55. data/app/jobs/action_agent/sandbox_provision_job.rb +77 -10
  56. data/app/jobs/action_agent/sandbox_run_job.rb +9 -37
  57. data/app/jobs/action_agent/verify_evaluation_fix_job.rb +37 -0
  58. data/app/models/action_agent/agent.rb +72 -26
  59. data/app/models/action_agent/agent_run.rb +134 -23
  60. data/app/models/action_agent/agent_template.rb +35 -1
  61. data/app/models/action_agent/api_key.rb +14 -0
  62. data/app/models/action_agent/catalog_scenario.rb +75 -0
  63. data/app/models/action_agent/code_session.rb +21 -5
  64. data/app/models/action_agent/draft_pull_request.rb +131 -0
  65. data/app/models/action_agent/evaluation.rb +148 -0
  66. data/app/models/action_agent/evaluation_run.rb +143 -53
  67. data/app/models/action_agent/evaluation_scenario_result.rb +15 -3
  68. data/app/models/action_agent/exploration.rb +847 -0
  69. data/app/models/action_agent/github_installation.rb +159 -0
  70. data/app/models/action_agent/input_request.rb +291 -0
  71. data/app/models/action_agent/model_pricing.rb +214 -34
  72. data/app/models/action_agent/project.rb +789 -0
  73. data/app/models/action_agent/project_secret.rb +339 -0
  74. data/app/models/action_agent/provider_key.rb +207 -4
  75. data/app/models/action_agent/recording_event.rb +145 -0
  76. data/app/models/action_agent/recording_snapshot.rb +7 -7
  77. data/app/models/action_agent/sandbox_session.rb +300 -16
  78. data/app/models/action_agent/scenario_catalog.rb +172 -0
  79. data/app/models/action_agent/scenario_product.rb +57 -0
  80. data/app/models/action_agent/scenario_set.rb +138 -0
  81. data/app/models/action_agent/session_recording.rb +121 -14
  82. data/app/models/action_agent/telemetry_trace.rb +74 -19
  83. data/app/models/concerns/action_agent/ownable.rb +84 -13
  84. data/app/serializers/action_agent/evaluation_serializer.rb +41 -10
  85. data/app/serializers/action_agent/input_request_serializer.rb +47 -0
  86. data/app/serializers/action_agent/scenario_catalog_serializer.rb +23 -0
  87. data/app/serializers/action_agent/session_summary_serializer.rb +146 -0
  88. data/app/services/action_agent/agent_execution_service.rb +450 -72
  89. data/app/services/action_agent/agent_scorecard.rb +50 -21
  90. data/app/services/action_agent/agent_sync.rb +5 -2
  91. data/app/services/action_agent/agent_tool_roster.rb +19 -4
  92. data/app/services/action_agent/agent_toolbox.rb +215 -9
  93. data/app/services/action_agent/browser_live_ticket.rb +78 -0
  94. data/app/services/action_agent/browser_sidecar.rb +213 -0
  95. data/app/services/action_agent/browser_sign_in.rb +163 -0
  96. data/app/services/action_agent/browser_tool_redaction.rb +92 -0
  97. data/app/services/action_agent/claude_code_auth.rb +70 -4
  98. data/app/services/action_agent/codex_session_events.rb +37 -0
  99. data/app/services/action_agent/dashboard_assistant_service.rb +24 -10
  100. data/app/services/action_agent/draft_pull_request_publisher.rb +719 -0
  101. data/app/services/action_agent/evaluation_fix.rb +101 -0
  102. data/app/services/action_agent/evaluation_fix_comparison.rb +66 -0
  103. data/app/services/action_agent/evaluation_report_import.rb +84 -5
  104. data/app/services/action_agent/evaluation_run_cost.rb +609 -0
  105. data/app/services/action_agent/evaluation_runner_service.rb +29 -10
  106. data/app/services/action_agent/evaluation_standing.rb +152 -0
  107. data/app/services/action_agent/explorer_execution_service.rb +517 -0
  108. data/app/services/action_agent/github_client.rb +405 -64
  109. data/app/services/action_agent/live_updates.rb +31 -0
  110. data/app/services/action_agent/local_sandbox_backend.rb +1263 -102
  111. data/app/services/action_agent/local_sandbox_claude_login.rb +151 -0
  112. data/app/services/action_agent/mcp_client.rb +6 -2
  113. data/app/services/action_agent/mcp_recording_middleware.rb +104 -145
  114. data/app/services/action_agent/mcp_tool_dispatcher.rb +36 -13
  115. data/app/services/action_agent/mock_sandbox_backend.rb +3 -1
  116. data/app/services/action_agent/playwright_mcp_client.rb +7 -0
  117. data/app/services/action_agent/project_boot_fix.rb +309 -0
  118. data/app/services/action_agent/project_capabilities.rb +200 -0
  119. data/app/services/action_agent/project_install_pull_request.rb +225 -0
  120. data/app/services/action_agent/project_preflight.rb +178 -0
  121. data/app/services/action_agent/project_secret_discovery.rb +142 -0
  122. data/app/services/action_agent/project_setup.rb +429 -0
  123. data/app/services/action_agent/provider_credentials.rb +102 -0
  124. data/app/services/action_agent/recording_event_ingest.rb +181 -0
  125. data/app/services/action_agent/runtime_tool_roster.rb +52 -0
  126. data/app/services/action_agent/sample_data/conference_ticket.rb +516 -0
  127. data/app/services/action_agent/sandbox_boot_spec.rb +558 -0
  128. data/app/services/action_agent/sandbox_browser.rb +299 -0
  129. data/app/services/action_agent/sandbox_browser_driver.rb +85 -0
  130. data/app/services/action_agent/sandbox_credential_paths.rb +26 -0
  131. data/app/services/action_agent/sandbox_mail.rb +103 -0
  132. data/app/services/action_agent/sandbox_manifest.rb +166 -13
  133. data/app/services/action_agent/sandbox_orchestrator.rb +290 -23
  134. data/app/services/action_agent/sandbox_patch.rb +253 -0
  135. data/app/services/action_agent/scenario_catalog_import.rb +255 -0
  136. data/app/services/action_agent/scenario_catalog_repository_import.rb +81 -0
  137. data/app/services/action_agent/scenario_evaluation_runner.rb +121 -17
  138. data/app/services/action_agent/secret_scrubber.rb +33 -4
  139. data/app/services/action_agent/session_index.rb +270 -0
  140. data/app/services/action_agent/session_recording_service.rb +11 -0
  141. data/app/services/action_agent/session_timeline.rb +505 -0
  142. data/app/views/action_agent/github_app_manifests/created.html.erb +55 -0
  143. data/app/views/action_agent/session_player/show.html.erb +19 -0
  144. data/config/routes.rb +153 -2
  145. data/lib/action_agent/claude_login_process.rb +107 -0
  146. data/lib/action_agent/engine.rb +55 -5
  147. data/lib/action_agent/secret_requests.rb +80 -0
  148. data/lib/action_agent/version.rb +1 -1
  149. data/lib/action_agent.rb +513 -22
  150. data/lib/generators/action_agent/install_generator.rb +45 -5
  151. data/lib/generators/action_agent/templates/action_agent.rb.erb +161 -0
  152. data/lib/generators/action_agent/templates/add_code_session_runner.rb.erb +9 -0
  153. data/lib/generators/action_agent/templates/migrations/015_add_provider_key_scope.rb.erb +72 -0
  154. data/lib/generators/action_agent/templates/migrations/016_create_active_agent_input_requests.rb.erb +85 -0
  155. data/lib/generators/action_agent/templates/migrations/017_create_active_agent_recording_events.rb.erb +57 -0
  156. data/lib/generators/action_agent/templates/migrations/018_add_browser_to_active_agent_sandbox_sessions.rb.erb +25 -0
  157. data/lib/generators/action_agent/templates/migrations/019_create_active_agent_projects.rb.erb +79 -0
  158. data/lib/generators/action_agent/templates/migrations/020_add_github_app_installations.rb.erb +62 -0
  159. data/lib/generators/action_agent/templates/migrations/021_create_active_agent_explorations.rb.erb +63 -0
  160. data/lib/generators/action_agent/templates/migrations/023_create_active_agent_draft_pull_requests.rb.erb +75 -0
  161. data/lib/generators/action_agent/templates/migrations/024_add_kind_to_active_agent_project_secrets.rb.erb +14 -0
  162. data/lib/generators/action_agent/templates/migrations/025_create_active_agent_scenario_catalogs.rb.erb +110 -0
  163. data/lib/generators/action_agent/templates/migrations/026_add_evaluation_fixes_to_code_sessions.rb.erb +21 -0
  164. data/lib/tasks/action_agent.rake +33 -0
  165. data/lib/tasks/browser.rake +22 -0
  166. data/lib/tasks/sandbox.rake +1 -1
  167. data/lib/tasks/scenario_catalogs.rake +71 -0
  168. 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, and
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.
@@ -141,7 +175,7 @@ module ActionAgent
141
175
  # ANTHROPIC_BASE_URL; a session inheriting those joins the developer's
142
176
  # session, and a base URL redirects the owner's credential. A session
143
177
  # gets exactly the Claude Code variables the backend sets.
144
- \A(?:ANTHROPIC|CLAUDE|OPENAI|OPEN_AI|OPENROUTER|OPEN_ROUTER|OLLAMA)(?:_|\z) | \ACLAUDECODE\z
178
+ \A(?:ANTHROPIC|CLAUDE|CODEX|OPENAI|OPEN_AI|OPENROUTER|OPEN_ROUTER|OLLAMA)(?:_|\z) | \ACLAUDECODE\z
145
179
  /x
146
180
  SECRET_VARIABLE = /
147
181
  SECRET | TOKEN | PASSWORD | PASSWD | PASSPHRASE | API_KEY | APIKEY | PRIVATE_KEY | CREDENTIAL | ACCESS_KEY |
@@ -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, and waits
366
- # until its MCP facade answers.
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
@@ -436,7 +585,50 @@ module ActionAgent
436
585
  0
437
586
  end
438
587
 
439
- # Runs Claude Code headless in the sandbox's checkout, yielding each
588
+ def code_runners
589
+ %w[claude_code codex]
590
+ end
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
+
631
+ # Runs a coding agent headless in the sandbox's checkout, yielding each
440
632
  # stream-json event (a Hash, already scrubbed of the sandbox's secrets)
441
633
  # as it arrives.
442
634
  #
@@ -448,9 +640,18 @@ module ActionAgent
448
640
  app = workspace.join("app")
449
641
  raise Error, "Sandbox #{session_id} has no local checkout: start the sandbox again" unless app.directory?
450
642
 
451
- credentials = session_credentials(sandbox)
643
+ runner = code_session.try(:runner) || "claude_code"
644
+ raise Error, "Unsupported code runner: #{runner}" unless code_runners.include?(runner)
645
+
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)
452
653
  secrets = sandbox_secrets(sandbox, credentials)
453
- argv = claude_argv(code_session)
654
+ argv = runner == "codex" ? codex_argv(code_session) : claude_argv(code_session)
454
655
  database_env = read_state(workspace)["database_env"]
455
656
  env = self.class.sanitized_environment
456
657
  .merge(database_env.is_a?(Hash) ? database_env.transform_values(&:to_s) : {})
@@ -466,12 +667,20 @@ module ActionAgent
466
667
  # own, in the workspace. With the machine's own login it must use the
467
668
  # user's: that is where `claude /login` left the credentials (HOME,
468
669
  # which the sanitized environment keeps, or the keychain).
469
- unless ClaudeCodeAuth.local_login?
470
- env["CLAUDE_CONFIG_DIR"] = workspace.join("claude").to_s
471
- FileUtils.mkdir_p(workspace.join("claude"), mode: 0o700)
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"
675
+ env["CODEX_HOME"] = workspace.join("codex").to_s
676
+ FileUtils.mkdir_p(workspace.join("codex"), mode: 0o700)
677
+ elsif !ClaudeCodeAuth.local_login?
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)
472
681
  end
473
682
 
474
- run_claude(workspace, code_session, argv, env, secrets, &on_event)
683
+ run_claude(workspace, code_session, argv, env, secrets, runner: runner, &on_event)
475
684
  end
476
685
 
477
686
  # Stops a running Claude Code session: SIGTERM to its process group. The
@@ -512,8 +721,177 @@ module ActionAgent
512
721
  true
513
722
  end
514
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
+
515
865
  private
516
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
+
517
895
  def ensure_enabled!
518
896
  return if ActionAgent.local_sandboxes_enabled?
519
897
 
@@ -521,28 +899,314 @@ module ActionAgent
521
899
  "set ActionAgent.local_sandboxes_enabled = true to allow them"
522
900
  end
523
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
+
524
1074
  # --- Boot -------------------------------------------------------------
525
1075
 
526
- def boot(session_id, spec, secrets)
1076
+ def boot(session_id, checkout, secrets, boot_spec = nil)
527
1077
  workspace = workspace_for(session_id)
528
- deadline = deadline_after(ActionAgent.local_sandbox_boot_timeout)
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)
529
1084
  booted = false
530
- server_pid = nil
1085
+ keep = false
531
1086
 
532
1087
  prepare_workspace(workspace)
533
- checkout!(workspace, spec, secrets, deadline)
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) }
534
1092
 
535
1093
  app = workspace.join("app")
536
- config = Config.load(app)
537
- databases = assign_databases(workspace, app, config, spec)
538
- env = self.class.sanitized_environment.merge(databases).merge(config.env).merge(
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(
539
1186
  # Merged after the file's env, so a checkout cannot move them.
540
1187
  SandboxManifest::PATH_ENV => workspace.join("runtime.json").to_s,
541
- SESSION_ID_ENV => session_id
1188
+ SESSION_ID_ENV => workspace.basename.to_s,
1189
+ SandboxMail::DIRECTORY_ENV => SandboxMail::DIRECTORY
542
1190
  )
1191
+ end
1192
+
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
543
1206
 
544
- config.setup.each do |command|
545
- run_step!(workspace, "setup", command, env: env, chdir: app, deadline: deadline, secrets: secrets)
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
546
1210
  end
547
1211
 
548
1212
  # Picked after setup, which can take minutes, so that the port is
@@ -551,33 +1215,305 @@ module ActionAgent
551
1215
  # of the server's own process group.
552
1216
  port = free_port
553
1217
  env = env.merge("PORT" => port.to_s)
554
- manifest = run_manifest!(workspace, config, env, deadline, secrets)
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
555
1224
 
556
- server_pid, waiter = start_server(workspace, config, env, port)
557
- wait_until_ready!(workspace, port, manifest, server_pid, waiter, deadline, secrets)
558
- booted = true
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
559
1231
 
560
1232
  {
561
- container_name: "#{HANDLE_PREFIX}#{session_id}",
1233
+ container_name: "#{HANDLE_PREFIX}#{workspace.basename}",
562
1234
  url: "http://127.0.0.1:#{port}",
563
1235
  container_ip: "127.0.0.1",
564
1236
  mcp_url: "http://127.0.0.1:#{port}#{manifest["mcp_path"]}",
565
1237
  mcp_token: manifest["mcp_token"],
1238
+ app_models: manifest["models"],
566
1239
  created_at: Time.current
567
1240
  }
568
- rescue Error
569
- raise
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 }
570
1462
  rescue StandardError => e
571
- raise Error, SecretScrubber.scrub("Sandbox boot failed: #{e.class.name}: #{e.message}", secrets)
572
- ensure
573
- # A failed boot leaves nothing running and nothing on disk: no handle
574
- # was reported, so nothing would ever reap it. The error carries the
575
- # failing step's log tail.
576
- unless booted
577
- stop_groups([ server_pid ].compact)
578
- # A setup that got as far as db:prepare created the databases.
579
- drop_databases(workspace) if workspace
580
- remove_workspace(workspace) if workspace
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)"
581
1517
  end
582
1518
  end
583
1519
 
@@ -586,9 +1522,9 @@ module ActionAgent
586
1522
  # or after a restart, drops what is recorded there. Claude Code sessions
587
1523
  # get them too, so a `bin/rails db:migrate` a session runs lands in the
588
1524
  # sandbox's database rather than the developer's.
589
- def assign_databases(workspace, app, config, spec)
1525
+ def assign_databases(workspace, app, overrides, spec)
590
1526
  plan = LocalSandboxDatabases.plan(
591
- app: app, workspace: workspace, session_id: workspace.basename.to_s, overrides: config.env,
1527
+ app: app, workspace: workspace, session_id: workspace.basename.to_s, overrides: overrides,
592
1528
  fallback_name: spec[:repository].to_s.split("/").last
593
1529
  )
594
1530
  if plan.notes.any?
@@ -622,11 +1558,17 @@ module ActionAgent
622
1558
  app = workspace.join("app")
623
1559
  return unless app.join("bin", "rails").file?
624
1560
 
625
- file_env = begin
626
- Config.load(app).env
627
- rescue Error
628
- {}
629
- end
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
630
1572
  env = self.class.sanitized_environment.merge(file_env).merge(database_env.transform_values(&:to_s)).merge(
631
1573
  SESSION_ID_ENV => workspace.basename.to_s,
632
1574
  "RAILS_ENV" => state.fetch("database_rails_env"),
@@ -682,13 +1624,22 @@ module ActionAgent
682
1624
  update_state(workspace) { |state| state["checkout_commit"] = commit } if status&.success? && COMMIT_ID.match?(commit)
683
1625
  end
684
1626
 
685
- def run_manifest!(workspace, config, env, deadline, secrets)
1627
+ def run_manifest!(workspace, step, env, deadline, secrets)
686
1628
  path = workspace.join("runtime.json")
687
1629
  FileUtils.rm_f(path)
688
- run_step!(workspace, "manifest", config.manifest, env: env, chdir: workspace.join("app"), deadline: deadline, secrets: secrets)
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)
1632
+
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?
689
1635
 
690
- log = log_path(workspace, "manifest")
691
- fail_step!("manifest", "`#{config.manifest}` wrote nothing to $#{SandboxManifest::PATH_ENV}", log, secrets) unless path.file?
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?
692
1643
 
693
1644
  # Written by the checkout's command, with its umask: the MCP token in
694
1645
  # it is for the dashboard alone.
@@ -697,48 +1648,78 @@ module ActionAgent
697
1648
  URI.parse("http://127.0.0.1#{manifest["mcp_path"]}")
698
1649
  manifest
699
1650
  rescue SandboxManifest::Error, URI::InvalidURIError => e
700
- fail_step!("manifest", e.message, log_path(workspace, "manifest"), secrets)
1651
+ fail_step!(step.label, e.message, log, secrets)
701
1652
  end
702
1653
 
703
1654
  # Runs one boot command to completion in its own process group, with its
704
- # output in logs/<step>.log. Anything it left running in that group is
705
- # stopped too: a setup command is not a way to start services the
706
- # sandbox never records (that is what `start` is for).
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+.
707
1660
  #
708
- # The group is stopped however the wait ends: the deadline, or any
1661
+ # The group is stopped however the wait ends: a deadline, or any
709
1662
  # exception at all (a worker shutting down raises into this thread with
710
1663
  # Thread#raise, which is no StandardError). While it runs its pid is in
711
1664
  # state.json as step_pid, so a terminate after this process itself died
712
1665
  # (a crashed dashboard, a killed worker) stops a hung `bundle install`
713
1666
  # too; nothing else would, the deadline having died with this process.
714
- def run_step!(workspace, step, command, env:, chdir:, deadline:, secrets:, label: command)
715
- log = log_path(workspace, step)
716
- 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
717
1671
 
718
1672
  pid = nil
719
1673
  waiter = nil
720
1674
  finished = false
1675
+ reader, writer = IO.pipe
1676
+ copier = nil
721
1677
  # Deferred until the group is recorded or stopped, so an interrupt
722
1678
  # never lands between the spawn and the ensure that stops it.
723
1679
  Thread.handle_interrupt(Object => :never) do
724
- pid = spawn_group(env, "sh", "-c", command, chdir: chdir, in: File::NULL, out: [ log.to_s, "a" ], err: [ :child, :out ])
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
725
1685
  begin
726
1686
  Thread.handle_interrupt(Object => :immediate) do
727
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) }
728
1691
  waiter = Process.detach(pid)
729
- finished = waiter.join(time_left(deadline))
1692
+ finished = waiter.join(time_left(until_time))
730
1693
  end
731
1694
  ensure
732
1695
  stop_groups([ pid ], grace: finished ? 1 : stop_grace)
733
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)
734
1699
  end
735
1700
  end
736
1701
 
737
1702
  unless finished
738
- fail_step!(step, "`#{label}` did not finish within the boot timeout (#{ActionAgent.local_sandbox_boot_timeout}s)", log, secrets)
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)
739
1705
  end
740
1706
  status = waiter.value
741
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.
742
1723
  end
743
1724
 
744
1725
  def record_step(workspace, pid)
@@ -759,12 +1740,16 @@ module ActionAgent
759
1740
  # The workspace is gone: nothing left to forget it in.
760
1741
  end
761
1742
 
762
- def start_server(workspace, config, env, port)
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)
763
1747
  log = log_path(workspace, "server")
764
- File.open(log, "a") { |file| file.puts("$ #{config.start}") }
1748
+ File.open(log, "a") { |file| file.puts("$ #{command}") }
765
1749
 
766
- pid = spawn_group(env, "sh", "-c", config.start,
1750
+ pid = spawn_group(env, "sh", "-c", command,
767
1751
  chdir: workspace.join("app"), in: File::NULL, out: [ log.to_s, "a" ], err: [ :child, :out ])
1752
+ @server_pid = pid
768
1753
  # Reaped by this thread for as long as the dashboard lives; after a
769
1754
  # restart the recorded pid is all that is left, hence state.json.
770
1755
  waiter = Process.detach(pid)
@@ -788,7 +1773,7 @@ module ActionAgent
788
1773
  # server this sandbox started. The port was free when it was picked, but
789
1774
  # nothing held it after: another process can bind it first, and then
790
1775
  # answers in the server's place — and would be handed the MCP token.
791
- 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)
792
1777
  log = log_path(workspace, "server")
793
1778
  mcp_path = manifest["mcp_path"]
794
1779
  uri = URI.parse("http://127.0.0.1:#{port}#{mcp_path}")
@@ -798,7 +1783,7 @@ module ActionAgent
798
1783
  loop do
799
1784
  unless waiter.alive?
800
1785
  taken = foreign ? " (another process was listening on port #{port})" : ""
801
- fail_step!("server", "the server exited with #{describe(waiter.value)} before it answered on port #{port}#{taken}",
1786
+ fail_step!(step.label, "the server exited with #{describe(waiter.value)} before it answered on port #{port}#{taken}",
802
1787
  log, secrets)
803
1788
  end
804
1789
 
@@ -816,13 +1801,38 @@ module ActionAgent
816
1801
  elsif last_status then " (last answer: #{last_status})"
817
1802
  else ""
818
1803
  end
819
- fail_step!("server", "the server did not answer #{mcp_path} on port #{port} within the boot timeout " \
820
- "(#{ActionAgent.local_sandbox_boot_timeout}s)#{answered}", log, secrets)
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)
821
1806
  end
822
1807
  sleep POLL_INTERVAL
823
1808
  end
824
1809
  end
825
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
+
826
1836
  # Whether what listens on +uri+'s port is the sandbox's server (process
827
1837
  # group +pgid+). Asked of the system where it can say (/proc on Linux,
828
1838
  # lsof elsewhere); the token is never sent before. Where it cannot say,
@@ -928,14 +1938,14 @@ module ActionAgent
928
1938
  nil
929
1939
  end
930
1940
 
931
- # The HTTP status a GET on the MCP path answers with, or nil while
932
- # nothing answers.
933
- 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)
934
1944
  # No proxy: the dashboard's HTTP(S)_PROXY does not know this loopback.
935
1945
  http = Net::HTTP.new(uri.host, uri.port, nil)
936
1946
  http.open_timeout = 1
937
- http.read_timeout = 2
938
- http.start { |connection| connection.request(Net::HTTP::Get.new(uri, "Accept" => "application/json")).code.to_i }
1947
+ http.read_timeout = read_timeout
1948
+ http.start { |connection| connection.request(Net::HTTP::Get.new(uri, "Accept" => accept)).code.to_i }
939
1949
  rescue SystemCallError, IOError, Timeout::Error, Net::HTTPBadResponse
940
1950
  nil
941
1951
  end
@@ -969,6 +1979,19 @@ module ActionAgent
969
1979
 
970
1980
  # --- Claude Code --------------------------------------------------------
971
1981
 
1982
+ def codex_argv(code_session)
1983
+ argv = [
1984
+ ActionAgent.codex_command.to_s, "exec", "--json", "--ephemeral",
1985
+ "--sandbox", "workspace-write", "--config", 'approval_policy="never"', "--color", "never"
1986
+ ]
1987
+ if (model = code_session.model.presence)
1988
+ raise Error, "#{model.inspect} is not a model name" unless MODEL_NAME.match?(model.to_s)
1989
+
1990
+ argv += [ "--model", model.to_s ]
1991
+ end
1992
+ argv + [ "-" ]
1993
+ end
1994
+
972
1995
  def claude_argv(code_session)
973
1996
  command = ActionAgent.claude_code_command.to_s
974
1997
  argv = [
@@ -979,6 +2002,11 @@ module ActionAgent
979
2002
  # that would prompt is denied instead of stalling the session.
980
2003
  argv += [ "--permission-prompts", "none" ] if permission_prompts_supported?(command)
981
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?
982
2010
 
983
2011
  if (model = code_session.model.presence)
984
2012
  # One argv entry, but one that must not read as a flag.
@@ -1009,18 +2037,20 @@ module ActionAgent
1009
2037
  LOGGED_OUT
1010
2038
  end
1011
2039
 
1012
- def run_claude(workspace, code_session, argv, env, secrets, &on_event)
2040
+ def run_claude(workspace, code_session, argv, env, secrets, runner: "claude_code", &on_event)
2041
+ label = runner == "codex" ? "Codex" : "Claude Code"
2042
+ timeout = runner == "codex" ? ActionAgent.codex_timeout : ActionAgent.claude_code_timeout
1013
2043
  key = code_session.id.to_s
1014
- log = log_path(workspace, "claude-#{key}")
1015
- deadline = deadline_after(ActionAgent.claude_code_timeout)
2044
+ log = log_path(workspace, "#{runner == 'codex' ? 'codex' : 'claude'}-#{key}")
2045
+ deadline = deadline_after(timeout)
1016
2046
  # Checked again once Claude Code is recorded; this saves starting it.
1017
2047
  state = read_state(workspace)
1018
- refuse_stopped_session!(state, key)
2048
+ refuse_stopped_session!(state, key, label: label)
1019
2049
  # A cancelled session frees its slot as soon as it is marked cancelled,
1020
2050
  # while its Claude Code may still be exiting (and diffing). Two in one
1021
2051
  # checkout would edit the same files.
1022
2052
  if other_session_running?(state, key, workspace.basename.to_s)
1023
- raise Error, "The previous Claude Code session in this sandbox is still stopping; try again in a moment"
2053
+ raise Error, "The previous code session in this sandbox is still stopping; try again in a moment"
1024
2054
  end
1025
2055
  stdin_read, stdin_write = IO.pipe
1026
2056
  stdout_read, stdout_write = IO.pipe
@@ -1029,7 +2059,7 @@ module ActionAgent
1029
2059
  begin
1030
2060
  pid = spawn_group(env, *argv, chdir: workspace.join("app"), in: stdin_read, out: stdout_write, err: stderr_write)
1031
2061
  rescue SystemCallError => e
1032
- raise Error, "Could not start Claude Code (#{argv.first}): #{e.message}"
2062
+ raise Error, "Could not start #{label} (#{argv.first}): #{e.message}"
1033
2063
  ensure
1034
2064
  [ stdin_read, stdout_write, stderr_write ].each(&:close)
1035
2065
  end
@@ -1040,7 +2070,7 @@ module ActionAgent
1040
2070
  case record_code_session(workspace, key, pid)
1041
2071
  when :terminating
1042
2072
  # Stopped by the ensure below, before it had the prompt.
1043
- raise Error, "The sandbox is being stopped, so Claude Code did not run"
2073
+ raise Error, "The sandbox is being stopped, so #{label} did not run"
1044
2074
  when :cancelled
1045
2075
  # Runs its course like any cancelled session: it ends on SIGTERM.
1046
2076
  signal_group(pid, "TERM")
@@ -1067,7 +2097,7 @@ module ActionAgent
1067
2097
  unless finished
1068
2098
  # Cancelled, and SIGTERM did not end it within the grace: SIGKILL,
1069
2099
  # and the session finishes like any cancelled one.
1070
- raise Error, "Claude Code did not finish within #{ActionAgent.claude_code_timeout}s and was stopped" unless cancel_seen
2100
+ raise Error, "#{label} did not finish within #{timeout}s and was stopped" unless cancel_seen
1071
2101
 
1072
2102
  stop_groups([ pid ], grace: 0)
1073
2103
  end
@@ -1091,11 +2121,11 @@ module ActionAgent
1091
2121
 
1092
2122
  # Why a session must not start: a terminate under way, or a cancel that
1093
2123
  # came before there was a process to stop.
1094
- def refuse_stopped_session!(state, key)
1095
- raise Error, "The sandbox is being stopped, so Claude Code did not run" if state["terminating"]
2124
+ def refuse_stopped_session!(state, key, label: "Claude Code")
2125
+ raise Error, "The sandbox is being stopped, so #{label} did not run" if state["terminating"]
1096
2126
 
1097
2127
  cancels = state["cancelled_code_sessions"]
1098
- raise Error, "Claude Code session #{key} was cancelled before it started" if cancels.is_a?(Hash) && cancels.key?(key)
2128
+ raise Error, "#{label} session #{key} was cancelled before it started" if cancels.is_a?(Hash) && cancels.key?(key)
1099
2129
  end
1100
2130
 
1101
2131
  # Records Claude Code's pid (and start time) under the state.json lock,
@@ -1227,20 +2257,17 @@ module ActionAgent
1227
2257
  app = workspace.join("app")
1228
2258
  env = self.class.sanitized_environment
1229
2259
  git = git_command(app)
1230
- # A filter driver in the checkout's git config (which the session could
1231
- # have written) runs its command on `git add` and `git diff`, as the
1232
- # dashboard's user. Rather than run it, report no diff.
1233
- drivers, = capture(env, [ *git, "config", "--local", "--includes", "--name-only", "--get-regexp", "^filter\\." ],
1234
- chdir: app, limit: 64 * 1024, timeout: GIT_TIMEOUT)
1235
- 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)
1236
2262
  return "(diff not recorded: the checkout's git config defines filter drivers, which would run commands)"
1237
2263
  end
1238
- capture(env, [ *git, "add", "--intent-to-add", "--all" ], chdir: app, limit: 64 * 1024, timeout: GIT_TIMEOUT)
2264
+ exclusions = SandboxCredentialPaths.pathspecs
2265
+ capture(env, [ *git, "add", "--intent-to-add", "--all", "--", ".", *exclusions ], chdir: app, limit: 64 * 1024, timeout: GIT_TIMEOUT)
1239
2266
 
1240
2267
  base = read_state(workspace)["checkout_commit"]
1241
2268
  base = "HEAD" unless base.is_a?(String) && COMMIT_ID.match?(base)
1242
2269
  diff = ->(commit) do
1243
- 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 ],
1244
2271
  chdir: app, limit: MAX_DIFF_BYTES, timeout: GIT_TIMEOUT)
1245
2272
  end
1246
2273
  output, status = diff.call(base)
@@ -1263,24 +2290,28 @@ module ActionAgent
1263
2290
  # The Claude Code variables a session runs with. An API key comes from
1264
2291
  # the owner's connection. The machine's own login needs none: `claude`
1265
2292
  # finds it itself, and the dashboard neither reads nor passes it on.
1266
- def session_credentials(sandbox)
1267
- return {} if ClaudeCodeAuth.local_login?
2293
+ def session_credentials(sandbox, runner: "claude_code")
2294
+ return {} if runner == "claude_code" && ClaudeCodeAuth.local_login?
1268
2295
 
1269
- credentials = sandbox.runtime_environment.to_h
2296
+ credentials = (runner == "codex" ? sandbox.runtime_environment(runner: runner) : sandbox.runtime_environment).to_h
1270
2297
  if credentials.empty?
1271
- raise Error, "Claude Code is not connected: connect an Anthropic API key in Settings → Integrations"
2298
+ label = runner == "codex" ? "Codex is not connected: connect an OpenAI API key" : "Claude Code is not connected: connect an Anthropic API key"
2299
+ raise Error, "#{label} in Settings → Integrations"
1272
2300
  end
1273
2301
 
1274
2302
  credentials
1275
2303
  end
1276
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.
1277
2308
  def sandbox_secrets(sandbox, credentials)
1278
2309
  token = begin
1279
2310
  sandbox.checkout_spec&.dig(:token)
1280
2311
  rescue StandardError
1281
2312
  nil
1282
2313
  end
1283
- [ token, *credentials.values ].compact.map(&:to_s)
2314
+ [ token, *credentials.values, *Array(sandbox.try(:project_scrub_values)) ].compact.map(&:to_s)
1284
2315
  end
1285
2316
 
1286
2317
  # Drops the session's pid, and the cancels it may have left.
@@ -1299,6 +2330,133 @@ module ActionAgent
1299
2330
  status.exitstatus || (status.termsig ? 128 + status.termsig : 1)
1300
2331
  end
1301
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
+
1302
2460
  # --- Processes ----------------------------------------------------------
1303
2461
 
1304
2462
  # Every sandbox process leads its own process group, so stopping one
@@ -1491,11 +2649,14 @@ module ActionAgent
1491
2649
  {}
1492
2650
  end
1493
2651
  sessions = state["code_sessions"].is_a?(Hash) ? state["code_sessions"].values : []
1494
- recorded = [ state["pid"], state["step_pid"], *sessions ].uniq.select { |pid| signalable?(pid) }
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) }
1495
2654
  identities = recorded.index_with { |pid| group_identity(pid, session_id, state) }
1496
2655
  stop_groups(recorded.select { |pid| identities[pid] == :ours })
1497
2656
  stop_escaped(session_id)
1498
2657
 
2658
+ logout_claude_workspace(workspace) if state["claude_login_pid"] || ClaudeCodeAuth.sandbox_login?
2659
+
1499
2660
  left = recorded.select { |pid| identities[pid] != :stranger && group_alive?(pid) }
1500
2661
  if left.any?
1501
2662
  Rails.logger.error("[ActionAgent] sandbox #{session_id}: process groups #{left.join(", ")} are still alive and " \