@prismnetwork/agent-sdk 0.4.0 → 0.6.0
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/README.md +11 -9
- package/package.json +14 -4
- package/prism.d.mts +105 -0
- package/prism.mjs +147 -31
- package/relay.mjs +176 -0
- package/toolset.d.mts +30 -0
- package/toolset.mjs +153 -38
- package/vault.mjs +1 -1
package/README.md
CHANGED
|
@@ -16,7 +16,7 @@ npm install @prismnetwork/agent-sdk viem
|
|
|
16
16
|
import { PrismAgent, DEFAULT_IMAGE } from "@prismnetwork/agent-sdk";
|
|
17
17
|
|
|
18
18
|
const agent = new PrismAgent({
|
|
19
|
-
privateKey: process.env.
|
|
19
|
+
privateKey: process.env.PRISM_AGENT_KEY, // agent's wallet
|
|
20
20
|
escrow: "0x62C042265991bEa17B07229322A01850974626dA",
|
|
21
21
|
});
|
|
22
22
|
|
|
@@ -32,12 +32,14 @@ agent.endLease(lease);
|
|
|
32
32
|
## Toolset
|
|
33
33
|
|
|
34
34
|
`@prismnetwork/agent-sdk/toolset` exports `PrismToolset`, the framework-neutral
|
|
35
|
-
tool surface the
|
|
36
|
-
|
|
37
|
-
human-readable string. It holds the wallet, the open
|
|
38
|
-
spending cap in one place, reads `PRISM_AGENT_KEY
|
|
39
|
-
|
|
40
|
-
|
|
35
|
+
tool surface the framework plugins (elizaOS, Virtuals GAME) wrap: `wallet`,
|
|
36
|
+
`listGpus`, `leaseAndRun`, `run`, `endLease`, each resolving to a
|
|
37
|
+
human-readable string, including on failure. It holds the wallet, the open
|
|
38
|
+
leases and the per-lease spending cap in one place, reads `PRISM_AGENT_KEY`,
|
|
39
|
+
`PRISM_ESCROW`, `PRISM_API_BASE` and `PRISM_RPC_URL` from the environment by
|
|
40
|
+
default (`agentFromEnv` accepts a getter for hosts with their own settings
|
|
41
|
+
store), and answers the read-only questions from the public API when no wallet
|
|
42
|
+
is configured.
|
|
41
43
|
|
|
42
44
|
## Vault
|
|
43
45
|
|
|
@@ -55,7 +57,7 @@ const value = await agent.vault.get(card.item_id, { json: true });
|
|
|
55
57
|
|
|
56
58
|
`unlock()` derives the key from a signature over a fixed statement. Ethereum's
|
|
57
59
|
ECDSA is deterministic, so the same wallet reproduces the same vault on any
|
|
58
|
-
machine
|
|
60
|
+
machine; no recovery copy is held anywhere. Pass `{ passphrase }` to require a
|
|
59
61
|
second factor beyond the wallet.
|
|
60
62
|
|
|
61
63
|
Every item carries the weakest workspace trust class it may ever be released
|
|
@@ -148,4 +150,4 @@ The wallet needs two balances on Robinhood Chain (id 4663): USDG (`0x5fc5360D040
|
|
|
148
150
|
Node >= 20, `viem` ^2 (peer), and `ssh` + `ssh-keygen` on PATH for `run()` and
|
|
149
151
|
for workspace save and restore.
|
|
150
152
|
|
|
151
|
-
See
|
|
153
|
+
See [example.mjs](https://github.com/prismnetwork-tech/prism/blob/main/sdk/example.mjs) for a full run.
|
package/package.json
CHANGED
|
@@ -1,12 +1,18 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@prismnetwork/agent-sdk",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.0",
|
|
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",
|
|
7
7
|
"exports": {
|
|
8
|
-
".":
|
|
9
|
-
|
|
8
|
+
".": {
|
|
9
|
+
"types": "./prism.d.mts",
|
|
10
|
+
"default": "./prism.mjs"
|
|
11
|
+
},
|
|
12
|
+
"./toolset": {
|
|
13
|
+
"types": "./toolset.d.mts",
|
|
14
|
+
"default": "./toolset.mjs"
|
|
15
|
+
},
|
|
10
16
|
"./vault": {
|
|
11
17
|
"types": "./vault.d.ts",
|
|
12
18
|
"default": "./vault.mjs"
|
|
@@ -18,6 +24,9 @@
|
|
|
18
24
|
},
|
|
19
25
|
"files": [
|
|
20
26
|
"prism.mjs",
|
|
27
|
+
"relay.mjs",
|
|
28
|
+
"toolset.d.mts",
|
|
29
|
+
"prism.d.mts",
|
|
21
30
|
"toolset.mjs",
|
|
22
31
|
"vault.mjs",
|
|
23
32
|
"vault.d.ts",
|
|
@@ -52,5 +61,6 @@
|
|
|
52
61
|
"license": "Apache-2.0",
|
|
53
62
|
"publishConfig": {
|
|
54
63
|
"access": "public"
|
|
55
|
-
}
|
|
64
|
+
},
|
|
65
|
+
"types": "prism.d.mts"
|
|
56
66
|
}
|
package/prism.d.mts
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
export declare const robinhoodChain: unknown;
|
|
2
|
+
export declare const USDG: string;
|
|
3
|
+
export declare const DEFAULT_IMAGE: string;
|
|
4
|
+
export declare const TRUST_CLASSES: readonly ["open", "isolated", "attested", "confidential"];
|
|
5
|
+
|
|
6
|
+
/// `mode` says which of the two shapes arrived. Brokered capacity fills in
|
|
7
|
+
/// `ssh_host` and `ssh_port`; a node that accepts nothing inbound fills in the
|
|
8
|
+
/// gateway fields instead and is reached through a relay.
|
|
9
|
+
export interface LeaseAccess {
|
|
10
|
+
mode?: "direct_ssh" | "gateway" | string;
|
|
11
|
+
ssh_host?: string;
|
|
12
|
+
ssh_port?: number;
|
|
13
|
+
ssh_user?: string;
|
|
14
|
+
gateway_host?: string;
|
|
15
|
+
relay_port?: number;
|
|
16
|
+
/// The root the relay's certificate chains to, in PEM. It is served under a
|
|
17
|
+
/// private CA, so this is what the client pins.
|
|
18
|
+
gateway_ca?: string;
|
|
19
|
+
token?: string;
|
|
20
|
+
jupyter_path?: string;
|
|
21
|
+
jupyter_token?: string;
|
|
22
|
+
expires_at?: string;
|
|
23
|
+
[key: string]: unknown;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/// A local address that forwards to the workspace until it is closed.
|
|
27
|
+
export interface RelayForwarder {
|
|
28
|
+
host: string;
|
|
29
|
+
port: number;
|
|
30
|
+
close(): Promise<void>;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface LeaseHandle {
|
|
34
|
+
leaseId: number;
|
|
35
|
+
access: LeaseAccess;
|
|
36
|
+
keyPath: string;
|
|
37
|
+
keyDir: string;
|
|
38
|
+
publicKey: string;
|
|
39
|
+
fundingHash: string;
|
|
40
|
+
quote: Record<string, unknown>;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface BatchLeaseHandle {
|
|
44
|
+
leaseId: number;
|
|
45
|
+
result: { exit_code?: number; stdout?: string; stderr?: string; truncated?: boolean };
|
|
46
|
+
fundingHash: string;
|
|
47
|
+
quote: Record<string, unknown>;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export interface RunResult {
|
|
51
|
+
code: number;
|
|
52
|
+
stdout: string;
|
|
53
|
+
stderr: string;
|
|
54
|
+
timedOut: boolean;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export declare class PrismAgent {
|
|
58
|
+
constructor(options: { privateKey: string; escrow: string; apiBase?: string; rpcUrl?: string });
|
|
59
|
+
readonly address: string;
|
|
60
|
+
readonly vault: unknown;
|
|
61
|
+
readonly workspace: unknown;
|
|
62
|
+
authenticate(): Promise<{ session: string }>;
|
|
63
|
+
offers(options?: { minTrust?: string }): Promise<Array<Record<string, unknown>>>;
|
|
64
|
+
balances(): Promise<{ address: string; usdg: string; eth: string }>;
|
|
65
|
+
transferUsdg(to: string, amountMicros: number | string | bigint): Promise<string>;
|
|
66
|
+
quote(options: {
|
|
67
|
+
image: string;
|
|
68
|
+
durationSeconds: number;
|
|
69
|
+
minVramMib?: number;
|
|
70
|
+
preferredNodeId?: string | null;
|
|
71
|
+
minTrustClass?: string;
|
|
72
|
+
command?: string | null;
|
|
73
|
+
}): Promise<Record<string, unknown>>;
|
|
74
|
+
fund(quote: Record<string, unknown>): Promise<{ hash: string; clientReference: string }>;
|
|
75
|
+
confirm(options: { quoteId: string; transactionHash: string; sshAuthorizedKey: string }): Promise<Record<string, unknown>>;
|
|
76
|
+
leases(): Promise<Array<Record<string, unknown>>>;
|
|
77
|
+
access(leaseId: number): Promise<LeaseAccess>;
|
|
78
|
+
result(leaseId: number): Promise<Record<string, unknown>>;
|
|
79
|
+
waitForResult(leaseId: number, options?: { timeoutMs?: number; intervalMs?: number }): Promise<Record<string, unknown>>;
|
|
80
|
+
waitForAccess(leaseId: number, options?: { timeoutMs?: number; intervalMs?: number }): Promise<LeaseAccess>;
|
|
81
|
+
lease(options: {
|
|
82
|
+
image: string;
|
|
83
|
+
durationSeconds: number;
|
|
84
|
+
minVramMib?: number;
|
|
85
|
+
preferredNodeId?: string | null;
|
|
86
|
+
maxDeposit?: number | string | bigint | null;
|
|
87
|
+
minTrustClass?: string;
|
|
88
|
+
command?: string | null;
|
|
89
|
+
}): Promise<LeaseHandle | BatchLeaseHandle>;
|
|
90
|
+
run(
|
|
91
|
+
lease: LeaseHandle,
|
|
92
|
+
command: string,
|
|
93
|
+
options?: { timeoutMs?: number; connectRetries?: number; connectDelayMs?: number; stdin?: string | null },
|
|
94
|
+
): Promise<RunResult>;
|
|
95
|
+
/// Only for a lease reached through the gateway. Use it for anything that is
|
|
96
|
+
/// not a one-shot command: scp, a notebook client, an interactive shell.
|
|
97
|
+
forward(lease: LeaseHandle, options?: { service?: "ssh" | "jupyter" }): Promise<RelayForwarder>;
|
|
98
|
+
endLease(lease: LeaseHandle): void;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export declare class PrismError extends Error {
|
|
102
|
+
readonly status: number;
|
|
103
|
+
readonly code: string;
|
|
104
|
+
readonly body: Record<string, unknown> | null | undefined;
|
|
105
|
+
}
|
package/prism.mjs
CHANGED
|
@@ -14,6 +14,7 @@ import {
|
|
|
14
14
|
stringToBytes,
|
|
15
15
|
} from "viem";
|
|
16
16
|
import { privateKeyToAccount } from "viem/accounts";
|
|
17
|
+
import { openRelayForwarder } from "./relay.mjs";
|
|
17
18
|
import { PrismVault } from "./vault.mjs";
|
|
18
19
|
import { PrismWorkspace } from "./workspace.mjs";
|
|
19
20
|
|
|
@@ -58,7 +59,7 @@ const escrowAbi = parseAbi([
|
|
|
58
59
|
|
|
59
60
|
// Matches the limit the control plane and the node both enforce, so a command
|
|
60
61
|
// that cannot run is rejected here rather than after an escrow is funded.
|
|
61
|
-
const MAX_COMMAND_BYTES = 8 * 1024;
|
|
62
|
+
export const MAX_COMMAND_BYTES = 8 * 1024;
|
|
62
63
|
|
|
63
64
|
function assertCommand(value) {
|
|
64
65
|
if (typeof value !== "string" || value.trim() === "") {
|
|
@@ -107,9 +108,21 @@ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
|
107
108
|
export class PrismAgent {
|
|
108
109
|
constructor({ privateKey, apiBase = "https://prismnetwork.tech", escrow, rpcUrl }) {
|
|
109
110
|
if (!escrow) throw new Error("escrow address is required");
|
|
111
|
+
if (typeof privateKey !== "string" || privateKey.trim() === "") {
|
|
112
|
+
throw new Error(
|
|
113
|
+
"privateKey is required: a 32-byte hex key, with or without 0x (most surfaces read it from PRISM_AGENT_KEY)",
|
|
114
|
+
);
|
|
115
|
+
}
|
|
110
116
|
this.apiBase = apiBase.replace(/\/$/, "");
|
|
111
117
|
this.escrow = escrow;
|
|
112
|
-
|
|
118
|
+
const trimmed = privateKey.trim();
|
|
119
|
+
try {
|
|
120
|
+
this.account = privateKeyToAccount(trimmed.startsWith("0x") ? trimmed : `0x${trimmed}`);
|
|
121
|
+
} catch (err) {
|
|
122
|
+
throw new Error(
|
|
123
|
+
`privateKey is not a valid key: ${err?.message ?? err}. Expected 32 bytes of hex, with or without the 0x prefix.`,
|
|
124
|
+
);
|
|
125
|
+
}
|
|
113
126
|
const transport = http(rpcUrl ?? robinhoodChain.rpcUrls.default.http[0]);
|
|
114
127
|
this.publicClient = createPublicClient({ chain: robinhoodChain, transport });
|
|
115
128
|
this.walletClient = createWalletClient({ account: this.account, chain: robinhoodChain, transport });
|
|
@@ -264,13 +277,43 @@ export class PrismAgent {
|
|
|
264
277
|
|
|
265
278
|
async waitForResult(leaseId, { timeoutMs = 900_000, intervalMs = 10_000 } = {}) {
|
|
266
279
|
const deadline = Date.now() + timeoutMs;
|
|
280
|
+
let polls = 0;
|
|
267
281
|
while (Date.now() < deadline) {
|
|
268
282
|
const res = await this.#proxy("GET", ["leases", String(leaseId), "result"], { raw: true });
|
|
269
283
|
if (res.status === 200) return res.body;
|
|
270
|
-
|
|
271
|
-
|
|
284
|
+
// The control plane keeps answering 404 for a batch whose node died
|
|
285
|
+
// without reporting, so check the lease state occasionally and stop
|
|
286
|
+
// waiting once it is terminal. 429 and 5xx are transient; aborting a
|
|
287
|
+
// paid wait on one would strand the deposit.
|
|
288
|
+
if (res.status === 404) {
|
|
289
|
+
polls += 1;
|
|
290
|
+
if (polls % 6 === 0) {
|
|
291
|
+
const state = await this.#terminalState(leaseId);
|
|
292
|
+
if (state) {
|
|
293
|
+
const again = await this.#proxy("GET", ["leases", String(leaseId), "result"], { raw: true });
|
|
294
|
+
if (again.status === 200) return again.body;
|
|
295
|
+
throw new PrismError(502, "batch_no_result", { lease_id: leaseId, state });
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
} else if (res.status !== 429 && res.status < 500) {
|
|
299
|
+
throw new PrismError(res.status, res.body?.code ?? "result_failed", res.body);
|
|
300
|
+
}
|
|
301
|
+
await sleep(intervalMs);
|
|
272
302
|
}
|
|
273
|
-
throw new PrismError(408, "result_timeout");
|
|
303
|
+
throw new PrismError(408, "result_timeout", { lease_id: leaseId });
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
async #terminalState(leaseId) {
|
|
307
|
+
let leases;
|
|
308
|
+
try {
|
|
309
|
+
leases = await this.leases();
|
|
310
|
+
} catch {
|
|
311
|
+
return null;
|
|
312
|
+
}
|
|
313
|
+
const record = Array.isArray(leases) ? leases.find((l) => l.lease_id === leaseId) : null;
|
|
314
|
+
const state = record?.state ?? "";
|
|
315
|
+
const terminal = ["closing", "settlement_pending", "finalized", "refunded", "failed"];
|
|
316
|
+
return terminal.includes(state) ? state : null;
|
|
274
317
|
}
|
|
275
318
|
|
|
276
319
|
async waitForAccess(leaseId, { timeoutMs = 600_000, intervalMs = 10_000 } = {}) {
|
|
@@ -281,10 +324,12 @@ export class PrismAgent {
|
|
|
281
324
|
if (!res.body?.ssh_host && res.body?.mode !== "gateway") throw new PrismError(502, "malformed_access");
|
|
282
325
|
return res.body;
|
|
283
326
|
}
|
|
284
|
-
if (res.status !== 404
|
|
327
|
+
if (res.status !== 404 && res.status !== 429 && res.status < 500) {
|
|
328
|
+
throw new PrismError(res.status, res.body?.error ?? "access_error", res.body);
|
|
329
|
+
}
|
|
285
330
|
await sleep(intervalMs);
|
|
286
331
|
}
|
|
287
|
-
throw new PrismError(408, "access_timeout");
|
|
332
|
+
throw new PrismError(408, "access_timeout", { lease_id: leaseId });
|
|
288
333
|
}
|
|
289
334
|
|
|
290
335
|
// quote -> ssh keygen -> fund on-chain -> confirm -> wait for access.
|
|
@@ -298,6 +343,18 @@ export class PrismAgent {
|
|
|
298
343
|
command = null,
|
|
299
344
|
} = {}) {
|
|
300
345
|
if (!this.session) await this.authenticate();
|
|
346
|
+
// A wallet with no balance at all cannot fund anything, and a doomed quote
|
|
347
|
+
// still holds capacity against other renters until it expires. Refuse
|
|
348
|
+
// before quoting.
|
|
349
|
+
const balances = await this.balances();
|
|
350
|
+
if (balances.usdg === "0" || balances.eth === "0") {
|
|
351
|
+
throw new PrismError(402, "wallet_unfunded", {
|
|
352
|
+
address: this.address,
|
|
353
|
+
usdg: balances.usdg,
|
|
354
|
+
eth_wei: balances.eth,
|
|
355
|
+
hint: "the wallet needs USDG for the deposit and native ETH for gas on Robinhood Chain (id 4663) before it can lease",
|
|
356
|
+
});
|
|
357
|
+
}
|
|
301
358
|
const quote = await this.quote({
|
|
302
359
|
image,
|
|
303
360
|
durationSeconds,
|
|
@@ -310,25 +367,30 @@ export class PrismAgent {
|
|
|
310
367
|
throw new PrismError(402, "cost_exceeds_max", { required: quote.maximum_escrow, max: String(maxDeposit) });
|
|
311
368
|
}
|
|
312
369
|
const key = this.#generateSshKey();
|
|
370
|
+
let funded = null;
|
|
371
|
+
let leaseId = null;
|
|
313
372
|
try {
|
|
314
|
-
|
|
373
|
+
funded = await this.fund(quote);
|
|
315
374
|
const record = await this.confirm({
|
|
316
375
|
quoteId: quote.quote_id,
|
|
317
376
|
transactionHash: funded.hash,
|
|
318
377
|
sshAuthorizedKey: key.publicKey,
|
|
319
378
|
});
|
|
320
|
-
if (!Number.isInteger(record?.lease_id))
|
|
379
|
+
if (!Number.isInteger(record?.lease_id)) {
|
|
380
|
+
throw new PrismError(502, "malformed_lease_record", { funding_hash: funded.hash });
|
|
381
|
+
}
|
|
382
|
+
leaseId = record.lease_id;
|
|
321
383
|
// A batch lease never hands out access, so waiting for it would block
|
|
322
384
|
// until the timeout and then report a failure that never happened. Wait
|
|
323
385
|
// for what the command printed instead.
|
|
324
386
|
if (command !== null) {
|
|
325
|
-
const result = await this.waitForResult(
|
|
387
|
+
const result = await this.waitForResult(leaseId, { timeoutMs: durationSeconds * 1000 + 900_000 });
|
|
326
388
|
rmSync(key.dir, { recursive: true, force: true });
|
|
327
|
-
return { leaseId
|
|
389
|
+
return { leaseId, result, fundingHash: funded.hash, quote };
|
|
328
390
|
}
|
|
329
|
-
const access = await this.waitForAccess(
|
|
391
|
+
const access = await this.waitForAccess(leaseId);
|
|
330
392
|
return {
|
|
331
|
-
leaseId
|
|
393
|
+
leaseId,
|
|
332
394
|
access,
|
|
333
395
|
keyPath: key.keyPath,
|
|
334
396
|
keyDir: key.dir,
|
|
@@ -337,8 +399,19 @@ export class PrismAgent {
|
|
|
337
399
|
quote,
|
|
338
400
|
};
|
|
339
401
|
} catch (err) {
|
|
340
|
-
|
|
341
|
-
|
|
402
|
+
// Before funding, the key opens nothing; discard it. After funding it is
|
|
403
|
+
// the only way into a machine that is being paid for, so it stays on
|
|
404
|
+
// disk and the error says where everything is.
|
|
405
|
+
if (funded === null) {
|
|
406
|
+
rmSync(key.dir, { recursive: true, force: true });
|
|
407
|
+
throw err;
|
|
408
|
+
}
|
|
409
|
+
const detail = { funding_hash: funded.hash, lease_id: leaseId, key_path: key.keyPath };
|
|
410
|
+
if (err instanceof PrismError) {
|
|
411
|
+
err.body = { ...(err.body ?? {}), ...detail };
|
|
412
|
+
throw err;
|
|
413
|
+
}
|
|
414
|
+
throw new PrismError(502, "lease_failed_after_funding", { ...detail, cause: err?.message ?? String(err) });
|
|
342
415
|
}
|
|
343
416
|
}
|
|
344
417
|
|
|
@@ -347,24 +420,66 @@ export class PrismAgent {
|
|
|
347
420
|
// can lag a few minutes after the box reports ready. `stdin` feeds the command
|
|
348
421
|
// its input, which keeps anything sensitive out of the remote process table.
|
|
349
422
|
async run(lease, command, { timeoutMs = 120_000, connectRetries = 24, connectDelayMs = 10_000, stdin = null } = {}) {
|
|
350
|
-
if (!lease?.access?.ssh_host || !lease.access.ssh_port || !lease.keyPath) {
|
|
351
|
-
throw new PrismError(400, "invalid_lease_handle");
|
|
352
|
-
}
|
|
353
423
|
if (typeof command !== "string" || command.length === 0) throw new PrismError(400, "command_required");
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
424
|
+
if (!lease?.keyPath) {
|
|
425
|
+
throw new PrismError(400, "invalid_lease_handle", {
|
|
426
|
+
mode: lease?.access?.mode ?? null,
|
|
427
|
+
lease_id: lease?.leaseId ?? null,
|
|
428
|
+
hint: "the lease handle carries no ssh key",
|
|
429
|
+
});
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
// A physical node accepts nothing inbound, so its session arrives through
|
|
433
|
+
// the gateway. Opening the renter's half of that tunnel gives a local port
|
|
434
|
+
// that behaves like any other host, which is why the retry loop below does
|
|
435
|
+
// not care which kind of capacity it is talking to.
|
|
436
|
+
const forwarder =
|
|
437
|
+
lease.access?.mode === "gateway" ? await openRelayForwarder(lease.access) : null;
|
|
438
|
+
try {
|
|
439
|
+
const target = forwarder
|
|
440
|
+
? {
|
|
441
|
+
host: forwarder.host,
|
|
442
|
+
port: forwarder.port,
|
|
443
|
+
user: lease.access.ssh_user ?? "workspace",
|
|
444
|
+
keyPath: lease.keyPath,
|
|
445
|
+
}
|
|
446
|
+
: {
|
|
447
|
+
host: lease.access?.ssh_host,
|
|
448
|
+
port: lease.access?.ssh_port,
|
|
449
|
+
user: lease.access?.ssh_user ?? "root",
|
|
450
|
+
keyPath: lease.keyPath,
|
|
451
|
+
};
|
|
452
|
+
if (!target.host || !target.port) {
|
|
453
|
+
throw new PrismError(400, "invalid_lease_handle", {
|
|
454
|
+
mode: lease.access?.mode ?? null,
|
|
455
|
+
lease_id: lease.leaseId ?? null,
|
|
456
|
+
hint: "the access grant names no reachable endpoint",
|
|
457
|
+
});
|
|
458
|
+
}
|
|
459
|
+
let last;
|
|
460
|
+
for (let attempt = 0; attempt <= connectRetries; attempt++) {
|
|
461
|
+
const res = await this.#ssh(target, command, timeoutMs, stdin);
|
|
462
|
+
if (!isSshWarmup(res)) return res;
|
|
463
|
+
last = res;
|
|
464
|
+
if (attempt < connectRetries) await sleep(connectDelayMs);
|
|
465
|
+
}
|
|
466
|
+
return last;
|
|
467
|
+
} finally {
|
|
468
|
+
if (forwarder) await forwarder.close();
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
/// A local address that forwards to the workspace for as long as it is open.
|
|
473
|
+
/// Use it for anything that is not a one-shot command: `scp`, port forwards,
|
|
474
|
+
/// an interactive shell, a notebook client. The caller closes it.
|
|
475
|
+
async forward(lease, { service = "ssh" } = {}) {
|
|
476
|
+
if (lease?.access?.mode !== "gateway") {
|
|
477
|
+
throw new PrismError(400, "forward_not_supported", {
|
|
478
|
+
mode: lease?.access?.mode ?? null,
|
|
479
|
+
hint: "this lease is reachable directly and needs no relay",
|
|
480
|
+
});
|
|
366
481
|
}
|
|
367
|
-
return
|
|
482
|
+
return openRelayForwarder(lease.access, { service });
|
|
368
483
|
}
|
|
369
484
|
|
|
370
485
|
// Releases local key material. The on-chain lease settles at the end of its duration.
|
|
@@ -475,6 +590,7 @@ export class PrismAgent {
|
|
|
475
590
|
export class PrismError extends Error {
|
|
476
591
|
constructor(status, code, body) {
|
|
477
592
|
super(`prism ${status}: ${code}`);
|
|
593
|
+
this.name = "PrismError";
|
|
478
594
|
this.status = status;
|
|
479
595
|
this.code = code;
|
|
480
596
|
this.body = body;
|
package/relay.mjs
ADDED
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
// Reaching a workspace that has no public address.
|
|
2
|
+
//
|
|
3
|
+
// Capacity brokered from a cloud gives the renter an SSH endpoint on the host.
|
|
4
|
+
// A physical node has none: it dials out to the gateway and accepts nothing
|
|
5
|
+
// inbound, so the renter's session is carried back through that tunnel. This
|
|
6
|
+
// opens the renter's half of it and presents the result as a local port, which
|
|
7
|
+
// is what lets `ssh`, `scp`, or anything else speak to a machine that cannot be
|
|
8
|
+
// addressed.
|
|
9
|
+
//
|
|
10
|
+
// The relay wants one JSON frame naming the grant and the service, answers with
|
|
11
|
+
// one saying whether it paired, and from then on the connection is the workspace
|
|
12
|
+
// socket. Frames are a big-endian u32 length followed by the payload.
|
|
13
|
+
import { createServer } from "node:net";
|
|
14
|
+
import { connect as tlsConnect } from "node:tls";
|
|
15
|
+
|
|
16
|
+
const MAX_FRAME_BYTES = 16 * 1_024;
|
|
17
|
+
const HANDSHAKE_TIMEOUT_MS = 20_000;
|
|
18
|
+
|
|
19
|
+
export class RelayError extends Error {
|
|
20
|
+
constructor(code, detail) {
|
|
21
|
+
super(code);
|
|
22
|
+
this.name = "RelayError";
|
|
23
|
+
this.code = code;
|
|
24
|
+
this.detail = detail ?? null;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function frame(value) {
|
|
29
|
+
const payload = Buffer.from(JSON.stringify(value));
|
|
30
|
+
if (payload.length > MAX_FRAME_BYTES) throw new RelayError("relay_frame_too_large");
|
|
31
|
+
const header = Buffer.alloc(4);
|
|
32
|
+
header.writeUInt32BE(payload.length, 0);
|
|
33
|
+
return Buffer.concat([header, payload]);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Resolves with the first frame and whatever bytes arrived behind it. Those
|
|
37
|
+
// trailing bytes are already workspace traffic, so losing them corrupts the
|
|
38
|
+
// session before it starts.
|
|
39
|
+
function readFrame(socket) {
|
|
40
|
+
return new Promise((resolve, reject) => {
|
|
41
|
+
let buffer = Buffer.alloc(0);
|
|
42
|
+
const timer = setTimeout(() => {
|
|
43
|
+
cleanup();
|
|
44
|
+
reject(new RelayError("relay_handshake_timeout"));
|
|
45
|
+
}, HANDSHAKE_TIMEOUT_MS);
|
|
46
|
+
|
|
47
|
+
const onData = (chunk) => {
|
|
48
|
+
buffer = Buffer.concat([buffer, chunk]);
|
|
49
|
+
if (buffer.length < 4) return;
|
|
50
|
+
const length = buffer.readUInt32BE(0);
|
|
51
|
+
if (length > MAX_FRAME_BYTES) {
|
|
52
|
+
cleanup();
|
|
53
|
+
reject(new RelayError("relay_frame_too_large"));
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
if (buffer.length < 4 + length) return;
|
|
57
|
+
cleanup();
|
|
58
|
+
try {
|
|
59
|
+
resolve({
|
|
60
|
+
message: JSON.parse(buffer.subarray(4, 4 + length).toString("utf8")),
|
|
61
|
+
rest: buffer.subarray(4 + length),
|
|
62
|
+
});
|
|
63
|
+
} catch (err) {
|
|
64
|
+
reject(new RelayError("relay_frame_malformed", err?.message ?? String(err)));
|
|
65
|
+
}
|
|
66
|
+
};
|
|
67
|
+
const onError = (err) => {
|
|
68
|
+
cleanup();
|
|
69
|
+
reject(new RelayError("relay_disconnected", err?.message ?? String(err)));
|
|
70
|
+
};
|
|
71
|
+
const onEnd = () => {
|
|
72
|
+
cleanup();
|
|
73
|
+
reject(new RelayError("relay_closed_early"));
|
|
74
|
+
};
|
|
75
|
+
function cleanup() {
|
|
76
|
+
clearTimeout(timer);
|
|
77
|
+
socket.off("data", onData);
|
|
78
|
+
socket.off("error", onError);
|
|
79
|
+
socket.off("end", onEnd);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
socket.on("data", onData);
|
|
83
|
+
socket.on("error", onError);
|
|
84
|
+
socket.on("end", onEnd);
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function dial(access) {
|
|
89
|
+
return new Promise((resolve, reject) => {
|
|
90
|
+
const socket = tlsConnect(
|
|
91
|
+
{
|
|
92
|
+
host: access.gateway_host,
|
|
93
|
+
port: access.relay_port,
|
|
94
|
+
servername: access.gateway_host,
|
|
95
|
+
// The relay runs under a private CA, so the public trust store says
|
|
96
|
+
// nothing about it. Pinning the root the control plane handed back is
|
|
97
|
+
// the whole reason it is in the grant.
|
|
98
|
+
ca: access.gateway_ca ? [access.gateway_ca] : undefined,
|
|
99
|
+
},
|
|
100
|
+
() => resolve(socket),
|
|
101
|
+
);
|
|
102
|
+
socket.once("error", (err) => reject(new RelayError("relay_connect_failed", err?.message ?? String(err))));
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// Checked before anything is opened, so a grant that can never work says so at
|
|
107
|
+
// once instead of leaving the caller with a port that resets every connection.
|
|
108
|
+
function assertUsable(access) {
|
|
109
|
+
if (!access?.gateway_host || !access?.relay_port || !access?.token) {
|
|
110
|
+
throw new RelayError("relay_access_incomplete");
|
|
111
|
+
}
|
|
112
|
+
if (!access.gateway_ca) {
|
|
113
|
+
throw new RelayError("relay_ca_missing", "the access grant carries no gateway root to verify against");
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// One relay connection, paired and ready to carry traffic.
|
|
118
|
+
export async function openRelayConnection(access, service = "ssh") {
|
|
119
|
+
assertUsable(access);
|
|
120
|
+
const socket = await dial(access);
|
|
121
|
+
socket.write(frame({ token: access.token, service }));
|
|
122
|
+
const { message, rest } = await readFrame(socket);
|
|
123
|
+
if (!message?.ready) {
|
|
124
|
+
socket.destroy();
|
|
125
|
+
throw new RelayError("relay_refused", message?.error ?? null);
|
|
126
|
+
}
|
|
127
|
+
return { socket, rest };
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/// A local port that forwards to the workspace, one relay connection per
|
|
131
|
+
/// inbound connection. `ssh` gets an address it can use and never learns the
|
|
132
|
+
/// session is tunnelled.
|
|
133
|
+
export async function openRelayForwarder(access, { service = "ssh" } = {}) {
|
|
134
|
+
assertUsable(access);
|
|
135
|
+
const server = createServer();
|
|
136
|
+
const sockets = new Set();
|
|
137
|
+
|
|
138
|
+
server.on("connection", (local) => {
|
|
139
|
+
sockets.add(local);
|
|
140
|
+
local.on("close", () => sockets.delete(local));
|
|
141
|
+
local.on("error", () => local.destroy());
|
|
142
|
+
openRelayConnection(access, service)
|
|
143
|
+
.then(({ socket, rest }) => {
|
|
144
|
+
if (local.destroyed) {
|
|
145
|
+
socket.destroy();
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
sockets.add(socket);
|
|
149
|
+
socket.on("close", () => sockets.delete(socket));
|
|
150
|
+
socket.on("error", () => {
|
|
151
|
+
socket.destroy();
|
|
152
|
+
local.destroy();
|
|
153
|
+
});
|
|
154
|
+
if (rest.length > 0) local.write(rest);
|
|
155
|
+
local.pipe(socket);
|
|
156
|
+
socket.pipe(local);
|
|
157
|
+
})
|
|
158
|
+
.catch(() => local.destroy());
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
await new Promise((resolve, reject) => {
|
|
162
|
+
server.once("error", reject);
|
|
163
|
+
server.listen(0, "127.0.0.1", resolve);
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
const { port } = server.address();
|
|
167
|
+
return {
|
|
168
|
+
host: "127.0.0.1",
|
|
169
|
+
port,
|
|
170
|
+
async close() {
|
|
171
|
+
for (const socket of sockets) socket.destroy();
|
|
172
|
+
sockets.clear();
|
|
173
|
+
await new Promise((resolve) => server.close(resolve));
|
|
174
|
+
},
|
|
175
|
+
};
|
|
176
|
+
}
|
package/toolset.d.mts
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import type { PrismAgent } from "./prism.mjs";
|
|
2
|
+
|
|
3
|
+
export declare const DEFAULT_ESCROW: string;
|
|
4
|
+
export declare const PUBLIC_API: string;
|
|
5
|
+
export declare const NO_WALLET: string;
|
|
6
|
+
|
|
7
|
+
export declare function isRefusal(body: string): boolean;
|
|
8
|
+
|
|
9
|
+
export declare function agentFromEnv(
|
|
10
|
+
get?: (name: string) => string | undefined,
|
|
11
|
+
): PrismAgent | null;
|
|
12
|
+
|
|
13
|
+
export interface LeaseAndRunOptions {
|
|
14
|
+
command: string;
|
|
15
|
+
durationSeconds?: number;
|
|
16
|
+
minVramMib?: number;
|
|
17
|
+
image?: string;
|
|
18
|
+
maxUsdg?: number;
|
|
19
|
+
minTrustClass?: "open" | "isolated" | "attested" | "confidential";
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export declare class PrismToolset {
|
|
23
|
+
constructor(options?: { agent?: PrismAgent | null; publicApi?: string });
|
|
24
|
+
readonly agent: PrismAgent | null;
|
|
25
|
+
wallet(): Promise<string>;
|
|
26
|
+
listGpus(minTrustClass?: string): Promise<string>;
|
|
27
|
+
leaseAndRun(options: LeaseAndRunOptions): Promise<string>;
|
|
28
|
+
run(leaseId: number, command: string): Promise<string>;
|
|
29
|
+
endLease(leaseId: number): string;
|
|
30
|
+
}
|
package/toolset.mjs
CHANGED
|
@@ -1,68 +1,154 @@
|
|
|
1
1
|
// A framework-neutral tool surface over PrismAgent. Agent frameworks disagree
|
|
2
2
|
// about how a tool is declared but agree about what one is: a named function
|
|
3
3
|
// with typed arguments that returns text. PrismToolset holds the wallet, the
|
|
4
|
-
// open leases, and the spending cap in one place so framework
|
|
5
|
-
// thin wrappers instead of diverging copies of the same logic.
|
|
4
|
+
// open leases, and the per-lease spending cap in one place so framework
|
|
5
|
+
// plugins stay thin wrappers instead of diverging copies of the same logic.
|
|
6
6
|
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
|
|
7
|
+
// Every method resolves to a string, including on failure. These tools are
|
|
8
|
+
// driven by language models, and a model can act on "the wallet holds 0 USDG"
|
|
9
|
+
// where a stack trace ends the conversation. Without a wallet the read-only
|
|
10
|
+
// questions still answer from the public API, the same degradation the MCP
|
|
11
|
+
// server offers.
|
|
12
|
+
import { rmSync } from "node:fs";
|
|
13
|
+
import { DEFAULT_IMAGE, MAX_COMMAND_BYTES, PrismAgent, PrismError, TRUST_CLASSES } from "./prism.mjs";
|
|
10
14
|
|
|
11
15
|
export const DEFAULT_ESCROW = "0x62C042265991bEa17B07229322A01850974626dA";
|
|
12
16
|
export const PUBLIC_API = "https://api.prismnetwork.tech";
|
|
13
17
|
|
|
18
|
+
export const NO_WALLET =
|
|
19
|
+
"No wallet is configured, so this needs PRISM_AGENT_KEY (a funded wallet on Robinhood Chain). " +
|
|
20
|
+
"Looking at capacity and prices works without one.";
|
|
21
|
+
|
|
14
22
|
const MICROS = 1_000_000;
|
|
23
|
+
const TRUST_MESSAGE = `min_trust_class must be one of ${TRUST_CLASSES.join(", ")}.`;
|
|
24
|
+
const COMMAND_MESSAGE = "command is required: the shell command to run on the GPU, e.g. 'nvidia-smi'.";
|
|
15
25
|
const usdg = (micros) => `${(Number(micros) / MICROS).toFixed(6)} USDG`;
|
|
16
26
|
|
|
17
|
-
|
|
18
|
-
|
|
27
|
+
// True for any string the toolset returns to describe a refusal or failure.
|
|
28
|
+
// Framework plugins map these to their own failed-action shape instead of
|
|
29
|
+
// keeping divergent copies of the wording.
|
|
30
|
+
export function isRefusal(body) {
|
|
31
|
+
return (
|
|
32
|
+
body === NO_WALLET ||
|
|
33
|
+
body === TRUST_MESSAGE ||
|
|
34
|
+
body === COMMAND_MESSAGE ||
|
|
35
|
+
body.startsWith("No active lease") ||
|
|
36
|
+
body.startsWith("The lease did not go through") ||
|
|
37
|
+
body.startsWith("The balance check failed") ||
|
|
38
|
+
body.startsWith("The command could not run") ||
|
|
39
|
+
body.startsWith("Prism capacity") ||
|
|
40
|
+
body.startsWith("command exceeds the") ||
|
|
41
|
+
body.startsWith("lease_id must be") ||
|
|
42
|
+
/^Lease \d+ is funded .* but the command could not run/.test(body)
|
|
43
|
+
);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// `get` lets hosts with their own settings store (elizaOS runtimes, test
|
|
47
|
+
// harnesses) resolve the variables without mutating process.env.
|
|
48
|
+
export function agentFromEnv(get = (name) => process.env[name]) {
|
|
49
|
+
const privateKey = (get("PRISM_AGENT_KEY") ?? "").trim();
|
|
19
50
|
if (!privateKey) return null;
|
|
20
|
-
return new PrismAgent({
|
|
51
|
+
return new PrismAgent({
|
|
52
|
+
privateKey,
|
|
53
|
+
escrow: get("PRISM_ESCROW") || DEFAULT_ESCROW,
|
|
54
|
+
apiBase: get("PRISM_API_BASE") || undefined,
|
|
55
|
+
rpcUrl: get("PRISM_RPC_URL") || undefined,
|
|
56
|
+
});
|
|
21
57
|
}
|
|
22
58
|
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
59
|
+
function describe(err) {
|
|
60
|
+
if (err instanceof PrismError) {
|
|
61
|
+
const body = err.body ?? {};
|
|
62
|
+
if (err.code === "cost_exceeds_max") {
|
|
63
|
+
return `the quote needs ${usdg(body.required ?? 0)} but the cap is ${usdg(body.max ?? 0)}; raise maxUsdg or shorten the lease`;
|
|
64
|
+
}
|
|
65
|
+
if (err.code === "wallet_unfunded") {
|
|
66
|
+
return (
|
|
67
|
+
`wallet ${body.address} holds ${usdg(body.usdg ?? 0)} and ${(Number(body.eth_wei ?? 0) / 1e18).toFixed(6)} ` +
|
|
68
|
+
"ETH for gas; fund it on Robinhood Chain (id 4663) before leasing"
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
const detail = body.cause ?? body.hint ?? body.message;
|
|
72
|
+
return detail ? `${err.code} (${detail})` : err.code;
|
|
73
|
+
}
|
|
74
|
+
return err?.message ?? String(err);
|
|
75
|
+
}
|
|
26
76
|
|
|
27
77
|
export class PrismToolset {
|
|
28
78
|
#agent;
|
|
29
79
|
#leases = new Map();
|
|
30
80
|
#publicApi;
|
|
31
81
|
|
|
32
|
-
constructor({ agent, publicApi
|
|
82
|
+
constructor({ agent, publicApi } = {}) {
|
|
33
83
|
this.#agent = agent === undefined ? agentFromEnv() : agent;
|
|
34
|
-
this.#publicApi = publicApi;
|
|
84
|
+
this.#publicApi = (publicApi ?? process.env.PRISM_PUBLIC_API ?? PUBLIC_API).replace(/\/$/, "");
|
|
85
|
+
process.once("exit", () => {
|
|
86
|
+
for (const lease of this.#leases.values()) {
|
|
87
|
+
try {
|
|
88
|
+
rmSync(lease.keyDir, { recursive: true, force: true });
|
|
89
|
+
} catch {
|
|
90
|
+
/* best effort */
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
});
|
|
35
94
|
}
|
|
36
95
|
|
|
37
96
|
get agent() {
|
|
38
97
|
return this.#agent;
|
|
39
98
|
}
|
|
40
99
|
|
|
100
|
+
#sweepExpired() {
|
|
101
|
+
const now = Date.now();
|
|
102
|
+
for (const [id, lease] of this.#leases) {
|
|
103
|
+
const expiry = Date.parse(lease.access?.expires_at ?? "");
|
|
104
|
+
if (Number.isFinite(expiry) && expiry < now) {
|
|
105
|
+
this.#agent.endLease(lease);
|
|
106
|
+
this.#leases.delete(id);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
41
111
|
async wallet() {
|
|
42
112
|
if (!this.#agent) return NO_WALLET;
|
|
43
|
-
|
|
113
|
+
let b;
|
|
114
|
+
try {
|
|
115
|
+
b = await this.#agent.balances();
|
|
116
|
+
} catch (err) {
|
|
117
|
+
return `The balance check failed: ${describe(err)}`;
|
|
118
|
+
}
|
|
44
119
|
return `address: ${b.address}\nusdg: ${usdg(b.usdg)}\neth: ${(Number(b.eth) / 1e18).toFixed(6)} for gas`;
|
|
45
120
|
}
|
|
46
121
|
|
|
47
|
-
async listGpus(
|
|
48
|
-
if (!TRUST_CLASSES.includes(
|
|
49
|
-
return `min_trust must be one of ${TRUST_CLASSES.join(", ")}`;
|
|
50
|
-
}
|
|
122
|
+
async listGpus(minTrustClass = "open") {
|
|
123
|
+
if (!TRUST_CLASSES.includes(minTrustClass)) return TRUST_MESSAGE;
|
|
51
124
|
let offers;
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
125
|
+
try {
|
|
126
|
+
if (this.#agent) {
|
|
127
|
+
offers = await this.#agent.offers({ minTrust: minTrustClass });
|
|
128
|
+
} else {
|
|
129
|
+
const url = new URL("/v1/offers", this.#publicApi);
|
|
130
|
+
url.searchParams.set("min_trust", minTrustClass);
|
|
131
|
+
const res = await fetch(url, {
|
|
132
|
+
headers: { accept: "application/json" },
|
|
133
|
+
signal: AbortSignal.timeout(10_000),
|
|
134
|
+
});
|
|
135
|
+
if (!res.ok) return `Prism capacity is unreachable right now (${res.status}).`;
|
|
136
|
+
offers = await res.json().catch(() => null);
|
|
137
|
+
}
|
|
138
|
+
} catch (err) {
|
|
139
|
+
return `Prism capacity is unreachable right now: ${describe(err)}`;
|
|
140
|
+
}
|
|
141
|
+
if (!Array.isArray(offers)) {
|
|
142
|
+
return "Prism capacity answered in an unexpected shape; try again shortly.";
|
|
143
|
+
}
|
|
144
|
+
if (!offers.length) {
|
|
145
|
+
return `No GPUs at trust class '${minTrustClass}' or above are online right now.`;
|
|
60
146
|
}
|
|
61
|
-
if (!offers.length) return "No GPUs are online to rent right now.";
|
|
62
147
|
return offers
|
|
63
148
|
.map((o) => {
|
|
64
149
|
const perHr = ((Number(o.rate_per_second) * 3600) / MICROS).toFixed(2);
|
|
65
|
-
|
|
150
|
+
const row = `${o.gpu?.model ?? "GPU"} · ${o.gpu?.vram_mib ?? "?"} MiB · ${perHr} USDG/hr · ${o.trust_class ?? "open"}`;
|
|
151
|
+
return o.staker_only ? `${row} · stakers only` : row;
|
|
66
152
|
})
|
|
67
153
|
.join("\n");
|
|
68
154
|
}
|
|
@@ -74,31 +160,60 @@ export class PrismToolset {
|
|
|
74
160
|
image = DEFAULT_IMAGE,
|
|
75
161
|
maxUsdg = 1,
|
|
76
162
|
minTrustClass = "open",
|
|
77
|
-
}) {
|
|
163
|
+
} = {}) {
|
|
78
164
|
if (!this.#agent) return NO_WALLET;
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
165
|
+
if (typeof command !== "string" || command.trim() === "") return COMMAND_MESSAGE;
|
|
166
|
+
if (Buffer.byteLength(command, "utf8") > MAX_COMMAND_BYTES) {
|
|
167
|
+
return `command exceeds the ${MAX_COMMAND_BYTES / 1024} KiB limit; fetch the payload on the box instead of inlining it.`;
|
|
168
|
+
}
|
|
169
|
+
if (!TRUST_CLASSES.includes(minTrustClass)) return TRUST_MESSAGE;
|
|
170
|
+
this.#sweepExpired();
|
|
171
|
+
let lease;
|
|
172
|
+
try {
|
|
173
|
+
lease = await this.#agent.lease({
|
|
174
|
+
image,
|
|
175
|
+
durationSeconds,
|
|
176
|
+
minVramMib,
|
|
177
|
+
maxDeposit: Math.round(maxUsdg * MICROS),
|
|
178
|
+
minTrustClass,
|
|
179
|
+
});
|
|
180
|
+
} catch (err) {
|
|
181
|
+
return `The lease did not go through: ${describe(err)}`;
|
|
182
|
+
}
|
|
86
183
|
this.#leases.set(lease.leaseId, lease);
|
|
87
|
-
|
|
184
|
+
let res;
|
|
185
|
+
try {
|
|
186
|
+
res = await this.#agent.run(lease, command);
|
|
187
|
+
} catch (err) {
|
|
188
|
+
return (
|
|
189
|
+
`Lease ${lease.leaseId} is funded (tx ${lease.fundingHash}) but the command could not run: ` +
|
|
190
|
+
`${describe(err)}. The lease stays open; try run(${lease.leaseId}, ...) or release it with endLease.`
|
|
191
|
+
);
|
|
192
|
+
}
|
|
88
193
|
const out = res.stdout || res.stderr || "";
|
|
89
194
|
return `lease ${lease.leaseId} funded onchain (tx ${lease.fundingHash}), exit ${res.code}:\n${out}`;
|
|
90
195
|
}
|
|
91
196
|
|
|
92
197
|
async run(leaseId, command) {
|
|
93
198
|
if (!this.#agent) return NO_WALLET;
|
|
199
|
+
leaseId = Number(leaseId);
|
|
200
|
+
if (!Number.isInteger(leaseId) || leaseId <= 0) return "lease_id must be a positive integer.";
|
|
94
201
|
const lease = this.#leases.get(leaseId);
|
|
95
202
|
if (!lease) return `No active lease ${leaseId} in this session.`;
|
|
96
|
-
|
|
203
|
+
if (typeof command !== "string" || command.trim() === "") return COMMAND_MESSAGE;
|
|
204
|
+
let res;
|
|
205
|
+
try {
|
|
206
|
+
res = await this.#agent.run(lease, command);
|
|
207
|
+
} catch (err) {
|
|
208
|
+
return `The command could not run on lease ${leaseId}: ${describe(err)}`;
|
|
209
|
+
}
|
|
97
210
|
return `exit ${res.code}:\n${res.stdout || res.stderr || ""}`;
|
|
98
211
|
}
|
|
99
212
|
|
|
100
213
|
endLease(leaseId) {
|
|
101
214
|
if (!this.#agent) return NO_WALLET;
|
|
215
|
+
leaseId = Number(leaseId);
|
|
216
|
+
if (!Number.isInteger(leaseId) || leaseId <= 0) return "lease_id must be a positive integer.";
|
|
102
217
|
const lease = this.#leases.get(leaseId);
|
|
103
218
|
if (!lease) return `No active lease ${leaseId} in this session.`;
|
|
104
219
|
this.#agent.endLease(lease);
|
package/vault.mjs
CHANGED
|
@@ -65,7 +65,7 @@ function fromHex(value) {
|
|
|
65
65
|
return bytes;
|
|
66
66
|
}
|
|
67
67
|
|
|
68
|
-
/// The wallet address, lowercased. Casing varies by source
|
|
68
|
+
/// The wallet address, lowercased. Casing varies by source: a checksummed
|
|
69
69
|
/// address from one wallet and a lowercase one from another must not derive
|
|
70
70
|
/// two different keys for the same vault.
|
|
71
71
|
export function vaultWallet(address) {
|