lib/openagents_web/controllers/controller_pairing_controller.ex

main at 58e6347eeb72 · 3 KB

defmodule OpenAgentsWeb.ControllerPairingController do
  @moduledoc """
  Device-style pairing API for the sarah-computer-controller CLI.

  `create` is unauthenticated: it registers a pending pairing and returns a
  short code the signed-in owner approves in the browser. `show` is polled by
  the CLI with the poll secret and hands the computer token over exactly once.
  """

  use OpenAgentsWeb, :controller

  alias OpenAgents.Computer
  alias OpenAgents.Machines

  plug :verify_enabled
  plug :put_no_store

  def create(conn, params) do
    attributes = %{
      "name" => params["name"],
      "tier" => params["tier"] || "probe",
      "platform" => bounded(params["platform"]),
      "agent_version" => bounded(params["agent_version"]),
      "roots" => bounded_roots(params["roots"])
    }

    case Machines.start_pairing(attributes) do
      {:ok, %{pairing: pairing, code: code, poll_secret: poll_secret}} ->
        json(conn, %{
          "pairing_id" => pairing.id,
          "code" => format_code(code),
          "poll_secret" => poll_secret,
          "verify_url" => url(~p"/computers"),
          "expires_at" => DateTime.to_iso8601(pairing.expires_at),
          "interval_seconds" => 3
        })

      {:error, _changeset} ->
        conn |> put_status(:unprocessable_entity) |> json(%{"error" => "invalid_pairing"})
    end
  end

  def show(conn, %{"id" => pairing_id}) do
    poll_secret = get_req_header(conn, "x-pairing-secret") |> List.first("")

    case Machines.claim_pairing(pairing_id, poll_secret) do
      {:ok, %{token: token, machine_id: machine_id, name: name}} ->
        json(conn, %{
          "status" => "approved",
          "machine_id" => machine_id,
          "name" => name,
          "token" => token
        })

      {:error, :pairing_pending} ->
        json(conn, %{"status" => "pending"})

      {:error, :pairing_expired} ->
        conn |> put_status(:gone) |> json(%{"status" => "expired"})

      {:error, _reason} ->
        conn |> put_status(:not_found) |> json(%{"error" => "pairing_not_found"})
    end
  end

  def status(conn, _params) do
    machine = conn.assigns.current_machine

    json(conn, %{
      "machine_id" => machine.id,
      "name" => machine.name,
      "status" => machine.status,
      "token_expires_at" => DateTime.to_iso8601(machine.token_expires_at)
    })
  end

  defp verify_enabled(conn, _options) do
    if Computer.enabled?() do
      conn
    else
      conn
      |> put_status(:not_found)
      |> json(%{"error" => "computer_controller_disabled"})
      |> halt()
    end
  end

  defp put_no_store(conn, _options), do: put_resp_header(conn, "cache-control", "no-store")

  defp bounded(value) when is_binary(value), do: String.slice(value, 0, 40)
  defp bounded(_value), do: nil

  defp bounded_roots(roots) when is_list(roots) do
    roots
    |> Enum.filter(&is_binary/1)
    |> Enum.take(16)
    |> Enum.map(&String.slice(&1, 0, 512))
  end

  defp bounded_roots(_roots), do: []

  defp format_code(<<first::binary-size(4), second::binary-size(4)>>),
    do: first <> "-" <> second

  defp format_code(code), do: code
end