agentevo-cloud 0.1.2__tar.gz → 0.1.3__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {agentevo_cloud-0.1.2 → agentevo_cloud-0.1.3}/LICENSE +1 -1
- {agentevo_cloud-0.1.2 → agentevo_cloud-0.1.3}/PKG-INFO +8 -2
- {agentevo_cloud-0.1.2 → agentevo_cloud-0.1.3}/README.md +6 -0
- {agentevo_cloud-0.1.2 → agentevo_cloud-0.1.3}/pyproject.toml +1 -1
- {agentevo_cloud-0.1.2 → agentevo_cloud-0.1.3}/src/agentevo_cloud/__init__.py +1 -0
- {agentevo_cloud-0.1.2 → agentevo_cloud-0.1.3}/src/agentevo_cloud/analytics.py +1 -0
- {agentevo_cloud-0.1.2 → agentevo_cloud-0.1.3}/src/agentevo_cloud/app.py +61 -10
- {agentevo_cloud-0.1.2 → agentevo_cloud-0.1.3}/src/agentevo_cloud/cli.py +1 -0
- {agentevo_cloud-0.1.2 → agentevo_cloud-0.1.3}/src/agentevo_cloud/config.py +1 -0
- {agentevo_cloud-0.1.2 → agentevo_cloud-0.1.3}/src/agentevo_cloud/db.py +16 -1
- {agentevo_cloud-0.1.2 → agentevo_cloud-0.1.3}/src/agentevo_cloud/devices.py +5 -2
- {agentevo_cloud-0.1.2 → agentevo_cloud-0.1.3}/src/agentevo_cloud/events.py +1 -0
- {agentevo_cloud-0.1.2 → agentevo_cloud-0.1.3}/src/agentevo_cloud/jobs.py +33 -14
- {agentevo_cloud-0.1.2 → agentevo_cloud-0.1.3}/src/agentevo_cloud/managed.py +1 -0
- {agentevo_cloud-0.1.2 → agentevo_cloud-0.1.3}/src/agentevo_cloud/metering.py +1 -0
- {agentevo_cloud-0.1.2 → agentevo_cloud-0.1.3}/src/agentevo_cloud/oidc.py +1 -0
- {agentevo_cloud-0.1.2 → agentevo_cloud-0.1.3}/src/agentevo_cloud/openapi.py +1 -0
- {agentevo_cloud-0.1.2 → agentevo_cloud-0.1.3}/src/agentevo_cloud/promote.py +3 -1
- {agentevo_cloud-0.1.2 → agentevo_cloud-0.1.3}/src/agentevo_cloud/recall.py +1 -0
- {agentevo_cloud-0.1.2 → agentevo_cloud-0.1.3}/src/agentevo_cloud/records.py +1 -0
- {agentevo_cloud-0.1.2 → agentevo_cloud-0.1.3}/src/agentevo_cloud/revoke.py +4 -1
- {agentevo_cloud-0.1.2 → agentevo_cloud-0.1.3}/src/agentevo_cloud/screen.py +1 -0
- {agentevo_cloud-0.1.2 → agentevo_cloud-0.1.3}/src/agentevo_cloud/sync.py +1 -0
- {agentevo_cloud-0.1.2 → agentevo_cloud-0.1.3}/src/agentevo_cloud/tenants.py +1 -0
- {agentevo_cloud-0.1.2 → agentevo_cloud-0.1.3}/src/agentevo_cloud/tokens.py +51 -4
- {agentevo_cloud-0.1.2 → agentevo_cloud-0.1.3}/src/agentevo_cloud/webhooks.py +1 -0
- {agentevo_cloud-0.1.2 → agentevo_cloud-0.1.3}/src/agentevo_cloud/workers.py +1 -0
- {agentevo_cloud-0.1.2 → agentevo_cloud-0.1.3}/tests/test_acceptance_m12.py +1 -0
- {agentevo_cloud-0.1.2 → agentevo_cloud-0.1.3}/tests/test_acceptance_m8.py +1 -0
- {agentevo_cloud-0.1.2 → agentevo_cloud-0.1.3}/tests/test_analytics.py +1 -0
- {agentevo_cloud-0.1.2 → agentevo_cloud-0.1.3}/tests/test_app.py +1 -0
- {agentevo_cloud-0.1.2 → agentevo_cloud-0.1.3}/tests/test_app_auth.py +63 -4
- {agentevo_cloud-0.1.2 → agentevo_cloud-0.1.3}/tests/test_app_jobs.py +61 -0
- {agentevo_cloud-0.1.2 → agentevo_cloud-0.1.3}/tests/test_app_managed.py +1 -0
- {agentevo_cloud-0.1.2 → agentevo_cloud-0.1.3}/tests/test_app_objects.py +1 -0
- {agentevo_cloud-0.1.2 → agentevo_cloud-0.1.3}/tests/test_app_sync.py +1 -0
- {agentevo_cloud-0.1.2 → agentevo_cloud-0.1.3}/tests/test_body_limit.py +1 -0
- {agentevo_cloud-0.1.2 → agentevo_cloud-0.1.3}/tests/test_cli.py +1 -0
- {agentevo_cloud-0.1.2 → agentevo_cloud-0.1.3}/tests/test_compile.py +1 -0
- {agentevo_cloud-0.1.2 → agentevo_cloud-0.1.3}/tests/test_devices_ci.py +2 -1
- {agentevo_cloud-0.1.2 → agentevo_cloud-0.1.3}/tests/test_events.py +1 -0
- {agentevo_cloud-0.1.2 → agentevo_cloud-0.1.3}/tests/test_foundation.py +55 -10
- {agentevo_cloud-0.1.2 → agentevo_cloud-0.1.3}/tests/test_jobs.py +59 -0
- {agentevo_cloud-0.1.2 → agentevo_cloud-0.1.3}/tests/test_maintenance.py +1 -0
- {agentevo_cloud-0.1.2 → agentevo_cloud-0.1.3}/tests/test_managed.py +4 -3
- {agentevo_cloud-0.1.2 → agentevo_cloud-0.1.3}/tests/test_metering_records.py +1 -0
- {agentevo_cloud-0.1.2 → agentevo_cloud-0.1.3}/tests/test_oidc.py +1 -0
- {agentevo_cloud-0.1.2 → agentevo_cloud-0.1.3}/tests/test_openapi.py +1 -0
- {agentevo_cloud-0.1.2 → agentevo_cloud-0.1.3}/tests/test_promote.py +1 -0
- {agentevo_cloud-0.1.2 → agentevo_cloud-0.1.3}/tests/test_recall.py +1 -0
- {agentevo_cloud-0.1.2 → agentevo_cloud-0.1.3}/tests/test_revoke.py +1 -0
- {agentevo_cloud-0.1.2 → agentevo_cloud-0.1.3}/tests/test_screen.py +1 -0
- {agentevo_cloud-0.1.2 → agentevo_cloud-0.1.3}/tests/test_smoke.py +1 -0
- {agentevo_cloud-0.1.2 → agentevo_cloud-0.1.3}/tests/test_sync_mode.py +1 -0
- {agentevo_cloud-0.1.2 → agentevo_cloud-0.1.3}/tests/test_sync_push_pull.py +1 -0
- {agentevo_cloud-0.1.2 → agentevo_cloud-0.1.3}/tests/test_webhooks.py +1 -0
- {agentevo_cloud-0.1.2 → agentevo_cloud-0.1.3}/tests/test_workers.py +1 -0
- {agentevo_cloud-0.1.2 → agentevo_cloud-0.1.3}/.gitignore +0 -0
- {agentevo_cloud-0.1.2 → agentevo_cloud-0.1.3}/openapi/openapi.v1.json +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: agentevo-cloud
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.3
|
|
4
4
|
Summary: Agentevo SaaS (TZ-3 control plane, TZ-4 managed intelligence): auth, metering, C2 APIs, workers.
|
|
5
5
|
Project-URL: Homepage, https://agentevo.sh
|
|
6
6
|
Author: Agentevo
|
|
@@ -9,7 +9,7 @@ License: Business Source License 1.1
|
|
|
9
9
|
Parameters
|
|
10
10
|
|
|
11
11
|
Licensor: Agentevo
|
|
12
|
-
Licensed Work: agentevo-cloud 0.1.
|
|
12
|
+
Licensed Work: agentevo-cloud 0.1.3
|
|
13
13
|
Copyright Notice: Copyright (c) 2026 Agentevo
|
|
14
14
|
|
|
15
15
|
Additional Use Grant:
|
|
@@ -125,3 +125,9 @@ agentevo-cloud serve
|
|
|
125
125
|
```
|
|
126
126
|
|
|
127
127
|
Part of the Agentevo monorepo; see the [main README](../../README.md).
|
|
128
|
+
|
|
129
|
+
## License
|
|
130
|
+
|
|
131
|
+
Business Source License 1.1 — see [LICENSE](./LICENSE). Free for internal
|
|
132
|
+
production use in your own infrastructure; converts to Apache-2.0 on the
|
|
133
|
+
Change Date stated in the license.
|
|
@@ -12,3 +12,9 @@ agentevo-cloud serve
|
|
|
12
12
|
```
|
|
13
13
|
|
|
14
14
|
Part of the Agentevo monorepo; see the [main README](../../README.md).
|
|
15
|
+
|
|
16
|
+
## License
|
|
17
|
+
|
|
18
|
+
Business Source License 1.1 — see [LICENSE](./LICENSE). Free for internal
|
|
19
|
+
production use in your own infrastructure; converts to Apache-2.0 on the
|
|
20
|
+
Change Date stated in the license.
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
# SPDX-License-Identifier: BUSL-1.1
|
|
1
2
|
"""Cloud Control API: FastAPI factory, error model, and routes.
|
|
2
3
|
|
|
3
4
|
Every route authenticates via Bearer tokens except /healthz and the auth
|
|
@@ -95,6 +96,7 @@ from agentevo_cloud.tokens import (
|
|
|
95
96
|
hash_token,
|
|
96
97
|
issue_pat,
|
|
97
98
|
list_tokens,
|
|
99
|
+
require_workspace,
|
|
98
100
|
revoke_token,
|
|
99
101
|
rotate_refresh,
|
|
100
102
|
verify_token,
|
|
@@ -538,9 +540,16 @@ def _client_ip(request: Request) -> str:
|
|
|
538
540
|
|
|
539
541
|
|
|
540
542
|
def _auth_rate_limit(request: Request, conn: sqlite3.Connection, name: str) -> None:
|
|
541
|
-
"""IP-keyed burst guard for the unauthenticated auth flows.
|
|
543
|
+
"""IP-keyed burst guard for the unauthenticated auth flows.
|
|
544
|
+
|
|
545
|
+
Commits the increment immediately: burst counters must survive the
|
|
546
|
+
request transaction's rollback-on-error, or failed guesses would never
|
|
547
|
+
count. Callers must run this before any request-conn writes (all do:
|
|
548
|
+
it gates handler entry, and dependencies only read).
|
|
549
|
+
"""
|
|
542
550
|
settings: Settings = request.app.state.settings
|
|
543
551
|
check_rate_limit(conn, f"auth:{name}:{_client_ip(request)}", settings.rate_limit_per_minute)
|
|
552
|
+
conn.commit()
|
|
544
553
|
|
|
545
554
|
|
|
546
555
|
def _idempotent( # noqa: PLR0913, PLR0917 - idempotency carries request context
|
|
@@ -555,6 +564,8 @@ def _idempotent( # noqa: PLR0913, PLR0917 - idempotency carries request context
|
|
|
555
564
|
|
|
556
565
|
Anonymous bootstrap flows (device start, CI exchange) share the empty
|
|
557
566
|
tenant namespace; their responses carry no tenant data before approval.
|
|
567
|
+
Stored replays withhold single-view secrets (issuance responses keep
|
|
568
|
+
ids/scopes/expiry only); the live response is unaffected.
|
|
558
569
|
"""
|
|
559
570
|
route = f"{request.method} {request.url.path}"
|
|
560
571
|
if key is not None:
|
|
@@ -573,7 +584,7 @@ def _idempotent( # noqa: PLR0913, PLR0917 - idempotency carries request context
|
|
|
573
584
|
key,
|
|
574
585
|
body=body,
|
|
575
586
|
status_code=status,
|
|
576
|
-
response=payload,
|
|
587
|
+
response=_scrub_replay_secrets(payload),
|
|
577
588
|
ttl=timedelta(seconds=settings.idempotency_ttl_seconds),
|
|
578
589
|
)
|
|
579
590
|
if raced is not None:
|
|
@@ -619,6 +630,8 @@ def _issue_token(
|
|
|
619
630
|
user_id=principal.user_id or "",
|
|
620
631
|
workspaces=workspaces,
|
|
621
632
|
ttl=ttl,
|
|
633
|
+
grantor_scopes=list(principal.scopes),
|
|
634
|
+
grantor_workspaces=list(principal.workspaces),
|
|
622
635
|
)
|
|
623
636
|
audit(
|
|
624
637
|
conn,
|
|
@@ -639,6 +652,19 @@ def _issue_token(
|
|
|
639
652
|
return _idempotent(request, conn, principal.tenant_id, idempotency_key, body, compute)
|
|
640
653
|
|
|
641
654
|
|
|
655
|
+
#: Top-level response keys withheld from the idempotency replay store.
|
|
656
|
+
#: Single-view issuance secrets must not persist in plaintext for the key
|
|
657
|
+
#: TTL: replays of issuance routes return metadata (ids, scopes, expiry)
|
|
658
|
+
#: without the secret itself. (Request bodies are hash-compared, never
|
|
659
|
+
#: stored; short-lived device/user codes stay stored: retry UX needs them.)
|
|
660
|
+
_SECRET_REPLAY_KEYS = frozenset({"secret", "access_token", "refresh_token", "token"})
|
|
661
|
+
|
|
662
|
+
|
|
663
|
+
def _scrub_replay_secrets(payload: dict[str, Any]) -> dict[str, Any]:
|
|
664
|
+
"""Copy ``payload`` minus single-view credential fields."""
|
|
665
|
+
return {key: value for key, value in payload.items() if key not in _SECRET_REPLAY_KEYS}
|
|
666
|
+
|
|
667
|
+
|
|
642
668
|
def _list_tokens(
|
|
643
669
|
principal: Principal = Depends(CONTROL_READ),
|
|
644
670
|
conn: sqlite3.Connection = Depends(get_conn),
|
|
@@ -1625,9 +1651,13 @@ def _body_target(body: dict[str, Any]) -> TrustState:
|
|
|
1625
1651
|
|
|
1626
1652
|
|
|
1627
1653
|
def _managed_or_404(
|
|
1628
|
-
conn: sqlite3.Connection,
|
|
1654
|
+
conn: sqlite3.Connection,
|
|
1655
|
+
tenant_id: str,
|
|
1656
|
+
object_id: str,
|
|
1657
|
+
version: int,
|
|
1658
|
+
principal: Principal,
|
|
1629
1659
|
) -> ManagedObject:
|
|
1630
|
-
"""One indexed version, tenant-scoped (404 reveals nothing
|
|
1660
|
+
"""One indexed version, tenant- and workspace-scoped (404 reveals nothing)."""
|
|
1631
1661
|
target = get_managed(conn, tenant_id, object_id, version)
|
|
1632
1662
|
if target is None:
|
|
1633
1663
|
raise AgentevoError(
|
|
@@ -1635,6 +1665,7 @@ def _managed_or_404(
|
|
|
1635
1665
|
"unknown object version",
|
|
1636
1666
|
{"object_id": object_id, "version": version},
|
|
1637
1667
|
)
|
|
1668
|
+
require_workspace(principal, target.workspace_id)
|
|
1638
1669
|
return target
|
|
1639
1670
|
|
|
1640
1671
|
|
|
@@ -1649,7 +1680,7 @@ def _verify_object( # noqa: PLR0913, PLR0917 - handler carries route context
|
|
|
1649
1680
|
version = _body_version(body)
|
|
1650
1681
|
environment = body.get("environment")
|
|
1651
1682
|
parse_environment(environment)
|
|
1652
|
-
_managed_or_404(conn, principal.tenant_id, object_id, version)
|
|
1683
|
+
target = _managed_or_404(conn, principal.tenant_id, object_id, version, principal)
|
|
1653
1684
|
params = {} if environment is None else {"environment": environment}
|
|
1654
1685
|
|
|
1655
1686
|
def compute() -> tuple[int, dict[str, Any]]:
|
|
@@ -1660,6 +1691,7 @@ def _verify_object( # noqa: PLR0913, PLR0917 - handler carries route context
|
|
|
1660
1691
|
object_id=object_id,
|
|
1661
1692
|
version=version,
|
|
1662
1693
|
params=params,
|
|
1694
|
+
workspace_id=target.workspace_id,
|
|
1663
1695
|
)
|
|
1664
1696
|
audit(
|
|
1665
1697
|
conn,
|
|
@@ -1685,7 +1717,7 @@ def _revalidate_object( # noqa: PLR0913, PLR0917 - handler carries route contex
|
|
|
1685
1717
|
version = _body_version(body)
|
|
1686
1718
|
environment = body.get("environment")
|
|
1687
1719
|
parse_environment(environment)
|
|
1688
|
-
target = _managed_or_404(conn, principal.tenant_id, object_id, version)
|
|
1720
|
+
target = _managed_or_404(conn, principal.tenant_id, object_id, version, principal)
|
|
1689
1721
|
if target.state not in _REVALIDATABLE_STATES:
|
|
1690
1722
|
raise AgentevoError(
|
|
1691
1723
|
ErrorCode.CONFLICT,
|
|
@@ -1704,6 +1736,7 @@ def _revalidate_object( # noqa: PLR0913, PLR0917 - handler carries route contex
|
|
|
1704
1736
|
object_id=object_id,
|
|
1705
1737
|
version=version,
|
|
1706
1738
|
params=params,
|
|
1739
|
+
workspace_id=target.workspace_id,
|
|
1707
1740
|
)
|
|
1708
1741
|
audit(
|
|
1709
1742
|
conn,
|
|
@@ -1724,7 +1757,7 @@ def _get_object_version(
|
|
|
1724
1757
|
principal: Principal = Depends(RECALL_READ),
|
|
1725
1758
|
conn: sqlite3.Connection = Depends(get_conn),
|
|
1726
1759
|
) -> dict[str, Any]:
|
|
1727
|
-
return _managed_or_404(conn, principal.tenant_id, object_id, version).to_dict()
|
|
1760
|
+
return _managed_or_404(conn, principal.tenant_id, object_id, version, principal).to_dict()
|
|
1728
1761
|
|
|
1729
1762
|
|
|
1730
1763
|
def _body_reason(body: dict[str, Any]) -> RevocationReason:
|
|
@@ -1768,12 +1801,17 @@ def _compile_experiences(
|
|
|
1768
1801
|
workspace_id = parse_required_str(body.get("workspace_id"), "workspace_id")
|
|
1769
1802
|
project_id = parse_required_str(body.get("project_id", DEFAULT_COMPILE_PROJECT), "project_id")
|
|
1770
1803
|
ensure_workspace(conn, principal.tenant_id, workspace_id)
|
|
1804
|
+
require_workspace(principal, workspace_id)
|
|
1771
1805
|
require_searchable(conn, principal.tenant_id, workspace_id, "compile managed experiences")
|
|
1772
1806
|
params = {"session_id": session_id, "workspace_id": workspace_id, "project_id": project_id}
|
|
1773
1807
|
|
|
1774
1808
|
def compute() -> tuple[int, dict[str, Any]]:
|
|
1775
1809
|
job = create_job(
|
|
1776
|
-
conn,
|
|
1810
|
+
conn,
|
|
1811
|
+
principal.tenant_id,
|
|
1812
|
+
JobType.COMPILE_EXPERIENCES.value,
|
|
1813
|
+
params=params,
|
|
1814
|
+
workspace_id=workspace_id,
|
|
1777
1815
|
)
|
|
1778
1816
|
audit(
|
|
1779
1817
|
conn,
|
|
@@ -1810,6 +1848,7 @@ def _compile_workflows(
|
|
|
1810
1848
|
"cross-workspace compile rejected",
|
|
1811
1849
|
{"workspaces": sorted({row.workspace_id for row in rows})},
|
|
1812
1850
|
)
|
|
1851
|
+
require_workspace(principal, rows[0].workspace_id)
|
|
1813
1852
|
require_searchable(conn, principal.tenant_id, rows[0].workspace_id, "compile managed workflows")
|
|
1814
1853
|
params: dict[str, Any] = {
|
|
1815
1854
|
"skills": [{"object_id": row.object_id, "version": row.version} for row in rows],
|
|
@@ -1820,7 +1859,13 @@ def _compile_workflows(
|
|
|
1820
1859
|
}
|
|
1821
1860
|
|
|
1822
1861
|
def compute() -> tuple[int, dict[str, Any]]:
|
|
1823
|
-
job = create_job(
|
|
1862
|
+
job = create_job(
|
|
1863
|
+
conn,
|
|
1864
|
+
principal.tenant_id,
|
|
1865
|
+
JobType.COMPILE_WORKFLOW.value,
|
|
1866
|
+
params=params,
|
|
1867
|
+
workspace_id=rows[0].workspace_id,
|
|
1868
|
+
)
|
|
1824
1869
|
audit(
|
|
1825
1870
|
conn,
|
|
1826
1871
|
principal.tenant_id,
|
|
@@ -1908,6 +1953,8 @@ def _get_job(
|
|
|
1908
1953
|
if job is None:
|
|
1909
1954
|
raise AgentevoError(ErrorCode.NOT_FOUND, "unknown job", {"job_id": job_id})
|
|
1910
1955
|
_require_job_scope(principal, job.job_type)
|
|
1956
|
+
if job.workspace_id is not None:
|
|
1957
|
+
require_workspace(principal, job.workspace_id)
|
|
1911
1958
|
return job.to_dict()
|
|
1912
1959
|
|
|
1913
1960
|
|
|
@@ -1927,7 +1974,9 @@ def _list_jobs(
|
|
|
1927
1974
|
raise AgentevoError(
|
|
1928
1975
|
ErrorCode.INVALID_ARGUMENT, f"unknown job state: {state}", {"state": state}
|
|
1929
1976
|
)
|
|
1930
|
-
jobs = list_jobs(
|
|
1977
|
+
jobs = list_jobs(
|
|
1978
|
+
conn, principal.tenant_id, state=state, limit=limit, workspaces=principal.workspaces
|
|
1979
|
+
)
|
|
1931
1980
|
return {"jobs": [job.to_dict() for job in jobs]}
|
|
1932
1981
|
|
|
1933
1982
|
|
|
@@ -1942,6 +1991,8 @@ def _cancel_job(
|
|
|
1942
1991
|
if job is None:
|
|
1943
1992
|
raise AgentevoError(ErrorCode.NOT_FOUND, "unknown job", {"job_id": job_id})
|
|
1944
1993
|
_require_job_scope(principal, job.job_type)
|
|
1994
|
+
if job.workspace_id is not None:
|
|
1995
|
+
require_workspace(principal, job.workspace_id)
|
|
1945
1996
|
|
|
1946
1997
|
def compute() -> tuple[int, dict[str, Any]]:
|
|
1947
1998
|
canceled = cancel_job(conn, principal.tenant_id, job_id)
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
# SPDX-License-Identifier: BUSL-1.1
|
|
1
2
|
"""Control-plane storage: SQLite engine plus versioned migrations.
|
|
2
3
|
|
|
3
4
|
The repository layer speaks plain SQL that stays portable to server RDBMS
|
|
@@ -16,7 +17,7 @@ from collections.abc import Callable, Iterator
|
|
|
16
17
|
from contextlib import contextmanager, suppress
|
|
17
18
|
from pathlib import Path
|
|
18
19
|
|
|
19
|
-
SCHEMA_VERSION =
|
|
20
|
+
SCHEMA_VERSION = 10
|
|
20
21
|
|
|
21
22
|
#: Migration mutual exclusion. Five compose services share one SQLite file
|
|
22
23
|
#: and migrate lazily on first use; without this, concurrent first-boots race
|
|
@@ -358,6 +359,19 @@ def _ensure_job_durability_columns(raw: sqlite3.Connection) -> None:
|
|
|
358
359
|
|
|
359
360
|
_MIGRATION_FOLLOWUPS[8] = _ensure_job_durability_columns
|
|
360
361
|
|
|
362
|
+
_SCHEMA_V10 = "-- V10: job workspace scoping column (applied by followup).\n"
|
|
363
|
+
|
|
364
|
+
|
|
365
|
+
def _ensure_job_workspace_column(raw: sqlite3.Connection) -> None:
|
|
366
|
+
"""Add `jobs.workspace_id` unless a replay already did (NULL = legacy)."""
|
|
367
|
+
names = {row[1] for row in raw.execute("PRAGMA table_info(jobs)").fetchall()}
|
|
368
|
+
if "workspace_id" not in names:
|
|
369
|
+
raw.execute("ALTER TABLE jobs ADD COLUMN workspace_id TEXT")
|
|
370
|
+
raw.execute("CREATE INDEX IF NOT EXISTS idx_jobs_workspace ON jobs (tenant_id, workspace_id)")
|
|
371
|
+
|
|
372
|
+
|
|
373
|
+
_MIGRATION_FOLLOWUPS[10] = _ensure_job_workspace_column
|
|
374
|
+
|
|
361
375
|
_SCHEMA_V9 = """
|
|
362
376
|
CREATE UNIQUE INDEX IF NOT EXISTS uq_repo_links_repo ON repo_links (repo);
|
|
363
377
|
"""
|
|
@@ -372,6 +386,7 @@ MIGRATIONS: tuple[tuple[int, str], ...] = (
|
|
|
372
386
|
(7, _SCHEMA_V7),
|
|
373
387
|
(8, _SCHEMA_V8),
|
|
374
388
|
(9, _SCHEMA_V9),
|
|
389
|
+
(10, _SCHEMA_V10),
|
|
375
390
|
)
|
|
376
391
|
|
|
377
392
|
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
# SPDX-License-Identifier: BUSL-1.1
|
|
1
2
|
"""Workspace devices and the OAuth device authorization flow (RFC 8628).
|
|
2
3
|
|
|
3
4
|
Devices register public keys under TOFU: each ``(key_id, workspace)``
|
|
@@ -669,9 +670,11 @@ def _granted_workspace(
|
|
|
669
670
|
{"workspace": requested},
|
|
670
671
|
)
|
|
671
672
|
if identity.workspaces and requested not in identity.workspaces:
|
|
673
|
+
# Same 404 as a missing workspace: a scoped approver must not learn
|
|
674
|
+
# whether the slug exists outside its grant (require_workspace rule).
|
|
672
675
|
raise AgentevoError(
|
|
673
|
-
ErrorCode.
|
|
674
|
-
"
|
|
676
|
+
ErrorCode.NOT_FOUND,
|
|
677
|
+
f"unknown workspace: {requested}",
|
|
675
678
|
{"workspace": requested},
|
|
676
679
|
)
|
|
677
680
|
return requested
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
# SPDX-License-Identifier: BUSL-1.1
|
|
1
2
|
"""Async job orchestration: durable verify/compile jobs with poll + webhooks.
|
|
2
3
|
|
|
3
4
|
Spec: API §5/§9/§11/§23. Submissions return 202 with a queued job;
|
|
@@ -96,6 +97,7 @@ class Job:
|
|
|
96
97
|
finished_at: str | None
|
|
97
98
|
attempts: int = 0
|
|
98
99
|
next_run_at: str | None = None
|
|
100
|
+
workspace_id: str | None = None
|
|
99
101
|
|
|
100
102
|
def to_dict(self) -> dict[str, Any]:
|
|
101
103
|
"""Poll response shape (tenant omitted: derived server-side)."""
|
|
@@ -104,6 +106,7 @@ class Job:
|
|
|
104
106
|
"type": self.job_type,
|
|
105
107
|
"object_id": self.object_id,
|
|
106
108
|
"version": self.version,
|
|
109
|
+
"workspace_id": self.workspace_id,
|
|
107
110
|
"state": self.state,
|
|
108
111
|
"params": self.params,
|
|
109
112
|
"result": self.result,
|
|
@@ -156,8 +159,9 @@ def create_job( # noqa: PLR0913 - submission carries job context
|
|
|
156
159
|
version: int | None = None,
|
|
157
160
|
params: dict[str, Any] | None = None,
|
|
158
161
|
now: datetime | None = None,
|
|
162
|
+
workspace_id: str | None = None,
|
|
159
163
|
) -> Job:
|
|
160
|
-
"""Queue one job (202 submission path)."""
|
|
164
|
+
"""Queue one job (202 submission path; workspace stamps its scope)."""
|
|
161
165
|
try:
|
|
162
166
|
kind = JobType(job_type)
|
|
163
167
|
except ValueError as exc:
|
|
@@ -169,7 +173,7 @@ def create_job( # noqa: PLR0913 - submission carries job context
|
|
|
169
173
|
payload = params or {}
|
|
170
174
|
conn.execute(
|
|
171
175
|
"INSERT INTO jobs (tenant_id, job_id, job_type, object_id, version,"
|
|
172
|
-
" state, params, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
|
|
176
|
+
" state, params, created_at, workspace_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
|
173
177
|
(
|
|
174
178
|
tenant_id,
|
|
175
179
|
job_id,
|
|
@@ -179,6 +183,7 @@ def create_job( # noqa: PLR0913 - submission carries job context
|
|
|
179
183
|
JobState.QUEUED.value,
|
|
180
184
|
_dump(payload),
|
|
181
185
|
moment,
|
|
186
|
+
workspace_id,
|
|
182
187
|
),
|
|
183
188
|
)
|
|
184
189
|
return Job(
|
|
@@ -194,6 +199,7 @@ def create_job( # noqa: PLR0913 - submission carries job context
|
|
|
194
199
|
created_at=moment,
|
|
195
200
|
started_at=None,
|
|
196
201
|
finished_at=None,
|
|
202
|
+
workspace_id=workspace_id,
|
|
197
203
|
)
|
|
198
204
|
|
|
199
205
|
|
|
@@ -206,23 +212,34 @@ def get_job(conn: sqlite3.Connection, tenant_id: str, job_id: str) -> Job | None
|
|
|
206
212
|
|
|
207
213
|
|
|
208
214
|
def list_jobs(
|
|
209
|
-
conn: sqlite3.Connection,
|
|
215
|
+
conn: sqlite3.Connection,
|
|
216
|
+
tenant_id: str,
|
|
217
|
+
*,
|
|
218
|
+
state: str | None = None,
|
|
219
|
+
limit: int = 50,
|
|
220
|
+
workspaces: tuple[str, ...] = (),
|
|
210
221
|
) -> list[Job]:
|
|
211
|
-
"""Tenant jobs, newest first (operator listing)."""
|
|
222
|
+
"""Tenant jobs, newest first (operator listing; scope-filtered)."""
|
|
212
223
|
if not 1 <= limit <= LIST_JOBS_MAX:
|
|
213
224
|
raise AgentevoError(
|
|
214
225
|
ErrorCode.INVALID_ARGUMENT, f"limit must be within [1, {LIST_JOBS_MAX}]", {}
|
|
215
226
|
)
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
227
|
+
clauses = ["tenant_id = ?"]
|
|
228
|
+
params: list[Any] = [tenant_id]
|
|
229
|
+
if state is not None:
|
|
230
|
+
clauses.append("state = ?")
|
|
231
|
+
params.append(state)
|
|
232
|
+
if workspaces:
|
|
233
|
+
placeholders = ", ".join("?" for _ in workspaces)
|
|
234
|
+
clauses.append(f"workspace_id IN ({placeholders})")
|
|
235
|
+
params.extend(workspaces)
|
|
236
|
+
query = (
|
|
237
|
+
"SELECT * FROM jobs WHERE " # noqa: S608 - static fragments + binds
|
|
238
|
+
+ " AND ".join(clauses)
|
|
239
|
+
+ " ORDER BY created_at DESC LIMIT ?"
|
|
240
|
+
)
|
|
241
|
+
params.append(limit)
|
|
242
|
+
rows = conn.execute(query, params).fetchall()
|
|
226
243
|
return [_job_row(row) for row in rows]
|
|
227
244
|
|
|
228
245
|
|
|
@@ -546,6 +563,8 @@ def _job_row(row: sqlite3.Row) -> Job:
|
|
|
546
563
|
finished_at=row["finished_at"],
|
|
547
564
|
attempts=row["attempts"],
|
|
548
565
|
next_run_at=row["next_run_at"],
|
|
566
|
+
# NOTE: sqlite3.Row `in` scans values, so `.keys()` is required here.
|
|
567
|
+
workspace_id=row["workspace_id"] if "workspace_id" in row.keys() else None, # noqa: SIM118
|
|
549
568
|
)
|
|
550
569
|
|
|
551
570
|
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
# SPDX-License-Identifier: BUSL-1.1
|
|
1
2
|
"""Managed promotion: Candidate -> Verified -> Trusted advancement.
|
|
2
3
|
|
|
3
4
|
Spec: PRD §17/§19, API §20. Mirrors the local PromotionService gates over
|
|
@@ -24,7 +25,7 @@ from datetime import UTC, datetime
|
|
|
24
25
|
from agentevo_cloud.jobs import JobState, JobType
|
|
25
26
|
from agentevo_cloud.managed import ManagedObject, get_managed
|
|
26
27
|
from agentevo_cloud.records import audit
|
|
27
|
-
from agentevo_cloud.tokens import Principal
|
|
28
|
+
from agentevo_cloud.tokens import Principal, require_workspace
|
|
28
29
|
from agentevo_core.errors import AgentevoError, ErrorCode
|
|
29
30
|
from agentevo_core.risk import RiskClass, requires_human_approval
|
|
30
31
|
from agentevo_core.sync import DeltaKind
|
|
@@ -75,6 +76,7 @@ def promote_managed( # noqa: PLR0913 - promotion carries object context
|
|
|
75
76
|
"unknown object version",
|
|
76
77
|
{"object_id": object_id, "version": version},
|
|
77
78
|
)
|
|
79
|
+
require_workspace(principal, row.workspace_id)
|
|
78
80
|
check_transition(TrustState(row.state), target)
|
|
79
81
|
moment = now or datetime.now(UTC)
|
|
80
82
|
tally = _tally(conn, principal.tenant_id, object_id, version)
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
# SPDX-License-Identifier: BUSL-1.1
|
|
1
2
|
"""Server-attested revocation: object-wide tombstones with full propagation.
|
|
2
3
|
|
|
3
4
|
Spec: Data Flow §13, API §20. Revocation is the safety override: one
|
|
@@ -35,7 +36,7 @@ from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
|
|
|
35
36
|
from agentevo_cloud.managed import index_accepted
|
|
36
37
|
from agentevo_cloud.records import audit
|
|
37
38
|
from agentevo_cloud.sync import store_server_delta
|
|
38
|
-
from agentevo_cloud.tokens import Principal
|
|
39
|
+
from agentevo_cloud.tokens import Principal, require_workspace
|
|
39
40
|
from agentevo_core.classification import Classification
|
|
40
41
|
from agentevo_core.errors import AgentevoError, ErrorCode
|
|
41
42
|
from agentevo_core.objects import LearnedObjectType
|
|
@@ -95,6 +96,8 @@ def revoke_object(
|
|
|
95
96
|
).fetchall()
|
|
96
97
|
if not rows:
|
|
97
98
|
raise AgentevoError(ErrorCode.NOT_FOUND, "unknown object", {"object_id": object_id})
|
|
99
|
+
for row in rows:
|
|
100
|
+
require_workspace(principal, row["workspace_id"])
|
|
98
101
|
live = [row for row in rows if row["state"] not in _TERMINAL_REVOKE_STATES]
|
|
99
102
|
moment = now or datetime.now(UTC)
|
|
100
103
|
actor = principal.user_id or principal.token_id
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
# SPDX-License-Identifier: BUSL-1.1
|
|
1
2
|
"""Token lifecycle: PATs, access tokens, refresh rotation, CI tokens.
|
|
2
3
|
|
|
3
4
|
All tokens are opaque random strings, SHA-256-hashed at rest, prefixed by
|
|
@@ -85,6 +86,16 @@ class Principal:
|
|
|
85
86
|
return not self.workspaces or slug in self.workspaces
|
|
86
87
|
|
|
87
88
|
|
|
89
|
+
def require_workspace(principal: Principal, slug: str) -> None:
|
|
90
|
+
"""404 unless the token's workspace scope covers ``slug``.
|
|
91
|
+
|
|
92
|
+
Cross-scope access fails exactly like a missing object: callers must
|
|
93
|
+
never reveal whether the target exists outside the token's scope.
|
|
94
|
+
"""
|
|
95
|
+
if not principal.allows_workspace(slug):
|
|
96
|
+
raise AgentevoError(ErrorCode.NOT_FOUND, "unknown object version", {})
|
|
97
|
+
|
|
98
|
+
|
|
88
99
|
def hash_token(secret: str) -> str:
|
|
89
100
|
"""SHA-256 hex digest used for at-rest token lookup.
|
|
90
101
|
|
|
@@ -106,9 +117,23 @@ def issue_pat( # noqa: PLR0913 - issuance carries grant context
|
|
|
106
117
|
workspaces: list[str] | None = None,
|
|
107
118
|
ttl: timedelta = timedelta(days=90),
|
|
108
119
|
now: datetime | None = None,
|
|
120
|
+
grantor_scopes: list[str] | None = None,
|
|
121
|
+
grantor_workspaces: list[str] | None = None,
|
|
109
122
|
) -> IssuedToken:
|
|
110
|
-
"""Issue a scoped personal token (elevated scopes need owner/admin).
|
|
111
|
-
|
|
123
|
+
"""Issue a scoped personal token (elevated scopes need owner/admin).
|
|
124
|
+
|
|
125
|
+
When the grantor's own grant is given, the new token is a subset of it;
|
|
126
|
+
``None`` means unconstrained bootstrap (operator provision only).
|
|
127
|
+
"""
|
|
128
|
+
_check_grantable(
|
|
129
|
+
conn,
|
|
130
|
+
tenant_id,
|
|
131
|
+
user_id,
|
|
132
|
+
scopes,
|
|
133
|
+
workspaces,
|
|
134
|
+
grantor_scopes,
|
|
135
|
+
grantor_workspaces,
|
|
136
|
+
)
|
|
112
137
|
return _store(
|
|
113
138
|
conn,
|
|
114
139
|
tenant_id=tenant_id,
|
|
@@ -309,8 +334,14 @@ def list_tokens(conn: sqlite3.Connection, tenant_id: str) -> list[TokenRecord]:
|
|
|
309
334
|
return [_record(row) for row in rows]
|
|
310
335
|
|
|
311
336
|
|
|
312
|
-
def _check_grantable(
|
|
313
|
-
conn: sqlite3.Connection,
|
|
337
|
+
def _check_grantable( # noqa: PLR0913, PLR0917 - grant check carries both grants
|
|
338
|
+
conn: sqlite3.Connection,
|
|
339
|
+
tenant_id: str,
|
|
340
|
+
user_id: str,
|
|
341
|
+
scopes: list[str],
|
|
342
|
+
workspaces: list[str] | None = None,
|
|
343
|
+
grantor_scopes: list[str] | None = None,
|
|
344
|
+
grantor_workspaces: list[str] | None = None,
|
|
314
345
|
) -> None:
|
|
315
346
|
for scope in scopes:
|
|
316
347
|
if scope not in GRANTABLE_SCOPES:
|
|
@@ -319,6 +350,22 @@ def _check_grantable(
|
|
|
319
350
|
f"unknown scope: {scope}",
|
|
320
351
|
{"scope": scope},
|
|
321
352
|
)
|
|
353
|
+
if grantor_scopes is not None and not set(scopes) <= set(grantor_scopes):
|
|
354
|
+
raise AgentevoError(
|
|
355
|
+
ErrorCode.FORBIDDEN,
|
|
356
|
+
"cannot grant scopes beyond your own",
|
|
357
|
+
{"tenant_id": tenant_id},
|
|
358
|
+
)
|
|
359
|
+
if (
|
|
360
|
+
grantor_workspaces
|
|
361
|
+
and workspaces is not None
|
|
362
|
+
and not set(workspaces) <= set(grantor_workspaces)
|
|
363
|
+
):
|
|
364
|
+
raise AgentevoError(
|
|
365
|
+
ErrorCode.FORBIDDEN,
|
|
366
|
+
"cannot grant workspaces beyond your own",
|
|
367
|
+
{"tenant_id": tenant_id},
|
|
368
|
+
)
|
|
322
369
|
if not set(scopes) & ELEVATED_SCOPES:
|
|
323
370
|
return
|
|
324
371
|
membership = get_membership(conn, tenant_id, user_id)
|