Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
120 changes: 60 additions & 60 deletions actionagent/app/assets/builds/action_agent.js

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,8 @@ module Api
# Getting a project's sandbox to boot and choosing what its App assistant
# may read:
#
# - starting the setup assistant on a failed boot (see ProjectSetup)
# - starting the setup assistant on a failed boot (see ProjectSetup), or
# Claude Code in its checkout (see ProjectBootFix)
# - the requests for input the project's agents are waiting on, which
# the Project page answers through the input requests API
# - the app's models as a boot listed them, and the models and columns
Expand All @@ -15,7 +16,7 @@ class ProjectSetupController < BaseController

before_action :require_owner!
before_action :set_project
before_action :require_execution_enabled!, only: [ :start ]
before_action :require_execution_enabled!, only: [ :start, :fix_with_claude_code ]

rescue_from Project::ConfirmationRequired, with: :confirmation_required
rescue_from Project::BootRefused, SandboxOrchestrator::UnsupportedBackendError, with: :boot_refused
Expand All @@ -37,8 +38,25 @@ def start
render json: { error: e.message, code: "setup_unavailable" }, status: :conflict
end

# POST /api/projects/:project_id/setup/claude_code
# Starts a Claude Code session in the failed boot's checkout to fix the
# step that failed; the boot resumes once it changed something. It
# runs on the caller's Claude Code, as a session started from the
# sandbox's page would.
def fix_with_claude_code
enforce_execution_quota!
return if performed?

session = ProjectBootFix.start!(@project, trigger: "requested", user_id: current_user.respond_to?(:id) ? current_user.id : @project.user_id)
record_execution_usage
render json: { project: @project.reload.summary, code_session: session.summary }, status: :accepted
rescue ProjectBootFix::Unavailable => e
render json: { error: e.message, code: "claude_code_unavailable" }, status: :conflict
end

# PATCH /api/projects/:project_id/setup { auto: false }
# Whether a failed boot starts the setup assistant on its own.
# Whether a failed boot starts Claude Code or the setup assistant on its
# own.
def update
return unless authorize_action!(:manage_project_secrets, secret_subject)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -174,7 +174,8 @@ def boot
# GET /api/projects/:id/boot
# The current sandbox's boot, step by step (see
# SandboxOrchestrator#boot_status), with the scrubbed tail of the step
# that failed or is running.
# that failed or is running, and the modes its browser can start in
# (none when the backend runs no browsers).
def boot_status
sandbox = @project.current_sandbox_session
orchestrator = SandboxOrchestrator.new
Expand All @@ -187,7 +188,8 @@ def boot_status
log_tail: status && log_tail(orchestrator, sandbox, status),
error: sandbox&.failed? ? SecretScrubber.scrub(sandbox.error_summary, @project.scrub_values) : nil,
logs: sandbox.present? && orchestrator.supports?(:boot_log),
confirmation: @project.local_boot_confirmation(orchestrator)
confirmation: @project.local_boot_confirmation(orchestrator),
browser_modes: browser_modes(orchestrator)
}
end

Expand Down Expand Up @@ -452,6 +454,14 @@ def listed_synced_agents
nil
end

# The modes the project's browser can start in on the configured
# backend: none when it runs no browsers, or cannot be loaded.
def browser_modes(orchestrator)
orchestrator.browser_modes.map(&:to_s)
rescue StandardError, LoadError
[]
end

# The last LOG_TAIL_BYTES of the step that failed, else of the one
# running, else of the last that ran: { step:, text:, truncated: }.
def log_tail(orchestrator, sandbox, status)
Expand Down
16 changes: 14 additions & 2 deletions actionagent/app/jobs/action_agent/code_session_job.rb
Original file line number Diff line number Diff line change
Expand Up @@ -30,8 +30,9 @@ def perform(code_session_id)
sandbox = code_session.sandbox_session

# Queued behind a Stop (the cleanup job shares this queue): never start
# Claude Code, with the owner's credential, in a stopped sandbox.
unless sandbox.reload.ready? && sandbox.active?
# Claude Code, with the owner's credential, in a stopped sandbox. A
# project's boot fix runs in the checkout its failed boot kept.
unless (sandbox.reload.ready? && sandbox.active?) || ProjectBootFix.runs_in_failed_boot?(code_session)
return fail!(code_session, "The sandbox was stopped before the session started")
end

Expand All @@ -58,14 +59,25 @@ def perform(code_session_id)

finish(code_session, outcome.to_h, result_event, secrets)
verify(code_session)
resume_boot(code_session)
rescue StandardError => e
message = SecretScrubber.scrub(e.message.to_s, secrets || safe_secrets(code_session))
Rails.logger.error("Code session #{code_session_id} failed: #{message}")
fail!(code_session, message) if code_session
resume_boot(code_session) if code_session
end

private

# A project's boot fix resumes the boot it fixed (ProjectBootFix). Its
# failure is the boot's, not the session's, so it is logged and left on
# the project rather than raised.
def resume_boot(code_session)
ProjectBootFix.after_session(code_session.reload)
rescue StandardError => e
Rails.logger.warn("Code session #{code_session.id}: the project's boot was not resumed: #{e.class}: #{e.message}")
end

# A fix session that changed the checkout is verified next. If that
# cannot even be queued, the error is the session's, so the sandbox is
# not left waiting for a verification that will never come.
Expand Down
8 changes: 5 additions & 3 deletions actionagent/app/jobs/action_agent/project_setup_job.rb
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
# frozen_string_literal: true

module ActionAgent
# Starts a project's setup assistant once a boot of the project failed
# (see ProjectSetup.after_boot_failed). Takes ids only; a project or
# Starts help for a project once a boot of the project failed: a Claude
# Code session in the failed checkout when the owner's Claude Code is
# connected (see ProjectBootFix.after_boot_failed), and the setup assistant
# otherwise (see ProjectSetup.after_boot_failed). Takes ids only; a project or
# sandbox deleted since, or a sandbox that is no longer the project's
# current one, starts nothing.
class ProjectSetupJob < ApplicationJob
Expand All @@ -13,7 +15,7 @@ def perform(project_id, sandbox_session_id)
sandbox = SandboxSession.find_by(id: sandbox_session_id)
return if project.nil? || sandbox.nil? || !sandbox.failed?

ProjectSetup.after_boot_failed(project, sandbox)
ProjectBootFix.after_boot_failed(project, sandbox) || ProjectSetup.after_boot_failed(project, sandbox)
end
end
end
13 changes: 8 additions & 5 deletions actionagent/app/models/action_agent/project.rb
Original file line number Diff line number Diff line change
Expand Up @@ -213,8 +213,10 @@ def choose_schema_tools!(choices)
# run_ids the setup runs it started, newest last
# last_run_id the latest of them
# retried_run_ids the runs that retried the boot
# attempts automatic runs since the last good boot
# attempts automatic runs (and Claude Code boot fixes) since
# the last good boot
# auto false when a failed boot starts no run on its own
# claude_code the Claude Code boot fixes (see ProjectBootFix)
def setup_settings
settings["setup"].is_a?(Hash) ? settings["setup"] : {}
end
Expand Down Expand Up @@ -444,9 +446,9 @@ def sandbox_ready!(sandbox, app_models: nil)
end
end

# Called by SandboxProvisionJob when +sandbox+'s boot failed. Starts the
# setup assistant on it in the background, unless that is switched off
# (see ProjectSetup.after_boot_failed).
# Called by SandboxProvisionJob when +sandbox+'s boot failed. Starts
# Claude Code or the setup assistant on it in the background, unless that
# is switched off (see ProjectSetupJob).
def sandbox_failed!(sandbox)
changed = settle!(sandbox) { { status: "failed" } }
ProjectSetupJob.perform_later(id, sandbox.id) if changed && auto_setup?
Expand Down Expand Up @@ -639,7 +641,8 @@ def setup_summary
auto: auto_setup?,
agent_id: setup_settings["agent_id"],
attempts: setup_settings["attempts"].to_i,
last_run: last_run && { id: last_run.id, status: last_run.status, created_at: last_run.created_at&.iso8601 }
last_run: last_run && { id: last_run.id, status: last_run.status, created_at: last_run.created_at&.iso8601 },
claude_code: ProjectBootFix.summary(self)
}
end

Expand Down
13 changes: 12 additions & 1 deletion actionagent/app/models/action_agent/sandbox_session.rb
Original file line number Diff line number Diff line change
Expand Up @@ -519,10 +519,21 @@ def summary
# Why provisioning failed. Scrubbed of the session's secrets when
# SandboxProvisionJob stored it.
error_message: error_summary,
browser: browser_summary
browser: browser_summary,
# The project this checkout was booted for, so a listing can link to
# its page; nil for a sandbox started on its own.
project: project_link
}
end

# { id:, name: } of the project this checkout was booted for, or nil. An
# install that has not run the projects migration has no column for it.
def project_link
return nil unless has_attribute?(:project_id) && project_id

project && { id: project.id, name: project.name }
end

# A failed boot's message is a one-line reason followed by the tail of
# the failing step's log, and the log's last lines usually hold the
# actual error — so a long message keeps its head and its end.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,8 @@ def initialize(message, code:)
"too_large" => "larger than #{MAX_FILE_BYTES / 1024 / 1024} MB",
"unreadable" => "could not be read",
"secret" => "contains a secret of this sandbox or a GitHub token",
"local_path_gem" => "points a gem at a path outside the repository (a local engine checkout, say); " \
"use a released version or a git source",
"not_read" => "not read: a preview reads at most #{MAX_FILES} files and #{READ_BUDGET_BYTES / 1024 / 1024} MB, " \
"so limit the paths to read it"
}.freeze
Expand Down Expand Up @@ -478,6 +480,8 @@ def read_contents(file, secrets)
file.refusal = "too_large"
elsif secret?(file.content, secrets)
file.refusal = "secret"
elsif local_path_gem?(file)
file.refusal = "local_path_gem"
end
file
end
Expand Down Expand Up @@ -517,6 +521,33 @@ def scan_values
values.compact.map(&:to_s).select { |value| value.length >= SecretScrubber::MIN_SECRET_LENGTH }.map(&:b).uniq
end

# Bundler files a sandbox may rewrite with gems from its own machine: the
# :local backend's add_engine step bundles a dashboard that runs its
# engine from a path with `bundle add --path`, and that path means
# nothing in the repository.
BUNDLER_FILES = %w[Gemfile Gemfile.lock gems.rb gems.locked].freeze
# A path: option, or a lockfile PATH remote, that is absolute, in a home
# directory or climbs out of the checkout.
OUTSIDE_PATH = %r{(?:\bpath:\s*|\bpath\(\s*|^\s+remote:\s+)["']?(?:/|~|\.\./)[^"'\s,)]*}

# Whether +file+ is a Bundler file that newly points a gem outside the
# checkout. One the repository already had is left alone.
def local_path_gem?(file)
return false unless BUNDLER_FILES.include?(File.basename(file.path)) && file.content

lockfile = file.path.end_with?(".lock", ".locked")
refs = ->(content) { path_references(content.to_s.b, lockfile) }
(refs.call(file.content) - refs.call(file.base_content)).any?
end

def path_references(content, lockfile)
if lockfile
content.scan(/^PATH\n((?: .*\n?)*)/).flatten.flat_map { |block| block.scan(OUTSIDE_PATH) }
else
content.lines.reject { |line| line.lstrip.start_with?("#") }.flat_map { |line| line.scan(OUTSIDE_PATH) }
end.map(&:strip).uniq
end

def secret?(content, secrets)
return false if content.nil?

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2002,6 +2002,11 @@ def claude_argv(code_session)
# that would prompt is denied instead of stalling the session.
argv += [ "--permission-prompts", "none" ] if permission_prompts_supported?(command)
argv += [ "--max-turns", ActionAgent.claude_code_max_turns.to_i.to_s ] if ActionAgent.claude_code_max_turns.present?
# A boot fix may run the few commands it needs to re-resolve the
# checkout's dependencies (ProjectBootFix::BUNDLER_COMMANDS); every
# other session runs none.
allowed = ProjectBootFix.allowed_tools_for(code_session)
argv += [ "--allowedTools", allowed.join(",") ] if allowed.any?

if (model = code_session.model.presence)
# One argv entry, but one that must not read as a flag.
Expand Down
Loading
Loading