@prismnetwork/agent-sdk 0.7.10 → 0.7.12
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.
- package/decision.mjs +137 -0
- package/e2ee.mjs +6 -1
- package/package.json +5 -1
- package/prism.mjs +16 -5
package/decision.mjs
ADDED
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
/// Authorise a spend before it happens, and bind the reason to the lease.
|
|
2
|
+
///
|
|
3
|
+
/// An agent with a wallet funds an escrow on its own judgement. The operator who
|
|
4
|
+
/// owns that wallet gets the bill and, today, no answer to "why did it spend
|
|
5
|
+
/// this". A log written afterwards by the process that spent the money is worth
|
|
6
|
+
/// what that process says it is worth.
|
|
7
|
+
///
|
|
8
|
+
/// So the reason is decided first, checked against a policy the caller wrote,
|
|
9
|
+
/// and hashed into the one field the escrow already commits on chain. A lease
|
|
10
|
+
/// that was not authorised is never funded, and one that was carries a
|
|
11
|
+
/// reference nobody can recompute without producing the decision behind it.
|
|
12
|
+
///
|
|
13
|
+
/// What this proves: a specific decision existed before the deposit was
|
|
14
|
+
/// broadcast, and the caller's own thresholds admitted it. What it does not: any
|
|
15
|
+
/// claim that the decision was sound. A confidently wrong judgement binds
|
|
16
|
+
/// exactly as well as a correct one.
|
|
17
|
+
///
|
|
18
|
+
/// The decision can come from anywhere. A System One model returning calibrated
|
|
19
|
+
/// probabilities, a general model, or a rule in the caller's own code all
|
|
20
|
+
/// produce the same record, and none of them is a dependency of this SDK.
|
|
21
|
+
/// Nothing but a hash leaves the caller's process.
|
|
22
|
+
import { keccak256, stringToBytes, concatBytes, hexToBytes } from "viem";
|
|
23
|
+
|
|
24
|
+
const NAME = /^[a-z][a-z0-9_]{0,63}$/;
|
|
25
|
+
|
|
26
|
+
export class DecisionRefused extends Error {
|
|
27
|
+
constructor(reasons) {
|
|
28
|
+
super(reasons.join("; "));
|
|
29
|
+
this.name = "DecisionRefused";
|
|
30
|
+
this.reasons = reasons;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/// One typed answer. `value` is the selected option, the score, or the
|
|
35
|
+
/// probability that a yes/no question is yes. `confidence` is how sure the
|
|
36
|
+
/// source is that the value is right, which is a different axis and is usually
|
|
37
|
+
/// what a spending threshold should read.
|
|
38
|
+
export function answer(name, value, confidence = null) {
|
|
39
|
+
if (!NAME.test(name)) throw new Error(`answer name ${JSON.stringify(name)} is not a lowercase identifier`);
|
|
40
|
+
if (confidence !== null && !(confidence >= 0 && confidence <= 1)) {
|
|
41
|
+
throw new Error(`${name}: confidence ${confidence} is outside 0..1`);
|
|
42
|
+
}
|
|
43
|
+
return { name, value, confidence };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/// Why a spend is about to happen. `source` names what judged, for example
|
|
47
|
+
/// "jev-1.13.0" or "policy:cpu-first". It is recorded rather than trusted: this
|
|
48
|
+
/// SDK cannot tell a model's answer from one a caller typed, and does not
|
|
49
|
+
/// pretend to.
|
|
50
|
+
export function decision({ action, source, answers = [], policyId = null }) {
|
|
51
|
+
if (!action?.trim()) throw new Error("a decision needs an action");
|
|
52
|
+
if (!source?.trim()) throw new Error("a decision needs a source, so the record says what judged");
|
|
53
|
+
const seen = new Set();
|
|
54
|
+
for (const a of answers) {
|
|
55
|
+
if (seen.has(a.name)) throw new Error(`duplicate answer ${JSON.stringify(a.name)}`);
|
|
56
|
+
seen.add(a.name);
|
|
57
|
+
}
|
|
58
|
+
return { action, source, answers, policyId };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/// The exact bytes that get hashed. Sorted by name and separator-pinned, so the
|
|
62
|
+
/// same decision produces the same reference in any language. The Python SDK
|
|
63
|
+
/// builds this byte for byte; a cross-language mismatch here would make a lease
|
|
64
|
+
/// funded by one unverifiable by the other.
|
|
65
|
+
export function canonical(d) {
|
|
66
|
+
return stringToBytes(JSON.stringify({
|
|
67
|
+
action: d.action,
|
|
68
|
+
source: d.source,
|
|
69
|
+
policy_id: d.policyId ?? null,
|
|
70
|
+
answers: [...d.answers]
|
|
71
|
+
.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0))
|
|
72
|
+
.map((a) => ({ name: a.name, value: a.value, confidence: a.confidence ?? null })),
|
|
73
|
+
}));
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/// Every reason this decision is not authorised. Empty means it is.
|
|
77
|
+
export function refusals(policy, d) {
|
|
78
|
+
const reasons = [];
|
|
79
|
+
const allow = policy.allow ?? [];
|
|
80
|
+
if (allow.length && !allow.includes(d.action)) {
|
|
81
|
+
reasons.push(
|
|
82
|
+
`action ${JSON.stringify(d.action)} is not in the policy's allowed set (${[...allow].sort().join(", ")})`,
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
if (d.policyId != null && d.policyId !== policy.policyId) {
|
|
86
|
+
reasons.push(`decision cites policy ${JSON.stringify(d.policyId)}, this is ${JSON.stringify(policy.policyId)}`);
|
|
87
|
+
}
|
|
88
|
+
for (const name of [...(policy.require ?? [])].sort()) {
|
|
89
|
+
if (!d.answers.some((a) => a.name === name)) {
|
|
90
|
+
reasons.push(`policy requires an answer for ${JSON.stringify(name)} and none was given`);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
for (const [name, floor] of Object.entries(policy.minimums ?? {}).sort()) {
|
|
94
|
+
const a = d.answers.find((x) => x.name === name);
|
|
95
|
+
if (!a) reasons.push(`policy sets a floor for ${JSON.stringify(name)} and no answer was given`);
|
|
96
|
+
else if (a.confidence == null) {
|
|
97
|
+
reasons.push(`${JSON.stringify(name)} carries no confidence, so the ${floor} floor cannot be met`);
|
|
98
|
+
} else if (a.confidence < floor) {
|
|
99
|
+
reasons.push(`${JSON.stringify(name)} confidence ${a.confidence} is under the ${floor} floor`);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
return reasons;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/// Return the decision, or throw before anything is signed.
|
|
106
|
+
export function authorise(policy, d) {
|
|
107
|
+
const reasons = refusals(policy, d);
|
|
108
|
+
if (reasons.length) throw new DecisionRefused(reasons);
|
|
109
|
+
return d;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/// The bytes32 the escrow records for this lease.
|
|
113
|
+
///
|
|
114
|
+
/// Without a decision this is what it has always been, the hash of the quote id,
|
|
115
|
+
/// so an unauthorised caller's leases stay byte-identical to before.
|
|
116
|
+
///
|
|
117
|
+
/// With one, the quote id is hashed together with the decision's digest. The
|
|
118
|
+
/// escrow refuses a reference it has already seen, so the quote id has to stay
|
|
119
|
+
/// in the preimage: two runs of the same decision are two different leases and
|
|
120
|
+
/// must not collide.
|
|
121
|
+
export function leaseReference(quoteId, d) {
|
|
122
|
+
const quoteDigest = keccak256(stringToBytes(quoteId));
|
|
123
|
+
if (!d) return quoteDigest;
|
|
124
|
+
return keccak256(concatBytes([hexToBytes(quoteDigest), hexToBytes(keccak256(canonical(d)))]));
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/// The hex digest the control plane needs to know which derivation to expect.
|
|
128
|
+
/// It is the decision's hash, not the decision: the service can check that the
|
|
129
|
+
/// funding log commits to something, and cannot read what that something says.
|
|
130
|
+
export function decisionDigest(d) {
|
|
131
|
+
return d ? keccak256(canonical(d)) : null;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/// Whether this decision is the one that funded that lease.
|
|
135
|
+
export function referenceMatches(quoteId, d, reference) {
|
|
136
|
+
return leaseReference(quoteId, d).toLowerCase() === String(reference).toLowerCase();
|
|
137
|
+
}
|
package/e2ee.mjs
CHANGED
|
@@ -156,7 +156,12 @@ export function encryptChatRequest(body, keyset, { now = Math.floor(Date.now() /
|
|
|
156
156
|
if (typeof message?.content !== "string") {
|
|
157
157
|
// Any plaintext string at a protected path fails the request upstream, so
|
|
158
158
|
// a body this client cannot fully protect is refused here instead.
|
|
159
|
-
throw new E2eeError(
|
|
159
|
+
throw new E2eeError(
|
|
160
|
+
`messages.${index}.content is not a string, and the sealed envelope covers string ` +
|
|
161
|
+
"content only: the protocol binds each ciphertext to the field path " +
|
|
162
|
+
"messages.N.content, which a content-parts array has no equivalent of. An image " +
|
|
163
|
+
"message can still be served in the enclave with e2ee off, where the relay sees it.",
|
|
164
|
+
);
|
|
160
165
|
}
|
|
161
166
|
const field = `messages.${index}.content`;
|
|
162
167
|
const aad = requestAad({ algo: serviceKey.algo, model: body.model, field, nonce, ts });
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@prismnetwork/agent-sdk",
|
|
3
|
-
"version": "0.7.
|
|
3
|
+
"version": "0.7.12",
|
|
4
4
|
"description": "Headless GPU leasing and renter-encrypted storage on Prism Network for wallet-holding agents.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "prism.mjs",
|
|
@@ -9,6 +9,9 @@
|
|
|
9
9
|
"types": "./prism.d.mts",
|
|
10
10
|
"default": "./prism.mjs"
|
|
11
11
|
},
|
|
12
|
+
"./decision": {
|
|
13
|
+
"default": "./decision.mjs"
|
|
14
|
+
},
|
|
12
15
|
"./attest": {
|
|
13
16
|
"types": "./attest.d.mts",
|
|
14
17
|
"default": "./attest.mjs"
|
|
@@ -39,6 +42,7 @@
|
|
|
39
42
|
"prism.d.mts",
|
|
40
43
|
"attest.mjs",
|
|
41
44
|
"attest.d.mts",
|
|
45
|
+
"decision.mjs",
|
|
42
46
|
"e2ee.mjs",
|
|
43
47
|
"e2ee.d.mts",
|
|
44
48
|
"hostkey.mjs",
|
package/prism.mjs
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
// Prism Network agent SDK: headless GPU leasing for wallet-holding agents.
|
|
2
2
|
// No browser, no Privy. Authenticate with a wallet signature, pay on-chain, run.
|
|
3
3
|
import { execFileSync, spawn } from "node:child_process";
|
|
4
|
+
import { authorise, decisionDigest, leaseReference } from "./decision.mjs";
|
|
4
5
|
import { mkdtempSync, readFileSync, rmSync } from "node:fs";
|
|
5
6
|
import { tmpdir } from "node:os";
|
|
6
7
|
import { join } from "node:path";
|
|
@@ -43,7 +44,6 @@ export const robinhoodChain = defineChain({
|
|
|
43
44
|
|
|
44
45
|
export const USDG = "0x5fc5360D0400a0Fd4f2af552ADD042D716F1d168";
|
|
45
46
|
|
|
46
|
-
|
|
47
47
|
// A digest-pinned image. MCP and x402 import this so their default can't drift
|
|
48
48
|
// from the SDK's.
|
|
49
49
|
export const DEFAULT_IMAGE =
|
|
@@ -275,13 +275,13 @@ export class PrismAgent {
|
|
|
275
275
|
return this.#submit(() => this.#fundNow(quote));
|
|
276
276
|
}
|
|
277
277
|
|
|
278
|
-
async #fundNow(quote) {
|
|
278
|
+
async #fundNow(quote, decision = null) {
|
|
279
279
|
if (typeof quote?.quote_id !== "string" || typeof quote?.node_id !== "string") {
|
|
280
280
|
throw new PrismError(400, "invalid_quote");
|
|
281
281
|
}
|
|
282
282
|
const deposit = parseBaseUnits(quote.maximum_escrow, "maximum_escrow");
|
|
283
283
|
const duration = parseDuration(quote.duration_seconds);
|
|
284
|
-
const clientReference =
|
|
284
|
+
const clientReference = leaseReference(quote.quote_id, decision);
|
|
285
285
|
let broadcast = null;
|
|
286
286
|
try {
|
|
287
287
|
// Approving and spending are one indivisible step. The approval covers
|
|
@@ -334,12 +334,17 @@ export class PrismAgent {
|
|
|
334
334
|
}
|
|
335
335
|
}
|
|
336
336
|
|
|
337
|
-
|
|
337
|
+
/// `decisionHash` travels when the lease was authorised: the client
|
|
338
|
+
/// reference on chain is derived from it, and the control plane cannot check
|
|
339
|
+
/// the funding log without knowing which derivation to expect. The digest is
|
|
340
|
+
/// all it gets; the decision stays here.
|
|
341
|
+
async confirm({ quoteId, transactionHash, sshAuthorizedKey, decisionHash = null }) {
|
|
338
342
|
return this.#proxy("POST", ["leases", "confirm"], {
|
|
339
343
|
body: {
|
|
340
344
|
quote_id: quoteId,
|
|
341
345
|
transaction_hash: transactionHash,
|
|
342
346
|
ssh_authorized_key: sshAuthorizedKey,
|
|
347
|
+
...(decisionHash ? { decision_hash: decisionHash } : {}),
|
|
343
348
|
},
|
|
344
349
|
});
|
|
345
350
|
}
|
|
@@ -426,7 +431,12 @@ export class PrismAgent {
|
|
|
426
431
|
maxDeposit = null,
|
|
427
432
|
minTrustClass = "open",
|
|
428
433
|
command = null,
|
|
434
|
+
decision = null,
|
|
435
|
+
policy = null,
|
|
429
436
|
} = {}) {
|
|
437
|
+
// Checked before a quote is taken, so a refusal costs nothing and does not
|
|
438
|
+
// hold capacity against other renters while it expires.
|
|
439
|
+
if (policy) authorise(policy, decision ?? { action: "unstated", source: "none", answers: [] });
|
|
430
440
|
if (!this.session) await this.authenticate();
|
|
431
441
|
// A wallet with no balance at all cannot fund anything, and a doomed quote
|
|
432
442
|
// still holds capacity against other renters until it expires. Refuse
|
|
@@ -463,11 +473,12 @@ export class PrismAgent {
|
|
|
463
473
|
if (maxDeposit != null && parseBaseUnits(quote.maximum_escrow, "maximum_escrow") > BigInt(maxDeposit)) {
|
|
464
474
|
throw new PrismError(402, "cost_exceeds_max", { required: quote.maximum_escrow, max: String(maxDeposit) });
|
|
465
475
|
}
|
|
466
|
-
funded = await this.#fundNow(quote);
|
|
476
|
+
funded = await this.#fundNow(quote, decision);
|
|
467
477
|
return this.confirm({
|
|
468
478
|
quoteId: quote.quote_id,
|
|
469
479
|
transactionHash: funded.hash,
|
|
470
480
|
sshAuthorizedKey: key.publicKey,
|
|
481
|
+
decisionHash: decisionDigest(decision),
|
|
471
482
|
});
|
|
472
483
|
});
|
|
473
484
|
if (!Number.isInteger(record?.lease_id)) {
|