@prismnetwork/agent-sdk 0.7.2 → 0.7.4
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 +50 -2
- package/hostkey.mjs +158 -0
- package/package.json +8 -9
- package/prism.d.mts +41 -1
- package/prism.mjs +78 -30
- package/x402.d.mts +7 -0
- package/x402.mjs +23 -0
package/README.md
CHANGED
|
@@ -133,6 +133,54 @@ Raise it at creation when they deserve more:
|
|
|
133
133
|
await agent.workspace.create("model-weights", { minTrustClass: "isolated" });
|
|
134
134
|
```
|
|
135
135
|
|
|
136
|
+
## Which machine answered
|
|
137
|
+
|
|
138
|
+
Every session `run()` opens checks the SSH host key on the far end. What that is
|
|
139
|
+
worth depends on what the lease publishes about it, and the SDK reports which of
|
|
140
|
+
the three it got:
|
|
141
|
+
|
|
142
|
+
```js
|
|
143
|
+
import { hostKeyPolicy } from "@prismnetwork/agent-sdk";
|
|
144
|
+
|
|
145
|
+
hostKeyPolicy(lease.access);
|
|
146
|
+
// { mode: "attested" | "reported" | "unverified", fingerprint, source }
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
`attested` means the fingerprint comes out of a hardware report whose signed
|
|
150
|
+
data commits to the key the guest generated at boot. Prism walks that report to
|
|
151
|
+
AMD's root and puts the fingerprint on the access grant; the SDK refuses any
|
|
152
|
+
session whose host key does not match it. The operator cannot put a different
|
|
153
|
+
machine on the other end, and neither can anything on the path between you and
|
|
154
|
+
it.
|
|
155
|
+
|
|
156
|
+
The chain walk runs in our control plane, so `attested` says we checked the
|
|
157
|
+
report. The SDK holds the session to the fingerprint we published and never sees
|
|
158
|
+
the report itself, which leaves us in the set you are trusting.
|
|
159
|
+
[ATTESTATION.md](https://github.com/winter0x/prism/blob/main/docs/ATTESTATION.md)
|
|
160
|
+
says what the report covers and what it does not.
|
|
161
|
+
|
|
162
|
+
`reported` means the node named the key on the signed report that opened access.
|
|
163
|
+
That rules out the relay, the network path and anyone in between; it does not
|
|
164
|
+
rule out the operator, whose bond is what a dispute reaches instead.
|
|
165
|
+
|
|
166
|
+
`unverified` means nobody published a key. Capacity brokered from a public cloud
|
|
167
|
+
is the case: the instance's host key is generated by the cloud and never shown to
|
|
168
|
+
the network, so there is nothing to publish. The key is recorded the first time
|
|
169
|
+
it is seen and held for the rest of the lease, which catches a machine swapped in
|
|
170
|
+
partway through and cannot catch one that was wrong from the start.
|
|
171
|
+
|
|
172
|
+
Nothing is written to your own `~/.ssh/known_hosts`. The record sits beside the
|
|
173
|
+
lease's private key and is removed with it by `endLease()`.
|
|
174
|
+
|
|
175
|
+
To refuse the third case outright:
|
|
176
|
+
|
|
177
|
+
```js
|
|
178
|
+
const agent = new PrismAgent({ privateKey, escrow, requireHostKey: true });
|
|
179
|
+
```
|
|
180
|
+
|
|
181
|
+
The lease still funds and provisions; `run()` refuses to open a session on it
|
|
182
|
+
with `host_key_unpublished`.
|
|
183
|
+
|
|
136
184
|
## Auth
|
|
137
185
|
|
|
138
186
|
`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...`).
|
|
@@ -147,7 +195,7 @@ The wallet needs two balances on Robinhood Chain (id 4663): USDG (`0x5fc5360D040
|
|
|
147
195
|
|
|
148
196
|
## Requirements
|
|
149
197
|
|
|
150
|
-
Node >= 20, `viem` ^2 (peer), and `ssh`
|
|
151
|
-
for workspace save and restore.
|
|
198
|
+
Node >= 20, `viem` ^2 (peer), and `ssh`, `ssh-keygen` and `ssh-keyscan` on PATH
|
|
199
|
+
for `run()` and for workspace save and restore.
|
|
152
200
|
|
|
153
201
|
See [example.mjs](https://github.com/winter0x/prism/blob/main/sdk/example.mjs) for a full run.
|
package/hostkey.mjs
ADDED
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
// Checking which machine answered.
|
|
2
|
+
//
|
|
3
|
+
// A lease hands the renter an address and a private key. Until the host key on
|
|
4
|
+
// the other end is checked, anything that can reach that address can take the
|
|
5
|
+
// session, read the work and answer as if it were the GPU. What the network can
|
|
6
|
+
// say about that key differs by where the capacity came from, so the decision is
|
|
7
|
+
// made here rather than defaulted:
|
|
8
|
+
//
|
|
9
|
+
// - the grant names a fingerprint, so the key is checked before the session
|
|
10
|
+
// opens and a mismatch ends the attempt;
|
|
11
|
+
// - the grant names none, so the key is recorded the first time it is seen and
|
|
12
|
+
// held for the rest of the lease. That catches a substitution partway
|
|
13
|
+
// through and cannot catch one that was there from the start.
|
|
14
|
+
//
|
|
15
|
+
// The record lives beside the lease's private key and goes when the lease does.
|
|
16
|
+
// Nothing here touches the caller's own ~/.ssh/known_hosts.
|
|
17
|
+
import { execFile } from "node:child_process";
|
|
18
|
+
import { createHash } from "node:crypto";
|
|
19
|
+
import { readFileSync, writeFileSync } from "node:fs";
|
|
20
|
+
import { dirname, join } from "node:path";
|
|
21
|
+
|
|
22
|
+
const SCAN_TIMEOUT_SECONDS = 10;
|
|
23
|
+
|
|
24
|
+
export class HostKeyError extends Error {
|
|
25
|
+
constructor(code, detail) {
|
|
26
|
+
super(code);
|
|
27
|
+
this.name = "HostKeyError";
|
|
28
|
+
this.code = code;
|
|
29
|
+
this.detail = detail ?? null;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/// What the network is willing to say about the machine behind a grant, in the
|
|
34
|
+
/// terms a renter would use to decide whether to send it anything.
|
|
35
|
+
///
|
|
36
|
+
/// `attested` is the only one that survives a hostile operator: the fingerprint
|
|
37
|
+
/// comes out of a report the processor signed. `reported` is the operator's word
|
|
38
|
+
/// under their bonded device key, which rules out everyone between them and the
|
|
39
|
+
/// renter. `unverified` means nobody published a key and the first connection
|
|
40
|
+
/// decides.
|
|
41
|
+
export function hostKeyPolicy(access) {
|
|
42
|
+
const fingerprint = access?.channel_key_fingerprint ?? null;
|
|
43
|
+
if (!fingerprint) return { mode: "unverified", fingerprint: null, source: null };
|
|
44
|
+
return {
|
|
45
|
+
mode: access.channel_key_source === "snp_report" ? "attested" : "reported",
|
|
46
|
+
fingerprint,
|
|
47
|
+
source: access.channel_key_source ?? null,
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function knownHostsPath(keyPath) {
|
|
52
|
+
return join(dirname(keyPath), "known_hosts");
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/// The `ssh-keygen -lf` form of the key in a `known_hosts` line, or null if the
|
|
56
|
+
/// line does not hold one. The control plane publishes fingerprints in exactly
|
|
57
|
+
/// this form, so the two are compared as strings.
|
|
58
|
+
export function knownHostsFingerprint(line) {
|
|
59
|
+
const blob = line.trim().split(/\s+/)[2];
|
|
60
|
+
if (!blob) return null;
|
|
61
|
+
const raw = Buffer.from(blob, "base64");
|
|
62
|
+
if (raw.length === 0) return null;
|
|
63
|
+
return `SHA256:${createHash("sha256").update(raw).digest("base64").replace(/=+$/, "")}`;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/// How `ssh` and `ssh-keyscan` both name a host in `known_hosts`. Anything off
|
|
67
|
+
/// the default port is bracketed, and an entry under the wrong name is an entry
|
|
68
|
+
/// `ssh` will not find.
|
|
69
|
+
function hostField(host, port) {
|
|
70
|
+
return Number(port) === 22 ? String(host) : `[${host}]:${port}`;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/// A relayed session has no address of its own: the tunnel opens on whatever
|
|
74
|
+
/// local port is free and closes with the command, so the name `ssh` would file
|
|
75
|
+
/// the key under is gone before the next one runs and a record made on first
|
|
76
|
+
/// sight would never be read again. `HostKeyAlias` files it under the lease
|
|
77
|
+
/// instead, which is what makes first-use pinning worth anything there. Capacity
|
|
78
|
+
/// with a real endpoint keeps its own host and port, which is stable and says
|
|
79
|
+
/// something true about where the session went.
|
|
80
|
+
function hostKeyAlias(access) {
|
|
81
|
+
if (access?.mode !== "gateway") return null;
|
|
82
|
+
return access.lease_id ? `prism-lease-${access.lease_id}` : "prism-lease";
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function scan(host, port) {
|
|
86
|
+
return new Promise((resolve) => {
|
|
87
|
+
execFile(
|
|
88
|
+
"ssh-keyscan",
|
|
89
|
+
["-T", String(SCAN_TIMEOUT_SECONDS), "-p", String(port), host],
|
|
90
|
+
{ timeout: (SCAN_TIMEOUT_SECONDS + 5) * 1_000 },
|
|
91
|
+
(err, stdout) => resolve({ err, lines: (stdout ?? "").split("\n").filter((l) => l && !l.startsWith("#")) }),
|
|
92
|
+
);
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// A record that names the right key under a name `ssh` will not look up is a
|
|
97
|
+
// record `ssh` will refuse to use. Both halves have to match for the scan to be
|
|
98
|
+
// worth skipping.
|
|
99
|
+
function alreadyPinned(path, name, fingerprint) {
|
|
100
|
+
try {
|
|
101
|
+
return readFileSync(path, "utf8")
|
|
102
|
+
.split("\n")
|
|
103
|
+
.some((line) => line.trim().split(/\s+/)[0] === name && knownHostsFingerprint(line) === fingerprint);
|
|
104
|
+
} catch {
|
|
105
|
+
return false;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/// Reads the host key off the wire and records it only if it is the one the
|
|
110
|
+
/// grant named.
|
|
111
|
+
///
|
|
112
|
+
/// Done as a separate exchange before ssh runs, because a fingerprint cannot be
|
|
113
|
+
/// turned into a `known_hosts` entry without the key itself, and letting ssh
|
|
114
|
+
/// learn the key first would mean trusting it to find out whether it should
|
|
115
|
+
/// have. Nothing is sent here that the machine could use: `ssh-keyscan` reads
|
|
116
|
+
/// the key the server offers and hangs up.
|
|
117
|
+
async function pin(host, port, fingerprint, path, name) {
|
|
118
|
+
if (alreadyPinned(path, name, fingerprint)) return;
|
|
119
|
+
const { err, lines } = await scan(host, port);
|
|
120
|
+
if (lines.length === 0) {
|
|
121
|
+
throw new HostKeyError("host_key_unavailable", err?.message ?? `${host}:${port} offered no host key`);
|
|
122
|
+
}
|
|
123
|
+
const match = lines.find((line) => knownHostsFingerprint(line) === fingerprint);
|
|
124
|
+
if (!match) {
|
|
125
|
+
throw new HostKeyError("host_key_mismatch", {
|
|
126
|
+
expected: fingerprint,
|
|
127
|
+
offered: lines.map(knownHostsFingerprint).filter(Boolean),
|
|
128
|
+
hint: "the machine answering is not the one the lease names; nothing was sent to it",
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
// Only the key that matched. Writing everything the machine offered would pin
|
|
132
|
+
// keys nobody vouched for alongside the one that was checked, and the name is
|
|
133
|
+
// rewritten to the one `ssh` will look the key up under.
|
|
134
|
+
writeFileSync(path, `${name} ${match.trim().split(/\s+/).slice(1).join(" ")}\n`, { mode: 0o600 });
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/// The `ssh` arguments that make the connection check the machine it reaches.
|
|
138
|
+
///
|
|
139
|
+
/// `requireHostKey` turns the unverified case into a refusal instead of a first
|
|
140
|
+
/// sighting, for callers who would rather not run at all than run somewhere they
|
|
141
|
+
/// cannot name.
|
|
142
|
+
export async function hostKeyArgs(target, access, { requireHostKey = false } = {}) {
|
|
143
|
+
const path = knownHostsPath(target.keyPath);
|
|
144
|
+
const alias = hostKeyAlias(access);
|
|
145
|
+
const where = ["-o", `UserKnownHostsFile=${path}`, ...(alias ? ["-o", `HostKeyAlias=${alias}`] : [])];
|
|
146
|
+
const policy = hostKeyPolicy(access);
|
|
147
|
+
if (policy.fingerprint === null) {
|
|
148
|
+
if (requireHostKey) {
|
|
149
|
+
throw new HostKeyError("host_key_unpublished", {
|
|
150
|
+
mode: access?.mode ?? null,
|
|
151
|
+
hint: "this lease publishes no host key, so which machine answers cannot be checked",
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
return [...where, "-o", "StrictHostKeyChecking=accept-new"];
|
|
155
|
+
}
|
|
156
|
+
await pin(target.host, target.port, policy.fingerprint, path, alias ?? hostField(target.host, target.port));
|
|
157
|
+
return [...where, "-o", "StrictHostKeyChecking=yes"];
|
|
158
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@prismnetwork/agent-sdk",
|
|
3
|
-
"version": "0.7.
|
|
3
|
+
"version": "0.7.4",
|
|
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",
|
|
@@ -28,6 +28,10 @@
|
|
|
28
28
|
"./workspace": {
|
|
29
29
|
"types": "./workspace.d.ts",
|
|
30
30
|
"default": "./workspace.mjs"
|
|
31
|
+
},
|
|
32
|
+
"./x402": {
|
|
33
|
+
"types": "./x402.d.mts",
|
|
34
|
+
"default": "./x402.mjs"
|
|
31
35
|
}
|
|
32
36
|
},
|
|
33
37
|
"files": [
|
|
@@ -37,6 +41,7 @@
|
|
|
37
41
|
"attest.d.mts",
|
|
38
42
|
"e2ee.mjs",
|
|
39
43
|
"e2ee.d.mts",
|
|
44
|
+
"hostkey.mjs",
|
|
40
45
|
"relay.mjs",
|
|
41
46
|
"toolset.mjs",
|
|
42
47
|
"toolset.d.mts",
|
|
@@ -44,6 +49,8 @@
|
|
|
44
49
|
"vault.d.ts",
|
|
45
50
|
"workspace.mjs",
|
|
46
51
|
"workspace.d.ts",
|
|
52
|
+
"x402.mjs",
|
|
53
|
+
"x402.d.mts",
|
|
47
54
|
"vendor/aci-verifier/*.mjs",
|
|
48
55
|
"README.md",
|
|
49
56
|
"LICENSE",
|
|
@@ -67,14 +74,6 @@
|
|
|
67
74
|
"attestation"
|
|
68
75
|
],
|
|
69
76
|
"homepage": "https://prismnetwork.tech",
|
|
70
|
-
"repository": {
|
|
71
|
-
"type": "git",
|
|
72
|
-
"url": "git+https://github.com/winter0x/prism.git",
|
|
73
|
-
"directory": "sdk"
|
|
74
|
-
},
|
|
75
|
-
"bugs": {
|
|
76
|
-
"url": "https://github.com/winter0x/prism/issues"
|
|
77
|
-
},
|
|
78
77
|
"license": "Apache-2.0",
|
|
79
78
|
"publishConfig": {
|
|
80
79
|
"access": "public"
|
package/prism.d.mts
CHANGED
|
@@ -7,6 +7,7 @@ export declare const TRUST_CLASSES: readonly ["open", "isolated", "attested", "c
|
|
|
7
7
|
|
|
8
8
|
export { DEFAULT_CONFIDENTIAL_BASE, EXPECTED_WORKLOAD, renderChecks, verifyConfidential } from "./attest.d.mts";
|
|
9
9
|
export type { AttestationCheck, AttestationResult, WorkloadPin } from "./attest.d.mts";
|
|
10
|
+
export { boundMessage, hashRequest } from "./x402.d.mts";
|
|
10
11
|
|
|
11
12
|
/// `mode` says which of the two shapes arrived. Brokered capacity fills in
|
|
12
13
|
/// `ssh_host` and `ssh_port`; a node that accepts nothing inbound fills in the
|
|
@@ -25,9 +26,40 @@ export interface LeaseAccess {
|
|
|
25
26
|
jupyter_path?: string;
|
|
26
27
|
jupyter_token?: string;
|
|
27
28
|
expires_at?: string;
|
|
29
|
+
/// The `ssh-keygen -lf` fingerprint of the SSH host key the workspace answers
|
|
30
|
+
/// on. Absent on capacity brokered from a public cloud, where the instance's
|
|
31
|
+
/// host key is generated by the cloud and never shown to the network.
|
|
32
|
+
channel_key_fingerprint?: string;
|
|
33
|
+
/// Where that fingerprint came from. `snp_report` is a verified guest report
|
|
34
|
+
/// and holds against the operator too; `node_report` is the operator's word
|
|
35
|
+
/// under their bonded device key.
|
|
36
|
+
channel_key_source?: "snp_report" | "node_report";
|
|
28
37
|
[key: string]: unknown;
|
|
29
38
|
}
|
|
30
39
|
|
|
40
|
+
/// What the network can say about the machine behind a grant.
|
|
41
|
+
export interface HostKeyPolicy {
|
|
42
|
+
mode: "attested" | "reported" | "unverified";
|
|
43
|
+
fingerprint: string | null;
|
|
44
|
+
source: "snp_report" | "node_report" | null;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export declare function hostKeyPolicy(access: LeaseAccess | null | undefined): HostKeyPolicy;
|
|
48
|
+
|
|
49
|
+
/// The `ssh` arguments that make a connection check the machine it reaches:
|
|
50
|
+
/// the host key is verified against the grant when one is published, and
|
|
51
|
+
/// recorded on first sight and held for the rest of the lease when none is.
|
|
52
|
+
export declare function hostKeyArgs(
|
|
53
|
+
target: { host: string; port: number; keyPath: string },
|
|
54
|
+
access: LeaseAccess | null | undefined,
|
|
55
|
+
options?: { requireHostKey?: boolean },
|
|
56
|
+
): Promise<string[]>;
|
|
57
|
+
|
|
58
|
+
export declare class HostKeyError extends Error {
|
|
59
|
+
readonly code: string;
|
|
60
|
+
readonly detail: unknown;
|
|
61
|
+
}
|
|
62
|
+
|
|
31
63
|
/// A local address that forwards to the workspace until it is closed.
|
|
32
64
|
export interface RelayForwarder {
|
|
33
65
|
host: string;
|
|
@@ -89,7 +121,15 @@ export interface ConfidentialRun {
|
|
|
89
121
|
}
|
|
90
122
|
|
|
91
123
|
export declare class PrismAgent {
|
|
92
|
-
constructor(options: {
|
|
124
|
+
constructor(options: {
|
|
125
|
+
privateKey: string;
|
|
126
|
+
escrow: string;
|
|
127
|
+
apiBase?: string;
|
|
128
|
+
rpcUrl?: string;
|
|
129
|
+
/// Refuse to open a session on a lease that publishes no host key, rather
|
|
130
|
+
/// than trusting whichever machine answers first.
|
|
131
|
+
requireHostKey?: boolean;
|
|
132
|
+
});
|
|
93
133
|
readonly address: string;
|
|
94
134
|
readonly vault: unknown;
|
|
95
135
|
readonly workspace: unknown;
|
package/prism.mjs
CHANGED
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
// Prism Network agent SDK: headless GPU leasing for wallet-holding agents.
|
|
2
2
|
// No browser, no Privy. Authenticate with a wallet signature, pay on-chain, run.
|
|
3
3
|
import { execFileSync, spawn } from "node:child_process";
|
|
4
|
-
import { createHash } from "node:crypto";
|
|
5
4
|
import { mkdtempSync, readFileSync, rmSync } from "node:fs";
|
|
6
5
|
import { tmpdir } from "node:os";
|
|
7
6
|
import { join } from "node:path";
|
|
@@ -17,12 +16,16 @@ import {
|
|
|
17
16
|
import { privateKeyToAccount } from "viem/accounts";
|
|
18
17
|
import { appraiseWorkload, DEFAULT_CONFIDENTIAL_BASE, EXPECTED_WORKLOAD, verifyConfidential } from "./attest.mjs";
|
|
19
18
|
import { decryptResponse, encryptChatRequest } from "./e2ee.mjs";
|
|
19
|
+
import { hostKeyArgs, HostKeyError } from "./hostkey.mjs";
|
|
20
20
|
import { openRelayForwarder } from "./relay.mjs";
|
|
21
21
|
import { PrismVault } from "./vault.mjs";
|
|
22
22
|
import { toHex, verifyComposeMeasurement, verifyQuote, verifyReportBinding } from "./vendor/aci-verifier/index.mjs";
|
|
23
|
+
import { boundMessage, hashRequest } from "./x402.mjs";
|
|
23
24
|
import { PrismWorkspace } from "./workspace.mjs";
|
|
24
25
|
|
|
25
26
|
export { DEFAULT_CONFIDENTIAL_BASE, EXPECTED_WORKLOAD, renderChecks, verifyConfidential } from "./attest.mjs";
|
|
27
|
+
export { hostKeyArgs, hostKeyPolicy, HostKeyError } from "./hostkey.mjs";
|
|
28
|
+
export { boundMessage, hashRequest } from "./x402.mjs";
|
|
26
29
|
export { PrismVault, VaultError, DEFAULT_TRUST_FLOOR, VAULT_KEY_STATEMENT } from "./vault.mjs";
|
|
27
30
|
export {
|
|
28
31
|
PrismWorkspace,
|
|
@@ -143,7 +146,7 @@ function decryptAnswer(bytes, clientKey, headers, receiptId) {
|
|
|
143
146
|
}
|
|
144
147
|
|
|
145
148
|
export class PrismAgent {
|
|
146
|
-
constructor({ privateKey, apiBase = "https://prismnetwork.tech", escrow, rpcUrl }) {
|
|
149
|
+
constructor({ privateKey, apiBase = "https://prismnetwork.tech", escrow, rpcUrl, requireHostKey = false }) {
|
|
147
150
|
if (!escrow) throw new Error("escrow address is required");
|
|
148
151
|
if (typeof privateKey !== "string" || privateKey.trim() === "") {
|
|
149
152
|
throw new Error(
|
|
@@ -152,6 +155,11 @@ export class PrismAgent {
|
|
|
152
155
|
}
|
|
153
156
|
this.apiBase = apiBase.replace(/\/$/, "");
|
|
154
157
|
this.escrow = escrow;
|
|
158
|
+
// Off by default because most capacity publishes no host key, and refusing
|
|
159
|
+
// those leases would take the network's own supply away from callers who
|
|
160
|
+
// never asked for the guarantee. On, nothing runs anywhere the grant cannot
|
|
161
|
+
// name.
|
|
162
|
+
this.requireHostKey = requireHostKey;
|
|
155
163
|
const trimmed = privateKey.trim();
|
|
156
164
|
try {
|
|
157
165
|
this.account = privateKeyToAccount(trimmed.startsWith("0x") ? trimmed : `0x${trimmed}`);
|
|
@@ -211,6 +219,7 @@ export class PrismAgent {
|
|
|
211
219
|
}
|
|
212
220
|
|
|
213
221
|
async transferUsdg(to, amountMicros) {
|
|
222
|
+
let broadcast = null;
|
|
214
223
|
try {
|
|
215
224
|
const hash = await this.#submit(() =>
|
|
216
225
|
this.walletClient.writeContract({
|
|
@@ -220,12 +229,20 @@ export class PrismAgent {
|
|
|
220
229
|
args: [to, BigInt(amountMicros)],
|
|
221
230
|
}),
|
|
222
231
|
);
|
|
232
|
+
broadcast = hash;
|
|
223
233
|
const receipt = await this.publicClient.waitForTransactionReceipt({ hash });
|
|
224
234
|
if (receipt.status !== "success") throw new PrismError(502, "transfer_reverted", { hash });
|
|
225
235
|
return hash;
|
|
226
236
|
} catch (err) {
|
|
227
237
|
if (err instanceof PrismError) throw err;
|
|
228
|
-
|
|
238
|
+
// A receipt that could not be read is not a transfer that never happened.
|
|
239
|
+
// The hash travels with the failure because it is the only thing that
|
|
240
|
+
// says the money left this wallet, and whatever is counting the day's
|
|
241
|
+
// spend has to be able to tell the two apart.
|
|
242
|
+
throw new PrismError(502, "chain_error", {
|
|
243
|
+
cause: err?.shortMessage ?? err?.message ?? String(err),
|
|
244
|
+
...(broadcast ? { payment_tx: broadcast } : {}),
|
|
245
|
+
});
|
|
229
246
|
}
|
|
230
247
|
}
|
|
231
248
|
|
|
@@ -262,6 +279,7 @@ export class PrismAgent {
|
|
|
262
279
|
const deposit = parseBaseUnits(quote.maximum_escrow, "maximum_escrow");
|
|
263
280
|
const duration = parseDuration(quote.duration_seconds);
|
|
264
281
|
const clientReference = keccak256(stringToBytes(quote.quote_id));
|
|
282
|
+
let broadcast = null;
|
|
265
283
|
try {
|
|
266
284
|
// Approving and spending are one indivisible step. The approval covers
|
|
267
285
|
// exactly this deposit, so a second lease that read the allowance before
|
|
@@ -289,6 +307,7 @@ export class PrismAgent {
|
|
|
289
307
|
functionName: "createLease",
|
|
290
308
|
args: [quote.node_id, duration, clientReference],
|
|
291
309
|
});
|
|
310
|
+
broadcast = funding;
|
|
292
311
|
// One confirmation here, not for the control-plane's benefit but so the
|
|
293
312
|
// allowance and the nonce are settled before the next lease reads them.
|
|
294
313
|
await this.publicClient.waitForTransactionReceipt({ hash: funding });
|
|
@@ -300,7 +319,13 @@ export class PrismAgent {
|
|
|
300
319
|
return { hash, clientReference };
|
|
301
320
|
} catch (err) {
|
|
302
321
|
if (err instanceof PrismError) throw err;
|
|
303
|
-
|
|
322
|
+
// The deposit is in the escrow the moment the chain accepts this, and
|
|
323
|
+
// waiting for confirmations is where a flaky rpc gives up. Losing the
|
|
324
|
+
// hash here would leave a funded lease nobody can name.
|
|
325
|
+
throw new PrismError(502, "chain_error", {
|
|
326
|
+
cause: err?.shortMessage ?? err?.message ?? String(err),
|
|
327
|
+
...(broadcast ? { funding_hash: broadcast } : {}),
|
|
328
|
+
});
|
|
304
329
|
}
|
|
305
330
|
}
|
|
306
331
|
|
|
@@ -506,12 +531,14 @@ export class PrismAgent {
|
|
|
506
531
|
port: forwarder.port,
|
|
507
532
|
user: lease.access.ssh_user ?? "workspace",
|
|
508
533
|
keyPath: lease.keyPath,
|
|
534
|
+
access: lease.access,
|
|
509
535
|
}
|
|
510
536
|
: {
|
|
511
537
|
host: lease.access?.ssh_host,
|
|
512
538
|
port: lease.access?.ssh_port,
|
|
513
539
|
user: lease.access?.ssh_user ?? "root",
|
|
514
540
|
keyPath: lease.keyPath,
|
|
541
|
+
access: lease.access,
|
|
515
542
|
};
|
|
516
543
|
if (!target.host || !target.port) {
|
|
517
544
|
throw new PrismError(400, "invalid_lease_handle", {
|
|
@@ -522,7 +549,23 @@ export class PrismAgent {
|
|
|
522
549
|
}
|
|
523
550
|
let last;
|
|
524
551
|
for (let attempt = 0; attempt <= connectRetries; attempt++) {
|
|
525
|
-
|
|
552
|
+
let res;
|
|
553
|
+
try {
|
|
554
|
+
res = await this.#ssh(target, command, timeoutMs, stdin);
|
|
555
|
+
} catch (err) {
|
|
556
|
+
// A box that is still coming up has nothing listening to read a key
|
|
557
|
+
// from, which is the same wait the retry loop already exists for.
|
|
558
|
+
// Being answered by the wrong machine is not a wait.
|
|
559
|
+
if (!(err instanceof HostKeyError) || err.code !== "host_key_unavailable") {
|
|
560
|
+
// `host_key_unpublished` is this client refusing on the caller's
|
|
561
|
+
// own policy. Anything else is the far end failing the check.
|
|
562
|
+
throw new PrismError(err?.code === "host_key_unpublished" ? 400 : 502, err?.code ?? "ssh_failed", {
|
|
563
|
+
lease_id: lease.leaseId ?? null,
|
|
564
|
+
detail: err?.detail ?? err?.message ?? String(err),
|
|
565
|
+
});
|
|
566
|
+
}
|
|
567
|
+
res = { code: 255, stdout: "", stderr: `ssh: ${err.detail ?? err.code}`, timedOut: false };
|
|
568
|
+
}
|
|
526
569
|
if (!isSshWarmup(res)) return res;
|
|
527
570
|
last = res;
|
|
528
571
|
if (attempt < connectRetries) await sleep(connectDelayMs);
|
|
@@ -588,33 +631,39 @@ export class PrismAgent {
|
|
|
588
631
|
caller = "call",
|
|
589
632
|
}) {
|
|
590
633
|
let sent = seal ? seal() : { bytes: asBytes(body), headers };
|
|
591
|
-
const identity =
|
|
634
|
+
const identity = hashRequest(fingerprint ?? sent.bytes);
|
|
592
635
|
const key = `${base}${path}:${price}:${identity}`;
|
|
593
|
-
let
|
|
594
|
-
if (!
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
this.#pendingPayments.set(key, pending);
|
|
599
|
-
}
|
|
600
|
-
// The transfer is on-chain and irreversible from here. The signed header is
|
|
601
|
-
// the only thing that redeems it, and it lives in this process.
|
|
602
|
-
const kept = {
|
|
603
|
-
payment_tx: pending.tx,
|
|
604
|
-
payment_header: pending.header,
|
|
605
|
-
hint:
|
|
606
|
-
`the payment (tx ${pending.tx}) settled on-chain and the endpoint did not serve. While this process lives, ` +
|
|
607
|
-
`the next ${caller} for this same request redeems it without paying again. payment_header is what redeems ` +
|
|
608
|
-
"it, so keep it to do that from anywhere else.",
|
|
609
|
-
};
|
|
636
|
+
let tx = this.#pendingPayments.get(key);
|
|
637
|
+
if (!tx) {
|
|
638
|
+
tx = await this.transferUsdg(payTo, price);
|
|
639
|
+
this.#pendingPayments.set(key, tx);
|
|
640
|
+
}
|
|
610
641
|
const deadline = Date.now() + PAID_CALL_DEADLINE_MS;
|
|
611
642
|
for (;;) {
|
|
643
|
+
// The signature covers the transaction and the bytes it buys, so a header
|
|
644
|
+
// read off the wire cannot be spent on a different request. A resealed
|
|
645
|
+
// attempt carries different bytes and is signed again; the transfer, which
|
|
646
|
+
// is the half that costs money, is made once.
|
|
647
|
+
const header = Buffer.from(JSON.stringify({
|
|
648
|
+
txHash: tx,
|
|
649
|
+
signature: await this.account.signMessage({ message: boundMessage(tx, hashRequest(sent.bytes)) }),
|
|
650
|
+
})).toString("base64");
|
|
651
|
+
// The transfer is on-chain and irreversible from here. The signed header
|
|
652
|
+
// is the only thing that redeems it, and it lives in this process.
|
|
653
|
+
const kept = {
|
|
654
|
+
payment_tx: tx,
|
|
655
|
+
payment_header: header,
|
|
656
|
+
hint:
|
|
657
|
+
`the payment (tx ${tx}) settled on-chain and the endpoint did not serve. While this process lives, ` +
|
|
658
|
+
`the next ${caller} for this same request redeems it without paying again. payment_header redeems it ` +
|
|
659
|
+
"from anywhere else, and only for this request: the signature covers these exact bytes.",
|
|
660
|
+
};
|
|
612
661
|
let res;
|
|
613
662
|
let bytes;
|
|
614
663
|
try {
|
|
615
664
|
res = await fetch(`${base}${path}`, {
|
|
616
665
|
method: "POST",
|
|
617
|
-
headers: { "content-type": "application/json", "x-payment":
|
|
666
|
+
headers: { "content-type": "application/json", "x-payment": header, ...sent.headers },
|
|
618
667
|
body: sent.bytes,
|
|
619
668
|
signal: AbortSignal.timeout(PAID_CALL_TIMEOUT_MS),
|
|
620
669
|
});
|
|
@@ -629,11 +678,11 @@ export class PrismAgent {
|
|
|
629
678
|
// this one's, whatever the status line says.
|
|
630
679
|
if (String(res.headers.get("x-prism-replayed") ?? "").toLowerCase() === "true") {
|
|
631
680
|
throw new PrismError(409, "payment_replayed", {
|
|
632
|
-
cause: `the endpoint replayed an earlier answer for tx ${
|
|
681
|
+
cause: `the endpoint replayed an earlier answer for tx ${tx}`,
|
|
633
682
|
hint: "this payment was already consumed by another call; pay again to have this request served",
|
|
634
683
|
});
|
|
635
684
|
}
|
|
636
|
-
return { status: 200, headers: res.headers, bytes, tx
|
|
685
|
+
return { status: 200, headers: res.headers, bytes, tx, sent };
|
|
637
686
|
}
|
|
638
687
|
const answered = (() => {
|
|
639
688
|
try {
|
|
@@ -653,7 +702,7 @@ export class PrismAgent {
|
|
|
653
702
|
const said = [answered?.detail, answered?.retry].filter(Boolean).join("; ");
|
|
654
703
|
throw new PrismError(res.status, answered?.error ?? "generation_failed", {
|
|
655
704
|
cause: said || answered?.error || `status ${res.status}`,
|
|
656
|
-
...(this.#pendingPayments.has(key) ? kept : { payment_tx:
|
|
705
|
+
...(this.#pendingPayments.has(key) ? kept : { payment_tx: tx }),
|
|
657
706
|
});
|
|
658
707
|
}
|
|
659
708
|
await sleep(retryDelayMs);
|
|
@@ -924,12 +973,11 @@ export class PrismAgent {
|
|
|
924
973
|
}
|
|
925
974
|
}
|
|
926
975
|
|
|
927
|
-
#ssh(target, command, timeoutMs, stdin = null) {
|
|
976
|
+
async #ssh(target, command, timeoutMs, stdin = null) {
|
|
928
977
|
const args = [
|
|
929
978
|
"-i", target.keyPath,
|
|
930
979
|
"-p", String(target.port),
|
|
931
|
-
|
|
932
|
-
"-o", "UserKnownHostsFile=/dev/null",
|
|
980
|
+
...(await hostKeyArgs(target, target.access, { requireHostKey: this.requireHostKey })),
|
|
933
981
|
"-o", "BatchMode=yes",
|
|
934
982
|
"-o", "ConnectTimeout=15",
|
|
935
983
|
`${target.user}@${target.host}`,
|
package/x402.d.mts
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
/// The digest both sides compare: the command for a job, the request bytes for
|
|
2
|
+
/// a generation.
|
|
3
|
+
export declare function hashRequest(payload: string | Uint8Array): string;
|
|
4
|
+
|
|
5
|
+
/// The message a payer signs on the legacy rail, binding a transaction to the
|
|
6
|
+
/// one request it buys.
|
|
7
|
+
export declare function boundMessage(txHash: string, requestHash: string): string;
|
package/x402.mjs
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
// What a payer signs on the legacy rail, where the transfer is already on-chain
|
|
2
|
+
// and the header only says who made it.
|
|
3
|
+
//
|
|
4
|
+
// Signing the transaction hash alone proves who paid, not what they paid for.
|
|
5
|
+
// Anyone who saw the header in flight could put their own command or prompt in
|
|
6
|
+
// front of it and spend someone else's transfer, so the request travels inside
|
|
7
|
+
// the signed message and the server checks it against the request that arrived.
|
|
8
|
+
//
|
|
9
|
+
// The definition lives here because @prismnetwork/x402 depends on this package
|
|
10
|
+
// and not the other way round. Its codec re-exports both, so a server and its
|
|
11
|
+
// clients read the same two lines.
|
|
12
|
+
import { createHash } from "node:crypto";
|
|
13
|
+
|
|
14
|
+
/// The digest both sides compare: the command for a job, the request bytes for
|
|
15
|
+
/// a generation. Text is hashed as UTF-8, which is how it goes on the wire.
|
|
16
|
+
export function hashRequest(payload) {
|
|
17
|
+
const bytes = typeof payload === "string" ? Buffer.from(payload, "utf8") : payload;
|
|
18
|
+
return createHash("sha256").update(bytes).digest("hex");
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function boundMessage(txHash, requestHash) {
|
|
22
|
+
return `prism-x402:v2\n${String(txHash).toLowerCase()}\n${requestHash}`;
|
|
23
|
+
}
|