@prismnetwork/agent-sdk 0.7.1 → 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 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` + `ssh-keygen` on PATH for `run()` and
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/attest.mjs CHANGED
@@ -683,7 +683,13 @@ async function sessionChecks(t, { root, fetchImpl, sessionId, servedAt }) {
683
683
  async function gpuChecks(t, { root, fetchImpl, model, digest, quote, collateralUrl, now }) {
684
684
  let fetched;
685
685
  try {
686
- const query = model ? `?model=${encodeURIComponent(model)}` : "";
686
+ // Name the instance we need. The model runs on several, and the endpoint
687
+ // answers from whichever the upstream picks, so asking blind returns a
688
+ // sibling most of the time: same image, same compose, different RTMR3.
689
+ const params = new URLSearchParams();
690
+ if (model) params.set("model", model);
691
+ if (digest) params.set("keyset_digest", digest);
692
+ const query = params.size ? `?${params}` : "";
687
693
  fetched = await getJson(fetchImpl, `${root}/v1/gpu-evidence${query}`, "the GPU evidence endpoint");
688
694
  } catch (err) {
689
695
  fetched = { ok: false, detail: err?.message ?? String(err) };
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.1",
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: { privateKey: string; escrow: string; apiBase?: string; rpcUrl?: string });
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
- throw new PrismError(502, "chain_error", { cause: err?.shortMessage ?? err?.message ?? String(err) });
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
- throw new PrismError(502, "chain_error", { cause: err?.shortMessage ?? err?.message ?? String(err) });
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
- const res = await this.#ssh(target, command, timeoutMs, stdin);
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 = createHash("sha256").update(fingerprint ?? sent.bytes).digest("hex");
634
+ const identity = hashRequest(fingerprint ?? sent.bytes);
592
635
  const key = `${base}${path}:${price}:${identity}`;
593
- let pending = this.#pendingPayments.get(key);
594
- if (!pending) {
595
- const tx = await this.transferUsdg(payTo, price);
596
- const signature = await this.account.signMessage({ message: tx });
597
- pending = { tx, header: Buffer.from(JSON.stringify({ txHash: tx, signature })).toString("base64") };
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": pending.header, ...sent.headers },
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 ${pending.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: pending.tx, sent };
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: pending.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
- "-o", "StrictHostKeyChecking=no",
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
+ }