@prismnetwork/agent-sdk 0.3.1 → 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 +73 -4
- package/package.json +20 -3
- package/prism.d.mts +84 -0
- package/prism.mjs +119 -22
- package/toolset.d.mts +30 -0
- package/toolset.mjs +223 -0
- package/vault.mjs +1 -1
- package/workspace.d.ts +101 -0
- package/workspace.mjs +503 -0
package/README.md
CHANGED
|
@@ -16,7 +16,7 @@ npm install @prismnetwork/agent-sdk viem
|
|
|
16
16
|
import { PrismAgent, DEFAULT_IMAGE } from "@prismnetwork/agent-sdk";
|
|
17
17
|
|
|
18
18
|
const agent = new PrismAgent({
|
|
19
|
-
privateKey: process.env.
|
|
19
|
+
privateKey: process.env.PRISM_AGENT_KEY, // agent's wallet
|
|
20
20
|
escrow: "0x62C042265991bEa17B07229322A01850974626dA",
|
|
21
21
|
});
|
|
22
22
|
|
|
@@ -29,6 +29,18 @@ agent.endLease(lease);
|
|
|
29
29
|
|
|
30
30
|
`image` must be an immutable digest-pinned reference (`repo@sha256:...`). `DEFAULT_IMAGE` is one; a plain tag is rejected.
|
|
31
31
|
|
|
32
|
+
## Toolset
|
|
33
|
+
|
|
34
|
+
`@prismnetwork/agent-sdk/toolset` exports `PrismToolset`, the framework-neutral
|
|
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.
|
|
43
|
+
|
|
32
44
|
## Vault
|
|
33
45
|
|
|
34
46
|
Cards, identity documents, API credentials and recovery codes go in the vault
|
|
@@ -45,7 +57,7 @@ const value = await agent.vault.get(card.item_id, { json: true });
|
|
|
45
57
|
|
|
46
58
|
`unlock()` derives the key from a signature over a fixed statement. Ethereum's
|
|
47
59
|
ECDSA is deterministic, so the same wallet reproduces the same vault on any
|
|
48
|
-
machine
|
|
60
|
+
machine; no recovery copy is held anywhere. Pass `{ passphrase }` to require a
|
|
49
61
|
second factor beyond the wallet.
|
|
50
62
|
|
|
51
63
|
Every item carries the weakest workspace trust class it may ever be released
|
|
@@ -65,6 +77,62 @@ ciphertext, so a service that moved an item between accounts, replayed an older
|
|
|
65
77
|
version, or lowered its floor would produce a failed decrypt rather than a
|
|
66
78
|
plausible wrong answer. See [docs/VAULT.md](../docs/VAULT.md).
|
|
67
79
|
|
|
80
|
+
## Workspaces
|
|
81
|
+
|
|
82
|
+
A lease destroys its machine, so training output, checkpoints and a working
|
|
83
|
+
directory need somewhere that outlives it. A workspace is that place: the SDK
|
|
84
|
+
archives a directory off the leased box, seals it here under a key derived from
|
|
85
|
+
your wallet, and uploads the ciphertext straight to object storage. Prism
|
|
86
|
+
records the version, the size and the hash, and holds nothing that opens it.
|
|
87
|
+
|
|
88
|
+
```js
|
|
89
|
+
await agent.workspace.unlock();
|
|
90
|
+
|
|
91
|
+
const ws = await agent.workspace.create("finetune-run");
|
|
92
|
+
const saved = await agent.workspace.save(lease, ws, "/root/out");
|
|
93
|
+
|
|
94
|
+
// On a later lease, onto a fresh machine.
|
|
95
|
+
await agent.workspace.restore(next, ws, "/root/out", { expectVersion: saved.version });
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
The workspace key comes from a different statement and a different salt than
|
|
99
|
+
the vault's, so opening one does not open the other. Pass `{ passphrase }` to
|
|
100
|
+
require a second factor beyond the wallet.
|
|
101
|
+
|
|
102
|
+
A restore hashes the downloaded ciphertext and compares it to the digest
|
|
103
|
+
recorded at save time before it decrypts anything, so bytes altered in storage
|
|
104
|
+
are reported as tampering rather than as a wrong key. The account, workspace,
|
|
105
|
+
version and trust floor are authenticated into the ciphertext, so a snapshot
|
|
106
|
+
served for the wrong workspace, or under a floor that has been rewritten, fails
|
|
107
|
+
to open rather than returning a plausible wrong answer.
|
|
108
|
+
|
|
109
|
+
An older snapshot is a different case worth being precise about: its own
|
|
110
|
+
associated data is genuine for its own version, so it decrypts cleanly and
|
|
111
|
+
nothing in the ciphertext gives it away. A restore therefore compares the
|
|
112
|
+
version it was granted against the version the record says is current, and
|
|
113
|
+
refuses a rollback on that basis. Pass `expectVersion` to pin a specific one,
|
|
114
|
+
and `expectTrustClass` to refuse a floor that has moved.
|
|
115
|
+
|
|
116
|
+
A restore names the lease it is landing on, and Prism refuses to issue the
|
|
117
|
+
download at all when that lease's trust class is below the workspace's floor.
|
|
118
|
+
The check is server-side deliberately: a client-side one would be a courtesy
|
|
119
|
+
that a modified client could skip.
|
|
120
|
+
|
|
121
|
+
Bulk data never passes through Prism. Uploads and downloads use presigned URLs
|
|
122
|
+
that live fifteen minutes, and they are used from your process, never handed to
|
|
123
|
+
the leased machine. The machine only ever sees `tar` and `base64`, which is all
|
|
124
|
+
this needs from it.
|
|
125
|
+
|
|
126
|
+
Snapshots travel over the lease's SSH channel, which caps a single save at 64
|
|
127
|
+
MiB of archive; a larger directory is refused on the machine before anything is
|
|
128
|
+
transferred. New workspaces default to the `open` trust floor, unlike vault
|
|
129
|
+
items: their contents are the files you are already handing to a rented box.
|
|
130
|
+
Raise it at creation when they deserve more:
|
|
131
|
+
|
|
132
|
+
```js
|
|
133
|
+
await agent.workspace.create("model-weights", { minTrustClass: "isolated" });
|
|
134
|
+
```
|
|
135
|
+
|
|
68
136
|
## Auth
|
|
69
137
|
|
|
70
138
|
`authenticate()` fetches a challenge (`GET /api/agent/challenge`), signs the message with the wallet, and exchanges it for a session (`POST /api/agent/session`). The session is a bearer token used on every `/api/agent/proxy/*` call. No shared secret, no cookie. The wallet is the identity (`subject = wallet:0x...`).
|
|
@@ -79,6 +147,7 @@ The wallet needs two balances on Robinhood Chain (id 4663): USDG (`0x5fc5360D040
|
|
|
79
147
|
|
|
80
148
|
## Requirements
|
|
81
149
|
|
|
82
|
-
Node >= 20, `viem` ^2 (peer), and `ssh` + `ssh-keygen` on PATH for `run()
|
|
150
|
+
Node >= 20, `viem` ^2 (peer), and `ssh` + `ssh-keygen` on PATH for `run()` and
|
|
151
|
+
for workspace save and restore.
|
|
83
152
|
|
|
84
|
-
See
|
|
153
|
+
See [example.mjs](https://github.com/prismnetwork-tech/prism/blob/main/sdk/example.mjs) for a full run.
|
package/package.json
CHANGED
|
@@ -1,20 +1,36 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@prismnetwork/agent-sdk",
|
|
3
|
-
"version": "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
|
-
".":
|
|
8
|
+
".": {
|
|
9
|
+
"types": "./prism.d.mts",
|
|
10
|
+
"default": "./prism.mjs"
|
|
11
|
+
},
|
|
12
|
+
"./toolset": {
|
|
13
|
+
"types": "./toolset.d.mts",
|
|
14
|
+
"default": "./toolset.mjs"
|
|
15
|
+
},
|
|
9
16
|
"./vault": {
|
|
10
17
|
"types": "./vault.d.ts",
|
|
11
18
|
"default": "./vault.mjs"
|
|
19
|
+
},
|
|
20
|
+
"./workspace": {
|
|
21
|
+
"types": "./workspace.d.ts",
|
|
22
|
+
"default": "./workspace.mjs"
|
|
12
23
|
}
|
|
13
24
|
},
|
|
14
25
|
"files": [
|
|
15
26
|
"prism.mjs",
|
|
27
|
+
"toolset.d.mts",
|
|
28
|
+
"prism.d.mts",
|
|
29
|
+
"toolset.mjs",
|
|
16
30
|
"vault.mjs",
|
|
17
31
|
"vault.d.ts",
|
|
32
|
+
"workspace.mjs",
|
|
33
|
+
"workspace.d.ts",
|
|
18
34
|
"README.md"
|
|
19
35
|
],
|
|
20
36
|
"engines": {
|
|
@@ -44,5 +60,6 @@
|
|
|
44
60
|
"license": "Apache-2.0",
|
|
45
61
|
"publishConfig": {
|
|
46
62
|
"access": "public"
|
|
47
|
-
}
|
|
63
|
+
},
|
|
64
|
+
"types": "prism.d.mts"
|
|
48
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
|
@@ -15,8 +15,15 @@ import {
|
|
|
15
15
|
} from "viem";
|
|
16
16
|
import { privateKeyToAccount } from "viem/accounts";
|
|
17
17
|
import { PrismVault } from "./vault.mjs";
|
|
18
|
+
import { PrismWorkspace } from "./workspace.mjs";
|
|
18
19
|
|
|
19
20
|
export { PrismVault, VaultError, DEFAULT_TRUST_FLOOR, VAULT_KEY_STATEMENT } from "./vault.mjs";
|
|
21
|
+
export {
|
|
22
|
+
PrismWorkspace,
|
|
23
|
+
WorkspaceError,
|
|
24
|
+
DEFAULT_WORKSPACE_TRUST_FLOOR,
|
|
25
|
+
WORKSPACE_KEY_STATEMENT,
|
|
26
|
+
} from "./workspace.mjs";
|
|
20
27
|
|
|
21
28
|
export const robinhoodChain = defineChain({
|
|
22
29
|
id: 4663,
|
|
@@ -51,7 +58,7 @@ const escrowAbi = parseAbi([
|
|
|
51
58
|
|
|
52
59
|
// Matches the limit the control plane and the node both enforce, so a command
|
|
53
60
|
// that cannot run is rejected here rather than after an escrow is funded.
|
|
54
|
-
const MAX_COMMAND_BYTES = 8 * 1024;
|
|
61
|
+
export const MAX_COMMAND_BYTES = 8 * 1024;
|
|
55
62
|
|
|
56
63
|
function assertCommand(value) {
|
|
57
64
|
if (typeof value !== "string" || value.trim() === "") {
|
|
@@ -100,22 +107,36 @@ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
|
100
107
|
export class PrismAgent {
|
|
101
108
|
constructor({ privateKey, apiBase = "https://prismnetwork.tech", escrow, rpcUrl }) {
|
|
102
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
|
+
}
|
|
103
115
|
this.apiBase = apiBase.replace(/\/$/, "");
|
|
104
116
|
this.escrow = escrow;
|
|
105
|
-
|
|
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
|
+
}
|
|
106
125
|
const transport = http(rpcUrl ?? robinhoodChain.rpcUrls.default.http[0]);
|
|
107
126
|
this.publicClient = createPublicClient({ chain: robinhoodChain, transport });
|
|
108
127
|
this.walletClient = createWalletClient({ account: this.account, chain: robinhoodChain, transport });
|
|
109
128
|
this.session = null;
|
|
110
129
|
this.vault = new PrismVault(this);
|
|
130
|
+
this.workspace = new PrismWorkspace(this);
|
|
111
131
|
}
|
|
112
132
|
|
|
113
133
|
get address() {
|
|
114
134
|
return this.account.address;
|
|
115
135
|
}
|
|
116
136
|
|
|
117
|
-
// The vault
|
|
118
|
-
// returned to the
|
|
137
|
+
// The vault and workspace keys are derived from this signature on the
|
|
138
|
+
// caller's machine. It is returned to the client that asked and never sent
|
|
139
|
+
// anywhere.
|
|
119
140
|
async signVaultStatement(statement) {
|
|
120
141
|
return this.account.signMessage({ message: statement });
|
|
121
142
|
}
|
|
@@ -124,6 +145,10 @@ export class PrismAgent {
|
|
|
124
145
|
return this.#proxy(method, ["vault", ...segments], { body });
|
|
125
146
|
}
|
|
126
147
|
|
|
148
|
+
async workspaceRequest(method, segments, { body = null } = {}) {
|
|
149
|
+
return this.#proxy(method, ["workspaces", ...segments], { body });
|
|
150
|
+
}
|
|
151
|
+
|
|
127
152
|
async authenticate() {
|
|
128
153
|
const challenge = await this.#json(`/api/agent/challenge?address=${this.address}`);
|
|
129
154
|
const signature = await this.account.signMessage({ message: challenge.message });
|
|
@@ -251,13 +276,43 @@ export class PrismAgent {
|
|
|
251
276
|
|
|
252
277
|
async waitForResult(leaseId, { timeoutMs = 900_000, intervalMs = 10_000 } = {}) {
|
|
253
278
|
const deadline = Date.now() + timeoutMs;
|
|
279
|
+
let polls = 0;
|
|
254
280
|
while (Date.now() < deadline) {
|
|
255
281
|
const res = await this.#proxy("GET", ["leases", String(leaseId), "result"], { raw: true });
|
|
256
282
|
if (res.status === 200) return res.body;
|
|
257
|
-
|
|
258
|
-
|
|
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);
|
|
259
301
|
}
|
|
260
|
-
throw new PrismError(408, "result_timeout");
|
|
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;
|
|
311
|
+
}
|
|
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;
|
|
261
316
|
}
|
|
262
317
|
|
|
263
318
|
async waitForAccess(leaseId, { timeoutMs = 600_000, intervalMs = 10_000 } = {}) {
|
|
@@ -268,10 +323,12 @@ export class PrismAgent {
|
|
|
268
323
|
if (!res.body?.ssh_host && res.body?.mode !== "gateway") throw new PrismError(502, "malformed_access");
|
|
269
324
|
return res.body;
|
|
270
325
|
}
|
|
271
|
-
if (res.status !== 404
|
|
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
|
+
}
|
|
272
329
|
await sleep(intervalMs);
|
|
273
330
|
}
|
|
274
|
-
throw new PrismError(408, "access_timeout");
|
|
331
|
+
throw new PrismError(408, "access_timeout", { lease_id: leaseId });
|
|
275
332
|
}
|
|
276
333
|
|
|
277
334
|
// quote -> ssh keygen -> fund on-chain -> confirm -> wait for access.
|
|
@@ -285,6 +342,18 @@ export class PrismAgent {
|
|
|
285
342
|
command = null,
|
|
286
343
|
} = {}) {
|
|
287
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
|
+
}
|
|
288
357
|
const quote = await this.quote({
|
|
289
358
|
image,
|
|
290
359
|
durationSeconds,
|
|
@@ -297,25 +366,30 @@ export class PrismAgent {
|
|
|
297
366
|
throw new PrismError(402, "cost_exceeds_max", { required: quote.maximum_escrow, max: String(maxDeposit) });
|
|
298
367
|
}
|
|
299
368
|
const key = this.#generateSshKey();
|
|
369
|
+
let funded = null;
|
|
370
|
+
let leaseId = null;
|
|
300
371
|
try {
|
|
301
|
-
|
|
372
|
+
funded = await this.fund(quote);
|
|
302
373
|
const record = await this.confirm({
|
|
303
374
|
quoteId: quote.quote_id,
|
|
304
375
|
transactionHash: funded.hash,
|
|
305
376
|
sshAuthorizedKey: key.publicKey,
|
|
306
377
|
});
|
|
307
|
-
if (!Number.isInteger(record?.lease_id))
|
|
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;
|
|
308
382
|
// A batch lease never hands out access, so waiting for it would block
|
|
309
383
|
// until the timeout and then report a failure that never happened. Wait
|
|
310
384
|
// for what the command printed instead.
|
|
311
385
|
if (command !== null) {
|
|
312
|
-
const result = await this.waitForResult(
|
|
386
|
+
const result = await this.waitForResult(leaseId, { timeoutMs: durationSeconds * 1000 + 900_000 });
|
|
313
387
|
rmSync(key.dir, { recursive: true, force: true });
|
|
314
|
-
return { leaseId
|
|
388
|
+
return { leaseId, result, fundingHash: funded.hash, quote };
|
|
315
389
|
}
|
|
316
|
-
const access = await this.waitForAccess(
|
|
390
|
+
const access = await this.waitForAccess(leaseId);
|
|
317
391
|
return {
|
|
318
|
-
leaseId
|
|
392
|
+
leaseId,
|
|
319
393
|
access,
|
|
320
394
|
keyPath: key.keyPath,
|
|
321
395
|
keyDir: key.dir,
|
|
@@ -324,17 +398,33 @@ export class PrismAgent {
|
|
|
324
398
|
quote,
|
|
325
399
|
};
|
|
326
400
|
} catch (err) {
|
|
327
|
-
|
|
328
|
-
|
|
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) });
|
|
329
414
|
}
|
|
330
415
|
}
|
|
331
416
|
|
|
332
417
|
// Run a command in the remote login shell over SSH (so pipes, redirects, and
|
|
333
418
|
// $(...) all evaluate on the GPU). Retries through the host's sshd warmup, which
|
|
334
|
-
// can lag a few minutes after the box reports ready.
|
|
335
|
-
|
|
419
|
+
// can lag a few minutes after the box reports ready. `stdin` feeds the command
|
|
420
|
+
// its input, which keeps anything sensitive out of the remote process table.
|
|
421
|
+
async run(lease, command, { timeoutMs = 120_000, connectRetries = 24, connectDelayMs = 10_000, stdin = null } = {}) {
|
|
336
422
|
if (!lease?.access?.ssh_host || !lease.access.ssh_port || !lease.keyPath) {
|
|
337
|
-
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
|
+
});
|
|
338
428
|
}
|
|
339
429
|
if (typeof command !== "string" || command.length === 0) throw new PrismError(400, "command_required");
|
|
340
430
|
const target = {
|
|
@@ -345,7 +435,7 @@ export class PrismAgent {
|
|
|
345
435
|
};
|
|
346
436
|
let last;
|
|
347
437
|
for (let attempt = 0; attempt <= connectRetries; attempt++) {
|
|
348
|
-
const res = await this.#ssh(target, command, timeoutMs);
|
|
438
|
+
const res = await this.#ssh(target, command, timeoutMs, stdin);
|
|
349
439
|
if (!isSshWarmup(res)) return res;
|
|
350
440
|
last = res;
|
|
351
441
|
if (attempt < connectRetries) await sleep(connectDelayMs);
|
|
@@ -376,7 +466,7 @@ export class PrismAgent {
|
|
|
376
466
|
}
|
|
377
467
|
}
|
|
378
468
|
|
|
379
|
-
#ssh(target, command, timeoutMs) {
|
|
469
|
+
#ssh(target, command, timeoutMs, stdin = null) {
|
|
380
470
|
const args = [
|
|
381
471
|
"-i", target.keyPath,
|
|
382
472
|
"-p", String(target.port),
|
|
@@ -398,6 +488,12 @@ export class PrismAgent {
|
|
|
398
488
|
}, timeoutMs);
|
|
399
489
|
child.stdout.on("data", (d) => (stdout += d));
|
|
400
490
|
child.stderr.on("data", (d) => (stderr += d));
|
|
491
|
+
if (stdin !== null) {
|
|
492
|
+
// A command that exits before reading its input closes the pipe, which
|
|
493
|
+
// is a normal end to the transfer and not a failure to report.
|
|
494
|
+
child.stdin.on("error", () => {});
|
|
495
|
+
child.stdin.end(stdin);
|
|
496
|
+
}
|
|
401
497
|
child.on("close", (code) => {
|
|
402
498
|
clearTimeout(timer);
|
|
403
499
|
resolve({ code: code ?? -1, stdout: stdout.trim(), stderr: stderr.trim(), timedOut });
|
|
@@ -455,6 +551,7 @@ export class PrismAgent {
|
|
|
455
551
|
export class PrismError extends Error {
|
|
456
552
|
constructor(status, code, body) {
|
|
457
553
|
super(`prism ${status}: ${code}`);
|
|
554
|
+
this.name = "PrismError";
|
|
458
555
|
this.status = status;
|
|
459
556
|
this.code = code;
|
|
460
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
|
+
}
|