@prismnetwork/agent-sdk 0.5.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/package.json +2 -1
- package/prism.d.mts +22 -1
- package/prism.mjs +55 -16
- package/relay.mjs +176 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
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",
|
|
@@ -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
|
|
|
@@ -419,28 +420,66 @@ export class PrismAgent {
|
|
|
419
420
|
// can lag a few minutes after the box reports ready. `stdin` feeds the command
|
|
420
421
|
// its input, which keeps anything sensitive out of the remote process table.
|
|
421
422
|
async run(lease, command, { timeoutMs = 120_000, connectRetries = 24, connectDelayMs = 10_000, stdin = null } = {}) {
|
|
422
|
-
if (
|
|
423
|
+
if (typeof command !== "string" || command.length === 0) throw new PrismError(400, "command_required");
|
|
424
|
+
if (!lease?.keyPath) {
|
|
423
425
|
throw new PrismError(400, "invalid_lease_handle", {
|
|
424
426
|
mode: lease?.access?.mode ?? null,
|
|
425
427
|
lease_id: lease?.leaseId ?? null,
|
|
426
|
-
hint: "
|
|
428
|
+
hint: "the lease handle carries no ssh key",
|
|
427
429
|
});
|
|
428
430
|
}
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
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
|
+
});
|
|
442
481
|
}
|
|
443
|
-
return
|
|
482
|
+
return openRelayForwarder(lease.access, { service });
|
|
444
483
|
}
|
|
445
484
|
|
|
446
485
|
// 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
|
+
}
|