Skip to content

Commit 4cad7ff

Browse files
author
Nick Ficano
committed
feat: close #55 provisioned credentials
1 parent f69a6ba commit 4cad7ff

31 files changed

Lines changed: 1788 additions & 13 deletions

CONFORMANCE.md

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -81,7 +81,7 @@ new v1.1 subsections appear after them.
8181
| Requirement | Status | Location |
8282
| --------------------------------------------------------------------------------------------------------- | ----------- | --------------------------------------------------------------------------------------------------------- |
8383
| §9.1 Lease is IMMUTABLE, granted at submit; capability → glob pattern[] | Implemented | `packages/core/src/messages/execution.ts:LeaseSchema`; `packages/runtime/src/lease.ts:validateLeaseShape` |
84-
| §9.2 Reserved namespaces `fs.read`, `fs.write`, `net.fetch`, `tool.call`, `agent.delegate`, `cost.budget` | Implemented | `packages/core/src/messages/execution.ts:RESERVED_CAPABILITY_NAMES` |
84+
| §9.2 Reserved namespaces `fs.read`, `fs.write`, `net.fetch`, `tool.call`, `agent.delegate`, `cost.budget`, `model.use` | Implemented | `packages/core/src/messages/lease-schema.ts:RESERVED_CAPABILITY_NAMES` |
8585
| §9.2 Glob `*` (single segment) / `**` (zero+ segments); anchored | Implemented | `packages/runtime/src/lease.ts:compileGlob`/`matchGlob` |
8686
| §9.3 Runtime MUST validate every operation against the lease; `PERMISSION_DENIED` on fail | Implemented | `packages/runtime/src/lease.ts:validateLeaseOp` |
8787
| §9.4 Lease subsetting for delegation | Implemented | `packages/runtime/src/lease.ts:isLeaseSubset`/`assertLeaseSubset` |
@@ -201,6 +201,8 @@ capability list in `session.hello`/`session.welcome`.
201201
| `subscribe` | §7.6 | Implemented | `packages/runtime/src/server.ts:handleJobSubscribe`; `ARCPClient.subscribe` |
202202
| `lease_expires_at` | §9.5 | Implemented | `packages/runtime/src/lease.ts:validateLeaseConstraints`; expiry watchdog in `server.ts:runHandler` |
203203
| `cost.budget` | §9.6 | Implemented | `packages/runtime/src/lease.ts:initialBudgetFromLease`; `Job.applyCostMetric`; `validateLeaseOp` budget check |
204+
| `model.use` | §9.7 | Implemented | `packages/core/src/messages/lease-schema.ts:RESERVED_CAPABILITY_NAMES`; `packages/runtime/src/lease.ts:validateLeaseOp` |
205+
| `provisioned_credentials` | §9.8 | Implemented | `packages/runtime/src/credential-provisioner.ts`; `packages/runtime/src/job-runner.ts:issueCredentials` |
204206
| `progress` | §8.2 | Implemented | `packages/core/src/messages/execution.ts:ProgressBodySchema`; `JobContext.progress` |
205207
| `result_chunk` | §8.4 | Implemented | `packages/core/src/messages/execution.ts:ResultChunkBodySchema`; `JobContext.streamResult` + `JobHandle.collectChunks` |
206208
| `agent_versions` | §7.5 | Implemented | `packages/core/src/messages/execution.ts:parseAgentRef`; `ARCPServer.registerAgentVersion`/`setDefaultAgentVersion`/`resolveAgent` |
@@ -345,6 +347,22 @@ Helpers:
345347
| Runtime MAY emit `cost.budget.remaining` metric events with debounce | Implemented | `packages/runtime/src/server.ts:metricInterceptor` + `Job.shouldEmitBudgetRemaining` (5 % threshold) |
346348
| `JobContext.budget` read-only snapshot of remaining counters | Implemented | `packages/runtime/src/job.ts:makeJobContext` |
347349

350+
## §9.7 / §9.8 Model Use and Provisioned Credentials
351+
352+
| Requirement | Status | Location |
353+
| ------------------------------------------------------------------------------ | ----------- | --------------------------------------------------------------------------------------------------------------- |
354+
| Feature flags `model.use` and `provisioned_credentials` | Implemented | `packages/core/src/version.ts:V1_1_FEATURES` |
355+
| `model.use` in `RESERVED_CAPABILITY_NAMES` | Implemented | `packages/core/src/messages/lease-schema.ts:RESERVED_CAPABILITY_NAMES` |
356+
| `model.use` glob matching and lease subsetting | Implemented | `packages/runtime/src/lease.ts:validateLeaseOp`; `packages/runtime/src/lease.ts:isLeaseSubset` |
357+
| Credential wire shape `{ id, scheme, value, endpoint, profile?, constraints? }` | Implemented | `packages/core/src/messages/credentials.ts`; `packages/core/src/messages/execution.ts:JobAcceptedPayloadSchema` |
358+
| Runtime issues credentials before `job.accepted` when a provisioner is set | Implemented | `packages/runtime/src/job-runner.ts:issueCredentials` |
359+
| Runtime revokes stored credential ids on terminal cleanup | Implemented | `packages/runtime/src/job.ts:revokeAll`; `packages/runtime/src/credential-store.ts` |
360+
| Runtime only advertises credential features when a provisioner is configured | Implemented | `packages/runtime/src/server.ts:advertisedFeatures` |
361+
| `credentialProvisioner` requires `credentialStore` | Implemented | `packages/runtime/src/server.ts:ARCPServer` constructor |
362+
| `job.subscribed` redacts credentials for non-submitters | Implemented | `packages/runtime/src/server-subscribe.ts:buildSubscribedPayload` |
363+
| Client surfaces accepted credentials on `JobHandle.credentials` | Implemented | `packages/client/src/client-handle.ts`; `packages/client/src/client-dispatch.ts` |
364+
| Upstream spend-cap failures can be translated to `BUDGET_EXHAUSTED` | Implemented | `packages/runtime/src/credential-provisioner.ts:toBudgetExhausted` |
365+
348366
## §11 Trace attributes (v1.1 additions)
349367

350368
| Requirement | Status | Location |

docs/guides/credentials.md

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
# Provisioned Credentials (§9.8)
2+
3+
Provisioned credentials let a runtime mint short-lived, scope-restricted
4+
secrets for a job after the effective lease is finalized. The client
5+
receives the credential in `job.accepted.payload.credentials`; the
6+
runtime revokes it when the job reaches a terminal state.
7+
8+
## Runtime Setup
9+
10+
Configure both a `CredentialProvisioner` and a `CredentialStore`:
11+
12+
```ts
13+
import {
14+
ARCPServer,
15+
InMemoryCredentialStore,
16+
type CredentialProvisioner,
17+
} from "@arcp/sdk";
18+
19+
const provisioner: CredentialProvisioner = {
20+
async issue(ctx) {
21+
const models = ctx.lease["model.use"] ?? [];
22+
if (models.length === 0) return [];
23+
return [
24+
{
25+
wire: {
26+
id: `${ctx.jobId}:llm`,
27+
scheme: "bearer",
28+
value: "short-lived-secret",
29+
endpoint: "https://llm-gateway.example/v1",
30+
constraints: { allowed_models: [...models] },
31+
},
32+
provisionerId: `${ctx.jobId}:llm`,
33+
},
34+
];
35+
},
36+
async revoke(_provisionerId) {},
37+
};
38+
39+
const server = new ARCPServer({
40+
runtime: { name: "runtime", version: "1.0.0" },
41+
capabilities: { encodings: ["json"] },
42+
credentialProvisioner: provisioner,
43+
credentialStore: new InMemoryCredentialStore(),
44+
});
45+
```
46+
47+
`InMemoryCredentialStore` is for tests and local demos. Production
48+
runtimes should use a durable store so revocation records survive
49+
process restarts.
50+
51+
## Wire Shape
52+
53+
Each credential has this shape:
54+
55+
```ts
56+
{
57+
id: string;
58+
scheme: "bearer";
59+
value: string;
60+
endpoint: string;
61+
profile?: string;
62+
constraints?: {
63+
expires_at?: string;
64+
allowed_models?: string[];
65+
max_spend?: { currency: string; amount: number };
66+
};
67+
}
68+
```
69+
70+
`value` is a secret. The runtime sends it only to the original job
71+
submitter and omits it from cross-principal subscription views.
72+
73+
## LiteLLM Mapping
74+
75+
LiteLLM is the reference shape for a pluggable provider, but it is not
76+
built into the SDK:
77+
78+
| ARCP field | LiteLLM `/key/generate` field |
79+
| ---------------------------------- | ----------------------------- |
80+
| `lease["model.use"]` | `allowed_models` |
81+
| `lease["cost.budget"]` | `max_budget` |
82+
| `leaseConstraints.expires_at` | key duration / expiry |
83+
| `Credential.provisionerId` | LiteLLM key alias/id |
84+
| `Credential.endpoint` | LiteLLM proxy base URL |
85+
86+
Use `toBudgetExhausted(error, details)` in a provisioner or gateway shim
87+
when the upstream reports a spend-cap failure; it converts the vendor
88+
failure to ARCP `BUDGET_EXHAUSTED`.

docs/guides/leases.md

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,8 @@ A capability name is `<namespace>:<resource>`. Reserved namespaces:
2222
| `net.fetch` | Outbound HTTP/S3/etc. Pattern is a URL glob. |
2323
| `tool.call` | Tool invocation. Pattern matches against `tool` name. |
2424
| `agent.delegate` | Spawning child jobs. Pattern matches child agent name. |
25+
| `model.use` | LLM model invocation. Pattern matches model id/name. |
26+
| `cost.budget` | Spend cap, encoded as `CURRENCY:amount` patterns. |
2527

2628
Custom namespaces MUST use `x-vendor.<vendor>.<cap>`:
2729

@@ -181,6 +183,31 @@ budget currency, the runtime decrements. Exhaustion throws
181183
The runtime also emits a `metric` event with name `budget_remaining`
182184
when consumption crosses 5% deltas (debounced).
183185

186+
## Model Use (v1.1, §9.7)
187+
188+
`model.use` narrows which upstream model ids the job may invoke:
189+
190+
```ts
191+
const handle = await client.submit({
192+
agent: "research",
193+
input: {},
194+
lease: {
195+
"model.use": ["gpt-4*", "claude-3-5-*"],
196+
"cost.budget": ["USD:2.00"],
197+
},
198+
});
199+
```
200+
201+
The runtime treats model ids like other lease targets: `*` and `**`
202+
are glob wildcards, matching is anchored, and delegation may only
203+
narrow the model set. For example, a child with `model.use:
204+
["gpt-4o-mini"]` is a subset of a parent with `["gpt-4*"]`; a child
205+
with `["**"]` is not.
206+
207+
When a credential provisioner is configured, `model.use` is also the
208+
source for credential constraints. Provisioners should map these
209+
patterns to the upstream provider's allowed-model list.
210+
184211
## Hand-written validation
185212

186213
`validateLeaseShape(lease)` checks structural well-formedness;
@@ -204,4 +231,5 @@ for (const cap of Object.keys(incoming)) {
204231
- [`examples/lease-violation/`](../../examples/lease-violation/) — denied access surfaces as `tool_result.error`.
205232
- [`examples/lease-expires-at/`](../../examples/lease-expires-at/) — v1.1 expiration.
206233
- [`examples/cost-budget/`](../../examples/cost-budget/) — v1.1 budgets.
234+
- [`examples/provisioned-credentials/`](../../examples/provisioned-credentials/) — v1.1 model-bound credentials.
207235
- [`examples/delegate/`](../../examples/delegate/) — subset validation on child spawn.

examples/README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ real `Transport`. No mocks. No in-memory shortcuts. Each example exits
3131
| [`agent-versions/`](./agent-versions/) | `name@version` grammar; default-version resolution for bare names; `AGENT_VERSION_NOT_AVAILABLE` on unregistered version. | §7.5, §12 |
3232
| [`lease-expires-at/`](./lease-expires-at/) | `lease_constraints.expires_at` deadline; agent's `validateLeaseOp` and runtime watchdog both trip `LEASE_EXPIRED`. | §9.5, §12 |
3333
| [`cost-budget/`](./cost-budget/) | `cost.budget` lease capability; `cost.*` metrics auto-decrement the counter; runtime emits debounced `cost.budget.remaining`; final call hits `BUDGET_EXHAUSTED`. | §9.6, §12 |
34+
| [`provisioned-credentials/`](./provisioned-credentials/) | Runtime mints a model-bound bearer credential from `model.use` and revokes it when the job completes. | §9.7, §9.8 |
3435
| [`progress/`](./progress/) | `progress` event kind; client renders a text progress bar. | §8.2.1 |
3536
| [`result-chunk/`](./result-chunk/) | `ctx.streamResult()` writes ~30 chunks; terminal `job.result` carries `result_id` + `result_size`; client `handle.collectChunks()` reassembles. | §8.4 |
3637

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
# provisioned-credentials example (v1.1)
2+
3+
Demonstrates `model.use` plus a runtime-side `CredentialProvisioner`.
4+
The server mints a deterministic bearer credential for the accepted job,
5+
echoes it in `job.accepted`, stores only its revocation id, and revokes it
6+
when the job completes.
7+
8+
## Run
9+
10+
In one terminal:
11+
12+
```sh
13+
pnpm tsx examples/provisioned-credentials/server.ts
14+
```
15+
16+
In a second terminal:
17+
18+
```sh
19+
pnpm tsx examples/provisioned-credentials/client.ts
20+
```
21+
22+
## What it demonstrates
23+
24+
- §9.7 `model.use` lease capability.
25+
- §9.8 `CredentialProvisioner` issue/revoke lifecycle.
26+
- Credential constraints derived from the job lease.
27+
- Client access via `handle.credentials`.
28+
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
import { ARCPClient, WebSocketTransport } from "@arcp/sdk";
2+
3+
const URL = process.env.ARCP_DEMO_URL ?? "ws://127.0.0.1:7892";
4+
const TOKEN = process.env.ARCP_DEMO_TOKEN ?? "demo-token";
5+
6+
async function main(): Promise<void> {
7+
const client = new ARCPClient({
8+
client: { name: "provisioned-credentials-demo-client", version: "1.0.0" },
9+
capabilities: { encodings: ["json"] },
10+
authScheme: "bearer",
11+
token: TOKEN,
12+
});
13+
14+
const transport = await WebSocketTransport.connect(URL);
15+
await client.connect(transport);
16+
17+
const handle = await client.submit({
18+
agent: "ask-model",
19+
input: { prompt: "hello" },
20+
lease: {
21+
"model.use": ["gpt-4o-mini"],
22+
"cost.budget": ["USD:0.10"],
23+
},
24+
});
25+
26+
const credential = handle.credentials?.[0];
27+
process.stdout.write(
28+
`accepted job_id=${handle.jobId} credential=${credential?.id ?? "none"} endpoint=${credential?.endpoint ?? "none"}\n`,
29+
);
30+
31+
const result = await handle.done;
32+
process.stdout.write(`${JSON.stringify(result.result)}\n`);
33+
await client.close();
34+
}
35+
36+
void main().catch((err) => {
37+
process.stderr.write(`${err instanceof Error ? err.stack : String(err)}\n`);
38+
process.exit(1);
39+
});
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
import {
2+
ARCPServer,
3+
InMemoryCredentialStore,
4+
StaticBearerVerifier,
5+
startWebSocketServer,
6+
type CredentialIssueContext,
7+
type CredentialProvisioner,
8+
type IssuedCredential,
9+
} from "@arcp/sdk";
10+
11+
const PORT = Number(process.env.ARCP_DEMO_PORT ?? 7892);
12+
const TOKEN = process.env.ARCP_DEMO_TOKEN ?? "demo-token";
13+
14+
class MockProvisioner implements CredentialProvisioner {
15+
public readonly revoked: string[] = [];
16+
17+
async issue(ctx: CredentialIssueContext): Promise<IssuedCredential[]> {
18+
const models = ctx.lease["model.use"] ?? [];
19+
if (models.length === 0) return [];
20+
const id = `${ctx.jobId}:mock-llm`;
21+
return [
22+
{
23+
wire: {
24+
id,
25+
scheme: "bearer",
26+
value: `mock-key-${ctx.jobId}`,
27+
endpoint: "http://localhost/mock-llm/v1",
28+
constraints: {
29+
allowed_models: [...models],
30+
...(ctx.leaseConstraints?.expires_at === undefined
31+
? {}
32+
: { expires_at: ctx.leaseConstraints.expires_at }),
33+
},
34+
},
35+
provisionerId: id,
36+
},
37+
];
38+
}
39+
40+
async revoke(provisionerId: string): Promise<void> {
41+
this.revoked.push(provisionerId);
42+
process.stdout.write(`revoked ${provisionerId}\n`);
43+
}
44+
}
45+
46+
async function main(): Promise<void> {
47+
const server = new ARCPServer({
48+
runtime: { name: "provisioned-credentials-demo", version: "1.0.0" },
49+
capabilities: {
50+
encodings: ["json"],
51+
agents: ["ask-model"],
52+
},
53+
bearer: new StaticBearerVerifier(new Map([[TOKEN, { principal: "demo" }]])),
54+
credentialProvisioner: new MockProvisioner(),
55+
credentialStore: new InMemoryCredentialStore(),
56+
});
57+
58+
server.registerAgent("ask-model", async (_input, ctx) => {
59+
return {
60+
modelLease: ctx.lease["model.use"] ?? [],
61+
};
62+
});
63+
64+
const ws = await startWebSocketServer({
65+
host: "127.0.0.1",
66+
port: PORT,
67+
onTransport: (t) => {
68+
server.accept(t);
69+
},
70+
});
71+
process.stdout.write(`ARCP server listening on ${ws.url}\n`);
72+
73+
process.on("SIGINT", () => {
74+
void ws.close().then(() => server.close());
75+
});
76+
}
77+
78+
void main().catch((err) => {
79+
process.stderr.write(`${err instanceof Error ? err.stack : String(err)}\n`);
80+
process.exit(1);
81+
});

packages/client/src/client-dispatch.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -348,6 +348,7 @@ function onJobAccepted(
348348
inv.agent = payload.agent;
349349
inv.leaseConstraints = payload.lease_constraints;
350350
inv.budget = payload.budget;
351+
inv.credentials = payload.credentials;
351352
inv.traceId = payload.trace_id ?? inv.traceId;
352353
target.invocationsByJobId.set(payload.job_id, inv);
353354
inv.acceptance.resolve(payload);

packages/client/src/client-handle.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import type {
77
Lease,
88
LeaseConstraints,
99
ResultChunkBody,
10+
Credential,
1011
} from "@arcp/core/messages";
1112
import type { Deferred } from "@arcp/core/util";
1213

@@ -18,6 +19,7 @@ export interface InvocationState {
1819
agent: string | undefined;
1920
leaseConstraints: LeaseConstraints | undefined;
2021
budget: Record<string, number> | undefined;
22+
credentials: Credential[] | undefined;
2123
traceId: TraceId | undefined;
2224
events: JobEventPayload[];
2325
acceptance: Deferred<JobAcceptedPayload>;
@@ -43,6 +45,9 @@ export function makeHandleFromInvocation(inv: InvocationState): JobHandle {
4345
get budget(): Record<string, number> | undefined {
4446
return inv.budget;
4547
},
48+
get credentials(): readonly Credential[] | undefined {
49+
return inv.credentials;
50+
},
4651
get traceId(): TraceId | undefined {
4752
return inv.traceId;
4853
},

packages/client/src/client.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -361,6 +361,7 @@ export class ARCPClient {
361361
agent: undefined,
362362
leaseConstraints: undefined,
363363
budget: undefined,
364+
credentials: undefined,
364365
traceId: opts.traceId,
365366
events: [],
366367
acceptance: new Deferred<JobAcceptedPayload>(),

0 commit comments

Comments
 (0)