Unify text and voice tool authority

9b37da1c6125 · AtlantisPleb · · parent 087167a907f2

Unify text and voice tool authority

Deploy story

What this commit did to the running system — joined from the forge receipt chain, the part a commit page elsewhere cannot show.

Not deployed through the forge lane

No push, promotion, build, or deploy receipt references this commit (receipts are scanned over a bounded recent window). Changes shipped by full node replacement carry their proof in the release gate receipt instead.

Changed files

  • added lib/openagents/tools/conversation_execution_context.ex
  • modified lib/openagents/turns/turn_server.ex
  • modified lib/openagents/voice_sessions/session_server.ex
  • added test/openagents/tools/conversation_execution_context_test.exs

Diff

4 files changed, +169 -78

lib/openagents/tools/conversation_execution_context.ex added +68

@@ -0,0 +1,68 @@

1
defmodule OpenAgents.Tools.ConversationExecutionContext do
2
  @moduledoc """
3
  Builds the shared tool execution context for text and voice conversations.
4
5
  Text and voice are two transports over one authority boundary. Keep every
6
  conversation-scoped authority and approval receipt here so adding a tool to
7
  one surface cannot silently leave the other surface behind.
8
  """
9
10
  alias OpenAgents.Accounts.User
11
  alias OpenAgents.Machines
12
  alias OpenAgents.Repo
13
  alias OpenAgents.SCV.Deployments
14
  alias OpenAgents.Tools.ExecutionContext
15
16
  @authorities MapSet.new([
17
                 "computer.control",
18
                 "conversation.read",
19
                 "github.read",
20
                 "memory.read",
21
                 "memory.write",
22
                 "module.discover",
23
                 "scv.deploy",
24
                 "work.delegate"
25
               ])
26
27
  @doc "The authority set shared by every conversation transport."
28
  @spec authorities() :: MapSet.t(String.t())
29
  def authorities, do: @authorities
30
31
  @doc "Build a tool execution context for a text or voice conversation."
32
  @spec build(map()) :: ExecutionContext.t()
33
  def build(
34
        %{
35
          surface: surface,
36
          conversation_id: conversation_id,
37
          owner_visitor_id: owner_visitor_id,
38
          owner_user_id: owner_user_id
39
        } = attributes
40
      )
41
      when surface in ["text", "voice"] and is_binary(conversation_id) and
42
             is_binary(owner_visitor_id) do
43
    scope_ref = "conversation:#{conversation_id}"
44
45
    %ExecutionContext{
46
      scope: "browser_conversation",
47
      scope_ref: scope_ref,
48
      authorities: authorities(),
49
      approval_receipts: approval_receipts(owner_user_id, scope_ref),
50
      surface: surface,
51
      conversation_id: conversation_id,
52
      current_user_message_id: Map.get(attributes, :current_user_message_id),
53
      owner_visitor_id: owner_visitor_id,
54
      memory_snapshot_ref: Map.get(attributes, :memory_snapshot_ref),
55
      profile_memory_snapshot_ref: Map.get(attributes, :profile_memory_snapshot_ref),
56
      module_registry_snapshot: Map.get(attributes, :module_registry_snapshot)
57
    }
58
  end
59
60
  defp approval_receipts(user_id, scope_ref) when is_binary(user_id) do
61
    user = Repo.get(User, user_id)
62
63
    Machines.approval_receipts(user_id, scope_ref) ++
64
      Deployments.approval_receipts(user, scope_ref)
65
  end
66
67
  defp approval_receipts(_user_id, _scope_ref), do: []
68
end
lib/openagents/turns/turn_server.ex modified +14 -46

@@ -20,7 +20,7 @@ defmodule OpenAgents.Turns.TurnServer do

20 20
  }
21 21
22 22
  alias OpenAgents.Providers.{ProviderEvent, Request, ToolOutput}
23
  alias OpenAgents.Tools.{ExecutionContext, Registry, Runner}
23
  alias OpenAgents.Tools.{ConversationExecutionContext, Registry, Runner}
24 24
25 25
  @maximum_tool_calls 16
26 26
  @maximum_continuations 16

@@ -346,7 +346,7 @@ defmodule OpenAgents.Turns.TurnServer do

346 346
      required_side_effect: "read_only",
347 347
      surface: "text",
348 348
      data_scope: "browser_conversation",
349
      authorities: execution_authorities(),
349
      authorities: ConversationExecutionContext.authorities(),
350 350
      exact_proposal: true
351 351
    })
352 352
  end

@@ -367,7 +367,7 @@ defmodule OpenAgents.Turns.TurnServer do

367 367
      required_side_effect: Atom.to_string(tool.side_effect),
368 368
      surface: "text",
369 369
      data_scope: tool.required_scope,
370
      authorities: execution_authorities(),
370
      authorities: ConversationExecutionContext.authorities(),
371 371
      proposal: proposal,
372 372
      exact_proposal: true
373 373
    })

@@ -432,19 +432,17 @@ defmodule OpenAgents.Turns.TurnServer do

432 432
        raw_arguments: pending_tool.raw_arguments
433 433
      }
434 434
435
      execution_context = %ExecutionContext{
436
        scope: "browser_conversation",
437
        scope_ref: "conversation:#{state.turn.conversation_id}",
438
        authorities: execution_authorities(),
439
        approval_receipts: machine_approval_receipts(state),
440
        surface: "text",
441
        conversation_id: state.turn.conversation_id,
442
        current_user_message_id: state.turn.user_message_id,
443
        owner_visitor_id: state.owner.id,
444
        memory_snapshot_ref: state.receipt.memory_snapshot_ref,
445
        profile_memory_snapshot_ref: state.profile_memory_snapshot.ref,
446
        module_registry_snapshot: state.tool_snapshot
447
      }
435
      execution_context =
436
        ConversationExecutionContext.build(%{
437
          surface: "text",
438
          conversation_id: state.turn.conversation_id,
439
          current_user_message_id: state.turn.user_message_id,
440
          owner_visitor_id: state.owner.id,
441
          owner_user_id: state.owner.user_id,
442
          memory_snapshot_ref: state.receipt.memory_snapshot_ref,
443
          profile_memory_snapshot_ref: state.profile_memory_snapshot.ref,
444
          module_registry_snapshot: state.tool_snapshot
445
        })
448 446
449 447
      cancellation = state.cancellation
450 448
      snapshot = state.tool_snapshot

@@ -802,36 +800,6 @@ defmodule OpenAgents.Turns.TurnServer do

802 800
    }
803 801
  end
804 802
805
  # Approval receipts admitted for this turn's owner: one per paired machine
806
  # (the pairing IS the operator approval), plus the SCV deployment receipt
807
  # when — and only when — this account is an OpenAgents operator. A
808
  # non-operator turn simply carries no SCV receipt, so `SurfacePolicy` refuses
809
  # the call independently of the check inside `SCV.Deployments.start/2`.
810
  defp machine_approval_receipts(state) do
811
    scope_ref = "conversation:#{state.turn.conversation_id}"
812
813
    Machines.approval_receipts(state.owner.user_id, scope_ref) ++
814
      OpenAgents.SCV.Deployments.approval_receipts(owner_account(state), scope_ref)
815
  end
816
817
  defp owner_account(%{owner: %{user_id: user_id}}) when is_binary(user_id),
818
    do: OpenAgents.Repo.get(OpenAgents.Accounts.User, user_id)
819
820
  defp owner_account(_state), do: nil
821
822
  defp execution_authorities,
823
    do:
824
      MapSet.new([
825
        "computer.control",
826
        "conversation.read",
827
        "github.read",
828
        "memory.read",
829
        "memory.write",
830
        "module.discover",
831
        "scv.deploy",
832
        "work.delegate"
833
      ])
834
835 803
  defp host_attribution_policy do
836 804
    policy = %{
837 805
      "id" => "sarah.attribution.host.v1",
lib/openagents/voice_sessions/session_server.ex modified +13 -32

@@ -6,9 +6,9 @@ defmodule OpenAgents.VoiceSessions.SessionServer do

6 6
  require Logger
7 7
8 8
  alias OpenAgents.Voice
9
  alias OpenAgents.{Conversations, Machines, Repo}
9
  alias OpenAgents.{Conversations, Repo}
10 10
  alias OpenAgents.Conversations.Conversation
11
  alias OpenAgents.Tools.{ExecutionContext, Runner}
11
  alias OpenAgents.Tools.{ConversationExecutionContext, Runner}
12 12
  alias OpenAgents.Tools.Registry, as: ToolRegistry
13 13
14 14
  alias OpenAgents.Voice.{

@@ -654,36 +654,17 @@ defmodule OpenAgents.VoiceSessions.SessionServer do

654 654
655 655
    context = state.response_context
656 656
657
    execution_context = %ExecutionContext{
658
      scope: "browser_conversation",
659
      scope_ref: "conversation:#{state.session.conversation_id}",
660
      # Voice carries the same computer authorities AND the same machine
661
      # approval receipts as a text turn: pairing a machine in the browser is
662
      # the operator's approval, so effectful computer_run/computer_agent work
663
      # from voice too. The machine's own local policy stays the real guardrail
664
      # (the audit's "machine is the policy authority").
665
      authorities:
666
        MapSet.new([
667
          "conversation.read",
668
          "github.read",
669
          "memory.read",
670
          "memory.write",
671
          "computer.control",
672
          "module.discover",
673
          "work.delegate"
674
        ]),
675
      approval_receipts:
676
        Machines.approval_receipts(
677
          state.owner.user_id,
678
          "conversation:#{state.session.conversation_id}"
679
        ),
680
      surface: "voice",
681
      conversation_id: state.session.conversation_id,
682
      current_user_message_id: context.user_message_id,
683
      owner_visitor_id: state.owner.id,
684
      memory_snapshot_ref: context.memory_snapshot_ref,
685
      profile_memory_snapshot_ref: context.profile_memory_snapshot_ref
686
    }
657
    execution_context =
658
      ConversationExecutionContext.build(%{
659
        surface: "voice",
660
        conversation_id: state.session.conversation_id,
661
        current_user_message_id: context.user_message_id,
662
        owner_visitor_id: state.owner.id,
663
        owner_user_id: state.owner.user_id,
664
        memory_snapshot_ref: context.memory_snapshot_ref,
665
        profile_memory_snapshot_ref: context.profile_memory_snapshot_ref,
666
        module_registry_snapshot: state.tool_snapshot
667
      })
687 668
688 669
    cancellation = :atomics.new(1, [])
689 670
    snapshot = state.tool_snapshot
test/openagents/tools/conversation_execution_context_test.exs added +74

@@ -0,0 +1,74 @@

1
defmodule OpenAgents.Tools.ConversationExecutionContextTest do
2
  use OpenAgents.DataCase, async: false
3
4
  alias OpenAgents.AccountsFixtures
5
  alias OpenAgents.Conversations
6
  alias OpenAgents.Modules.SurfacePolicy
7
  alias OpenAgents.Tools.{ConversationExecutionContext, Registry, ScvDeploy}
8
9
  setup do
10
    original_admin_ids = Application.get_env(:openagents, :admin_github_ids, [])
11
12
    on_exit(fn ->
13
      Application.put_env(:openagents, :admin_github_ids, original_admin_ids)
14
    end)
15
16
    :ok
17
  end
18
19
  test "text and voice share conversation authorities and operator receipts" do
20
    user = AccountsFixtures.repository_user_fixture("conversation-context-operator")
21
    {:ok, conversation} = Conversations.ensure_conversation(user)
22
23
    Application.put_env(:openagents, :admin_github_ids, [user.github_id])
24
25
    text = build("text", conversation, user)
26
    voice = build("voice", conversation, user)
27
28
    assert text.authorities == voice.authorities
29
    assert text.approval_receipts == voice.approval_receipts
30
    assert "scv.deploy" in text.authorities
31
32
    assert Enum.any?(text.approval_receipts, fn receipt ->
33
             receipt["module_id"] == "sarah.tool.scv_deploy.v1" and
34
               receipt["scope_ref"] == "conversation:#{conversation.id}" and
35
               receipt["receipt_ref"] == "operator:#{user.id}"
36
           end)
37
38
    assert {:ok, snapshot} = Registry.build([ScvDeploy])
39
    assert {:ok, artifact} = Registry.module_for_tool(snapshot, "scv_deploy", 1)
40
    assert :ok = SurfacePolicy.authorize_execution(artifact, voice)
41
  end
42
43
  test "non-operators receive the shared authorities without an SCV receipt" do
44
    user = AccountsFixtures.repository_user_fixture("conversation-context-member")
45
    {:ok, conversation} = Conversations.ensure_conversation(user)
46
47
    context = build("voice", conversation, user)
48
49
    assert "scv.deploy" in context.authorities
50
51
    refute Enum.any?(context.approval_receipts, fn receipt ->
52
             receipt["module_id"] == "sarah.tool.scv_deploy.v1"
53
           end)
54
55
    assert {:ok, snapshot} = Registry.build([ScvDeploy])
56
    assert {:ok, artifact} = Registry.module_for_tool(snapshot, "scv_deploy", 1)
57
58
    assert {:error, :module_approval_required} =
59
             SurfacePolicy.authorize_execution(artifact, context)
60
  end
61
62
  defp build(surface, conversation, user) do
63
    ConversationExecutionContext.build(%{
64
      surface: surface,
65
      conversation_id: conversation.id,
66
      current_user_message_id: Ecto.UUID.generate(),
67
      owner_visitor_id: conversation.visitor_id,
68
      owner_user_id: user.id,
69
      memory_snapshot_ref: "memory-snapshot:test",
70
      profile_memory_snapshot_ref: "profile-memory-snapshot:test",
71
      module_registry_snapshot: :snapshot
72
    })
73
  end
74
end

This page updates live while a promote is in flight · changelog