@prismnetwork/agent-sdk 0.5.0 → 0.6.1

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.
Files changed (4) hide show
  1. package/package.json +2 -1
  2. package/prism.d.mts +22 -1
  3. package/prism.mjs +59 -17
  4. package/relay.mjs +176 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@prismnetwork/agent-sdk",
3
- "version": "0.5.0",
3
+ "version": "0.6.1",
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",
@@ -24,6 +24,7 @@
24
24
  },
25
25
  "files": [
26
26
  "prism.mjs",
27
+ "relay.mjs",
27
28
  "toolset.d.mts",
28
29
  "prism.d.mts",
29
30
  "toolset.mjs",
package/prism.d.mts CHANGED
@@ -3,15 +3,33 @@ export declare const USDG: string;
3
3
  export declare const DEFAULT_IMAGE: string;
4
4
  export declare const TRUST_CLASSES: readonly ["open", "isolated", "attested", "confidential"];
5
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.
6
9
  export interface LeaseAccess {
7
- mode?: string;
10
+ mode?: "direct_ssh" | "gateway" | string;
8
11
  ssh_host?: string;
9
12
  ssh_port?: number;
10
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;
11
22
  expires_at?: string;
12
23
  [key: string]: unknown;
13
24
  }
14
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
+
15
33
  export interface LeaseHandle {
16
34
  leaseId: number;
17
35
  access: LeaseAccess;
@@ -74,6 +92,9 @@ export declare class PrismAgent {
74
92
  command: string,
75
93
  options?: { timeoutMs?: number; connectRetries?: number; connectDelayMs?: number; stdin?: string | null },
76
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>;
77
98
  endLease(lease: LeaseHandle): void;
78
99
  }
79
100
 
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
 
@@ -324,7 +325,10 @@ export class PrismAgent {
324
325
  return res.body;
325
326
  }
326
327
  if (res.status !== 404 && res.status !== 429 && res.status < 500) {
327
- throw new PrismError(res.status, res.body?.error ?? "access_error", res.body);
328
+ // The control plane names the reason in `code`; without it a lease that
329
+ // will never open access reports as a generic `access_error` and the
330
+ // caller has to go and read the body to learn anything.
331
+ throw new PrismError(res.status, res.body?.error ?? res.body?.code ?? "access_error", res.body);
328
332
  }
329
333
  await sleep(intervalMs);
330
334
  }
@@ -419,28 +423,66 @@ export class PrismAgent {
419
423
  // can lag a few minutes after the box reports ready. `stdin` feeds the command
420
424
  // its input, which keeps anything sensitive out of the remote process table.
421
425
  async run(lease, command, { timeoutMs = 120_000, connectRetries = 24, connectDelayMs = 10_000, stdin = null } = {}) {
422
- if (!lease?.access?.ssh_host || !lease.access.ssh_port || !lease.keyPath) {
426
+ if (typeof command !== "string" || command.length === 0) throw new PrismError(400, "command_required");
427
+ if (!lease?.keyPath) {
423
428
  throw new PrismError(400, "invalid_lease_handle", {
424
429
  mode: lease?.access?.mode ?? null,
425
430
  lease_id: lease?.leaseId ?? null,
426
- hint: "gateway-mode access has no ssh endpoint",
431
+ hint: "the lease handle carries no ssh key",
427
432
  });
428
433
  }
429
- if (typeof command !== "string" || command.length === 0) throw new PrismError(400, "command_required");
430
- const target = {
431
- host: lease.access.ssh_host,
432
- port: lease.access.ssh_port,
433
- user: lease.access.ssh_user ?? "root",
434
- keyPath: lease.keyPath,
435
- };
436
- let last;
437
- for (let attempt = 0; attempt <= connectRetries; attempt++) {
438
- const res = await this.#ssh(target, command, timeoutMs, stdin);
439
- if (!isSshWarmup(res)) return res;
440
- last = res;
441
- if (attempt < connectRetries) await sleep(connectDelayMs);
434
+
435
+ // A physical node accepts nothing inbound, so its session arrives through
436
+ // the gateway. Opening the renter's half of that tunnel gives a local port
437
+ // that behaves like any other host, which is why the retry loop below does
438
+ // not care which kind of capacity it is talking to.
439
+ const forwarder =
440
+ lease.access?.mode === "gateway" ? await openRelayForwarder(lease.access) : null;
441
+ try {
442
+ const target = forwarder
443
+ ? {
444
+ host: forwarder.host,
445
+ port: forwarder.port,
446
+ user: lease.access.ssh_user ?? "workspace",
447
+ keyPath: lease.keyPath,
448
+ }
449
+ : {
450
+ host: lease.access?.ssh_host,
451
+ port: lease.access?.ssh_port,
452
+ user: lease.access?.ssh_user ?? "root",
453
+ keyPath: lease.keyPath,
454
+ };
455
+ if (!target.host || !target.port) {
456
+ throw new PrismError(400, "invalid_lease_handle", {
457
+ mode: lease.access?.mode ?? null,
458
+ lease_id: lease.leaseId ?? null,
459
+ hint: "the access grant names no reachable endpoint",
460
+ });
461
+ }
462
+ let last;
463
+ for (let attempt = 0; attempt <= connectRetries; attempt++) {
464
+ const res = await this.#ssh(target, command, timeoutMs, stdin);
465
+ if (!isSshWarmup(res)) return res;
466
+ last = res;
467
+ if (attempt < connectRetries) await sleep(connectDelayMs);
468
+ }
469
+ return last;
470
+ } finally {
471
+ if (forwarder) await forwarder.close();
472
+ }
473
+ }
474
+
475
+ /// A local address that forwards to the workspace for as long as it is open.
476
+ /// Use it for anything that is not a one-shot command: `scp`, port forwards,
477
+ /// an interactive shell, a notebook client. The caller closes it.
478
+ async forward(lease, { service = "ssh" } = {}) {
479
+ if (lease?.access?.mode !== "gateway") {
480
+ throw new PrismError(400, "forward_not_supported", {
481
+ mode: lease?.access?.mode ?? null,
482
+ hint: "this lease is reachable directly and needs no relay",
483
+ });
442
484
  }
443
- return last;
485
+ return openRelayForwarder(lease.access, { service });
444
486
  }
445
487
 
446
488
  // Releases local key material. The on-chain lease settles at the end of its duration.
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
+ }