lib/openagents/tools/computer_run.ex

58e6347eeb72 · 7 KB

defmodule OpenAgents.Tools.ComputerRun do
  @moduledoc """
  Runs one command on one of the user's paired computers.

  The command is a typed argv array — never a shell string — and the
  controller's local policy is the final authority: a computer paired at a
  lower tier, a denied command, or a path outside its declared roots comes
  back as a typed refusal, not an execution. Output is streamed on the
  computer, collected bounded here, and secret-shaped text is masked before
  it ever leaves the computer.
  """

  @behaviour OpenAgents.Tools.Tool

  alias OpenAgents.{Computer, Machines}
  alias OpenAgents.Modules.Metadata
  alias OpenAgents.Tools.{ExecutionResult, OwnerContext, Tool}

  @maximum_output_characters 12_000
  @maximum_timeout_ms 110_000

  @impl true
  def specification do
    %Tool{
      module_id: "sarah.tool.computer_run.v1",
      name: "computer_run",
      version: 1,
      description:
        "Runs one command on one of the user's paired computers and returns its output and " <>
          "exit code. Pass machine_id from computer_list and the command as an argv array " <>
          "(for example [\"git\", \"status\"]) — shell syntax like pipes or && is not supported. " <>
          "The computer's own policy can refuse a command; a refusal names the reason. " <>
          "Optional: cwd (absolute path inside the computer's shared folders) and timeout_ms.",
      input_schema: input_schema(),
      output_schema: output_schema(),
      side_effect: :external_effect,
      required_scope: "browser_conversation",
      required_authority: "computer.control",
      executor: %{
        id: "sarah.computer.controller",
        disclosure: "Command execution on the user's paired computer, bounded by its local policy"
      },
      maintainer: "OpenAgents",
      attribution: ["OpenAgentsInc/openagents.com", "OpenAgentsInc/sarah-computer-controller"],
      policy_facets: %{
        "privacy" => "signed_browser_owner",
        "residency" => "operator_machine",
        "consent" => "computer_pairing"
      },
      module_metadata:
        Metadata.first_party("computer.control", "browser_conversation",
          effect: :external_effect,
          approval_class: "explicit_operator_approval",
          privacy: "signed_browser_owner",
          residency: "operator_machine"
        ),
      timeout_ms: 120_000,
      maximum_input_bytes: 8_192,
      maximum_output_bytes: 65_536,
      implementation: __MODULE__,
      reach: [:signed_in_owner, :paired_computer]
    }
  end

  @impl true
  def execute(%{"machine_id" => machine_id, "argv" => argv} = arguments, context)
      when is_binary(machine_id) and is_list(argv) do
    with :ok <- validate_argv(argv),
         {:ok, user} <- OwnerContext.resolve(context),
         {:ok, machine} <- Machines.get_machine(user.id, machine_id) do
      timeout_ms = bounded_timeout(arguments["timeout_ms"])

      payload =
        %{"argv" => argv, "timeout_ms" => timeout_ms}
        |> put_optional("cwd", arguments["cwd"])

      case Computer.request_run(machine.id, payload, timeout_ms + 10_000) do
        {:ok, exit_payload} ->
          {:ok, result(machine, exit_payload)}

        {:refused, reason, detail} ->
          {:ok, refusal(machine, reason, detail)}

        {:error, reason} ->
          {:ok, failure(machine, reason)}
      end
    end
  end

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

  defp validate_argv([_command | _arguments] = argv) do
    if Enum.all?(argv, &(is_binary(&1) and byte_size(&1) <= 2_000)) and length(argv) <= 64,
      do: :ok,
      else: {:error, :invalid_command_request}
  end

  defp validate_argv(_argv), do: {:error, :invalid_command_request}

  defp bounded_timeout(value) when is_integer(value) and value > 0,
    do: min(value, @maximum_timeout_ms)

  defp bounded_timeout(_value), do: 30_000

  defp put_optional(payload, _key, nil), do: payload

  defp put_optional(payload, key, value) when is_binary(value),
    do: Map.put(payload, key, String.slice(value, 0, 500))

  defp put_optional(payload, _key, _value), do: payload

  defp result(machine, exit_payload) do
    %ExecutionResult{
      result: %{
        "schema" => "sarah.computer_run_result.v1",
        "status" => text(exit_payload["status"], 32),
        "machine_id" => machine.id,
        "machine_name" => machine.name,
        "exit_code" => integer(exit_payload["exit_code"]),
        "output" => output(exit_payload["output"]),
        "truncated" => exit_payload["truncated"] == true,
        "duration_ms" => integer(exit_payload["duration_ms"]),
        "detail" => text(exit_payload["detail"], 500)
      },
      target_receipt_refs: ["machine:#{machine.id}"]
    }
  end

  defp refusal(machine, reason, detail) do
    %ExecutionResult{
      result: %{
        "schema" => "sarah.computer_run_result.v1",
        "status" => "refused",
        "machine_id" => machine.id,
        "machine_name" => machine.name,
        "exit_code" => -1,
        "output" => "",
        "truncated" => false,
        "duration_ms" => 0,
        "detail" => String.slice("#{reason}: #{detail}", 0, 500)
      },
      target_receipt_refs: ["machine:#{machine.id}"]
    }
  end

  defp failure(machine, reason) do
    %ExecutionResult{
      result: %{
        "schema" => "sarah.computer_run_result.v1",
        "status" => Atom.to_string(reason),
        "machine_id" => machine.id,
        "machine_name" => machine.name,
        "exit_code" => -1,
        "output" => "",
        "truncated" => false,
        "duration_ms" => 0,
        "detail" => ""
      },
      target_receipt_refs: ["machine:#{machine.id}"]
    }
  end

  defp output(value) when is_binary(value), do: String.slice(value, 0, @maximum_output_characters)
  defp output(_value), do: ""

  defp text(value, maximum) when is_binary(value), do: String.slice(value, 0, maximum)
  defp text(_value, _maximum), do: ""

  defp integer(value) when is_integer(value), do: value
  defp integer(_value), do: -1

  defp input_schema do
    %{
      "type" => "object",
      "properties" => %{
        "machine_id" => %{"type" => "string", "maxLength" => 64},
        "argv" => %{
          "type" => "array",
          "items" => %{"type" => "string", "maxLength" => 2_000},
          "minItems" => 1,
          "maxItems" => 64
        },
        "cwd" => %{"type" => "string", "maxLength" => 500},
        "timeout_ms" => %{"type" => "integer", "minimum" => 1, "maximum" => @maximum_timeout_ms}
      },
      "required" => ["machine_id", "argv"],
      "additionalProperties" => false
    }
  end

  defp output_schema do
    %{
      "type" => "object",
      "properties" => %{
        "schema" => %{"type" => "string", "maxLength" => 64},
        "status" => %{"type" => "string", "maxLength" => 32},
        "machine_id" => %{"type" => "string", "maxLength" => 64},
        "machine_name" => %{"type" => "string", "maxLength" => 80},
        "exit_code" => %{"type" => "integer"},
        "output" => %{"type" => "string", "maxLength" => @maximum_output_characters},
        "truncated" => %{"type" => "boolean"},
        "duration_ms" => %{"type" => "integer"},
        "detail" => %{"type" => "string", "maxLength" => 500}
      },
      "required" => [
        "schema",
        "status",
        "machine_id",
        "machine_name",
        "exit_code",
        "output",
        "truncated",
        "duration_ms",
        "detail"
      ],
      "additionalProperties" => false
    }
  end
end