test/openagents_web/controllers/delegations_controller_test.exs

58e6347eeb72 · 16 KB

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

  alias OpenAgents.{Agents, ApiTokens, Conversations, Machines, Repo, Work}
  alias OpenAgents.Box.ConversationBox
  alias OpenAgents.Box.Run
  alias OpenAgents.Support.FakeController
  alias OpenAgents.Work.Job

  @root "/tmp/openagents-unified-delegation"

  test "inventory lists only the target kinds held by the credential", %{conn: conn} do
    user = github_user("unified-inventory")
    {:ok, conversation} = Conversations.ensure_conversation(user)
    box = insert_box(conversation.id, "bx-unified-inventory", "build-box")
    machine_id = paired_machine(user, "build-computer", ["codex"]).id

    response =
      conn
      |> put_api_token(user, ["computer:control"])
      |> get(~p"/api/v1/conversations/#{conversation.id}/delegation-targets")
      |> json_response(200)

    assert response["schema"] == "openagents.delegation_targets.v1"
    assert [%{"kind" => "computer", "computer_id" => ^machine_id}] = response["targets"]
    refute Enum.any?(response["targets"], &(&1["id"] == "box:#{box.id}"))

    response =
      build_conn()
      |> put_api_token(user, ["box:control"])
      |> get(~p"/api/v1/conversations/#{conversation.id}/delegation-targets")
      |> json_response(200)

    assert [%{"kind" => "box", "id" => "box:" <> _}] = response["targets"]
  end

  test "inventory isolates foreign conversations and target identifiers", %{conn: conn} do
    owner = github_user("unified-owner")
    outsider = github_user("unified-outsider")
    {:ok, conversation} = Conversations.ensure_conversation(owner)
    box = insert_box(conversation.id, "bx-unified-private", "private-box")

    response =
      conn
      |> put_api_token(outsider, ["box:control"])
      |> get(~p"/api/v1/conversations/#{conversation.id}/delegation-targets")
      |> json_response(404)

    assert response == %{"error" => %{"code" => "conversation_not_found"}}

    response =
      build_conn()
      |> put_api_token(owner, ["box:control"])
      |> get(
        ~p"/api/v1/conversations/#{conversation.id}/delegations/box-run:#{Ecto.UUID.generate()}"
      )
      |> json_response(404)

    assert response == %{"error" => %{"code" => "delegation_not_found"}}
    assert box.label == "private-box"
  end

  test "malformed and unknown delegation identifiers have the missing response", %{conn: conn} do
    user = github_user("unified-identifiers")
    {:ok, conversation} = Conversations.ensure_conversation(user)

    for id <- ["not-an-id", "box:not-an-id", "computer-job:not-an-id"] do
      response =
        conn
        |> put_api_token(user, ["box:control", "computer:control"])
        |> get(~p"/api/v1/conversations/#{conversation.id}/delegations/#{id}")
        |> json_response(404)

      assert response == %{"error" => %{"code" => "delegation_not_found"}}
    end
  end

  test "computer delegation preserves typed policy refusals and redaction", %{conn: conn} do
    user = github_user("unified-computer-policy")
    {:ok, conversation} = Conversations.ensure_conversation(user)
    machine = paired_machine(user, "policy-computer", ["claude"])
    start_supervised!({FakeController, machine_id: machine.id, script: fn _request -> :ok end})

    token_conn = put_api_token(conn, user, ["computer:control"])

    missing_agent =
      token_conn
      |> post(~p"/api/v1/conversations/#{conversation.id}/delegations", %{
        "target_id" => "computer:#{machine.id}",
        "agent_id" => "codex",
        "prompt" => "private prompt",
        "cwd" => @root
      })
      |> json_response(422)

    assert missing_agent == %{"error" => %{"code" => "agent_not_available"}}
    refute inspect(missing_agent) =~ "private prompt"

    invalid_cwd =
      build_conn()
      |> put_api_token(user, ["computer:control"])
      |> post(~p"/api/v1/conversations/#{conversation.id}/delegations", %{
        "target_id" => "computer:#{machine.id}",
        "agent_id" => "claude",
        "prompt" => "private prompt",
        "cwd" => "/tmp/outside"
      })
      |> json_response(422)

    assert invalid_cwd == %{"error" => %{"code" => "cwd_not_allowed"}}
  end

  test "an offline Computer is refused without creating a queued job", %{conn: conn} do
    user = github_user("unified-computer-offline")
    {:ok, conversation} = Conversations.ensure_conversation(user)
    machine = paired_machine(user, "offline-computer", ["codex"])
    before = Repo.aggregate(Work.Job, :count)

    response =
      conn
      |> put_api_token(user, ["computer:control"])
      |> post(~p"/api/v1/conversations/#{conversation.id}/delegations", %{
        "target_id" => "computer:#{machine.id}",
        "agent_id" => "codex",
        "prompt" => "do not queue",
        "cwd" => @root
      })
      |> json_response(409)

    assert response == %{"error" => %{"code" => "computer_offline"}}
    assert Repo.aggregate(Work.Job, :count) == before
  end

  test "human control scopes refuse the opposite target kind", %{conn: conn} do
    user = github_user("unified-scope-independence")
    {:ok, conversation} = Conversations.ensure_conversation(user)
    machine = paired_machine(user, "scope-computer", ["codex"])
    box = insert_box(conversation.id, "bx-unified-scope", "scope-box")

    response =
      conn
      |> put_api_token(user, ["box:control"])
      |> post(~p"/api/v1/conversations/#{conversation.id}/delegations", %{
        "target_id" => "computer:#{machine.id}",
        "agent_id" => "codex",
        "prompt" => "bounded",
        "cwd" => @root
      })
      |> json_response(403)

    assert response == %{"error" => %{"code" => "insufficient_scope"}}

    response =
      build_conn()
      |> put_api_token(user, ["computer:control"])
      |> post(~p"/api/v1/conversations/#{conversation.id}/delegations", %{
        "target_id" => "box:#{box.id}",
        "command" => "echo bounded"
      })
      |> json_response(403)

    assert response == %{"error" => %{"code" => "insufficient_scope"}}
  end

  test "delegated grants refuse the opposite target kind", %{conn: conn} do
    user = github_user("unified-delegated-scope")
    {:ok, conversation} = Conversations.ensure_conversation(user)
    machine = paired_machine(user, "delegated-scope-computer", ["codex"])
    box = insert_box(conversation.id, "bx-delegated-scope", "delegated-scope-box")
    run = insert_run(conversation.id, box.id, "completed", "bounded")
    job = insert_job(conversation, machine, "private prompt", "bounded")

    {:ok, box_agent, box_credential} = register_agent("unified-box-only")
    {:ok, box_link} = Agents.request_link(box_agent, user)
    {:ok, _link} = Agents.accept_link(user, box_link.id)
    {:ok, _grant} = Agents.grant_box_control(user, box_agent)

    response =
      conn
      |> bearer(box_credential)
      |> post(~p"/api/v1/conversations/#{conversation.id}/delegations", %{
        "target_id" => "computer:#{machine.id}",
        "agent_id" => "codex",
        "prompt" => "must refuse",
        "cwd" => @root
      })
      |> json_response(403)

    assert response == %{"error" => %{"code" => "agent_computer_control_forbidden"}}

    response =
      build_conn()
      |> bearer(box_credential)
      |> get(~p"/api/v1/conversations/#{conversation.id}/delegations/computer-job:#{job.id}")
      |> json_response(403)

    assert response == %{"error" => %{"code" => "agent_computer_control_forbidden"}}

    response =
      build_conn()
      |> bearer(box_credential)
      |> delete(~p"/api/v1/conversations/#{conversation.id}/delegations/computer-job:#{job.id}")
      |> json_response(403)

    assert response == %{"error" => %{"code" => "agent_computer_control_forbidden"}}

    {:ok, computer_agent, computer_credential} = register_agent("unified-computer-only")
    {:ok, computer_link} = Agents.request_link(computer_agent, user)
    {:ok, _link} = Agents.accept_link(user, computer_link.id)
    {:ok, _grant} = Agents.grant_computer_control(user, computer_agent)

    response =
      build_conn()
      |> bearer(computer_credential)
      |> post(~p"/api/v1/conversations/#{conversation.id}/delegations", %{
        "target_id" => "box:#{box.id}",
        "command" => "must refuse"
      })
      |> json_response(403)

    assert response == %{"error" => %{"code" => "agent_box_control_forbidden"}}

    response =
      build_conn()
      |> bearer(computer_credential)
      |> get(~p"/api/v1/conversations/#{conversation.id}/delegations/box-run:#{run.id}")
      |> json_response(403)

    assert response == %{"error" => %{"code" => "agent_box_control_forbidden"}}

    response =
      build_conn()
      |> bearer(computer_credential)
      |> delete(~p"/api/v1/conversations/#{conversation.id}/delegations/box-run:#{run.id}")
      |> json_response(403)

    assert response == %{"error" => %{"code" => "agent_box_control_forbidden"}}
  end

  test "an unlinked agent cannot start, read, or cancel a delegation", %{conn: conn} do
    user = github_user("unified-unlinked-agent")
    {:ok, conversation} = Conversations.ensure_conversation(user)
    box = insert_box(conversation.id, "bx-unified-unlinked", "unlinked-box")
    run = insert_run(conversation.id, box.id, "completed", "bounded")
    {:ok, agent, credential} = register_agent("unified-unlinked")
    {:ok, link} = Agents.request_link(agent, user)
    {:ok, _link} = Agents.accept_link(user, link.id)
    {:ok, _grant} = Agents.grant_box_control(user, agent)
    {:ok, _unlinked} = Agents.unlink(agent, user)

    response =
      conn
      |> bearer(credential)
      |> post(~p"/api/v1/conversations/#{conversation.id}/delegations", %{
        "target_id" => "box:#{box.id}",
        "command" => "must refuse"
      })
      |> json_response(404)

    assert response == %{"error" => %{"code" => "target_not_found"}}

    response =
      build_conn()
      |> bearer(credential)
      |> get(~p"/api/v1/conversations/#{conversation.id}/delegations/box-run:#{run.id}")
      |> json_response(404)

    assert response == %{"error" => %{"code" => "delegation_not_found"}}

    response =
      build_conn()
      |> bearer(credential)
      |> delete(~p"/api/v1/conversations/#{conversation.id}/delegations/box-run:#{run.id}")
      |> json_response(404)

    assert response == %{"error" => %{"code" => "delegation_not_found"}}
  end

  test "a revoked grant cannot start, read, or cancel a delegation", %{conn: conn} do
    user = github_user("unified-revoked-grant")
    {:ok, conversation} = Conversations.ensure_conversation(user)
    box = insert_box(conversation.id, "bx-unified-revoked", "revoked-box")
    run = insert_run(conversation.id, box.id, "completed", "bounded")
    {:ok, agent, credential} = register_agent("unified-revoked")
    {:ok, link} = Agents.request_link(agent, user)
    {:ok, _link} = Agents.accept_link(user, link.id)
    {:ok, _grant} = Agents.grant_box_control(user, agent)
    {:ok, _revoked} = Agents.revoke_box_control(user, agent)

    response =
      conn
      |> bearer(credential)
      |> post(~p"/api/v1/conversations/#{conversation.id}/delegations", %{
        "target_id" => "box:#{box.id}",
        "command" => "must refuse"
      })
      |> json_response(403)

    assert response == %{"error" => %{"code" => "agent_box_control_forbidden"}}

    response =
      build_conn()
      |> bearer(credential)
      |> get(~p"/api/v1/conversations/#{conversation.id}/delegations/box-run:#{run.id}")
      |> json_response(403)

    assert response == %{"error" => %{"code" => "agent_box_control_forbidden"}}

    response =
      build_conn()
      |> bearer(credential)
      |> delete(~p"/api/v1/conversations/#{conversation.id}/delegations/box-run:#{run.id}")
      |> json_response(403)

    assert response == %{"error" => %{"code" => "agent_box_control_forbidden"}}
  end

  test "status projects durable Box and Computer records through one envelope", %{conn: conn} do
    user = github_user("unified-status")
    {:ok, conversation} = Conversations.ensure_conversation(user)
    box = insert_box(conversation.id, "bx-unified-status", "status-box")

    run =
      insert_run(
        conversation.id,
        box.id,
        "completed",
        "box output https://viewer.ascii.dev/private"
      )

    machine = paired_machine(user, "status-computer", ["codex"])
    job = insert_job(conversation, machine, "private prompt", "private prompt report")

    response =
      conn
      |> put_api_token(user, ["box:control", "computer:control"])
      |> get(~p"/api/v1/conversations/#{conversation.id}/delegations/box-run:#{run.id}")
      |> json_response(200)

    assert response["delegation"]["kind"] == "box"
    assert response["delegation"]["state"] == "completed"
    assert response["delegation"]["target_id"] == "box:#{box.id}"
    refute inspect(response) =~ "viewer.ascii.dev"

    response =
      build_conn()
      |> put_api_token(user, ["computer:control"])
      |> get(~p"/api/v1/conversations/#{conversation.id}/delegations/computer-job:#{job.id}")
      |> json_response(200)

    assert response["delegation"]["kind"] == "computer"
    assert response["delegation"]["state"] == "completed"
    assert response["delegation"]["output"] == "[redacted] report"
    refute inspect(response) =~ "private prompt"
  end

  defp insert_box(conversation_id, box_id, label) do
    %ConversationBox{}
    |> ConversationBox.changeset(%{
      conversation_id: conversation_id,
      box_id: box_id,
      label: label,
      state: "ready",
      setup_status: "done"
    })
    |> Repo.insert!()
  end

  defp insert_run(conversation_id, conversation_box_id, state, output) do
    now = DateTime.utc_now() |> DateTime.truncate(:microsecond)

    %Run{}
    |> Run.changeset(%{
      conversation_id: conversation_id,
      conversation_box_id: conversation_box_id,
      requesting_principal: %{"type" => "user"},
      command: "echo status",
      idempotency_key: Ecto.UUID.generate(),
      state: state,
      output: output,
      run_directory: "/tmp/openagents/unified-status",
      admitted_at: now,
      deadline_at: DateTime.add(now, 60, :second),
      finished_at: now,
      exit_status: 0
    })
    |> Repo.insert!()
  end

  defp insert_job(conversation, machine, prompt, report) do
    delegation = %{
      "agent_id" => "codex",
      "machine_id" => machine.id,
      "machine_name" => machine.name,
      "prompt" => prompt,
      "timeout_ms" => 3_600_000,
      "cwd" => @root
    }

    {:ok, job} =
      %Job{}
      |> Job.create_changeset(%{
        conversation_id: conversation.id,
        owner_visitor_id: conversation.visitor_id,
        machine_id: machine.id,
        surface: "text",
        kind: "delegation",
        goal: "status",
        delegation: delegation,
        authority_snapshot: %{
          "machine_tier" => machine.tier,
          "roots" => machine.roots,
          "cwd" => @root,
          "agent_id" => "codex",
          "machine_name" => machine.name
        },
        budget_snapshot: %{
          "wall_clock_ms" => 3_600_000,
          "maximum_prompt_bytes" => 8_000,
          "maximum_report_bytes" => 8_000
        }
      })
      |> Repo.insert()

    job =
      job
      |> Job.lifecycle_changeset(%{status: "running", started_at: DateTime.utc_now()})
      |> Repo.update!()

    job
    |> Job.lifecycle_changeset(%{
      status: "completed",
      report: report,
      completed_at: DateTime.utc_now() |> DateTime.truncate(:microsecond)
    })
    |> Repo.update!()
  end

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

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

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

    machine
  end

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

    put_req_header(conn, "authorization", "Bearer " <> plaintext)
  end

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

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