@prismnetwork/agent-sdk 0.1.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 +48 -0
- package/package.json +21 -0
- package/prism.mjs +377 -0
package/README.md
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# @prismnetwork/agent-sdk
|
|
2
|
+
|
|
3
|
+
Headless GPU leasing on [Prism Network](https://prismnetwork.tech) for autonomous agents. No browser, no Privy. An agent authenticates with a wallet signature, pays on-chain in USDG, and gets SSH access to a GPU.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
Not yet published to npm. Until it is, install it from the repo alongside its `viem` peer dependency:
|
|
8
|
+
|
|
9
|
+
```
|
|
10
|
+
npm install /path/to/prism-public/sdk viem
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
## Use
|
|
14
|
+
|
|
15
|
+
```js
|
|
16
|
+
import { PrismAgent, DEFAULT_IMAGE } from "@prismnetwork/agent-sdk";
|
|
17
|
+
|
|
18
|
+
const agent = new PrismAgent({
|
|
19
|
+
privateKey: process.env.AGENT_KEY, // agent's wallet
|
|
20
|
+
escrow: "0x71Df0eF3bc81022cB3bec0b1a05f52f12bAfcDeD",
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
await agent.authenticate();
|
|
24
|
+
const lease = await agent.lease({ image: DEFAULT_IMAGE, durationSeconds: 900, minVramMib: 16000 });
|
|
25
|
+
const out = await agent.run(lease, "nvidia-smi");
|
|
26
|
+
console.log(out.stdout);
|
|
27
|
+
agent.endLease(lease);
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
`image` must be an immutable digest-pinned reference (`repo@sha256:...`). `DEFAULT_IMAGE` is one; a plain tag is rejected.
|
|
31
|
+
|
|
32
|
+
## Auth
|
|
33
|
+
|
|
34
|
+
`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...`).
|
|
35
|
+
|
|
36
|
+
## Payment
|
|
37
|
+
|
|
38
|
+
`lease()` (and the lower-level `fund()`) reproduce the escrow's quote binding: `clientReference = keccak256(quote_id)`, `approve(escrow, maximum_escrow)`, then `createLease(...)`, waiting 12 confirmations.
|
|
39
|
+
|
|
40
|
+
## Funding
|
|
41
|
+
|
|
42
|
+
The wallet needs two balances on Robinhood Chain (id 4663): USDG (`0x5fc5360D0400a0Fd4f2af552ADD042D716F1d168`, 6 decimals) for the lease deposit, and native ETH for gas. Bridge from L1 to fund a fresh wallet. `authenticate()`, `offers()`, and `quote()` need neither, so the read paths work before you fund anything.
|
|
43
|
+
|
|
44
|
+
## Requirements
|
|
45
|
+
|
|
46
|
+
Node >= 20, `viem` ^2 (peer), and `ssh` + `ssh-keygen` on PATH for `run()`.
|
|
47
|
+
|
|
48
|
+
See `example.mjs` for a full run.
|
package/package.json
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@prismnetwork/agent-sdk",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Headless GPU leasing on Prism Network for wallet-holding agents.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "prism.mjs",
|
|
7
|
+
"exports": { ".": "./prism.mjs" },
|
|
8
|
+
"files": ["prism.mjs", "README.md"],
|
|
9
|
+
"engines": { "node": ">=20" },
|
|
10
|
+
"peerDependencies": { "viem": "^2" },
|
|
11
|
+
"keywords": ["prism", "gpu", "agent", "web3", "usdg", "compute", "llm"],
|
|
12
|
+
"homepage": "https://prismnetwork.tech",
|
|
13
|
+
"repository": {
|
|
14
|
+
"type": "git",
|
|
15
|
+
"url": "git+https://github.com/prismnetwork-tech/prism.git",
|
|
16
|
+
"directory": "sdk"
|
|
17
|
+
},
|
|
18
|
+
"bugs": { "url": "https://github.com/prismnetwork-tech/prism/issues" },
|
|
19
|
+
"license": "Apache-2.0",
|
|
20
|
+
"publishConfig": { "access": "public" }
|
|
21
|
+
}
|
package/prism.mjs
ADDED
|
@@ -0,0 +1,377 @@
|
|
|
1
|
+
// Prism Network agent SDK: headless GPU leasing for wallet-holding agents.
|
|
2
|
+
// No browser, no Privy. Authenticate with a wallet signature, pay on-chain, run.
|
|
3
|
+
import { execFileSync, spawn } from "node:child_process";
|
|
4
|
+
import { mkdtempSync, readFileSync, rmSync } from "node:fs";
|
|
5
|
+
import { tmpdir } from "node:os";
|
|
6
|
+
import { join } from "node:path";
|
|
7
|
+
import {
|
|
8
|
+
createPublicClient,
|
|
9
|
+
createWalletClient,
|
|
10
|
+
defineChain,
|
|
11
|
+
http,
|
|
12
|
+
keccak256,
|
|
13
|
+
parseAbi,
|
|
14
|
+
stringToBytes,
|
|
15
|
+
} from "viem";
|
|
16
|
+
import { privateKeyToAccount } from "viem/accounts";
|
|
17
|
+
|
|
18
|
+
export const robinhoodChain = defineChain({
|
|
19
|
+
id: 4663,
|
|
20
|
+
name: "Robinhood Chain",
|
|
21
|
+
nativeCurrency: { name: "Ether", symbol: "ETH", decimals: 18 },
|
|
22
|
+
rpcUrls: { default: { http: ["https://rpc.mainnet.chain.robinhood.com"] } },
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
export const USDG = "0x5fc5360D0400a0Fd4f2af552ADD042D716F1d168";
|
|
26
|
+
|
|
27
|
+
// A digest-pinned image. MCP and x402 import this so their default can't drift
|
|
28
|
+
// from the SDK's.
|
|
29
|
+
export const DEFAULT_IMAGE =
|
|
30
|
+
"docker.io/ollama/ollama@sha256:a61a8fd395dbb931cc8cb1b5da7a2510746575c87113fdc45b647ee59ef7f808";
|
|
31
|
+
|
|
32
|
+
const CONFIRMATIONS = 12;
|
|
33
|
+
const FETCH_TIMEOUT_MS = 30_000;
|
|
34
|
+
|
|
35
|
+
const erc20Abi = parseAbi([
|
|
36
|
+
"function approve(address spender, uint256 value) returns (bool)",
|
|
37
|
+
"function allowance(address owner, address spender) view returns (uint256)",
|
|
38
|
+
"function balanceOf(address owner) view returns (uint256)",
|
|
39
|
+
"function transfer(address to, uint256 value) returns (bool)",
|
|
40
|
+
]);
|
|
41
|
+
const escrowAbi = parseAbi([
|
|
42
|
+
"function createLease(bytes32 nodeId, uint32 duration, bytes32 clientReference) returns (uint256)",
|
|
43
|
+
]);
|
|
44
|
+
|
|
45
|
+
function parseBaseUnits(value, field) {
|
|
46
|
+
if (typeof value === "number" && Number.isInteger(value) && value >= 0) return BigInt(value);
|
|
47
|
+
if (typeof value === "string" && /^[0-9]+$/.test(value)) return BigInt(value);
|
|
48
|
+
throw new PrismError(400, `invalid_quote_${field}`);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function parseDuration(value) {
|
|
52
|
+
const n = typeof value === "string" ? Number(value) : value;
|
|
53
|
+
if (!Number.isInteger(n) || n <= 0 || n > 0xff_ff_ff_ff) throw new PrismError(400, "invalid_quote_duration");
|
|
54
|
+
return n;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// True only for SSH transport/auth failures (host still booting, key not yet
|
|
58
|
+
// synced), not a remote command that happens to exit 255. SSH's own errors are
|
|
59
|
+
// prefixed "ssh:" or are the publickey-not-ready case that produces no stdout.
|
|
60
|
+
function isSshWarmup(res) {
|
|
61
|
+
if (res.code !== 255 || res.timedOut) return false;
|
|
62
|
+
const e = res.stderr;
|
|
63
|
+
return (
|
|
64
|
+
/(^|\n)ssh: /.test(e) ||
|
|
65
|
+
/kex_exchange_identification|Connection reset by peer/.test(e) ||
|
|
66
|
+
(/Permission denied \(publickey/.test(e) && res.stdout === "")
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
71
|
+
|
|
72
|
+
export class PrismAgent {
|
|
73
|
+
constructor({ privateKey, apiBase = "https://prismnetwork.tech", escrow, rpcUrl }) {
|
|
74
|
+
if (!escrow) throw new Error("escrow address is required");
|
|
75
|
+
this.apiBase = apiBase.replace(/\/$/, "");
|
|
76
|
+
this.escrow = escrow;
|
|
77
|
+
this.account = privateKeyToAccount(privateKey);
|
|
78
|
+
const transport = http(rpcUrl ?? robinhoodChain.rpcUrls.default.http[0]);
|
|
79
|
+
this.publicClient = createPublicClient({ chain: robinhoodChain, transport });
|
|
80
|
+
this.walletClient = createWalletClient({ account: this.account, chain: robinhoodChain, transport });
|
|
81
|
+
this.session = null;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
get address() {
|
|
85
|
+
return this.account.address;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
async authenticate() {
|
|
89
|
+
const challenge = await this.#json(`/api/agent/challenge?address=${this.address}`);
|
|
90
|
+
const signature = await this.account.signMessage({ message: challenge.message });
|
|
91
|
+
const session = await this.#json("/api/agent/session", {
|
|
92
|
+
method: "POST",
|
|
93
|
+
body: { challenge: challenge.challenge, address: this.address, signature },
|
|
94
|
+
});
|
|
95
|
+
this.session = session.session;
|
|
96
|
+
return session;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
async offers() {
|
|
100
|
+
return this.#proxy("GET", ["offers"]);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
async balances() {
|
|
104
|
+
const [usdg, eth] = await Promise.all([
|
|
105
|
+
this.publicClient.readContract({ address: USDG, abi: erc20Abi, functionName: "balanceOf", args: [this.address] }),
|
|
106
|
+
this.publicClient.getBalance({ address: this.address }),
|
|
107
|
+
]);
|
|
108
|
+
return { address: this.address, usdg: usdg.toString(), eth: eth.toString() };
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
async transferUsdg(to, amountMicros) {
|
|
112
|
+
try {
|
|
113
|
+
const hash = await this.walletClient.writeContract({
|
|
114
|
+
address: USDG,
|
|
115
|
+
abi: erc20Abi,
|
|
116
|
+
functionName: "transfer",
|
|
117
|
+
args: [to, BigInt(amountMicros)],
|
|
118
|
+
});
|
|
119
|
+
const receipt = await this.publicClient.waitForTransactionReceipt({ hash });
|
|
120
|
+
if (receipt.status !== "success") throw new PrismError(502, "transfer_reverted", { hash });
|
|
121
|
+
return hash;
|
|
122
|
+
} catch (err) {
|
|
123
|
+
if (err instanceof PrismError) throw err;
|
|
124
|
+
throw new PrismError(502, "chain_error", { cause: err?.shortMessage ?? err?.message ?? String(err) });
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
async quote({ image, durationSeconds, minVramMib = 16000, preferredNodeId = null } = {}) {
|
|
129
|
+
if (typeof image !== "string" || !/@sha256:[0-9a-f]{64}$/.test(image)) {
|
|
130
|
+
throw new PrismError(400, "image_must_be_digest_pinned", { hint: "use ollama@sha256:... or DEFAULT_IMAGE" });
|
|
131
|
+
}
|
|
132
|
+
if (!Number.isInteger(durationSeconds) || durationSeconds <= 0) throw new PrismError(400, "invalid_duration");
|
|
133
|
+
if (!Number.isInteger(minVramMib) || minVramMib <= 0) throw new PrismError(400, "invalid_min_vram_mib");
|
|
134
|
+
return this.#proxy("POST", ["leases", "match"], {
|
|
135
|
+
request: {
|
|
136
|
+
image,
|
|
137
|
+
duration_seconds: durationSeconds,
|
|
138
|
+
min_vram_mib: minVramMib,
|
|
139
|
+
preferred_node_id: preferredNodeId,
|
|
140
|
+
},
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// Approve USDG and create the on-chain lease bound to the quote. The escrow
|
|
145
|
+
// binds funding to keccak256(quote_id), so reproduce it exactly or confirm rejects.
|
|
146
|
+
async fund(quote) {
|
|
147
|
+
if (typeof quote?.quote_id !== "string" || typeof quote?.node_id !== "string") {
|
|
148
|
+
throw new PrismError(400, "invalid_quote");
|
|
149
|
+
}
|
|
150
|
+
const deposit = parseBaseUnits(quote.maximum_escrow, "maximum_escrow");
|
|
151
|
+
const duration = parseDuration(quote.duration_seconds);
|
|
152
|
+
const clientReference = keccak256(stringToBytes(quote.quote_id));
|
|
153
|
+
try {
|
|
154
|
+
const allowance = await this.publicClient.readContract({
|
|
155
|
+
address: USDG,
|
|
156
|
+
abi: erc20Abi,
|
|
157
|
+
functionName: "allowance",
|
|
158
|
+
args: [this.address, this.escrow],
|
|
159
|
+
});
|
|
160
|
+
if (allowance < deposit) {
|
|
161
|
+
const approveHash = await this.walletClient.writeContract({
|
|
162
|
+
address: USDG,
|
|
163
|
+
abi: erc20Abi,
|
|
164
|
+
functionName: "approve",
|
|
165
|
+
args: [this.escrow, deposit],
|
|
166
|
+
});
|
|
167
|
+
const approved = await this.publicClient.waitForTransactionReceipt({ hash: approveHash });
|
|
168
|
+
if (approved.status !== "success") throw new PrismError(402, "approve_reverted", { hash: approveHash });
|
|
169
|
+
}
|
|
170
|
+
const hash = await this.walletClient.writeContract({
|
|
171
|
+
address: this.escrow,
|
|
172
|
+
abi: escrowAbi,
|
|
173
|
+
functionName: "createLease",
|
|
174
|
+
args: [quote.node_id, duration, clientReference],
|
|
175
|
+
});
|
|
176
|
+
// 12 confirmations: the control-plane rejects funding until the tx is final.
|
|
177
|
+
const receipt = await this.publicClient.waitForTransactionReceipt({ hash, confirmations: CONFIRMATIONS });
|
|
178
|
+
if (receipt.status !== "success") throw new PrismError(402, "lease_funding_reverted", { hash });
|
|
179
|
+
return { hash, clientReference };
|
|
180
|
+
} catch (err) {
|
|
181
|
+
if (err instanceof PrismError) throw err;
|
|
182
|
+
throw new PrismError(502, "chain_error", { cause: err?.shortMessage ?? err?.message ?? String(err) });
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
async confirm({ quoteId, transactionHash, sshAuthorizedKey }) {
|
|
187
|
+
return this.#proxy("POST", ["leases", "confirm"], {
|
|
188
|
+
quote_id: quoteId,
|
|
189
|
+
transaction_hash: transactionHash,
|
|
190
|
+
ssh_authorized_key: sshAuthorizedKey,
|
|
191
|
+
});
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
async leases() {
|
|
195
|
+
return this.#proxy("GET", ["leases"]);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
async access(leaseId) {
|
|
199
|
+
return this.#proxy("GET", ["leases", String(leaseId), "access"]);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
async waitForAccess(leaseId, { timeoutMs = 600_000, intervalMs = 10_000 } = {}) {
|
|
203
|
+
const deadline = Date.now() + timeoutMs;
|
|
204
|
+
while (Date.now() < deadline) {
|
|
205
|
+
const res = await this.#proxy("GET", ["leases", String(leaseId), "access"], null, true);
|
|
206
|
+
if (res.status === 200) {
|
|
207
|
+
if (!res.body?.ssh_host && res.body?.mode !== "gateway") throw new PrismError(502, "malformed_access");
|
|
208
|
+
return res.body;
|
|
209
|
+
}
|
|
210
|
+
if (res.status !== 404) throw new PrismError(res.status, res.body?.error ?? "access_error");
|
|
211
|
+
await sleep(intervalMs);
|
|
212
|
+
}
|
|
213
|
+
throw new PrismError(408, "access_timeout");
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// quote -> ssh keygen -> fund on-chain -> confirm -> wait for access.
|
|
217
|
+
async lease({ image, durationSeconds, minVramMib, preferredNodeId = null, maxDeposit = null } = {}) {
|
|
218
|
+
if (!this.session) await this.authenticate();
|
|
219
|
+
const quote = await this.quote({ image, durationSeconds, minVramMib, preferredNodeId });
|
|
220
|
+
if (maxDeposit != null && parseBaseUnits(quote.maximum_escrow, "maximum_escrow") > BigInt(maxDeposit)) {
|
|
221
|
+
throw new PrismError(402, "cost_exceeds_max", { required: quote.maximum_escrow, max: String(maxDeposit) });
|
|
222
|
+
}
|
|
223
|
+
const key = this.#generateSshKey();
|
|
224
|
+
try {
|
|
225
|
+
const funded = await this.fund(quote);
|
|
226
|
+
const record = await this.confirm({
|
|
227
|
+
quoteId: quote.quote_id,
|
|
228
|
+
transactionHash: funded.hash,
|
|
229
|
+
sshAuthorizedKey: key.publicKey,
|
|
230
|
+
});
|
|
231
|
+
if (!Number.isInteger(record?.lease_id)) throw new PrismError(502, "malformed_lease_record");
|
|
232
|
+
const access = await this.waitForAccess(record.lease_id);
|
|
233
|
+
return {
|
|
234
|
+
leaseId: record.lease_id,
|
|
235
|
+
access,
|
|
236
|
+
keyPath: key.keyPath,
|
|
237
|
+
keyDir: key.dir,
|
|
238
|
+
publicKey: key.publicKey,
|
|
239
|
+
fundingHash: funded.hash,
|
|
240
|
+
quote,
|
|
241
|
+
};
|
|
242
|
+
} catch (err) {
|
|
243
|
+
rmSync(key.dir, { recursive: true, force: true });
|
|
244
|
+
throw err;
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// Run a command in the remote login shell over SSH (so pipes, redirects, and
|
|
249
|
+
// $(...) all evaluate on the GPU). Retries through the host's sshd warmup, which
|
|
250
|
+
// can lag a few minutes after the box reports ready.
|
|
251
|
+
async run(lease, command, { timeoutMs = 120_000, connectRetries = 24, connectDelayMs = 10_000 } = {}) {
|
|
252
|
+
if (!lease?.access?.ssh_host || !lease.access.ssh_port || !lease.keyPath) {
|
|
253
|
+
throw new PrismError(400, "invalid_lease_handle");
|
|
254
|
+
}
|
|
255
|
+
if (typeof command !== "string" || command.length === 0) throw new PrismError(400, "command_required");
|
|
256
|
+
const target = {
|
|
257
|
+
host: lease.access.ssh_host,
|
|
258
|
+
port: lease.access.ssh_port,
|
|
259
|
+
user: lease.access.ssh_user ?? "root",
|
|
260
|
+
keyPath: lease.keyPath,
|
|
261
|
+
};
|
|
262
|
+
let last;
|
|
263
|
+
for (let attempt = 0; attempt <= connectRetries; attempt++) {
|
|
264
|
+
const res = await this.#ssh(target, command, timeoutMs);
|
|
265
|
+
if (!isSshWarmup(res)) return res;
|
|
266
|
+
last = res;
|
|
267
|
+
if (attempt < connectRetries) await sleep(connectDelayMs);
|
|
268
|
+
}
|
|
269
|
+
return last;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
// Releases local key material. The on-chain lease settles at the end of its duration.
|
|
273
|
+
endLease(lease) {
|
|
274
|
+
if (lease?.keyDir) {
|
|
275
|
+
try {
|
|
276
|
+
rmSync(lease.keyDir, { recursive: true, force: true });
|
|
277
|
+
} catch {
|
|
278
|
+
/* best effort */
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
#generateSshKey() {
|
|
284
|
+
const dir = mkdtempSync(join(tmpdir(), "prism-ssh-"));
|
|
285
|
+
try {
|
|
286
|
+
const keyPath = join(dir, "id_ed25519");
|
|
287
|
+
execFileSync("ssh-keygen", ["-t", "ed25519", "-N", "", "-q", "-f", keyPath, "-C", "prism-agent"]);
|
|
288
|
+
return { dir, keyPath, publicKey: readFileSync(`${keyPath}.pub`, "utf8").trim() };
|
|
289
|
+
} catch (err) {
|
|
290
|
+
rmSync(dir, { recursive: true, force: true });
|
|
291
|
+
throw new PrismError(500, "ssh_keygen_failed", { cause: err?.message ?? String(err) });
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
#ssh(target, command, timeoutMs) {
|
|
296
|
+
const args = [
|
|
297
|
+
"-i", target.keyPath,
|
|
298
|
+
"-p", String(target.port),
|
|
299
|
+
"-o", "StrictHostKeyChecking=no",
|
|
300
|
+
"-o", "UserKnownHostsFile=/dev/null",
|
|
301
|
+
"-o", "BatchMode=yes",
|
|
302
|
+
"-o", "ConnectTimeout=15",
|
|
303
|
+
`${target.user}@${target.host}`,
|
|
304
|
+
command,
|
|
305
|
+
];
|
|
306
|
+
return new Promise((resolve) => {
|
|
307
|
+
const child = spawn("ssh", args);
|
|
308
|
+
let stdout = "";
|
|
309
|
+
let stderr = "";
|
|
310
|
+
let timedOut = false;
|
|
311
|
+
const timer = setTimeout(() => {
|
|
312
|
+
timedOut = true;
|
|
313
|
+
child.kill("SIGKILL");
|
|
314
|
+
}, timeoutMs);
|
|
315
|
+
child.stdout.on("data", (d) => (stdout += d));
|
|
316
|
+
child.stderr.on("data", (d) => (stderr += d));
|
|
317
|
+
child.on("close", (code) => {
|
|
318
|
+
clearTimeout(timer);
|
|
319
|
+
resolve({ code: code ?? -1, stdout: stdout.trim(), stderr: stderr.trim(), timedOut });
|
|
320
|
+
});
|
|
321
|
+
child.on("error", (err) => {
|
|
322
|
+
clearTimeout(timer);
|
|
323
|
+
resolve({ code: 255, stdout: "", stderr: String(err), timedOut });
|
|
324
|
+
});
|
|
325
|
+
});
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
async #proxy(method, segments, body = null, raw = false, reauthed = false) {
|
|
329
|
+
if (!this.session) await this.authenticate();
|
|
330
|
+
const res = await this.#fetch(`/api/agent/proxy/${segments.join("/")}`, {
|
|
331
|
+
method,
|
|
332
|
+
body,
|
|
333
|
+
headers: { authorization: `Bearer ${this.session}` },
|
|
334
|
+
});
|
|
335
|
+
// Sessions expire after an hour; provisioning can outlive one. Re-auth once.
|
|
336
|
+
if (res.status === 401 && !reauthed) {
|
|
337
|
+
this.session = null;
|
|
338
|
+
await this.authenticate();
|
|
339
|
+
return this.#proxy(method, segments, body, raw, true);
|
|
340
|
+
}
|
|
341
|
+
if (raw) return { status: res.status, body: await res.json().catch(() => null) };
|
|
342
|
+
return this.#unwrap(res);
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
async #json(path, init) {
|
|
346
|
+
const res = await this.#fetch(path, init);
|
|
347
|
+
return this.#unwrap(res);
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
async #fetch(path, { method = "GET", body = null, headers = {} } = {}) {
|
|
351
|
+
try {
|
|
352
|
+
return await fetch(`${this.apiBase}${path}`, {
|
|
353
|
+
method,
|
|
354
|
+
headers: { accept: "application/json", ...(body ? { "content-type": "application/json" } : {}), ...headers },
|
|
355
|
+
body: body ? JSON.stringify(body) : undefined,
|
|
356
|
+
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
|
|
357
|
+
});
|
|
358
|
+
} catch (err) {
|
|
359
|
+
throw new PrismError(504, "control_plane_unreachable", { cause: err?.message ?? String(err) });
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
async #unwrap(res) {
|
|
364
|
+
const data = await res.json().catch(() => null);
|
|
365
|
+
if (!res.ok) throw new PrismError(res.status, data?.error ?? data?.code ?? "request_failed", data);
|
|
366
|
+
return data;
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
export class PrismError extends Error {
|
|
371
|
+
constructor(status, code, body) {
|
|
372
|
+
super(`prism ${status}: ${code}`);
|
|
373
|
+
this.status = status;
|
|
374
|
+
this.code = code;
|
|
375
|
+
this.body = body;
|
|
376
|
+
}
|
|
377
|
+
}
|