diff --git a/cloud-console/README.md b/cloud-console/README.md index 2831a11..539ff40 100644 --- a/cloud-console/README.md +++ b/cloud-console/README.md @@ -20,6 +20,13 @@ Accounts have fixed roles: - `operator`: viewer access plus task submission APIs - `admin`: all API scopes +Administrators can use **Users & limits** to manage accounts, restrict task +submission to explicit Host/Device targets, configure Host self-submission and +active-task limits, and inspect non-secret Cloud-proxy usage. Daily token +budgets are enforced only for Hosts reporting `AI_PLANNER_TRANSPORT=cloud`; +direct-provider Hosts are labelled **unmetered** rather than budget compliant. +The configured proxy reservation ceiling must fit within any daily budget. + ## Local development ```bash diff --git a/docs/CLOUD_DEPLOYMENT.md b/docs/CLOUD_DEPLOYMENT.md index e00f040..4b93bb5 100644 --- a/docs/CLOUD_DEPLOYMENT.md +++ b/docs/CLOUD_DEPLOYMENT.md @@ -229,7 +229,9 @@ The repository ships an independent Vue 3 + Vite SPA at `cloud-console/` that renders task history, devices, hosts, and plugins. Human operators sign in with a username and password; the Cloud API creates an expiring, revocable `HttpOnly` session cookie and uses a separate CSRF cookie/header for writes. -The Console has no bearer-token fallback or user-directory view. +An administrator can manage accounts, submission policies, Host operational +limits, and non-secret Cloud-proxy token usage from **Users & limits**. Bearer +tokens remain a compatibility path for API/SDK automation. ### HTTPS and session configuration @@ -396,6 +398,34 @@ mode: `AI_PLANNER_TRANSPORT` unset or `direct` preserves the existing direct-to-provider behavior with no change. +### Host governance and Cloud-proxy token budgets + +After deploying the API migration, an administrator can use **Users & limits** +in `/console/` to restrict a user's task submission to explicit Host/Device +targets, enable or disable Host self-submission, and set a Host active-task +limit. Policies are enforced by the API and scheduler; hiding Console controls +is not an authorization boundary. + +Cloud-enforced token budgets require `AI_PLANNER_TRANSPORT=cloud`. Before each +proxy decision, the API atomically reserves +`CLOUD_PLANNER_TOKEN_RESERVATION_CEILING` tokens (default `4096`) for the +current UTC day. Set `CLOUD_PLANNER_TOKEN_RESERVATION_TTL_SECONDS` (default +`300`) to bound an unknown-usage reservation after provider/transport failure. +The daily Host budget must accommodate the reservation ceiling; otherwise the +proxy rejects before calling the provider. On a provider response, the +reservation is settled to reported usage and the Console retains only timestamp, +provider/model, token counts, and optional task/attempt identifiers. + +Hosts reporting `AI_PLANNER_TRANSPORT=direct` are explicitly shown as +**unmetered**. Cloud cannot enforce or verify their provider token use. Do not +interpret an unmetered Host's absence of usage events as budget compliance. + +Roll out in this order: migrate the Cloud database, deploy the Cloud API, +switch a pilot Host to Cloud transport, configure a budget above the reservation +ceiling, then review its usage events before enabling budgets fleet-wide. A +rollback to direct transport requires valid provider credentials on that Host; +preserve usage and policy rows rather than deleting accounting history. + ## Operational Limitations Run exactly one scheduler-enabled Cloud API process. SQLite supports only the diff --git a/openspec/changes/cloud-console-governance/tasks.md b/openspec/changes/cloud-console-governance/tasks.md index 21cbf1b..3a1d16c 100644 --- a/openspec/changes/cloud-console-governance/tasks.md +++ b/openspec/changes/cloud-console-governance/tasks.md @@ -12,16 +12,16 @@ ## 2. Governance domain, persistence, and migration -- [ ] 2.1 Define target-selector, user-submission-policy, Host-governance- +- [x] 2.1 Define target-selector, user-submission-policy, Host-governance- policy, token-usage-event, and token-reservation domain models with non-secret representations and revision semantics. -- [ ] 2.2 Extend the Cloud repository port with transactional CRUD/query +- [x] 2.2 Extend the Cloud repository port with transactional CRUD/query operations for policies, active Host capacity, budget reservations, settlement, expiry cleanup, and bounded usage summaries/events. -- [ ] 2.3 Add SQLAlchemy rows, indexes, conversion helpers, and concurrency- +- [x] 2.3 Add SQLAlchemy rows, indexes, conversion helpers, and concurrency- safe PostgreSQL/SQLite implementations for governance policies, usage events, reservations, and safe policy audits. -- [ ] 2.4 Add an Alembic forward/downgrade revision that preserves existing +- [x] 2.4 Add an Alembic forward/downgrade revision that preserves existing users, Hosts, devices, tasks, and attempts; advance schema readiness checks to the new head. - [ ] 2.5 Add repository and migration tests for policy revisions, null versus @@ -59,7 +59,7 @@ credentials and validate any named local Device ownership. - [x] 4.4 Enforce the Host policy's self-submission and active-task limits in the Cloud service/scheduler, not only in Host-local code. -- [ ] 4.5 Add Cloud API and Host Agent tests for revision convergence, +- [x] 4.5 Add Cloud API and Host Agent tests for revision convergence, unchanged-policy replies, self-targeted task creation, foreign target rejection, disabled self-submission, and outbound-only compatibility. @@ -98,7 +98,7 @@ ## 7. Documentation, verification, and rollout -- [ ] 7.1 Update deployment and Console documentation with the dependency +- [x] 7.1 Update deployment and Console documentation with the dependency order, migration/rollback sequence, policy semantics, Cloud transport prerequisite, budget reservation behavior, direct-host limitation, and safe administrator operations. diff --git a/tests/test_host_agent_internal_api.py b/tests/test_host_agent_internal_api.py index 77a413a..86d6a42 100644 --- a/tests/test_host_agent_internal_api.py +++ b/tests/test_host_agent_internal_api.py @@ -253,6 +253,49 @@ def test_heartbeat_and_self_submission_preserve_host_isolation(tmp_path) -> None assert foreign.status_code == 403 +def test_host_policy_converges_and_disables_self_submission(tmp_path) -> None: + client, pool = _build_client(tmp_path) + headers = {"Authorization": "Bearer token-a"} + client.put( + "/internal/v1/hosts/host-a/heartbeat", + headers=headers, + json=_heartbeat_payload("host-a", "device-a"), + ) + pool.store.upsert_host_governance_policy( + host_id="host-a", + self_submission_enabled=False, + max_active_tasks=2, + daily_token_budget=1000, + updated_at=datetime.now(UTC), + ) + + stale = client.put( + "/internal/v1/hosts/host-a/heartbeat", + headers=headers, + json={**_heartbeat_payload("host-a", "device-a"), "policy_revision": 0}, + ) + assert stale.status_code == 200 + assert stale.json()["policy"] == { + "revision": 1, + "self_submission_enabled": False, + "max_active_tasks": 2, + "daily_token_budget": 1000, + } + current = client.put( + "/internal/v1/hosts/host-a/heartbeat", + headers=headers, + json={**_heartbeat_payload("host-a", "device-a"), "policy_revision": 1}, + ) + assert current.status_code == 200 + assert current.json()["policy"] is None + disabled = client.post( + "/internal/v1/hosts/host-a/tasks", + headers=headers, + json={"host_id": "host-a", "goal": "should be rejected"}, + ) + assert disabled.status_code == 403 + + def test_planner_proxy_reserves_and_enforces_host_daily_token_budget(tmp_path) -> None: class FakePlannerClient: calls = 0