lib/openagents/tools/box_exec.ex

main at 58e6347eeb72 · 4 KB

defmodule OpenAgents.Tools.BoxExec do
  @moduledoc """
  Runs one shell command on a conversation-owned Box VM.

  The command runs remotely through the Box command endpoint and returns the
  standard exit status with bounded, redacted output. Ownership is checked
  before the request leaves the host, so a box id from another conversation
  refuses without a remote call.
  """

  @behaviour OpenAgents.Tools.Tool

  alias OpenAgents.Box
  alias OpenAgents.Modules.Metadata
  alias OpenAgents.Tools.{BoxOutput, ExecutionResult, Tool}

  @default_timeout_seconds 60
  # The registry caps a tool run at 600 seconds; the remote command budget
  # stays below it so the HTTP round trip fits inside the tool budget.
  @maximum_timeout_seconds 570

  @impl true
  def specification do
    %Tool{
      module_id: "openagents.tool.box_exec.v1",
      name: "box_exec",
      version: 1,
      description:
        "Runs one shell command on one of this conversation's Box VMs and returns its exit " <>
          "code with bounded stdout and stderr. Use `opencode run \"<task>\"` to drive the " <>
          "installed OpenCode harness. Get box ids from box_list or box_new.",
      input_schema: %{
        "type" => "object",
        "properties" => %{
          "box_id" => %{"type" => "string", "maxLength" => 32},
          "command" => %{"type" => "string", "maxLength" => 4_000},
          "timeout_seconds" => %{
            "type" => "integer",
            "minimum" => 1,
            "maximum" => @maximum_timeout_seconds
          }
        },
        "required" => ["box_id", "command"],
        "additionalProperties" => false
      },
      output_schema: %{"type" => "object", "properties" => %{}, "additionalProperties" => true},
      side_effect: :reversible_write,
      required_scope: "browser_conversation",
      required_authority: "box.control",
      executor: %{id: "ascii.box", disclosure: "the Box VM service at ascii.dev"},
      maintainer: "OpenAgents",
      attribution: ["OpenAgentsInc/openagents.com"],
      policy_facets: %{"privacy" => "browser_conversation", "residency" => "external_provider"},
      module_metadata:
        Metadata.first_party("box.control", "browser_conversation",
          effect: :reversible_write,
          privacy: "browser_conversation",
          residency: "external_provider",
          surfaces: ["text", "voice"],
          approval_class: "exact_current_user_consent",
          approval_enforcement: "executor_consent"
        ),
      timeout_ms: (@maximum_timeout_seconds + 30) * 1_000,
      maximum_input_bytes: 8_192,
      maximum_output_bytes: 64 * 1_024,
      implementation: __MODULE__
    }
  end

  @impl true
  def execute(%{"box_id" => box_id, "command" => command} = arguments, context)
      when is_binary(box_id) and is_binary(command) do
    with :ok <- validate_command(command),
         {:ok, timeout_seconds} <- timeout_seconds(arguments),
         {:ok, body} <-
           Box.run_command(context.conversation_id, box_id, command, timeout_seconds) do
      build_result(box_id, body)
    end
  end

  def execute(_arguments, _context), do: {:error, :invalid_command}

  defp validate_command(command) do
    cond do
      String.trim(command) == "" -> {:error, :invalid_command}
      not String.valid?(command) -> {:error, :invalid_command}
      String.contains?(command, "\0") -> {:error, :invalid_command}
      true -> :ok
    end
  end

  defp timeout_seconds(arguments) do
    case Map.get(arguments, "timeout_seconds", @default_timeout_seconds) do
      seconds when is_integer(seconds) and seconds >= 1 and seconds <= @maximum_timeout_seconds ->
        {:ok, seconds}

      _invalid ->
        {:error, :invalid_command_timeout}
    end
  end

  defp build_result(box_id, body) do
    {stdout, stdout_truncated} = BoxOutput.bounded(body["stdout"])
    {stderr, stderr_truncated} = BoxOutput.bounded(body["stderr"])
    exit_code = body["exitCode"]
    timed_out = body["timedOut"] == true

    status =
      cond do
        timed_out -> "failed"
        exit_code == 0 -> "succeeded"
        true -> "failed"
      end

    {:ok,
     %ExecutionResult{
       result: %{
         "schema" => "openagents.box_exec_result.v1",
         "box_id" => box_id,
         "exit_code" => exit_code,
         "signal" => body["signal"],
         "timed_out" => timed_out,
         "stdout" => stdout,
         "stderr" => stderr,
         "stdout_truncated" => stdout_truncated or body["stdoutTruncated"] == true,
         "stderr_truncated" => stderr_truncated or body["stderrTruncated"] == true
       },
       status: status,
       error:
         if(status == "failed",
           do: %{
             "code" => if(timed_out, do: "command_timed_out", else: "command_failed"),
             "message" =>
               if(timed_out,
                 do: "The command did not finish within the requested timeout.",
                 else: "The command exited with a nonzero status."
               )
           }
         ),
       target_receipt_refs: ["box:#{box_id}"]
     }}
  end
end