test/openagents/inference/computer_revocation_test.exs

58e6347eeb72 · 8 KB

defmodule OpenAgents.Inference.ComputerRevocationTest do
  @moduledoc """
  IDENTITY-008's authority clause: a revoked computer holds no inference
  authority.

  `inference_grants.machine_id` was written by
  `OpenAgents.Work.DelegationServer` and read by nothing, and the absence was
  the bug. Revoking a computer closed its channel and finished its assignments
  and left its grants `active` — each one a plaintext token already delivered to
  that computer, and `OpenAgentsWeb.InferenceProxyController` authenticates the
  token and never the computer.

  Two properties, because they fail differently.

  **Outstanding grants stop working.** The sweep runs in the same transaction
  that writes the revoked computer row, so a grant minted before the decision is
  terminal the moment the decision commits.

  **A grant minted inside the window does not survive it.** Ecto's sandbox runs
  each test inside one uncommitted transaction, so a mint attempted after
  `revoke_machine/2` returns here is a mint attempted after the revocation
  decided and before it committed — the window itself, not a model of it. It is
  refused twice: by `OpenAgents.Inference.mint/1`, and, with that call bypassed,
  by `inference_grants_refuse_revoked_computer` in PostgreSQL.

  What that pair does not show is a mint on a *different* connection blocking
  until the revocation commits. That rests on the `FOR SHARE` the trigger takes
  on the computer row, which conflicts with the `FOR NO KEY UPDATE` an ordinary
  `UPDATE machines` takes. The last test asserts that lock is in the deployed
  trigger, read back from `pg_get_functiondef` rather than from the migration
  file.
  """

  use OpenAgentsWeb.ConnCase, async: false

  import Ecto.Query

  alias OpenAgents.Inference
  alias OpenAgents.Inference.Grant
  alias OpenAgents.Machines
  alias OpenAgents.Repo

  defp computer(owner, key) do
    {:ok, %{code: code}} =
      Machines.start_pairing(%{
        "name" => "revocation-#{key}",
        "tier" => "curated",
        "platform" => "linux-x64",
        "agent_version" => "0.1.0",
        "roots" => ["/home/x/code"]
      })

    {:ok, machine} = Machines.approve_pairing(owner, code)
    machine
  end

  defp scope(key) do
    owner = github_user("revoke-#{key}")
    {:ok, conversation} = OpenAgents.Conversations.ensure_conversation(owner)

    %{
      owner: owner,
      owner_visitor_id: conversation.visitor_id,
      conversation_id: conversation.id,
      machine: computer(owner, key)
    }
  end

  defp mint!(scope, machine) do
    {:ok, grant, token} =
      Inference.mint(%{
        owner_visitor_id: scope.owner_visitor_id,
        conversation_id: scope.conversation_id,
        machine_id: machine.id
      })

    {grant, token}
  end

  describe "an outstanding grant does not outlive its computer" do
    test "every grant the revoked computer holds becomes terminal" do
      scope = scope("outstanding")
      {first, first_token} = mint!(scope, scope.machine)
      {second, second_token} = mint!(scope, scope.machine)

      assert {:ok, %Grant{}} = Inference.resolve(first_token)
      assert {:ok, %Grant{}} = Inference.resolve(second_token)

      assert {:ok, revoked} = Machines.revoke_machine(scope.owner, scope.machine.id)
      assert revoked.status == "revoked"

      assert {:error, :grant_revoked} = Inference.resolve(first_token)
      assert {:error, :grant_revoked} = Inference.resolve(second_token)

      for id <- [first.id, second.id] do
        stored = Repo.get(Grant, id)
        assert stored.status == "revoked"
        assert stored.revoked_at
      end
    end

    test "the proxy refuses the revoked computer's token", %{conn: conn} do
      scope = scope("proxy")
      {_grant, token} = mint!(scope, scope.machine)

      assert {:ok, _revoked} = Machines.revoke_machine(scope.owner, scope.machine.id)

      conn =
        conn
        |> put_req_header("authorization", "Bearer #{token}")
        |> put_req_header("content-type", "application/json")
        |> post(
          ~p"/api/inference/proxy",
          Jason.encode!(%{"messages" => [%{"role" => "user", "content" => "still here?"}]})
        )

      assert conn.status == 403
      assert Jason.decode!(conn.resp_body) == %{"error" => %{"code" => "grant_revoked"}}
    end

    test "revocation ends authority without touching the fence THREAD-001 requires" do
      scope = scope("fence")
      {grant, _token} = mint!(scope, scope.machine)

      assert {:ok, _revoked} = Machines.revoke_machine(scope.owner, scope.machine.id)

      stored = Repo.get(Grant, grant.id)
      assert stored.status == "revoked"
      assert stored.conversation_id == scope.conversation_id
      assert is_nil(stored.thread_id)
      assert stored.machine_id == scope.machine.id
    end

    test "revocation reaches only the revoked computer's grants" do
      scope = scope("neighbour")
      other = computer(scope.owner, "neighbour-other")

      {_revoked_grant, revoked_token} = mint!(scope, scope.machine)
      {_kept_grant, kept_token} = mint!(scope, other)

      {:ok, _machineless, machineless_token} =
        Inference.mint(%{
          owner_visitor_id: scope.owner_visitor_id,
          conversation_id: scope.conversation_id
        })

      assert {:ok, _} = Machines.revoke_machine(scope.owner, scope.machine.id)

      assert {:error, :grant_revoked} = Inference.resolve(revoked_token)
      assert {:ok, %Grant{status: "active"}} = Inference.resolve(kept_token)
      assert {:ok, %Grant{status: "active"}} = Inference.resolve(machineless_token)
    end

    test "an expired pairing revokes its computer's grants on the same path" do
      owner = github_user("revoke-pairing")
      {:ok, conversation} = OpenAgents.Conversations.ensure_conversation(owner)

      {:ok, %{pairing: pairing, code: code, poll_secret: poll_secret}} =
        Machines.start_pairing(%{
          "name" => "revocation-pairing",
          "tier" => "curated",
          "platform" => "linux-x64",
          "agent_version" => "0.1.0",
          "roots" => ["/home/x/code"]
        })

      {:ok, machine} = Machines.approve_pairing(owner, code)

      {:ok, _grant, token} =
        Inference.mint(%{
          owner_visitor_id: conversation.visitor_id,
          conversation_id: conversation.id,
          machine_id: machine.id
        })

      # Expire the pairing so the claim takes the expiry branch, which revokes
      # the computer it already created.
      Repo.update_all(
        from(p in OpenAgents.Machines.Pairing, where: p.id == ^pairing.id),
        set: [expires_at: DateTime.add(DateTime.utc_now(), -60, :second)]
      )

      assert {:error, :pairing_expired} = Machines.claim_pairing(pairing.id, poll_secret)
      assert Repo.get(OpenAgents.Machines.Machine, machine.id).status == "revoked"
      assert {:error, :grant_revoked} = Inference.resolve(token)
    end
  end

  describe "a grant minted inside the revocation window does not survive it" do
    test "mint/1 refuses and writes nothing" do
      scope = scope("window")

      # The sandbox holds this test in one uncommitted transaction, so the
      # revocation below has decided and has not committed. This is the window.
      assert {:ok, _revoked} = Machines.revoke_machine(scope.owner, scope.machine.id)

      before = Repo.aggregate(Grant, :count)

      assert {:error, :machine_revoked} =
               Inference.mint(%{
                 owner_visitor_id: scope.owner_visitor_id,
                 conversation_id: scope.conversation_id,
                 machine_id: scope.machine.id
               })

      assert Repo.aggregate(Grant, :count) == before
    end

    test "PostgreSQL refuses the same insert with mint/1 bypassed" do
      scope = scope("window-db")
      assert {:ok, _revoked} = Machines.revoke_machine(scope.owner, scope.machine.id)

      attrs = %{
        owner_visitor_id: scope.owner_visitor_id,
        conversation_id: scope.conversation_id,
        machine_id: scope.machine.id,
        model_id: "test-model",
        token_digest: :crypto.hash(:sha256, "sig_bypass"),
        max_total_tokens: 100,
        max_calls: 1,
        max_cost_microusd: 100,
        expires_at: DateTime.add(DateTime.utc_now(), 900, :second)
      }

      error =
        assert_raise Postgrex.Error, fn ->
          attrs |> Grant.mint_changeset() |> Repo.insert()
        end

      assert error.postgres.message =~ "cannot name computer"
      assert error.postgres.message =~ "revoked"
    end

    test "the deployed guard reads the computer row under a conflicting lock" do
      %Postgrex.Result{rows: [[definition]]} =
        Ecto.Adapters.SQL.query!(
          Repo,
          "SELECT pg_get_functiondef('inference_grants_refuse_revoked_computer'::regproc)",
          []
        )

      assert definition =~ "FROM machines"
      # FOR SHARE conflicts with the FOR NO KEY UPDATE an ordinary
      # `UPDATE machines SET status = 'revoked'` takes; the foreign key's own
      # FOR KEY SHARE does not, and would leave the two free to interleave.
      assert definition =~ "FOR SHARE"
    end
  end
end