lib/openagents_web/live/computers_live.ex

main at 436d37facc96 · 24 KB

defmodule OpenAgentsWeb.ComputersLive do
  @moduledoc """
  Approve controller pairing codes and manage this account's paired computers.
  """

  use OpenAgentsWeb, :live_view

  alias OpenAgents.Computer
  alias OpenAgents.Machines
  alias OpenAgents.Machines.Machine
  alias OpenAgents.Repositories

  @presence_refresh_ms 15_000

  @impl true
  def mount(params, _session, socket) do
    socket =
      socket
      |> stream_configure(:computers, dom_id: &"computer-#{&1.id}")
      |> assign(:page_title, "Computers · Sarah")
      |> assign(:controller_enabled?, Computer.enabled?())
      |> assign(:pairing_form, to_form(%{"code" => prefilled_code(params)}, as: :pairing))
      |> assign(:pairing_error, nil)
      |> assign(:operation_success, nil)
      |> assign(:subscribed_computer_ids, MapSet.new())
      |> assign(:presence, %{})
      |> assign(:computer_count, 0)
      |> assign(:grant_form, to_form(%{}, as: :grant))
      |> assign(:grantable_repositories, [])
      |> assign(:repository_grants, %{})
      |> load_computers()

    if connected?(socket), do: schedule_presence_refresh()
    {:ok, socket}
  end

  # A pairing link may carry its code, the way `/device` does. Without this the
  # code has to be read off the agent's terminal and retyped, and three pairing
  # attempts expired unapproved because of it. Only the shape a code can have is
  # accepted, so a crafted link cannot put arbitrary text in the field.
  defp prefilled_code(params) when is_map(params) do
    case params["user_code"] || params["code"] do
      code when is_binary(code) ->
        normalized = code |> String.trim() |> String.upcase()
        if Regex.match?(~r/\A[A-Z0-9]{4}-[A-Z0-9]{4}\z/, normalized), do: normalized, else: ""

      _ ->
        ""
    end
  end

  defp prefilled_code(_), do: ""

  @impl true
  def handle_event("approve_pairing", _params, %{assigns: %{controller_enabled?: false}} = socket) do
    {:noreply,
     socket
     |> assign(:operation_success, nil)
     |> assign(:pairing_error, "Computer pairing is currently unavailable.")}
  end

  def handle_event(
        "approve_pairing",
        %{"pairing" => pairing_params},
        socket
      ) do
    code = Map.get(pairing_params, "code", "")
    enabled = Map.get(pairing_params, "scoped_forge_credentials_enabled", false)

    case Machines.approve_pairing(socket.assigns.current_user, code,
           scoped_forge_credentials_enabled: enabled in ["true", true, "1"]
         ) do
      {:ok, machine} ->
        {:noreply,
         socket
         |> assign(:pairing_error, nil)
         |> assign(:operation_success, %{
           id: "pairing-success",
           label: "PAIRED",
           message:
             "Computer \"#{machine.name}\" paired. It will appear online after the controller connects."
         })
         |> assign(:pairing_form, to_form(%{"code" => ""}, as: :pairing))
         |> load_computers()}

      {:error, reason} ->
        {:noreply,
         socket
         |> assign(:operation_success, nil)
         |> assign(:pairing_error, pairing_error(reason))}
    end
  end

  def handle_event(
        "update_scoped_forge_credentials",
        %{"id" => machine_id, "enabled" => enabled},
        socket
      ) do
    enabled = enabled in ["true", true, "1"]

    case Machines.update_scoped_forge_credentials(
           socket.assigns.current_user,
           machine_id,
           enabled
         ) do
      {:ok, machine} ->
        {:noreply,
         socket
         |> assign(:pairing_error, nil)
         |> assign(:operation_success, %{
           id: "credentials-policy-success",
           label: "POLICY UPDATED",
           message:
             "Scoped forge credentials are now #{if(enabled, do: "allowed", else: "disabled")} for \"#{machine.name}\"."
         })
         |> load_computers()}

      {:error, _reason} ->
        {:noreply,
         socket
         |> assign(:operation_success, nil)
         |> assign(:pairing_error, "Computer not found.")}
    end
  end

  # Repository access for a computer. `repository_machine_grants` is the only
  # thing `OpenAgents.Forge.GitHTTP` consults for a `{:machine, id}` principal,
  # and until this event existed nothing wrote a row, so every Git request a
  # paired computer made answered `404 unknown repository` (#182).
  #
  # Neither identifier selects anything on its own: `OpenAgents.Repositories`
  # resolves the computer through the acting account and the repository through
  # that account's administering membership, so a foreign computer, a
  # repository this account does not administer, and an identifier that names
  # nothing are one refusal (IDENTITY-002).
  def handle_event(
        "grant_repository_access",
        %{"grant" => %{"machine_id" => machine_id, "repository_id" => repository_id} = params},
        socket
      ) do
    operations = if params["operations"] == "write", do: ~w(read write), else: ~w(read)

    case Repositories.grant_machine_access(
           socket.assigns.current_user,
           machine_id,
           repository_id,
           operations
         ) do
      {:ok, grant} ->
        {:noreply,
         socket
         |> assign(:pairing_error, nil)
         |> assign(:operation_success, %{
           id: "repository-grant-success",
           label: "REPOSITORY GRANTED",
           message:
             "The computer can now #{Enum.join(grant.operations, " and ")} that repository over Git."
         })
         |> load_computers()}

      {:error, reason} ->
        {:noreply,
         socket
         |> assign(:operation_success, nil)
         |> assign(:pairing_error, grant_error(reason))}
    end
  end

  def handle_event(
        "revoke_repository_access",
        %{"id" => machine_id, "repository-id" => repository_id},
        socket
      ) do
    case Repositories.revoke_machine_access(
           socket.assigns.current_user,
           machine_id,
           repository_id
         ) do
      {:ok, _grant} ->
        {:noreply,
         socket
         |> assign(:pairing_error, nil)
         |> assign(:operation_success, %{
           id: "repository-grant-revoked",
           label: "REPOSITORY WITHDRAWN",
           message: "That computer can no longer reach the repository over Git."
         })
         |> load_computers()}

      {:error, reason} ->
        {:noreply,
         socket
         |> assign(:operation_success, nil)
         |> assign(:pairing_error, grant_error(reason))}
    end
  end

  def handle_event("revoke_computer", %{"id" => machine_id}, socket) do
    case Machines.revoke_machine(socket.assigns.current_user, machine_id) do
      {:ok, machine} ->
        {:noreply,
         socket
         |> assign(:operation_success, %{
           id: "revocation-success",
           label: "REVOKED",
           message:
             "Access for \"#{machine.name}\" was revoked. Any active connection was closed."
         })
         |> assign(:pairing_error, nil)
         |> load_computers()}

      {:error, _reason} ->
        {:noreply,
         socket
         |> assign(:operation_success, nil)
         |> assign(:pairing_error, "Computer not found.")}
    end
  end

  @impl true
  def handle_info({:computer_presence, machine_id, presence}, socket)
      when presence in [:online, :offline] do
    case Machines.get_machine(socket.assigns.current_user.id, machine_id) do
      {:ok, machine} ->
        presence_map = Map.put(socket.assigns.presence, machine_id, presence == :online)

        {:noreply,
         socket
         |> assign(:presence, presence_map)
         |> stream_insert(:computers, machine)}

      {:error, :machine_not_found} ->
        {:noreply, socket}
    end
  end

  def handle_info(
        {:machine_updated, %Machine{user_id: user_id} = machine},
        %{assigns: %{current_user: %{id: user_id}}} = socket
      ) do
    presence_map = Map.put(socket.assigns.presence, machine.id, Computer.online?(machine.id))

    {:noreply,
     socket
     |> assign(:presence, presence_map)
     |> stream_insert(:computers, machine)}
  end

  def handle_info({:machine_updated, %Machine{}}, socket), do: {:noreply, socket}

  def handle_info({:machine_revoked, machine_id}, socket) do
    case Machines.get_machine(socket.assigns.current_user.id, machine_id) do
      {:ok, machine} ->
        presence_map = Map.put(socket.assigns.presence, machine.id, false)

        {:noreply,
         socket
         |> assign(:presence, presence_map)
         |> stream_insert(:computers, machine)}

      {:error, :machine_not_found} ->
        {:noreply, socket}
    end
  end

  def handle_info(:refresh_computer_presence, socket) do
    schedule_presence_refresh()
    {:noreply, load_computers(socket)}
  end

  def handle_info(_message, socket), do: {:noreply, socket}

  defp load_computers(socket) do
    user = socket.assigns.current_user
    machines = Machines.list_machines(user.id)
    socket = subscribe_to_computers(socket, machines)

    presence =
      Map.new(machines, fn machine ->
        {machine.id, machine.status == "active" and Computer.online?(machine.id)}
      end)

    # The grants render inside the stream, so they are reloaded and the stream
    # is reset together — an assign that changes streamed content and is not
    # re-streamed with it goes stale on the client.
    grants = Map.new(machines, &{&1.id, Repositories.list_machine_grants(user, &1.id)})

    socket
    |> assign(:presence, presence)
    |> assign(:computer_count, length(machines))
    |> assign(:grantable_repositories, Repositories.list_grantable_repositories(user))
    |> assign(:repository_grants, grants)
    |> stream(:computers, machines, reset: true)
  end

  defp subscribe_to_computers(socket, machines) do
    if connected?(socket) do
      subscribed = socket.assigns.subscribed_computer_ids
      machine_ids = MapSet.new(machines, & &1.id)

      machine_ids
      |> MapSet.difference(subscribed)
      |> Enum.each(&Computer.subscribe/1)

      assign(socket, :subscribed_computer_ids, MapSet.union(subscribed, machine_ids))
    else
      socket
    end
  end

  defp schedule_presence_refresh do
    Process.send_after(self(), :refresh_computer_presence, @presence_refresh_ms)
  end

  defp pairing_error(:pairing_not_found), do: "No pairing found for that code."
  defp pairing_error(:pairing_expired), do: "That pairing code expired. Start over on the CLI."
  defp pairing_error(:pairing_consumed), do: "That pairing code was already used."
  defp pairing_error(:too_many_machines), do: "Computer limit reached. Revoke one to free a slot."
  defp pairing_error(_reason), do: "Pairing failed."

  defp grant_error(:machine_not_owned), do: "Computer not found."
  defp grant_error(:repository_not_allowed), do: "You do not administer that repository."
  defp grant_error(:grant_not_found), do: "That computer has no access to that repository."
  defp grant_error(_reason), do: "Repository access could not be changed."

  defp grants_for(grants, machine), do: Map.get(grants, machine.id, [])

  defp operations_label(operations) do
    if "write" in operations, do: "Read and write", else: "Read only"
  end

  defp online?(machine, presence) do
    machine.status == "active" and Map.get(presence, machine.id, false)
  end

  defp credential_variant("active"), do: :success
  defp credential_variant(_status), do: :warning

  defp policy_label(tier), do: String.upcase(tier) <> " POLICY"

  defp root_label([]), do: "No approved roots"
  defp root_label([_root]), do: "1 approved root"
  defp root_label(roots), do: "#{length(roots)} approved roots"

  @impl true
  def render(assigns) do
    ~H"""
    <Layouts.app
      flash={@flash}
      sidebar_sections={assigns[:sidebar_sections]}
      current_scope={assigns[:current_scope]}
      title="Computers"
    >
      <main id="computers-page" class="app-shell computers-shell">
        <section id="computers-manager" class="computers" aria-label="Paired computers">
          <div class="computers__inner">
            <header class="computers-heading">
              <span class="computers-heading__kicker">YOUR DEVICES</span>
              <h1>Computers</h1>
              <p>
                Give Sarah a governed connection to computers you control. Pairing authorizes
                the declared policy; live presence tells you whether that controller is reachable now.
              </p>
            </header>

            <.card :if={@controller_enabled?} id="pairing-card" class="computers-pairing">
              <div class="computers-pairing__intro">
                <span class="computers-pairing__icon"><.icon name="terminal-lg" /></span>
                <div>
                  <h2>Pair a computer</h2>
                  <p>
                    Run <code>oa computer pair</code> on the computer, then enter its one-time
                    code. Codes expire after ten minutes.
                  </p>
                </div>
              </div>

              <.form
                for={@pairing_form}
                id="pairing-form"
                class="computers-pairing__form"
                phx-submit="approve_pairing"
              >
                <.field class="computers-pairing__field">
                  <.label for={@pairing_form[:code].id}>Pairing code</.label>
                  <.input
                    id={@pairing_form[:code].id}
                    name={@pairing_form[:code].name}
                    value={@pairing_form[:code].value}
                    placeholder="ABCD-EFGH"
                    autocomplete="off"
                    maxlength="9"
                    required
                  />
                </.field>
                <.field class="computers-pairing__field">
                  <label>
                    <input
                      type="checkbox"
                      name="pairing[scoped_forge_credentials_enabled]"
                      value="true"
                    /> Allow scoped forge credentials on this computer
                  </label>
                </.field>
                <.button id="approve-pairing" type="submit" phx-disable-with="Pairing…">
                  Approve pairing
                </.button>
              </.form>
            </.card>

            <.alert
              :if={!@controller_enabled?}
              id="computer-controller-disabled"
              variant={:warning}
              appearance={:notice}
              label="PAIRING UNAVAILABLE"
            >
              <p>
                New pairing codes are disabled in this environment. Existing computers remain
                visible and can still be revoked.
              </p>
            </.alert>

            <div class="computers-notices" aria-live="polite">
              <.alert
                :if={@pairing_error}
                id="pairing-error"
                variant={:danger}
                appearance={:notice}
                label="ATTENTION"
              >
                <p>{@pairing_error}</p>
              </.alert>

              <.alert
                :if={@operation_success}
                id={@operation_success.id}
                variant={:success}
                appearance={:notice}
                label={@operation_success.label}
              >
                <p>{@operation_success.message}</p>
              </.alert>
            </div>

            <section class="computers-list-section" aria-labelledby="computers-list-heading">
              <header class="computers-list-heading">
                <div>
                  <h2 id="computers-list-heading">Paired computers</h2>
                  <p>Credential access and live presence are shown separately.</p>
                </div>
                <.badge variant={:dim}>{@computer_count} total</.badge>
              </header>

              <div id="computers-list" class="computers-list" phx-update="stream">
                <.empty
                  id="computers-empty"
                  class={["hidden", "only:block"]}
                  title="No computers paired"
                >
                  Run the controller on a computer you trust, then approve its one-time code here.
                </.empty>

                <.card
                  :for={{dom_id, machine} <- @streams.computers}
                  id={dom_id}
                  class="computer-card"
                  state={machine.status}
                  data-computer-id={machine.id}
                  data-presence={if(online?(machine, @presence), do: "online", else: "offline")}
                >
                  <div class="computer-card__top">
                    <div class="computer-card__identity">
                      <span class="computer-card__glyph"><.icon name="desktop" /></span>
                      <div>
                        <h3 title={machine.name}>{machine.name}</h3>
                        <span class="computer-card__presence">
                          <.status_indicator
                            state={
                              if(online?(machine, @presence), do: "connected", else: "unavailable")
                            }
                            label={if(online?(machine, @presence), do: "Online", else: "Offline")}
                            decorative
                          />
                          {if(online?(machine, @presence), do: "Online", else: "Offline")}
                        </span>
                      </div>
                    </div>

                    <div class="computer-card__badges">
                      <.badge variant={credential_variant(machine.status)}>
                        {String.upcase(machine.status)} ACCESS
                      </.badge>
                      <.badge variant={:dim}>{policy_label(machine.tier)}</.badge>
                    </div>
                  </div>

                  <dl class="computer-card__facts">
                    <div>
                      <dt>Platform</dt>
                      <dd>{machine.platform || "Not reported"}</dd>
                    </div>
                    <div>
                      <dt>Controller</dt>
                      <dd>{machine.agent_version || "Not reported"}</dd>
                    </div>
                    <div id={"computer-agent-#{machine.id}"}>
                      <dt>Codex</dt>
                      <dd>{codex_runtime(machine.last_probe)}</dd>
                    </div>
                    <div>
                      <dt>Scope</dt>
                      <dd>{root_label(machine.roots)}</dd>
                    </div>
                    <div>
                      <dt>Last seen</dt>
                      <dd>
                        <time
                          :if={machine.last_seen_at}
                          datetime={DateTime.to_iso8601(machine.last_seen_at)}
                        >
                          {Calendar.strftime(machine.last_seen_at, "%Y-%m-%d %H:%M UTC")}
                        </time>
                        <span :if={!machine.last_seen_at}>Never connected</span>
                      </dd>
                    </div>
                  </dl>

                  <section
                    :if={machine.status == "active"}
                    id={"repository-access-#{machine.id}"}
                    class="computer-card__repositories"
                    aria-label={"Repository access for #{machine.name}"}
                  >
                    <h4>Repository access</h4>
                    <p>
                      A computer reaches the forge over Git with its own credential. It can read
                      or write only the repositories granted here.
                    </p>

                    <ul
                      :if={grants_for(@repository_grants, machine) != []}
                      class="computer-card__grants"
                    >
                      <li
                        :for={grant <- grants_for(@repository_grants, machine)}
                        id={"grant-#{machine.id}-#{grant.repository_id}"}
                      >
                        <span class="computer-card__grant-name">
                          {grant.repository.owner}/{grant.repository.name}
                        </span>
                        <.badge variant={:dim}>{operations_label(grant.operations)}</.badge>
                        <.text_button
                          id={"revoke-grant-#{machine.id}-#{grant.repository_id}"}
                          tone={:danger}
                          phx-click="revoke_repository_access"
                          phx-value-id={machine.id}
                          phx-value-repository-id={grant.repository_id}
                          phx-disable-with="Withdrawing…"
                        >
                          Withdraw
                        </.text_button>
                      </li>
                    </ul>

                    <p
                      :if={grants_for(@repository_grants, machine) == []}
                      class="computer-card__grants-empty"
                    >
                      No repositories granted. Git requests from this computer are refused.
                    </p>

                    <.form
                      :if={@grantable_repositories != []}
                      for={@grant_form}
                      id={"repository-grant-form-#{machine.id}"}
                      class="computer-card__grant-form"
                      phx-submit="grant_repository_access"
                    >
                      <input type="hidden" name="grant[machine_id]" value={machine.id} />
                      <.input
                        id={"grant-repository-#{machine.id}"}
                        name="grant[repository_id]"
                        value=""
                        type="select"
                        label="Repository"
                        prompt="Choose a repository"
                        options={
                          Enum.map(
                            @grantable_repositories,
                            &{"#{&1.owner}/#{&1.name}", &1.id}
                          )
                        }
                        required
                      />
                      <.input
                        id={"grant-operations-#{machine.id}"}
                        name="grant[operations]"
                        value="read"
                        type="select"
                        label="Access"
                        options={[{"Read only", "read"}, {"Read and write", "write"}]}
                      />
                      <.button
                        id={"grant-repository-submit-#{machine.id}"}
                        type="submit"
                        phx-disable-with="Granting…"
                      >
                        Grant access
                      </.button>
                    </.form>
                  </section>

                  <footer :if={machine.status == "active"} class="computer-card__actions">
                    <p>Revoking closes its connection and can stop work currently running there.</p>
                    <.text_button
                      id={"credentials-policy-#{machine.id}"}
                      phx-click="update_scoped_forge_credentials"
                      phx-value-id={machine.id}
                      phx-value-enabled={
                        if(machine.scoped_forge_credentials_enabled, do: "false", else: "true")
                      }
                      phx-disable-with="Updating…"
                    >
                      {if(machine.scoped_forge_credentials_enabled,
                        do: "Disable scoped forge credentials",
                        else: "Allow scoped forge credentials"
                      )}
                    </.text_button>
                    <.text_button
                      id={"revoke-#{machine.id}"}
                      tone={:danger}
                      phx-click="revoke_computer"
                      phx-value-id={machine.id}
                      phx-disable-with="Revoking…"
                      data-confirm={"Revoke access for #{machine.name}? Running work on this computer may stop."}
                    >
                      Revoke access
                    </.text_button>
                  </footer>
                </.card>
              </div>
            </section>
          </div>
        </section>
      </main>
    </Layouts.app>
    """
  end

  defp codex_runtime(%{"acp_agents" => agents}) when is_list(agents) do
    case Enum.find(agents, &(is_map(&1) and &1["id"] == "codex")) do
      %{} = agent ->
        [agent["model"], agent["reasoning_effort"], agent["mode"]]
        |> Enum.filter(&(is_binary(&1) and &1 != ""))
        |> case do
          [] -> "Adapter defaults"
          values -> Enum.join(values, " · ")
        end

      nil ->
        "Not available"
    end
  end

  defp codex_runtime(_probe), do: "Not reported"
end