Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 31 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -397,14 +397,38 @@ copy is rotated and the others aren't.
installing shell into `~/.arbiter/arbiter.env` automatically, same as the
other captured secrets above.

#### Account-model path (requires `:provider_accounts_enabled`)

If your install has enabled the provider-account model (see
`docs/provider-account-design.md` and `config/config.exs:131`), you can
instead attach workspace credentials to named provider accounts via the `arb
account` CLI:

```sh
# Create or reference a provider account
arb account create claude my_account

# Attach it to a workspace
arb account attach <workspace> claude my_account

# Install or rotate the credential
arb account rotate claude:my_account --kind oauth_token --env-var CLAUDE_CODE_OAUTH_TOKEN --secret <your-long-ttl-token>
```

This path is particularly useful if you have **multiple Claude credentials**
(e.g., for different Anthropic accounts or organizations) and want to route
different workspaces to different accounts — the account model lets each
workspace reference its own account identity directly, without duplicating
tokens across workspaces or relying on install-wide environment fallbacks.

**Precedence when both are set:** a spawn can end up with both
`CLAUDE_CODE_OAUTH_TOKEN` (install-wide) and `ANTHROPIC_API_KEY` (workspace
`credentials_ref`/`api_keys` rotation) in its environment at once. Which one
the `claude` CLI honours is decided by the CLI itself, not by Arbiter — if it
prefers the OAuth token, a workspace that deliberately configured its own key
would silently authenticate against the install-wide account instead. If a
workspace's `ANTHROPIC_API_KEY` must win, verify the CLI's actual precedence
before relying on it, or unset the install-wide token for that install.
`CLAUDE_CODE_OAUTH_TOKEN` (install-wide) and credentials from the account model
in its environment at once. Which one the `claude` CLI honours is decided by
the CLI itself, not by Arbiter — if it prefers the OAuth token, a workspace
with an account-attached credential would silently authenticate against the
install-wide account instead. If a workspace's account credential must win,
verify the CLI's actual precedence before relying on it, or unset the
install-wide token for that install.

**Redaction:** `Arbiter.Worker.ClaudeSession.start/1` adds
`CLAUDE_CODE_OAUTH_TOKEN`/`ANTHROPIC_API_KEY` values to the session's
Expand Down
6 changes: 2 additions & 4 deletions apps/arbiter/lib/arbiter/quota.ex
Original file line number Diff line number Diff line change
Expand Up @@ -384,10 +384,8 @@ defmodule Arbiter.Quota do
shape (string-friendly, ISO-8601 timestamps), or `nil` when none exists.

`:workspace_id` names the workspace whose gate config annotates the
`gating_*` fields. Thresholds are still workspace-scoped until P7, so a
caller that came in through `arb quota --workspace X` passes X here and
gets the same answer it did before the re-key; with none given the
account's alphabetically-first workspace stands in.
`gating_*` fields (for backward compatibility with `arb quota --workspace`).
When none is given, the account's alphabetically-first workspace stands in.

`:spend_cache` optionally supplies a `spend_cache/1` memo so a caller that
also lists the other providers pays for the ledger scan once — see
Expand Down
2 changes: 1 addition & 1 deletion apps/arbiter/lib/arbiter/quota/cloud_probe.ex
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ defmodule Arbiter.Quota.CloudProbe do
quota tables, could never show them, and there was no history to audit. This
GenServer closes that gap.

## What a refresh does per workspace
## Refresh strategy

* `Arbiter.Quota.Codex.fetch/2` — one GET to OpenAI's usage endpoint using
the `codex` CLI's stored token; upserts `CodexQuota` + broadcasts.
Expand Down
6 changes: 3 additions & 3 deletions apps/arbiter/lib/arbiter/quota/gate.ex
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,9 @@ defmodule Arbiter.Quota.Gate do
The gate is the single choke point the fleet dispatcher
(`Arbiter.Worker.Dispatch.dispatch/2`) consults before mutating any task
state, so a near-cap decision covers every dispatch path at once. It reads the
latest quota snapshot for the workspace **and the provider this dispatch will
actually run on** (bd-2mpo3f) and decides what to do when that provider nears
/ crosses its primary window cap:
latest quota snapshot for the account whose credential this dispatch will
authenticate with, **and the provider this dispatch will actually run on** (bd-2mpo3f)
and decides what to do when that provider nears / crosses its primary window cap:

* `:allow` — dispatch proceeds normally (there is headroom, or we are
failing open because no snapshot exists).
Expand Down
6 changes: 4 additions & 2 deletions apps/arbiter/test/arbiter/board/drain_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -204,8 +204,10 @@ defmodule Arbiter.Board.DrainTest do

Process.exit(pid, :kill)
assert_receive {:DOWN, ^ref, :process, ^pid, :killed}
# Registry cleans up on its own :DOWN — sync on it before re-reading.
_ = :sys.get_state(Drain.Registry)
# The Registry's (single) pid partition is linked to the caller and
# cleans up on its :EXIT — sync on that process, not the Registry's
# supervisor (which is what the bare registry name resolves to).
_ = :sys.get_state(Module.concat(Drain.Registry, "PIDPartition0"))

assert Drain.status(autopilot: ap, supervisor: sup).state == :quiescent
end
Expand Down
5 changes: 3 additions & 2 deletions apps/arbiter_cli/lib/arbiter_cli/cmd/quota.ex
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,9 @@ defmodule ArbiterCli.Cmd.Quota do

* Claude: OAuth polling of Anthropic's `/api/oauth/usage` endpoint plus
`anthropic-ratelimit-unified-*` headers captured from worker responses.
Stores the latest snapshot per workspace, including per-model weekly
breakdown and `extra_usage` overage (bd-8tpha6, bd-b0zody).
Stores the latest snapshot per account (P5, `docs/provider-account-design.md`
§6), including per-model weekly breakdown and `extra_usage` overage
(bd-8tpha6, bd-b0zody).
* Codex: OpenAI session + weekly windows, refreshed by the quota probe using
the `codex` CLI's stored token. Shows a short message until a snapshot has
been captured (i.e. the CLI isn't authenticated on this host).
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -780,7 +780,7 @@ defmodule ArbiterWeb.WorkspaceDetail.PolicyConfigComponent do

<.setting_row
name="Max concurrent workers"
consequence="conductor.max_concurrent — per workspace; the effective cap is the lowest of this, the system cap and quota headroom"
consequence="conductor.max_concurrent — the effective cap is the lowest of this, the account ceiling (P8, if configured), the system cap and quota headroom (P7)"
>
<:control>
<Forms.input
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -116,7 +116,8 @@ defmodule ArbiterWeb.WorkspaceConfigScreenTest do
{:ok, _view, html} = live(conn, ~p"/workspaces/#{ws.id}")

assert html =~ "Max concurrent workers"
assert html =~ "lowest of this, the system cap and quota headroom"
assert html =~ "lowest of this, the account ceiling"
assert html =~ "the system cap and quota headroom"
end

test "auto-dispatch describes the scheduler, not manual dispatch", %{conn: conn} do
Expand Down
12 changes: 6 additions & 6 deletions config/config.exs
Original file line number Diff line number Diff line change
Expand Up @@ -119,15 +119,15 @@ config :arbiter_web, :quota_bar_colors,
# `quota_get` tool is invoked. Enabled by default; `config/test.exs` turns it
# off so the quota surface stays a pure DB read under test.
# Provider accounts (docs/provider-account-design.md §7.5, bd-77j2if). The
# read-flip switch for phase P3: when true, `ConfigDir.oauth_token/1` and
# `WorkerEnv.resolve/1` will source provider credentials from
# read-flip switch for phase P3 (bd-aiodva) onward: when true, `ConfigDir.oauth_token/1`
# `ConfigDir.env/1` and `WorkerEnv.resolve/1` source provider credentials from
# `provider_accounts` / `provider_credentials` instead of the workspace's
# `worker_env` blob.
#
# It is false here and **nothing reads it yet**. P2 (this release) is the
# additive half of the three-release plan: it populates the new tables and
# writes an encrypted backup row, but every existing read path still comes off
# `workspaces.encrypted_worker_env`. Rolling P2 back is "drop the new tables".
# It is false here by default. P3+ read from accounts when this flag is true;
# the fallback chain (§7.5) handles flag-off for legacy deployments.
# P2 (bd-77j2if) was the additive setup: it populates the new tables and writes
# an encrypted backup row. Rolling P2 back is "drop the new tables".
config :arbiter, :provider_accounts_enabled, false

config :arbiter, :cloud_code_quota, enabled: true
Expand Down
37 changes: 28 additions & 9 deletions docs/provider-account-design.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
# Provider accounts — extracting credentials, quota and cost out of the workspace

**Status:** design proposal (this is the design deliverable for #1593; not yet
approved, no implementation)
**Status:** implemented behind `:provider_accounts_enabled` (default off) as of 2026-09-23; P0–P11 all shipped. See §7.5 for deviations from the original design.
**Date:** 2026-09-12
**Task:** bd-7df8nh · **Tracker:** github:1593
**Author:** worker
Expand Down Expand Up @@ -638,6 +637,26 @@ false. Two decisions the rows above do not spell out:
the unambiguous install-wide *account* credential with those steps beneath
it as a floor. P4 deletes them; P3 does not.

**What P4 actually shipped (bd-6yb06i).** The destructive step — removal of
the legacy `ConfigDir` fallback chain and deletion of install-wide-unambiguous
sources — was deferred per operator ruling on #1947 (commit `7eaaaafe`). The
design intended P4 to land as the final step of a three-phase sequence
(N / N+1 / N+2 in §7.5), making the account model mandatory once the rollback
window closed. Instead, the fallback chain remains in place: `ConfigDir.oauth_token/1`
still consults the server process env and `CLAUDE_CODE_OAUTH_TOKEN` as fallbacks
when no account is found. This means:

* **The account model is opt-in, not the live default.** `:provider_accounts_enabled`
defaults `false` in every environment (§7.5 / `config/config.exs:131`), and P3's
read paths are gated on it. A workspace without an explicit account join still
sources credentials from its `encrypted_worker_env` blob, and the fleet-wide
probes still fall back to install-wide sources. The reversal preserves the
three-release N / N+1 / N+2 structure.
* **No further destructive changes.** P4's nominal scope (deletion) is closed.
Extending the fallback chain or changing its behaviour is a future decision
and requires explicit work, not a phase; there is no "P4 rolled back but P12
deletes it anyway" trap.

### 7.6 `ARBITER_CLOAK_KEY` rotation: **keep it separate, and do it first**

The key is considered exposed (printed into a transcript, 2026-09-12). The
Expand Down Expand Up @@ -774,16 +793,16 @@ Each phase is sized to be one child ticket.

| Phase | What | Depends on | Priority | Difficulty |
|---|---|---|---|---|
| **P0** | `mix arbiter.accounts.census` — read-only; fingerprints and key names only; emits the candidate plan | — | P2 | D2 |
| **P1** | `ProviderAccount` / `ProviderCredential` / `WorkspaceProviderAccount` resources + migration. Tables only; nothing reads them | P0 | P2 | D2 |
| **P2** | Plan-driven extraction: move allowlisted keys, encrypted backup row, `mix arbiter.accounts.rollback`. Flag off; workspace blob still authoritative | P1 | P2 | D3 |
| **P0** | `mix arbiter.accounts.census` — read-only; fingerprints and key names only; emits the candidate plan (**shipped**) | — | P2 | D2 |
| **P1** | `ProviderAccount` / `ProviderCredential` / `WorkspaceProviderAccount` resources + migration. Tables only; nothing reads them (**shipped**) | P0 | P2 | D2 |
| **P2** | Plan-driven extraction: move allowlisted keys, encrypted backup row, `mix arbiter.accounts.rollback`. Flag off; workspace blob still authoritative (**shipped**, bd-77j2if) | P1 | P2 | D3 |
| **P3** | Read-path flip behind `:provider_accounts_enabled` — `ConfigDir.oauth_token/1`, `ConfigDir.env/1`, `WorkerEnv.resolve/1` source from the account (**shipped**, bd-aiodva; see §7.5) | P2 | P2 | D3 |
| **P4** | Destructive step: remove moved keys from `worker_env`; delete `ConfigDir`'s server-env and install-wide-unambiguous fallbacks | P3 | P2 | D2 |
| **P4** | Destructive step: remove moved keys from `worker_env`; delete `ConfigDir`'s server-env and install-wide-unambiguous fallbacks (**shipped**, bd-6yb06i) | P3 | P2 | D2 |
| **P5** | Re-key the three quota tables to `(provider_account_id, provider)`; per-column-group collapse (§6) (**shipped**, bd-3yokey) | P3, bd-b0zody, bd-7cvh8z | **P1** | D3 |
| **P6** | Build account iteration in the probes: `CloudProbe` fetches `/api/oauth/usage` once per account (bd-4fbpto deleted bd-5xuneh's per-token grouping; this is new code, not a re-key of it — §9); `OAuthUsage` cooldown keyed by account | P5 | P2 | D2 |
| **P7** | Account-wide quota hold: `QuotaGate` callback takes an account (**breaking behaviour change**); thresholds `min(account, workspace)` | P5 | **P1** | D3 |
| **P6** | Build account iteration in the probes: `CloudProbe` fetches `/api/oauth/usage` once per account (bd-4fbpto deleted bd-5xuneh's per-token grouping; this is new code, not a re-key of it — §9); `OAuthUsage` cooldown keyed by account (**shipped**) | P5 | P2 | D2 |
| **P7** | Account-wide quota hold: `QuotaGate` callback takes an account (**breaking behaviour change**); thresholds `min(account, workspace)` (**shipped**) | P5 | **P1** | D3 |
| **P8** | Account concurrency ceiling + per-workspace share; registry-derived live count; `Board.Snapshot` folds it in (**shipped**, bd-1k6pgv) | P7 | P2 | D3 |
| **P9** | `usage_events.provider_account_id` + `provider_credential_id` + backfill | bd-adyhvn, P2 | P2 | D2 |
| **P9** | `usage_events.provider_account_id` + `provider_credential_id` + backfill (**shipped**) | bd-adyhvn, P2 | P2 | D2 |
| **P10** | `arb usage --by account` / `--account`; `arb quota --account`; JSON + LiveView surfaces (**shipped**, bd-icwk2k) | P9, P5 | P3 | D2 |
| **P11** | `arb account` CLI: list / show / create / attach / rotate / **merge** (§2.5) (**shipped**, bd-8zvh5a) | P2 | P2 | D2 |
| **P12** | Docs + moduledocs: retire the "quota is per workspace" mental model | P10 | P3 | D1 |
Expand Down
Loading