@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 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.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
- ".": "./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,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
- this.account = privateKeyToAccount(privateKey);
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
- if (res.status !== 404) throw new PrismError(res.status, res.body?.code ?? "result_failed", res.body);
271
- await new Promise((resolve) => setTimeout(resolve, intervalMs));
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) throw new PrismError(res.status, res.body?.error ?? "access_error");
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
- const funded = await this.fund(quote);
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)) throw new PrismError(502, "malformed_lease_record");
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(record.lease_id);
387
+ const result = await this.waitForResult(leaseId, { timeoutMs: durationSeconds * 1000 + 900_000 });
326
388
  rmSync(key.dir, { recursive: true, force: true });
327
- return { leaseId: record.lease_id, result, fundingHash: funded.hash, quote };
389
+ return { leaseId, result, fundingHash: funded.hash, quote };
328
390
  }
329
- const access = await this.waitForAccess(record.lease_id);
391
+ const access = await this.waitForAccess(leaseId);
330
392
  return {
331
- leaseId: record.lease_id,
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
- rmSync(key.dir, { recursive: true, force: true });
341
- throw err;
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
- const target = {
355
- host: lease.access.ssh_host,
356
- port: lease.access.ssh_port,
357
- user: lease.access.ssh_user ?? "root",
358
- keyPath: lease.keyPath,
359
- };
360
- let last;
361
- for (let attempt = 0; attempt <= connectRetries; attempt++) {
362
- const res = await this.#ssh(target, command, timeoutMs, stdin);
363
- if (!isSshWarmup(res)) return res;
364
- last = res;
365
- if (attempt < connectRetries) await sleep(connectDelayMs);
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 last;
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 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) {