@prismnetwork/agent-sdk 0.4.0 → 0.5.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 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.AGENT_KEY, // agent's wallet
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 MCP server and the framework plugins (elizaOS, Virtuals GAME)
36
- wrap: `wallet`, `listGpus`, `leaseAndRun`, `run`, `endLease`, each returning a
37
- human-readable string. It holds the wallet, the open leases and the per-lease
38
- spending cap in one place, reads `PRISM_AGENT_KEY`/`PRISM_ESCROW` from the
39
- environment by default, and answers the read-only questions from the public API
40
- when no wallet is configured.
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 no recovery copy is held anywhere. Pass `{ passphrase }` to require a
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 `example.mjs` for a full run.
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.4.0",
3
+ "version": "0.5.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
- ".": "./prism.mjs",
9
- "./toolset": "./toolset.mjs",
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,8 @@
18
24
  },
19
25
  "files": [
20
26
  "prism.mjs",
27
+ "toolset.d.mts",
28
+ "prism.d.mts",
21
29
  "toolset.mjs",
22
30
  "vault.mjs",
23
31
  "vault.d.ts",
@@ -52,5 +60,6 @@
52
60
  "license": "Apache-2.0",
53
61
  "publishConfig": {
54
62
  "access": "public"
55
- }
63
+ },
64
+ "types": "prism.d.mts"
56
65
  }
package/prism.d.mts ADDED
@@ -0,0 +1,84 @@
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
+ export interface LeaseAccess {
7
+ mode?: string;
8
+ ssh_host?: string;
9
+ ssh_port?: number;
10
+ ssh_user?: string;
11
+ expires_at?: string;
12
+ [key: string]: unknown;
13
+ }
14
+
15
+ export interface LeaseHandle {
16
+ leaseId: number;
17
+ access: LeaseAccess;
18
+ keyPath: string;
19
+ keyDir: string;
20
+ publicKey: string;
21
+ fundingHash: string;
22
+ quote: Record<string, unknown>;
23
+ }
24
+
25
+ export interface BatchLeaseHandle {
26
+ leaseId: number;
27
+ result: { exit_code?: number; stdout?: string; stderr?: string; truncated?: boolean };
28
+ fundingHash: string;
29
+ quote: Record<string, unknown>;
30
+ }
31
+
32
+ export interface RunResult {
33
+ code: number;
34
+ stdout: string;
35
+ stderr: string;
36
+ timedOut: boolean;
37
+ }
38
+
39
+ export declare class PrismAgent {
40
+ constructor(options: { privateKey: string; escrow: string; apiBase?: string; rpcUrl?: string });
41
+ readonly address: string;
42
+ readonly vault: unknown;
43
+ readonly workspace: unknown;
44
+ authenticate(): Promise<{ session: string }>;
45
+ offers(options?: { minTrust?: string }): Promise<Array<Record<string, unknown>>>;
46
+ balances(): Promise<{ address: string; usdg: string; eth: string }>;
47
+ transferUsdg(to: string, amountMicros: number | string | bigint): Promise<string>;
48
+ quote(options: {
49
+ image: string;
50
+ durationSeconds: number;
51
+ minVramMib?: number;
52
+ preferredNodeId?: string | null;
53
+ minTrustClass?: string;
54
+ command?: string | null;
55
+ }): Promise<Record<string, unknown>>;
56
+ fund(quote: Record<string, unknown>): Promise<{ hash: string; clientReference: string }>;
57
+ confirm(options: { quoteId: string; transactionHash: string; sshAuthorizedKey: string }): Promise<Record<string, unknown>>;
58
+ leases(): Promise<Array<Record<string, unknown>>>;
59
+ access(leaseId: number): Promise<LeaseAccess>;
60
+ result(leaseId: number): Promise<Record<string, unknown>>;
61
+ waitForResult(leaseId: number, options?: { timeoutMs?: number; intervalMs?: number }): Promise<Record<string, unknown>>;
62
+ waitForAccess(leaseId: number, options?: { timeoutMs?: number; intervalMs?: number }): Promise<LeaseAccess>;
63
+ lease(options: {
64
+ image: string;
65
+ durationSeconds: number;
66
+ minVramMib?: number;
67
+ preferredNodeId?: string | null;
68
+ maxDeposit?: number | string | bigint | null;
69
+ minTrustClass?: string;
70
+ command?: string | null;
71
+ }): Promise<LeaseHandle | BatchLeaseHandle>;
72
+ run(
73
+ lease: LeaseHandle,
74
+ command: string,
75
+ options?: { timeoutMs?: number; connectRetries?: number; connectDelayMs?: number; stdin?: string | null },
76
+ ): Promise<RunResult>;
77
+ endLease(lease: LeaseHandle): void;
78
+ }
79
+
80
+ export declare class PrismError extends Error {
81
+ readonly status: number;
82
+ readonly code: string;
83
+ readonly body: Record<string, unknown> | null | undefined;
84
+ }
package/prism.mjs CHANGED
@@ -58,7 +58,7 @@ const escrowAbi = parseAbi([
58
58
 
59
59
  // Matches the limit the control plane and the node both enforce, so a command
60
60
  // that cannot run is rejected here rather than after an escrow is funded.
61
- const MAX_COMMAND_BYTES = 8 * 1024;
61
+ export const MAX_COMMAND_BYTES = 8 * 1024;
62
62
 
63
63
  function assertCommand(value) {
64
64
  if (typeof value !== "string" || value.trim() === "") {
@@ -107,9 +107,21 @@ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
107
107
  export class PrismAgent {
108
108
  constructor({ privateKey, apiBase = "https://prismnetwork.tech", escrow, rpcUrl }) {
109
109
  if (!escrow) throw new Error("escrow address is required");
110
+ if (typeof privateKey !== "string" || privateKey.trim() === "") {
111
+ throw new Error(
112
+ "privateKey is required: a 32-byte hex key, with or without 0x (most surfaces read it from PRISM_AGENT_KEY)",
113
+ );
114
+ }
110
115
  this.apiBase = apiBase.replace(/\/$/, "");
111
116
  this.escrow = escrow;
112
- this.account = privateKeyToAccount(privateKey);
117
+ const trimmed = privateKey.trim();
118
+ try {
119
+ this.account = privateKeyToAccount(trimmed.startsWith("0x") ? trimmed : `0x${trimmed}`);
120
+ } catch (err) {
121
+ throw new Error(
122
+ `privateKey is not a valid key: ${err?.message ?? err}. Expected 32 bytes of hex, with or without the 0x prefix.`,
123
+ );
124
+ }
113
125
  const transport = http(rpcUrl ?? robinhoodChain.rpcUrls.default.http[0]);
114
126
  this.publicClient = createPublicClient({ chain: robinhoodChain, transport });
115
127
  this.walletClient = createWalletClient({ account: this.account, chain: robinhoodChain, transport });
@@ -264,13 +276,43 @@ export class PrismAgent {
264
276
 
265
277
  async waitForResult(leaseId, { timeoutMs = 900_000, intervalMs = 10_000 } = {}) {
266
278
  const deadline = Date.now() + timeoutMs;
279
+ let polls = 0;
267
280
  while (Date.now() < deadline) {
268
281
  const res = await this.#proxy("GET", ["leases", String(leaseId), "result"], { raw: true });
269
282
  if (res.status === 200) return res.body;
270
- if (res.status !== 404) throw new PrismError(res.status, res.body?.code ?? "result_failed", res.body);
271
- await new Promise((resolve) => setTimeout(resolve, intervalMs));
283
+ // The control plane keeps answering 404 for a batch whose node died
284
+ // without reporting, so check the lease state occasionally and stop
285
+ // waiting once it is terminal. 429 and 5xx are transient; aborting a
286
+ // paid wait on one would strand the deposit.
287
+ if (res.status === 404) {
288
+ polls += 1;
289
+ if (polls % 6 === 0) {
290
+ const state = await this.#terminalState(leaseId);
291
+ if (state) {
292
+ const again = await this.#proxy("GET", ["leases", String(leaseId), "result"], { raw: true });
293
+ if (again.status === 200) return again.body;
294
+ throw new PrismError(502, "batch_no_result", { lease_id: leaseId, state });
295
+ }
296
+ }
297
+ } else if (res.status !== 429 && res.status < 500) {
298
+ throw new PrismError(res.status, res.body?.code ?? "result_failed", res.body);
299
+ }
300
+ await sleep(intervalMs);
301
+ }
302
+ throw new PrismError(408, "result_timeout", { lease_id: leaseId });
303
+ }
304
+
305
+ async #terminalState(leaseId) {
306
+ let leases;
307
+ try {
308
+ leases = await this.leases();
309
+ } catch {
310
+ return null;
272
311
  }
273
- throw new PrismError(408, "result_timeout");
312
+ const record = Array.isArray(leases) ? leases.find((l) => l.lease_id === leaseId) : null;
313
+ const state = record?.state ?? "";
314
+ const terminal = ["closing", "settlement_pending", "finalized", "refunded", "failed"];
315
+ return terminal.includes(state) ? state : null;
274
316
  }
275
317
 
276
318
  async waitForAccess(leaseId, { timeoutMs = 600_000, intervalMs = 10_000 } = {}) {
@@ -281,10 +323,12 @@ export class PrismAgent {
281
323
  if (!res.body?.ssh_host && res.body?.mode !== "gateway") throw new PrismError(502, "malformed_access");
282
324
  return res.body;
283
325
  }
284
- if (res.status !== 404) throw new PrismError(res.status, res.body?.error ?? "access_error");
326
+ if (res.status !== 404 && res.status !== 429 && res.status < 500) {
327
+ throw new PrismError(res.status, res.body?.error ?? "access_error", res.body);
328
+ }
285
329
  await sleep(intervalMs);
286
330
  }
287
- throw new PrismError(408, "access_timeout");
331
+ throw new PrismError(408, "access_timeout", { lease_id: leaseId });
288
332
  }
289
333
 
290
334
  // quote -> ssh keygen -> fund on-chain -> confirm -> wait for access.
@@ -298,6 +342,18 @@ export class PrismAgent {
298
342
  command = null,
299
343
  } = {}) {
300
344
  if (!this.session) await this.authenticate();
345
+ // A wallet with no balance at all cannot fund anything, and a doomed quote
346
+ // still holds capacity against other renters until it expires. Refuse
347
+ // before quoting.
348
+ const balances = await this.balances();
349
+ if (balances.usdg === "0" || balances.eth === "0") {
350
+ throw new PrismError(402, "wallet_unfunded", {
351
+ address: this.address,
352
+ usdg: balances.usdg,
353
+ eth_wei: balances.eth,
354
+ hint: "the wallet needs USDG for the deposit and native ETH for gas on Robinhood Chain (id 4663) before it can lease",
355
+ });
356
+ }
301
357
  const quote = await this.quote({
302
358
  image,
303
359
  durationSeconds,
@@ -310,25 +366,30 @@ export class PrismAgent {
310
366
  throw new PrismError(402, "cost_exceeds_max", { required: quote.maximum_escrow, max: String(maxDeposit) });
311
367
  }
312
368
  const key = this.#generateSshKey();
369
+ let funded = null;
370
+ let leaseId = null;
313
371
  try {
314
- const funded = await this.fund(quote);
372
+ funded = await this.fund(quote);
315
373
  const record = await this.confirm({
316
374
  quoteId: quote.quote_id,
317
375
  transactionHash: funded.hash,
318
376
  sshAuthorizedKey: key.publicKey,
319
377
  });
320
- if (!Number.isInteger(record?.lease_id)) throw new PrismError(502, "malformed_lease_record");
378
+ if (!Number.isInteger(record?.lease_id)) {
379
+ throw new PrismError(502, "malformed_lease_record", { funding_hash: funded.hash });
380
+ }
381
+ leaseId = record.lease_id;
321
382
  // A batch lease never hands out access, so waiting for it would block
322
383
  // until the timeout and then report a failure that never happened. Wait
323
384
  // for what the command printed instead.
324
385
  if (command !== null) {
325
- const result = await this.waitForResult(record.lease_id);
386
+ const result = await this.waitForResult(leaseId, { timeoutMs: durationSeconds * 1000 + 900_000 });
326
387
  rmSync(key.dir, { recursive: true, force: true });
327
- return { leaseId: record.lease_id, result, fundingHash: funded.hash, quote };
388
+ return { leaseId, result, fundingHash: funded.hash, quote };
328
389
  }
329
- const access = await this.waitForAccess(record.lease_id);
390
+ const access = await this.waitForAccess(leaseId);
330
391
  return {
331
- leaseId: record.lease_id,
392
+ leaseId,
332
393
  access,
333
394
  keyPath: key.keyPath,
334
395
  keyDir: key.dir,
@@ -337,8 +398,19 @@ export class PrismAgent {
337
398
  quote,
338
399
  };
339
400
  } catch (err) {
340
- rmSync(key.dir, { recursive: true, force: true });
341
- throw err;
401
+ // Before funding, the key opens nothing; discard it. After funding it is
402
+ // the only way into a machine that is being paid for, so it stays on
403
+ // disk and the error says where everything is.
404
+ if (funded === null) {
405
+ rmSync(key.dir, { recursive: true, force: true });
406
+ throw err;
407
+ }
408
+ const detail = { funding_hash: funded.hash, lease_id: leaseId, key_path: key.keyPath };
409
+ if (err instanceof PrismError) {
410
+ err.body = { ...(err.body ?? {}), ...detail };
411
+ throw err;
412
+ }
413
+ throw new PrismError(502, "lease_failed_after_funding", { ...detail, cause: err?.message ?? String(err) });
342
414
  }
343
415
  }
344
416
 
@@ -348,7 +420,11 @@ export class PrismAgent {
348
420
  // its input, which keeps anything sensitive out of the remote process table.
349
421
  async run(lease, command, { timeoutMs = 120_000, connectRetries = 24, connectDelayMs = 10_000, stdin = null } = {}) {
350
422
  if (!lease?.access?.ssh_host || !lease.access.ssh_port || !lease.keyPath) {
351
- throw new PrismError(400, "invalid_lease_handle");
423
+ throw new PrismError(400, "invalid_lease_handle", {
424
+ mode: lease?.access?.mode ?? null,
425
+ lease_id: lease?.leaseId ?? null,
426
+ hint: "gateway-mode access has no ssh endpoint",
427
+ });
352
428
  }
353
429
  if (typeof command !== "string" || command.length === 0) throw new PrismError(400, "command_required");
354
430
  const target = {
@@ -475,6 +551,7 @@ export class PrismAgent {
475
551
  export class PrismError extends Error {
476
552
  constructor(status, code, body) {
477
553
  super(`prism ${status}: ${code}`);
554
+ this.name = "PrismError";
478
555
  this.status = status;
479
556
  this.code = code;
480
557
  this.body = body;
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 plugins stay
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
- // Without a wallet it still answers the read-only questions (capacity, prices)
8
- // from the public API, the same degradation the MCP server offers.
9
- import { DEFAULT_IMAGE, PrismAgent, TRUST_CLASSES } from "./prism.mjs";
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
- export function agentFromEnv() {
18
- const privateKey = process.env.PRISM_AGENT_KEY;
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({ privateKey, escrow: process.env.PRISM_ESCROW ?? DEFAULT_ESCROW });
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
- const NO_WALLET =
24
- "No wallet is configured, so this needs PRISM_AGENT_KEY (a funded wallet on Robinhood Chain). " +
25
- "Looking at capacity and prices works without one.";
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 = PUBLIC_API } = {}) {
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
- const b = await this.#agent.balances();
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(minTrust = "open") {
48
- if (!TRUST_CLASSES.includes(minTrust)) {
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
- if (this.#agent) {
53
- offers = await this.#agent.offers({ minTrust });
54
- } else {
55
- const url = new URL("/v1/offers", this.#publicApi);
56
- url.searchParams.set("min_trust", minTrust);
57
- const res = await fetch(url, { headers: { accept: "application/json" } });
58
- if (!res.ok) return `Prism capacity is unreachable right now (${res.status}).`;
59
- offers = await res.json();
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
- return `${o.gpu.model} · ${o.gpu.vram_mib} MiB · $${perHr}/hr · ${o.trust_class ?? "open"}`;
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
- const lease = await this.#agent.lease({
80
- image,
81
- durationSeconds,
82
- minVramMib,
83
- maxDeposit: Math.round(maxUsdg * MICROS),
84
- minTrustClass,
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
- const res = await this.#agent.run(lease, command);
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
- const res = await this.#agent.run(lease, command);
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 a checksummed
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) {