Revoke a computer's inference grants when the computer is revoked

57b367f52239 · AtlantisPleb · · parent 73fb8de34043

Revoke a computer's inference grants when the computer is revoked

`inference_grants.machine_id` names the computer a probe delegation's grant
was minted for. `OpenAgents.Work.DelegationServer` wrote it and nothing read
it, and the absence was the gap: `OpenAgents.Machines.revoke_machine/2` closed
the computer's channel and finished its assignments, and every grant that
computer held stayed `active`.

An outstanding grant is not a provider credential, but it is authority to
spend the owner's account at `OpenAgentsWeb.InferenceProxyController`, which
authenticates the grant token and never asks which computer presented it. The
plaintext was already injected into the probe process on the wire. So a
revoked computer kept buying model calls against the owner's account until the
grant's own budget or `expires_at` closed it — up to
`inference_grant_ttl_seconds` after the mint, within the grant's remaining
tokens, calls, and estimated cost.

`OpenAgents.Inference.revoke_active_for_machine/1` now runs inside the
transaction that writes the revoked computer row, on both paths that revoke
one: `revoke_machine/2` and the pairing-expiry branch of `claim_pairing/2`. It
moves `status` and the terminal stamp only, so revocation is not a way around
the exactly-one-fence rule THREAD-001 states.

The window between the revocation decision and its commit is closed in
PostgreSQL. `inference_grants_refuse_revoked_computer` fires before every
insert that names a computer, reads that computer's row `FOR SHARE`, and
refuses unless it is active. `FOR SHARE` conflicts with the `FOR NO KEY UPDATE`
an ordinary `UPDATE machines SET status = 'revoked'` takes, so the two
transactions cannot overlap on that row: a mint that lands first is found by
the sweep, and one that arrives second re-reads `revoked` and raises. The
foreign key's own `FOR KEY SHARE` does not conflict and would not have served.
The guard is in the database rather than at the call site because `machine_id`
reaches `mint/1` from a variable — a source scan finds call sites, not values.
`mint/1` performs the same read first, so an ordinary caller gets
`{:error, :machine_revoked}` and a delegation degrades to a grant-less one.

IDENTITY-008 is amended with the authority clause and its residue: the tests
establish the refusal inside the revocation's open window at both layers, and
assert the lock from `pg_get_functiondef` in the live database, because the
sandbox holds a test in one transaction and cannot show contention across two
connections.

Closes #183.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DTmy4SEXrHXouw5sZbs3f4
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Closes
#183

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

  • modified INVARIANTS.md
  • modified lib/openagents/inference.ex
  • modified lib/openagents/machines.ex
  • modified priv/migration_lineages/prior-2026-08-19.json
  • added priv/repo/migrations/20260824032226_refuse_inference_grants_for_revoked_computers.exs
  • added test/openagents/inference/computer_revocation_test.exs

Diff

6 files changed, +496 -10

INVARIANTS.md modified +53 -2

@@ -439,9 +439,60 @@ execution; the API cannot widen a tier, add a root, or request an unadvertised

439 439
capability. Computer projections never expose a computer token, token digest, or
440 440
raw probe document.
441 441
442
Amended 2026-08-24 (issue #183): revoking a computer ends the inference
443
authority it holds, and cannot be raced.
444
445
An `inference_grants` row names the computer it was minted for. A grant is not
446
a provider credential, but it is authority to spend the owner's account at
447
`OpenAgentsWeb.InferenceProxyController`, and that controller authenticates the
448
grant token alone — it never asks which computer presented it, and the
449
plaintext was delivered to the computer at delegation start. So revoking the
450
computer used to close its channel and finish its assignments while every grant
451
it held stayed `active` until its own budget or `expires_at` closed it, up to
452
`inference_grant_ttl_seconds` later. `machine_id` was written by
453
`OpenAgents.Work.DelegationServer` and read by nothing; the absence was the
454
gap.
455
456
`OpenAgents.Inference.revoke_active_for_machine/1` now runs inside the same
457
transaction that writes the revoked computer row, on both paths that revoke one
458
— `OpenAgents.Machines.revoke_machine/2` and the pairing-expiry branch of
459
`OpenAgents.Machines.claim_pairing/2`. It moves `status` and the terminal stamp
460
and nothing else, so revocation is not a way to acquire, exchange, or shed the
461
fence THREAD-001 requires; `sarah_guard_inference_grant_update` refuses that
462
independently.
463
464
The window between the decision and its commit is closed in PostgreSQL rather
465
than at the call site. `inference_grants_refuse_revoked_computer` fires before every
466
insert that names a computer, reads that computer's row `FOR SHARE`, and
467
refuses unless it is active. `FOR SHARE` conflicts with the `FOR NO KEY UPDATE`
468
an ordinary `UPDATE machines SET status = 'revoked'` takes, so a mint and a
469
revocation cannot overlap on that row: either the mint commits first and the
470
sweep finds its grant, or the revocation commits first and the mint re-reads
471
`revoked` and raises. The foreign key's own `FOR KEY SHARE` does not conflict
472
with an ordinary update and would not have served.
473
`OpenAgents.Inference.mint/1` performs the same read first so an ordinary
474
caller gets `{:error, :machine_revoked}` rather than a `Postgrex.Error`, and a
475
mint failure degrades a delegation to a grant-less one rather than blocking it.
476
477
The guard is in the database because a source scan finds call sites and not
478
values: `machine_id` reaches `mint/1` from a variable, and a future writer that
479
never appears in a grep is fenced the day it lands. What the proof does not
480
show is contention across two connections — the sandbox holds a test in one
481
transaction — so the tests establish the refusal inside the revocation's open
482
window at both layers, and assert the lock itself from `pg_get_functiondef` in
483
the live database.
484
485
A computer's token expiry is a separate clock and is left alone: an active
486
computer whose `token_expires_at` has passed can no longer authenticate, and
487
its grants close on their own `expires_at`.
488
442 489
Evidence: `OpenAgentsWeb.Plugs.AssignmentControlAuth`,
443 490
`OpenAgents.ComputerAgentJobs`, `OpenAgentsWeb.ComputersController`,
444
and `test/openagents_web/controllers/computer_control_api_test.exs`.
491
`OpenAgents.Machines.revoke_machine/2`,
492
`OpenAgents.Inference.revoke_active_for_machine/1`,
493
`priv/repo/migrations/20260824032226_refuse_inference_grants_for_revoked_computers.exs`,
494
`test/openagents_web/controllers/computer_control_api_test.exs`, and
495
`test/openagents/inference/computer_revocation_test.exs`.
445 496
446 497
### IDENTITY-009 — Unified delegation preserves substrate authority
447 498

@@ -4219,7 +4270,7 @@ contract; the invariant prose above defines the assertion, not the filename.

4219 4270
| IDENTITY-005 | `test/openagents_web/controllers/box_controller_test.exs` |
4220 4271
| IDENTITY-006 | `test/openagents/forge/assignment_test.exs` |
4221 4272
| IDENTITY-007 | `test/openagents/agents_test.exs` |
4222
| IDENTITY-008 | `test/openagents_web/controllers/computer_control_api_test.exs` |
4273
| IDENTITY-008 | `test/openagents_web/controllers/computer_control_api_test.exs`, `test/openagents/inference/computer_revocation_test.exs` |
4223 4274
| IDENTITY-009 | `test/openagents_web/controllers/delegations_controller_test.exs` |
4224 4275
| IDENTITY-010 | `test/openagents/forge/assignment_test.exs`, `test/openagents/forge/assignment_credential_reach_test.exs` |
4225 4276
| CAPACITY-002 | `test/openagents/box_fanout_test.exs` |
lib/openagents/inference.ex modified +84 -3

@@ -17,6 +17,7 @@ defmodule OpenAgents.Inference do

17 17
18 18
  import Ecto.Query
19 19
  alias OpenAgents.Inference.Grant
20
  alias OpenAgents.Machines.Machine
20 21
  alias OpenAgents.Repo
21 22
22 23
  @token_prefix "sig_"

@@ -50,8 +51,13 @@ defmodule OpenAgents.Inference do

50 51
  A thread's budget is a different question — the caller asked for it, and it
51 52
  lives as long as someone is working — so `OpenAgents.Threads` passes
52 53
  `OpenAgents.Threads.ceilings/0` rather than borrowing these numbers.
54
55
  A grant that names a computer is minted only while that computer is active,
56
  and only inside the transaction that established it (IDENTITY-008). A revoked
57
  computer answers `{:error, :machine_revoked}`.
53 58
  """
54
  @spec mint(mint_input()) :: {:ok, Grant.t(), String.t()} | {:error, Ecto.Changeset.t()}
59
  @spec mint(mint_input()) ::
60
          {:ok, Grant.t(), String.t()} | {:error, Ecto.Changeset.t() | :machine_revoked}
55 61
  def mint(%{} = input) do
56 62
    token = @token_prefix <> Base.url_encode64(:crypto.strong_rand_bytes(32), padding: false)
57 63
    ceilings = Map.get(input, :ceilings) || delegation_ceilings()

@@ -69,9 +75,58 @@ defmodule OpenAgents.Inference do

69 75
      expires_at: DateTime.add(now(), ceilings.ttl_seconds, :second)
70 76
    }
71 77
72
    case attrs |> Grant.mint_changeset() |> Repo.insert() do
78
    changeset = Grant.mint_changeset(attrs)
79
80
    case Map.get(input, :machine_id) do
81
      nil ->
82
        case Repo.insert(changeset) do
83
          {:ok, grant} -> {:ok, grant, token}
84
          {:error, changeset} -> {:error, changeset}
85
        end
86
87
      machine_id ->
88
        mint_for_computer(changeset, machine_id, token)
89
    end
90
  end
91
92
  # A computer-bound grant is minted inside the transaction that reads its
93
  # computer's row under `FOR SHARE`. `OpenAgents.Machines.revoke_machine/2`
94
  # takes a conflicting lock on that row before it sweeps the computer's active
95
  # grants, so a mint and a revocation cannot interleave: a mint that gets there
96
  # first is found by the sweep, and one that gets there second reads `revoked`
97
  # and is refused. The same read runs in `inference_grants_refuse_revoked_computer`
98
  # for every other writer, because a source scan finds call sites and not
99
  # values.
100
  defp mint_for_computer(changeset, machine_id, token) do
101
    Repo.transaction(fn ->
102
      case computer_status(machine_id) do
103
        "active" ->
104
          case Repo.insert(changeset) do
105
            {:ok, grant} -> grant
106
            {:error, invalid} -> Repo.rollback(invalid)
107
          end
108
109
        _absent_or_terminal ->
110
          Repo.rollback(:machine_revoked)
111
      end
112
    end)
113
    |> case do
73 114
      {:ok, grant} -> {:ok, grant, token}
74
      {:error, changeset} -> {:error, changeset}
115
      {:error, reason} -> {:error, reason}
116
    end
117
  end
118
119
  defp computer_status(machine_id) do
120
    case Ecto.UUID.cast(machine_id) do
121
      {:ok, id} ->
122
        Machine
123
        |> where([m], m.id == ^id)
124
        |> lock("FOR SHARE")
125
        |> select([m], m.status)
126
        |> Repo.one()
127
128
      :error ->
129
        nil
75 130
    end
76 131
  end
77 132

@@ -184,6 +239,32 @@ defmodule OpenAgents.Inference do

184 239
185 240
  def revoke_active_for_thread(_), do: {0, nil}
186 241
242
  @doc """
243
  Revoke every active grant minted for a computer.
244
245
  A grant's plaintext token is on the computer from the moment it is minted, so
246
  closing the computer's channel and finishing its assignments does not stop it
247
  spending — `OpenAgentsWeb.InferenceProxyController` authenticates the token
248
  and nothing else. This is the transition that does, and
249
  `OpenAgents.Machines.revoke_machine/2` runs it inside the transaction that
250
  writes the revoked computer row (IDENTITY-008).
251
252
  It moves `status` and the terminal stamp and nothing else. A grant's fences —
253
  `conversation_id`, `thread_id`, and `machine_id` — stay immutable under the
254
  `inference_grants` update trigger, so revoking a computer cannot become a way
255
  to acquire, exchange, or shed the fence THREAD-001 requires.
256
  """
257
  @spec revoke_active_for_machine(String.t()) :: {non_neg_integer(), nil}
258
  def revoke_active_for_machine(machine_id) when is_binary(machine_id) do
259
    stamp = now()
260
261
    Grant
262
    |> where([g], g.machine_id == ^machine_id and g.status == "active")
263
    |> Repo.update_all(set: [status: "revoked", revoked_at: stamp, updated_at: stamp])
264
  end
265
266
  def revoke_active_for_machine(_), do: {0, nil}
267
187 268
  @doc """
188 269
  Expire every one of an owner's active grants whose clock has run out.
189 270
lib/openagents/machines.ex modified +35 -4

@@ -293,12 +293,38 @@ defmodule OpenAgents.Machines do

293 293
294 294
  def store_probe(_machine, _report), do: {:error, :invalid_probe_report}
295 295
296
  @doc """
297
  Revoke a computer: close its channel, finish its assignments, and end the
298
  inference authority it is still holding.
299
300
  The last of those used to be missing. A revoked computer kept every
301
  `inference_grants` row minted for it, each carrying a plaintext token already
302
  delivered to that computer and each good at the inference proxy — which
303
  authenticates the token and never the computer — until its own budget or
304
  `expires_at` closed it. `OpenAgents.Inference.revoke_active_for_machine/1`
305
  now runs in the same transaction as the revoked row, and a grant minted while
306
  that transaction is open is refused rather than left behind (IDENTITY-008).
307
  """
296 308
  @spec revoke_machine(User.t(), String.t()) :: {:ok, Machine.t()} | {:error, atom()}
297 309
  def revoke_machine(%User{id: user_id}, machine_id) do
298 310
    with {:ok, machine} <- get_machine(user_id, machine_id) do
299
      machine
300
      |> Ecto.Changeset.change(status: "revoked", revoked_at: DateTime.utc_now())
301
      |> Repo.update()
311
      # One transaction, and the computer row is locked before the sweep, so a
312
      # concurrent mint either lands before it and is swept, or blocks on the
313
      # lock, re-reads `revoked`, and is refused.
314
      Repo.transaction(fn ->
315
        locked = Repo.get_for_update!(Machine, machine.id)
316
317
        case locked
318
             |> Ecto.Changeset.change(status: "revoked", revoked_at: DateTime.utc_now())
319
             |> Repo.update() do
320
          {:ok, revoked} ->
321
            _ = OpenAgents.Inference.revoke_active_for_machine(revoked.id)
322
            revoked
323
324
          {:error, _changeset} ->
325
            Repo.rollback(:machine_not_found)
326
        end
327
      end)
302 328
      |> case do
303 329
        {:ok, revoked} ->
304 330
          _ = OpenAgents.Forge.Assignments.finish_for_machine(machine.id)

@@ -311,7 +337,7 @@ defmodule OpenAgents.Machines do

311 337
312 338
          {:ok, revoked}
313 339
314
        {:error, _changeset} ->
340
        {:error, _reason} ->
315 341
          {:error, :machine_not_found}
316 342
      end
317 343
    end

@@ -392,6 +418,11 @@ defmodule OpenAgents.Machines do

392 418
        where: machine.id == ^pairing.machine_id and machine.status == "active"
393 419
      )
394 420
      |> Repo.update_all(set: [status: "revoked", revoked_at: now, updated_at: now])
421
422
      # The other revocation path, and it owes the same thing: this runs inside
423
      # the claim transaction, after the update has taken the computer row's
424
      # lock, so an interleaved mint cannot slip a live grant past it.
425
      _ = OpenAgents.Inference.revoke_active_for_machine(pairing.machine_id)
395 426
    end
396 427
397 428
    :ok
priv/migration_lineages/prior-2026-08-19.json modified +2 -1

@@ -282,7 +282,8 @@

282 282
    20260824010337,
283 283
    20260824010826,
284 284
    20260824011303,
285
    20260824024146
285
    20260824024146,
286
    20260824032226
286 287
  ],
287 288
  "required_tables": [
288 289
    "users",
priv/repo/migrations/20260824032226_refuse_inference_grants_for_revoked_computers.exs added +73

@@ -0,0 +1,73 @@

1
defmodule OpenAgents.Repo.Migrations.RefuseInferenceGrantsForRevokedComputers do
2
  @moduledoc """
3
  A grant is authority to spend the owner's account at the inference proxy, and
4
  `inference_grants.machine_id` names the computer it was minted for. Revoking
5
  that computer left the grant `active`: its plaintext token was already on the
6
  computer, and it kept buying tokens until its own budget or `expires_at`
7
  closed it. `OpenAgents.Inference.revoke_active_for_machine/1` closes the
8
  outstanding ones. This closes the window underneath that.
9
10
  Between the moment a revocation decides a computer is gone and the moment it
11
  commits, a delegation can mint a new grant for the same computer. The sweep
12
  has already run; the new row is not in it. So the fence is here rather than
13
  only in Elixir: every insert that names a computer reads that computer's row
14
  under `FOR SHARE` and refuses unless it is active.
15
16
  `FOR SHARE` is what makes the ordering total. A revocation's
17
  `UPDATE machines SET status = 'revoked'` takes `FOR NO KEY UPDATE`, which
18
  conflicts with it, so the two transactions cannot overlap on that row. Either
19
  the mint commits first and the revocation's sweep finds its grant, or the
20
  revocation commits first and the mint blocks, re-reads `revoked`, and raises.
21
  The foreign key's own `FOR KEY SHARE` does not conflict with an ordinary
22
  update and would not have served.
23
24
  The guard is a trigger rather than a `CHECK` because it reads another table,
25
  and it covers every writer rather than the one call site a source scan finds.
26
  `OpenAgents.Inference.mint/1` performs the same read first, so an ordinary
27
  caller gets `{:error, :machine_revoked}` instead of a `Postgrex.Error`.
28
  """
29
30
  use Ecto.Migration
31
32
  def up do
33
    execute("""
34
    CREATE OR REPLACE FUNCTION inference_grants_refuse_revoked_computer()
35
    RETURNS trigger AS $$
36
    DECLARE
37
      computer_status text;
38
    BEGIN
39
      IF NEW.machine_id IS NULL THEN
40
        RETURN NEW;
41
      END IF;
42
43
      SELECT status INTO computer_status
44
      FROM machines
45
      WHERE id = NEW.machine_id
46
      FOR SHARE;
47
48
      IF computer_status IS DISTINCT FROM 'active' THEN
49
        RAISE EXCEPTION
50
          'inference_grants cannot name computer % (%)',
51
          NEW.machine_id, COALESCE(computer_status, 'absent');
52
      END IF;
53
54
      RETURN NEW;
55
    END;
56
    $$ LANGUAGE plpgsql;
57
    """)
58
59
    execute("""
60
    CREATE TRIGGER inference_grants_refuse_revoked_computer
61
    BEFORE INSERT ON inference_grants
62
    FOR EACH ROW EXECUTE FUNCTION inference_grants_refuse_revoked_computer();
63
    """)
64
  end
65
66
  def down do
67
    execute(
68
      "DROP TRIGGER IF EXISTS inference_grants_refuse_revoked_computer ON inference_grants;"
69
    )
70
71
    execute("DROP FUNCTION IF EXISTS inference_grants_refuse_revoked_computer();")
72
  end
73
end
test/openagents/inference/computer_revocation_test.exs added +249

@@ -0,0 +1,249 @@

1
defmodule OpenAgents.Inference.ComputerRevocationTest do
2
  @moduledoc """
3
  IDENTITY-008's authority clause: a revoked computer holds no inference
4
  authority.
5
6
  `inference_grants.machine_id` was written by
7
  `OpenAgents.Work.DelegationServer` and read by nothing, and the absence was
8
  the bug. Revoking a computer closed its channel and finished its assignments
9
  and left its grants `active` — each one a plaintext token already delivered to
10
  that computer, and `OpenAgentsWeb.InferenceProxyController` authenticates the
11
  token and never the computer.
12
13
  Two properties, because they fail differently.
14
15
  **Outstanding grants stop working.** The sweep runs in the same transaction
16
  that writes the revoked computer row, so a grant minted before the decision is
17
  terminal the moment the decision commits.
18
19
  **A grant minted inside the window does not survive it.** Ecto's sandbox runs
20
  each test inside one uncommitted transaction, so a mint attempted after
21
  `revoke_machine/2` returns here is a mint attempted after the revocation
22
  decided and before it committed — the window itself, not a model of it. It is
23
  refused twice: by `OpenAgents.Inference.mint/1`, and, with that call bypassed,
24
  by `inference_grants_refuse_revoked_computer` in PostgreSQL.
25
26
  What that pair does not show is a mint on a *different* connection blocking
27
  until the revocation commits. That rests on the `FOR SHARE` the trigger takes
28
  on the computer row, which conflicts with the `FOR NO KEY UPDATE` an ordinary
29
  `UPDATE machines` takes. The last test asserts that lock is in the deployed
30
  trigger, read back from `pg_get_functiondef` rather than from the migration
31
  file.
32
  """
33
34
  use OpenAgentsWeb.ConnCase, async: false
35
36
  import Ecto.Query
37
38
  alias OpenAgents.Inference
39
  alias OpenAgents.Inference.Grant
40
  alias OpenAgents.Machines
41
  alias OpenAgents.Repo
42
43
  defp computer(owner, key) do
44
    {:ok, %{code: code}} =
45
      Machines.start_pairing(%{
46
        "name" => "revocation-#{key}",
47
        "tier" => "curated",
48
        "platform" => "linux-x64",
49
        "agent_version" => "0.1.0",
50
        "roots" => ["/home/x/code"]
51
      })
52
53
    {:ok, machine} = Machines.approve_pairing(owner, code)
54
    machine
55
  end
56
57
  defp scope(key) do
58
    owner = github_user("revoke-#{key}")
59
    {:ok, conversation} = OpenAgents.Conversations.ensure_conversation(owner)
60
61
    %{
62
      owner: owner,
63
      owner_visitor_id: conversation.visitor_id,
64
      conversation_id: conversation.id,
65
      machine: computer(owner, key)
66
    }
67
  end
68
69
  defp mint!(scope, machine) do
70
    {:ok, grant, token} =
71
      Inference.mint(%{
72
        owner_visitor_id: scope.owner_visitor_id,
73
        conversation_id: scope.conversation_id,
74
        machine_id: machine.id
75
      })
76
77
    {grant, token}
78
  end
79
80
  describe "an outstanding grant does not outlive its computer" do
81
    test "every grant the revoked computer holds becomes terminal" do
82
      scope = scope("outstanding")
83
      {first, first_token} = mint!(scope, scope.machine)
84
      {second, second_token} = mint!(scope, scope.machine)
85
86
      assert {:ok, %Grant{}} = Inference.resolve(first_token)
87
      assert {:ok, %Grant{}} = Inference.resolve(second_token)
88
89
      assert {:ok, revoked} = Machines.revoke_machine(scope.owner, scope.machine.id)
90
      assert revoked.status == "revoked"
91
92
      assert {:error, :grant_revoked} = Inference.resolve(first_token)
93
      assert {:error, :grant_revoked} = Inference.resolve(second_token)
94
95
      for id <- [first.id, second.id] do
96
        stored = Repo.get(Grant, id)
97
        assert stored.status == "revoked"
98
        assert stored.revoked_at
99
      end
100
    end
101
102
    test "the proxy refuses the revoked computer's token", %{conn: conn} do
103
      scope = scope("proxy")
104
      {_grant, token} = mint!(scope, scope.machine)
105
106
      assert {:ok, _revoked} = Machines.revoke_machine(scope.owner, scope.machine.id)
107
108
      conn =
109
        conn
110
        |> put_req_header("authorization", "Bearer #{token}")
111
        |> put_req_header("content-type", "application/json")
112
        |> post(
113
          ~p"/api/inference/proxy",
114
          Jason.encode!(%{"messages" => [%{"role" => "user", "content" => "still here?"}]})
115
        )
116
117
      assert conn.status == 403
118
      assert Jason.decode!(conn.resp_body) == %{"error" => %{"code" => "grant_revoked"}}
119
    end
120
121
    test "revocation ends authority without touching the fence THREAD-001 requires" do
122
      scope = scope("fence")
123
      {grant, _token} = mint!(scope, scope.machine)
124
125
      assert {:ok, _revoked} = Machines.revoke_machine(scope.owner, scope.machine.id)
126
127
      stored = Repo.get(Grant, grant.id)
128
      assert stored.status == "revoked"
129
      assert stored.conversation_id == scope.conversation_id
130
      assert is_nil(stored.thread_id)
131
      assert stored.machine_id == scope.machine.id
132
    end
133
134
    test "revocation reaches only the revoked computer's grants" do
135
      scope = scope("neighbour")
136
      other = computer(scope.owner, "neighbour-other")
137
138
      {_revoked_grant, revoked_token} = mint!(scope, scope.machine)
139
      {_kept_grant, kept_token} = mint!(scope, other)
140
141
      {:ok, _machineless, machineless_token} =
142
        Inference.mint(%{
143
          owner_visitor_id: scope.owner_visitor_id,
144
          conversation_id: scope.conversation_id
145
        })
146
147
      assert {:ok, _} = Machines.revoke_machine(scope.owner, scope.machine.id)
148
149
      assert {:error, :grant_revoked} = Inference.resolve(revoked_token)
150
      assert {:ok, %Grant{status: "active"}} = Inference.resolve(kept_token)
151
      assert {:ok, %Grant{status: "active"}} = Inference.resolve(machineless_token)
152
    end
153
154
    test "an expired pairing revokes its computer's grants on the same path" do
155
      owner = github_user("revoke-pairing")
156
      {:ok, conversation} = OpenAgents.Conversations.ensure_conversation(owner)
157
158
      {:ok, %{pairing: pairing, code: code, poll_secret: poll_secret}} =
159
        Machines.start_pairing(%{
160
          "name" => "revocation-pairing",
161
          "tier" => "curated",
162
          "platform" => "linux-x64",
163
          "agent_version" => "0.1.0",
164
          "roots" => ["/home/x/code"]
165
        })
166
167
      {:ok, machine} = Machines.approve_pairing(owner, code)
168
169
      {:ok, _grant, token} =
170
        Inference.mint(%{
171
          owner_visitor_id: conversation.visitor_id,
172
          conversation_id: conversation.id,
173
          machine_id: machine.id
174
        })
175
176
      # Expire the pairing so the claim takes the expiry branch, which revokes
177
      # the computer it already created.
178
      Repo.update_all(
179
        from(p in OpenAgents.Machines.Pairing, where: p.id == ^pairing.id),
180
        set: [expires_at: DateTime.add(DateTime.utc_now(), -60, :second)]
181
      )
182
183
      assert {:error, :pairing_expired} = Machines.claim_pairing(pairing.id, poll_secret)
184
      assert Repo.get(OpenAgents.Machines.Machine, machine.id).status == "revoked"
185
      assert {:error, :grant_revoked} = Inference.resolve(token)
186
    end
187
  end
188
189
  describe "a grant minted inside the revocation window does not survive it" do
190
    test "mint/1 refuses and writes nothing" do
191
      scope = scope("window")
192
193
      # The sandbox holds this test in one uncommitted transaction, so the
194
      # revocation below has decided and has not committed. This is the window.
195
      assert {:ok, _revoked} = Machines.revoke_machine(scope.owner, scope.machine.id)
196
197
      before = Repo.aggregate(Grant, :count)
198
199
      assert {:error, :machine_revoked} =
200
               Inference.mint(%{
201
                 owner_visitor_id: scope.owner_visitor_id,
202
                 conversation_id: scope.conversation_id,
203
                 machine_id: scope.machine.id
204
               })
205
206
      assert Repo.aggregate(Grant, :count) == before
207
    end
208
209
    test "PostgreSQL refuses the same insert with mint/1 bypassed" do
210
      scope = scope("window-db")
211
      assert {:ok, _revoked} = Machines.revoke_machine(scope.owner, scope.machine.id)
212
213
      attrs = %{
214
        owner_visitor_id: scope.owner_visitor_id,
215
        conversation_id: scope.conversation_id,
216
        machine_id: scope.machine.id,
217
        model_id: "test-model",
218
        token_digest: :crypto.hash(:sha256, "sig_bypass"),
219
        max_total_tokens: 100,
220
        max_calls: 1,
221
        max_cost_microusd: 100,
222
        expires_at: DateTime.add(DateTime.utc_now(), 900, :second)
223
      }
224
225
      error =
226
        assert_raise Postgrex.Error, fn ->
227
          attrs |> Grant.mint_changeset() |> Repo.insert()
228
        end
229
230
      assert error.postgres.message =~ "cannot name computer"
231
      assert error.postgres.message =~ "revoked"
232
    end
233
234
    test "the deployed guard reads the computer row under a conflicting lock" do
235
      %Postgrex.Result{rows: [[definition]]} =
236
        Ecto.Adapters.SQL.query!(
237
          Repo,
238
          "SELECT pg_get_functiondef('inference_grants_refuse_revoked_computer'::regproc)",
239
          []
240
        )
241
242
      assert definition =~ "FROM machines"
243
      # FOR SHARE conflicts with the FOR NO KEY UPDATE an ordinary
244
      # `UPDATE machines SET status = 'revoked'` takes; the foreign key's own
245
      # FOR KEY SHARE does not, and would leave the two free to interleave.
246
      assert definition =~ "FOR SHARE"
247
    end
248
  end
249
end

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