test/openagents_web/controllers/computer_control_api_test.exs

main at 58e6347eeb72 · 17 KB

defmodule OpenAgentsWeb.ComputerControlApiTest do
  use OpenAgentsWeb.ConnCase, async: false

  import Ecto.Query
  import OpenAgents.AccountsFixtures

  alias OpenAgents.Agents
  alias OpenAgents.ApiTokens
  alias OpenAgents.Forge.{AssignmentCredential, AssignmentCredentialVault, Assignments}
  alias OpenAgents.Issues
  alias OpenAgents.Machines
  alias OpenAgents.Repo
  alias OpenAgents.Support.FakeController
  alias OpenAgents.Work

  @root "/tmp/openagents-computer-control-api"

  test "computer control is an independent human token scope", %{conn: conn} do
    user = github_user("computer-control-scope")

    assert build_conn()
           |> put_api_token(user, ["computer:control"])
           |> get(~p"/api/v1/computers")
           |> json_response(200) == %{
             "schema" => "openagents.computers.v1",
             "computers" => [],
             "pairing_enabled" => true
           }

    assert build_conn()
           |> put_api_token(user, ["box:control"])
           |> get(~p"/api/v1/computers")
           |> json_response(401) == %{"error" => %{"code" => "invalid_api_token"}}

    assert build_conn()
           |> put_api_token(user, ["computer:control"])
           |> get(~p"/api/v1/conversations/not-a-conversation/boxes")
           |> json_response(401) == %{"error" => %{"code" => "invalid_api_token"}}

    assert conn
           |> get(~p"/api/v1/computers")
           |> json_response(401) == %{"error" => %{"code" => "invalid_api_token"}}
  end

  test "human computer tokens list safe capability projections and probe a computer" do
    user = github_user("computer-control-list")
    machine = paired_machine(user, "listed-computer", ["codex"])
    token_conn = build_conn() |> put_api_token(user, ["computer:control"])

    listed =
      token_conn
      |> get(~p"/api/v1/computers")
      |> json_response(200)
      |> get_in(["computers", Access.at(0)])

    assert listed["id"] == machine.id
    assert listed["tier"] == "curated"
    assert listed["roots"] == [@root]
    assert listed["online"] == false
    assert [%{"id" => "codex"}] = listed["acp_agents"]
    refute Map.has_key?(listed, "last_probe")
    refute inspect(listed) =~ "token_digest"

    start_supervised!(
      {FakeController,
       machine_id: machine.id,
       script: fn {:probe, request_id, _payload, caller} ->
         FakeController.exit(caller, request_id, %{
           "acp_agents" => [
             %{"id" => "codex", "version" => "2.0", "private" => "hidden"}
           ],
           "private_probe_detail" => "hidden"
         })
       end}
    )

    probed =
      token_conn
      |> post(~p"/api/v1/computers/#{machine.id}/probe")
      |> json_response(200)

    assert probed["computer"]["online"]
    assert [%{"id" => "codex", "version" => "2.0"}] = probed["computer"]["acp_agents"]
    refute inspect(probed) =~ "private_probe_detail"
    refute inspect(probed) =~ "token_digest"
  end

  test "agent computer grants authorize the linked human authority and revoke immediately" do
    user = github_user("computer-control-agent-owner")
    {:ok, agent, credential} = register_agent("computer-control-agent")
    {:ok, link} = Agents.request_link(agent, user)
    {:ok, _linked} = Agents.accept_link(user, link.id)
    assert {:ok, grant} = Agents.grant_computer_control(user, agent)
    assert grant.target_kind == "computer"
    assert grant.scope == "computer:control"

    machine = paired_machine(user, "agent-computer", ["codex"])

    assert build_conn()
           |> bearer(credential)
           |> get(~p"/api/v1/computers")
           |> json_response(200)
           |> Map.get("computers")
           |> Enum.any?(&(&1["id"] == machine.id))

    assert build_conn()
           |> bearer(credential)
           |> patch(~p"/api/v1/computers/#{machine.id}", %{
             "scoped_forge_credentials_enabled" => true
           })
           |> api_error_code(401) == "unauthenticated"

    assert {:ok, _revoked} = Agents.revoke_computer_control(user, agent)

    assert build_conn()
           |> bearer(credential)
           |> get(~p"/api/v1/computers")
           |> json_response(403) == %{
             "error" => %{"code" => "agent_computer_control_forbidden"}
           }
  end

  test "delegated Box and Computer grants do not cross-authorize routes", %{conn: conn} do
    owner = github_user("delegated-cross-kind-owner")
    {:ok, conversation} = OpenAgents.Conversations.ensure_conversation(owner)

    {:ok, box_agent, box_credential} = register_agent("delegated-box-only")
    {:ok, box_link} = Agents.request_link(box_agent, owner)
    {:ok, _linked} = Agents.accept_link(owner, box_link.id)
    assert {:ok, _box_grant} = Agents.grant_box_control(owner, box_agent)

    assert conn
           |> bearer(box_credential)
           |> get(~p"/api/v1/computers")
           |> json_response(403) == %{
             "error" => %{"code" => "agent_computer_control_forbidden"}
           }

    {:ok, computer_agent, computer_credential} = register_agent("delegated-computer-only")
    {:ok, computer_link} = Agents.request_link(computer_agent, owner)
    {:ok, _linked} = Agents.accept_link(owner, computer_link.id)
    assert {:ok, _computer_grant} = Agents.grant_computer_control(owner, computer_agent)

    assert conn
           |> bearer(computer_credential)
           |> get(~p"/api/v1/conversations/#{conversation.id}/boxes")
           |> json_response(403) == %{
             "error" => %{"code" => "agent_box_control_forbidden"}
           }
  end

  test "computer delegation enters the shared policy authority and scopes jobs to the owner" do
    user = github_user("computer-control-delegation")
    outsider = github_user("computer-control-outsider")
    machine = paired_machine(user, "delegated-computer", ["codex"])
    test_pid = self()

    start_supervised!(
      {FakeController,
       machine_id: machine.id,
       script: fn {:agent, request_id, _payload, caller} ->
         send(test_pid, {:agent_request, request_id, caller})
       end}
    )

    token_conn = build_conn() |> put_api_token(user, ["computer:control"])

    response =
      token_conn
      |> post(~p"/api/v1/computers/#{machine.id}/agent-jobs", %{
        "agent_id" => "codex",
        "prompt" => "do not echo this prompt",
        "cwd" => @root
      })
      |> json_response(202)

    job_id = response["job"]["id"]
    refute inspect(response) =~ "do not echo this prompt"
    assert_receive {:agent_request, _request_id, _caller}, 1_000

    assert build_conn()
           |> put_api_token(outsider, ["computer:control"])
           |> get(~p"/api/v1/computer-agent-jobs/#{job_id}")
           |> json_response(404) == %{"error" => "job_not_found"}

    [{pid, _value}] = Horde.Registry.lookup(OpenAgents.HordeRegistry, {:work_job, job_id})
    ref = Process.monitor(pid)

    assert token_conn
           |> delete(~p"/api/v1/computer-agent-jobs/#{job_id}")
           |> json_response(202)
           |> Map.take(["job_id", "status"])
           |> Map.equal?(%{"job_id" => job_id, "status" => "stopping"})

    assert_receive {:DOWN, ^ref, :process, _pid, :normal}, 1_000
    assert %Work.Job{status: "cancelled"} = Work.get_job!(job_id)
  end

  test "delegation refuses missing probe agents and cwd outside declared roots" do
    user = github_user("computer-control-policy")
    machine = paired_machine(user, "policy-computer", ["claude"])
    start_supervised!({FakeController, machine_id: machine.id, script: fn _request -> :ok end})
    token_conn = build_conn() |> put_api_token(user, ["computer:control"])

    missing_agent =
      token_conn
      |> post(~p"/api/v1/computers/#{machine.id}/agent-jobs", %{
        "agent_id" => "codex",
        "prompt" => "bounded",
        "cwd" => @root
      })
      |> json_response(422)

    assert missing_agent["error"] == "agent_not_available"

    invalid_cwd =
      token_conn
      |> post(~p"/api/v1/computers/#{machine.id}/agent-jobs", %{
        "agent_id" => "claude",
        "prompt" => "bounded",
        "cwd" => "/tmp/outside"
      })
      |> json_response(422)

    assert invalid_cwd["error"] == "cwd_not_allowed"
  end

  test "Computer assignments report opt-in refusal but continue without push authority" do
    user = github_user("computer-assignment-no-opt-in")
    {:ok, conversation} = OpenAgents.Conversations.ensure_conversation(user)
    repository = repository_with_member_fixture(user)
    {:ok, issue} = Issues.create_issue(repository, %{title: "Computer assignment"})
    machine = paired_machine(user, "assignment-no-opt-in", ["codex"])
    test_pid = self()

    start_supervised!(
      {FakeController,
       machine_id: machine.id,
       script: fn {:agent, request_id, payload, caller} ->
         send(test_pid, {:assignment_request, request_id, payload, caller})
       end}
    )

    response =
      build_conn()
      |> put_api_token(user, ["computer:control"])
      |> post(~p"/api/v1/conversations/#{conversation.id}/computers/#{machine.id}/assignments", %{
        "repository_id" => repository.id,
        "issue_number" => issue.number,
        "branch" => "agent/computer-#{issue.number}",
        "agent_id" => "codex",
        "prompt" => "Implement the issue",
        "cwd" => @root
      })
      |> json_response(202)

    assert response["assignment"]["credential_delivery"] == %{
             "status" => "refused",
             "reason" => "computer_scoped_forge_credentials_not_enabled"
           }

    assert_receive {:assignment_request, request_id, payload, caller}, 1_000
    refute Map.has_key?(payload, "assignment_credential")

    assert {:error, :invalid_assignment_credential} =
             Assignments.authenticate("oa_assignment_fake")

    assignment_id = response["assignment"]["id"]

    job =
      Repo.one!(
        from j in Work.Job,
          where: fragment("?->>'assignment_id'", j.delegation) == ^assignment_id
      )

    [{job_pid, _value}] = Horde.Registry.lookup(OpenAgents.HordeRegistry, {:work_job, job.id})
    ref = Process.monitor(job_pid)
    FakeController.exit(caller, request_id, %{"status" => "completed", "output" => "done"})
    assert_receive {:DOWN, ^ref, :process, ^job_pid, :normal}, 1_000
  end

  test "owners can change the scoped forge credential policy and withdraw active credentials" do
    user = github_user("computer-assignment-policy-update")
    {:ok, conversation} = OpenAgents.Conversations.ensure_conversation(user)
    repository = repository_with_member_fixture(user)
    {:ok, issue} = Issues.create_issue(repository, %{title: "Policy update"})
    machine = paired_machine(user, "assignment-policy-update", ["codex"], true)
    test_pid = self()

    controller =
      start_supervised!(
        {FakeController,
         machine_id: machine.id,
         script: fn {:agent, request_id, payload, caller} ->
           send(test_pid, {:assignment_request, request_id, payload, caller})
         end}
      )

    response =
      build_conn()
      |> put_api_token(user, ["computer:control"])
      |> post(~p"/api/v1/conversations/#{conversation.id}/computers/#{machine.id}/assignments", %{
        "repository_id" => repository.id,
        "issue_number" => issue.number,
        "branch" => "agent/policy-#{issue.number}",
        "agent_id" => "codex",
        "prompt" => "Implement the issue",
        "cwd" => @root
      })
      |> json_response(202)

    assignment_id = response["assignment"]["id"]
    assert response["assignment"]["credential_delivery"] == %{"status" => "enabled"}
    assert_receive {:assignment_request, _request_id, _payload, _caller}, 1_000

    updated =
      build_conn()
      |> put_api_token(user, ["computer:control"])
      |> patch(~p"/api/v1/computers/#{machine.id}", %{
        "scoped_forge_credentials_enabled" => false
      })
      |> json_response(200)

    assert updated["computer"]["scoped_forge_credentials_enabled"] == false
    assignment = Repo.get!(OpenAgents.Forge.Assignment, assignment_id)
    assert assignment.state == "failed"
    assert assignment.failure_reason == "scoped_forge_credentials_disabled"
    assert assignment.credential_delivery_status == "enabled"
    assert Assignments.credential(assignment).revoked_at
    assert AssignmentCredentialVault.take(assignment_id) == nil

    job =
      Repo.one!(
        from j in Work.Job,
          where: fragment("?->>'assignment_id'", j.delegation) == ^assignment_id
      )

    [{job_pid, _value}] = Horde.Registry.lookup(OpenAgents.HordeRegistry, {:work_job, job.id})
    ref = Process.monitor(job_pid)
    assert GenServer.stop(controller, :normal) == :ok
    assert_receive {:DOWN, ^ref, :process, ^job_pid, :normal}, 1_000
  end

  test "controller disconnect finishes the assignment and revokes its credential" do
    user = github_user("computer-assignment-disconnect")
    {:ok, conversation} = OpenAgents.Conversations.ensure_conversation(user)
    repository = repository_with_member_fixture(user)
    {:ok, issue} = Issues.create_issue(repository, %{title: "Controller disconnect"})
    machine = paired_machine(user, "assignment-disconnect", ["codex"], true)
    test_pid = self()

    controller =
      start_supervised!(
        {FakeController,
         machine_id: machine.id,
         script: fn {:agent, request_id, payload, caller} ->
           send(test_pid, {:assignment_request, request_id, payload, caller})
         end}
      )

    response =
      build_conn()
      |> put_api_token(user, ["computer:control"])
      |> post(~p"/api/v1/conversations/#{conversation.id}/computers/#{machine.id}/assignments", %{
        "repository_id" => repository.id,
        "issue_number" => issue.number,
        "branch" => "agent/disconnect-#{issue.number}",
        "agent_id" => "codex",
        "prompt" => "Implement the issue",
        "cwd" => @root
      })
      |> json_response(202)

    assignment_id = response["assignment"]["id"]
    assert_receive {:assignment_request, _request_id, _payload, _caller}, 1_000

    job =
      Repo.one!(
        from j in Work.Job,
          where: fragment("?->>'assignment_id'", j.delegation) == ^assignment_id
      )

    [{job_pid, _value}] = Horde.Registry.lookup(OpenAgents.HordeRegistry, {:work_job, job.id})
    ref = Process.monitor(job_pid)
    assert GenServer.stop(controller, :normal) == :ok
    assert_receive {:DOWN, ^ref, :process, ^job_pid, :normal}, 1_000

    assignment = Repo.get!(OpenAgents.Forge.Assignment, assignment_id)
    assert assignment.state == "failed"
    assert assignment.failure_reason == "machine_disconnected"
    assert Assignments.credential(assignment).revoked_at
    assert AssignmentCredentialVault.take(assignment_id) == nil
  end

  test "Computer assignment credentials are sent only in the agent frame and revoked on completion" do
    user = github_user("computer-assignment-opt-in")
    {:ok, conversation} = OpenAgents.Conversations.ensure_conversation(user)
    repository = repository_with_member_fixture(user)
    {:ok, issue} = Issues.create_issue(repository, %{title: "Computer assignment credential"})
    machine = paired_machine(user, "assignment-opt-in", ["codex"], true)
    test_pid = self()

    start_supervised!(
      {FakeController,
       machine_id: machine.id,
       script: fn {:agent, request_id, payload, caller} ->
         send(test_pid, {:assignment_request, request_id, payload, caller})
       end}
    )

    response =
      build_conn()
      |> put_api_token(user, ["computer:control"])
      |> post(~p"/api/v1/conversations/#{conversation.id}/computers/#{machine.id}/assignments", %{
        "repository_id" => repository.id,
        "issue_number" => issue.number,
        "branch" => "agent/computer-#{issue.number}",
        "agent_id" => "codex",
        "prompt" => "Implement the issue",
        "cwd" => @root
      })
      |> json_response(202)

    assignment_id = response["assignment"]["id"]
    assert_receive {:assignment_request, request_id, payload, caller}, 1_000
    credential = payload["assignment_credential"]
    assert is_binary(credential)
    assert String.starts_with?(credential, "oa_assignment_")
    refute response |> inspect() =~ credential
    assignment = Repo.get!(OpenAgents.Forge.Assignment, assignment_id)
    assert %AssignmentCredential{} = Assignments.credential(assignment)

    job =
      Repo.one!(
        from j in Work.Job,
          where: fragment("?->>'assignment_id'", j.delegation) == ^assignment_id
      )

    [{job_pid, _value}] = Horde.Registry.lookup(OpenAgents.HordeRegistry, {:work_job, job.id})

    ref = Process.monitor(job_pid)
    FakeController.exit(caller, request_id, %{"status" => "completed", "output" => "done"})
    assert_receive {:DOWN, ^ref, :process, ^job_pid, :normal}, 1_000

    assignment = Repo.get!(OpenAgents.Forge.Assignment, assignment_id)
    assert assignment.state == "completed"
    assert Assignments.credential(assignment).revoked_at
    assert AssignmentCredentialVault.take(assignment_id) == nil
    refute Repo.get!(Work.Job, job.id).report =~ credential
  end

  defp paired_machine(user, name, agent_ids, scoped_forge_credentials_enabled \\ false) do
    assert {:ok, pairing} =
             Machines.start_pairing(%{
               "name" => name,
               "tier" => "curated",
               "platform" => "linux-x64",
               "agent_version" => "0.4.0",
               "roots" => [@root]
             })

    assert {:ok, machine} =
             Machines.approve_pairing(user, pairing.code,
               scoped_forge_credentials_enabled: scoped_forge_credentials_enabled
             )

    assert {:ok, machine} =
             Machines.store_probe(machine, %{
               "acp_agents" => Enum.map(agent_ids, &%{"id" => &1, "version" => "1.0"})
             })

    machine
  end

  defp register_agent(handle) do
    Agents.register(%{
      "handle" => handle,
      "display_name" => handle,
      "registration_ip" => "192.0.2.125"
    })
  end

  defp put_api_token(conn, user, scopes) do
    {:ok, _token, plaintext} =
      ApiTokens.create(user, %{name: "Computer API test", scopes: scopes, lifetime_days: 1})

    bearer(conn, plaintext)
  end

  defp bearer(conn, token), do: put_req_header(conn, "authorization", "Bearer " <> token)
end