Skip to content

Commit 2e880b5

Browse files
committed
Implement provisioned credentials
Closes #29
1 parent d1ea7c4 commit 2e880b5

28 files changed

Lines changed: 1118 additions & 14 deletions
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
---
2+
title: "Provisioned credentials"
3+
sdk: python
4+
spec_sections: ["§9.7", "§9.8", "§14"]
5+
order: 11
6+
kind: feature
7+
---
8+
9+
## What it is
10+
11+
`model.use` constrains which upstream model identifiers a job may
12+
use. When a runtime is configured with a credential provisioner, it
13+
can mint short-lived credentials scoped to the job's `cost.budget`,
14+
`model.use`, and `lease_constraints.expires_at`, then attach them to
15+
`job.accepted.payload.credentials`.
16+
17+
Credentials are issued only for the submitting session and are
18+
revoked when the job reaches any terminal state. List and subscribe
19+
surfaces intentionally omit credential values.
20+
21+
## Feature flags
22+
23+
- `model.use`
24+
- `provisioned_credentials`
25+
26+
The runtime advertises these flags only when `credential_provisioner`
27+
and `revocation_log` are configured.
28+
29+
## Python API
30+
31+
```python
32+
runtime = ARCPRuntime(
33+
runtime=RuntimeInfo(name="demo", version="1.1.0"),
34+
bearer=StaticBearerVerifier({"demo-token": "p1"}),
35+
credential_provisioner=InMemoryCredentialProvisioner(),
36+
revocation_log=InMemoryRevocationLog(),
37+
)
38+
39+
handle = await client.submit(
40+
agent="summarize",
41+
lease_request={
42+
"model.use": ["tier-fast/*"],
43+
"cost.budget": ["USD:5.00"],
44+
},
45+
)
46+
47+
credential = handle.credentials[0]
48+
```
49+
50+
Inside an agent, call `ctx.authorize_model("tier-fast/mini")` before
51+
using a model id when the runtime is in the call path. Provisioner
52+
adapters can translate upstream budget failures by raising
53+
`UpstreamBudgetExhausted`; the runtime emits `BUDGET_EXHAUSTED`.
54+
55+
## See also
56+
57+
- Example: [`../04-examples/provisioned-credentials.md`](../04-examples/provisioned-credentials.md).
58+
- Spec: [`../../../spec/docs/draft-arcp-1.1.md`](../../../spec/docs/draft-arcp-1.1.md) §§9.7–9.8.
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
---
2+
title: "Provisioned credentials"
3+
sdk: python
4+
order: 22
5+
kind: example
6+
---
7+
8+
A runtime installs an `InMemoryCredentialProvisioner`, advertises
9+
`model.use` and `provisioned_credentials`, and accepts a job whose
10+
lease scopes the generated credential to `tier-fast/*` and `USD:1.00`.
11+
12+
Source: [`../../examples/provisioned_credentials/`](../../examples/provisioned_credentials/).
13+
14+
```sh
15+
uv run python -m examples.provisioned_credentials.server &
16+
uv run python -m examples.provisioned_credentials.client
17+
```
18+
19+
## See also
20+
21+
- Feature: [`../03-features/provisioned-credentials.md`](../03-features/provisioned-credentials.md).

docs/06-conformance.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,10 @@ values are `Implemented` or `Deferred` only. Citations are
8787
| §9.6 `cost.budget` initial snapshot | Implemented | `arcp/_runtime/lease.py:L115` |
8888
| §9.6 Per-event budget decrement | Implemented | `arcp/_runtime/job.py:L68` |
8989
| §9.6 `BUDGET_EXHAUSTED` on floor | Implemented | `arcp/_runtime/job.py:L68` |
90+
| §9.7 `model.use` authorization | Implemented | `arcp/_runtime/job.py:L285` |
91+
| §9.8 Provisioned credential interface | Implemented | `arcp/_runtime/credentials.py:L1` |
92+
| §9.8 Issue credentials on job acceptance | Implemented | `arcp/_runtime/_handlers.py:L158` |
93+
| §9.8 Revoke credentials on terminal state | Implemented | `arcp/_runtime/_job_runner.py:L145` |
9094

9195
## §10 Delegation
9296

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
# Provisioned Credentials
2+
3+
This example runs a vendor-neutral credential provisioner. The runtime
4+
issues one deterministic bearer credential when the job is accepted and
5+
revokes it when the job completes.
6+
7+
```sh
8+
uv run python -m examples.provisioned_credentials.server
9+
uv run python -m examples.provisioned_credentials.client
10+
```
11+
12+
The in-memory provisioner is a test double. Production adapters should
13+
implement `CredentialProvisioner` against a gateway such as LiteLLM
14+
without adding that vendor dependency to the SDK core.
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
"""provisioned_credentials client — reads credentials from job.accepted."""
2+
3+
from __future__ import annotations
4+
5+
import asyncio
6+
import contextlib
7+
import os
8+
import sys
9+
10+
from arcp import ClientInfo, WebSocketTransport
11+
from arcp.client import ARCPClient
12+
13+
PORT = int(os.environ.get("ARCP_DEMO_PORT", "7892"))
14+
URL = os.environ.get("ARCP_DEMO_URL", f"ws://127.0.0.1:{PORT}/arcp")
15+
TOKEN = os.environ.get("ARCP_DEMO_TOKEN", "demo-token")
16+
17+
18+
async def main() -> int:
19+
client = ARCPClient(
20+
client=ClientInfo(name="provisioned-credentials-client", version="1.1.0"),
21+
token=TOKEN,
22+
features=("model.use", "provisioned_credentials", "cost.budget"),
23+
)
24+
async with contextlib.aclosing(client):
25+
transport = await WebSocketTransport.connect(URL)
26+
await client.connect(transport)
27+
handle = await client.submit(
28+
agent="model-user",
29+
input={"model": "tier-fast/demo"},
30+
lease_request={
31+
"model.use": ["tier-fast/*"],
32+
"cost.budget": ["USD:1.00"],
33+
},
34+
)
35+
credential = handle.credentials[0]
36+
print(
37+
f"credential id={credential.id} scheme={credential.scheme} endpoint={credential.endpoint}"
38+
)
39+
result = await handle.done
40+
assert result.final_status == "success"
41+
assert result.result == {"model": "tier-fast/demo", "credential_count": 1}
42+
return 0
43+
44+
45+
if __name__ == "__main__":
46+
sys.exit(asyncio.run(main()))
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
"""provisioned_credentials server — issues a lease-bound bearer credential (§9.8)."""
2+
3+
from __future__ import annotations
4+
5+
import asyncio
6+
import os
7+
8+
from arcp import RuntimeInfo, serve_websocket
9+
from arcp.runtime import (
10+
ARCPRuntime,
11+
InMemoryCredentialProvisioner,
12+
InMemoryRevocationLog,
13+
JobContext,
14+
StaticBearerVerifier,
15+
)
16+
17+
PORT = int(os.environ.get("ARCP_DEMO_PORT", "7892"))
18+
TOKEN = os.environ.get("ARCP_DEMO_TOKEN", "demo-token")
19+
20+
21+
async def model_user(input_value: dict, ctx: JobContext) -> dict:
22+
model = str(input_value.get("model", "tier-fast/demo"))
23+
ctx.authorize_model(model)
24+
await ctx.status("using_model", model)
25+
return {"model": model, "credential_count": len(ctx.credentials)}
26+
27+
28+
async def main() -> None:
29+
provisioner = InMemoryCredentialProvisioner()
30+
runtime = ARCPRuntime(
31+
runtime=RuntimeInfo(name="provisioned-credentials-server", version="1.1.0"),
32+
bearer=StaticBearerVerifier({TOKEN: "demo-principal"}),
33+
credential_provisioner=provisioner,
34+
revocation_log=InMemoryRevocationLog(),
35+
)
36+
runtime.register_agent("model-user", model_user)
37+
server = await serve_websocket(runtime.accept, host="127.0.0.1", port=PORT, path="/arcp")
38+
print(f"listening on ws://127.0.0.1:{PORT}/arcp")
39+
try:
40+
await asyncio.Future()
41+
finally:
42+
print(f"revoked={provisioner.revoked}")
43+
server.close()
44+
await server.wait_closed()
45+
await runtime.close()
46+
47+
48+
if __name__ == "__main__":
49+
asyncio.run(main())

src/arcp/__init__.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,8 @@
2525
error_from_payload,
2626
)
2727
from ._messages.execution import (
28+
CredentialConstraintsPayload,
29+
CredentialPayload,
2830
Lease,
2931
LeaseConstraints,
3032
parse_agent_ref,
@@ -38,6 +40,7 @@
3840
SessionResume,
3941
SessionWelcomePayload,
4042
)
43+
from ._runtime.credentials import Credential, CredentialConstraints
4144
from ._transport.base import Transport, TransportClosed
4245
from ._transport.in_memory import MemoryTransport, pair_memory_transports
4346
from ._transport.stdio import StdioTransport
@@ -59,6 +62,10 @@
5962
# messages (commonly-used)
6063
"Capabilities",
6164
"ClientInfo",
65+
"Credential",
66+
"CredentialConstraints",
67+
"CredentialConstraintsPayload",
68+
"CredentialPayload",
6269
"DuplicateKeyError",
6370
# envelope
6471
"Envelope",

src/arcp/_client/handles.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
from typing import Any
1010

1111
from .._messages.execution import (
12+
CredentialPayload,
1213
JobAcceptedPayload,
1314
JobResultPayload,
1415
Lease,
@@ -43,6 +44,10 @@ def lease_constraints(self) -> LeaseConstraints | None:
4344
def budget(self) -> dict[str, str] | None:
4445
return self.accepted.budget
4546

47+
@property
48+
def credentials(self) -> tuple[CredentialPayload, ...]:
49+
return self.accepted.credentials or ()
50+
4651
@property
4752
def trace_id(self) -> str | None:
4853
return self.accepted.trace_id

src/arcp/_logger.py

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
from __future__ import annotations
44

55
import logging
6+
from copy import deepcopy
67
from typing import Any
78

89
import structlog
@@ -39,4 +40,26 @@ def get_logger(name: str | None = None, **initial: Any) -> Any:
3940
return log
4041

4142

42-
__all__ = ("get_logger",)
43+
def redact_credentials(obj: Any) -> Any:
44+
"""Return a copy with every `credentials[*].value` replaced by `<redacted>`."""
45+
out = deepcopy(obj)
46+
_redact_credentials_in_place(out)
47+
return out
48+
49+
50+
def _redact_credentials_in_place(obj: Any) -> None:
51+
if isinstance(obj, dict):
52+
mapping: dict[Any, Any] = obj
53+
credentials = mapping.get("credentials")
54+
if isinstance(credentials, (list, tuple)):
55+
for cred in credentials:
56+
if isinstance(cred, dict) and "value" in cred:
57+
cred["value"] = "<redacted>"
58+
for value in mapping.values():
59+
_redact_credentials_in_place(value)
60+
elif isinstance(obj, (list, tuple)):
61+
for item in obj:
62+
_redact_credentials_in_place(item)
63+
64+
65+
__all__ = ("get_logger", "redact_credentials")

src/arcp/_messages/execution.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,23 @@ def _check_agent(cls, v: str) -> str:
110110
parse_agent_ref(v)
111111
return v
112112

113+
class CredentialConstraintsPayload(BaseModel):
114+
model_config = ConfigDict(extra="allow", populate_by_name=True)
115+
cost_budget: tuple[str, ...] = Field(default=(), alias="cost.budget")
116+
model_use: tuple[str, ...] = Field(default=(), alias="model.use")
117+
expires_at: str | None = None
118+
119+
120+
class CredentialPayload(BaseModel):
121+
model_config = ConfigDict(extra="allow", populate_by_name=True)
122+
id: str
123+
scheme: Literal["bearer"]
124+
value: str
125+
endpoint: str
126+
profile: str | None = None
127+
constraints: CredentialConstraintsPayload | None = None
128+
129+
113130
class JobAcceptedPayload(BaseModel):
114131
model_config = ConfigDict(extra="allow")
115132
job_id: str
@@ -121,6 +138,7 @@ class JobAcceptedPayload(BaseModel):
121138
parent_job_id: str | None = None
122139
delegate_id: str | None = None
123140
trace_id: str | None = None
141+
credentials: tuple[CredentialPayload, ...] | None = None
124142

125143
class JobCancelPayload(BaseModel):
126144
model_config = ConfigDict(extra="allow")
@@ -183,6 +201,8 @@ class JobUnsubscribePayload(BaseModel):
183201
__all__ = (
184202
"EVENT_KINDS",
185203
"ArtifactRefBody",
204+
"CredentialConstraintsPayload",
205+
"CredentialPayload",
186206
"DelegateBody",
187207
"JobAcceptedPayload",
188208
"JobCancelPayload",

0 commit comments

Comments
 (0)