@prismnetwork/agent-sdk 0.3.1 → 0.4.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 CHANGED
@@ -29,6 +29,16 @@ 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 MCP server and the framework plugins (elizaOS, Virtuals GAME)
36
+ wrap: `wallet`, `listGpus`, `leaseAndRun`, `run`, `endLease`, each returning a
37
+ human-readable string. It holds the wallet, the open leases and the per-lease
38
+ spending cap in one place, reads `PRISM_AGENT_KEY`/`PRISM_ESCROW` from the
39
+ environment by default, and answers the read-only questions from the public API
40
+ when no wallet is configured.
41
+
32
42
  ## Vault
33
43
 
34
44
  Cards, identity documents, API credentials and recovery codes go in the vault
@@ -65,6 +75,62 @@ ciphertext, so a service that moved an item between accounts, replayed an older
65
75
  version, or lowered its floor would produce a failed decrypt rather than a
66
76
  plausible wrong answer. See [docs/VAULT.md](../docs/VAULT.md).
67
77
 
78
+ ## Workspaces
79
+
80
+ A lease destroys its machine, so training output, checkpoints and a working
81
+ directory need somewhere that outlives it. A workspace is that place: the SDK
82
+ archives a directory off the leased box, seals it here under a key derived from
83
+ your wallet, and uploads the ciphertext straight to object storage. Prism
84
+ records the version, the size and the hash, and holds nothing that opens it.
85
+
86
+ ```js
87
+ await agent.workspace.unlock();
88
+
89
+ const ws = await agent.workspace.create("finetune-run");
90
+ const saved = await agent.workspace.save(lease, ws, "/root/out");
91
+
92
+ // On a later lease, onto a fresh machine.
93
+ await agent.workspace.restore(next, ws, "/root/out", { expectVersion: saved.version });
94
+ ```
95
+
96
+ The workspace key comes from a different statement and a different salt than
97
+ the vault's, so opening one does not open the other. Pass `{ passphrase }` to
98
+ require a second factor beyond the wallet.
99
+
100
+ A restore hashes the downloaded ciphertext and compares it to the digest
101
+ recorded at save time before it decrypts anything, so bytes altered in storage
102
+ are reported as tampering rather than as a wrong key. The account, workspace,
103
+ version and trust floor are authenticated into the ciphertext, so a snapshot
104
+ served for the wrong workspace, or under a floor that has been rewritten, fails
105
+ to open rather than returning a plausible wrong answer.
106
+
107
+ An older snapshot is a different case worth being precise about: its own
108
+ associated data is genuine for its own version, so it decrypts cleanly and
109
+ nothing in the ciphertext gives it away. A restore therefore compares the
110
+ version it was granted against the version the record says is current, and
111
+ refuses a rollback on that basis. Pass `expectVersion` to pin a specific one,
112
+ and `expectTrustClass` to refuse a floor that has moved.
113
+
114
+ A restore names the lease it is landing on, and Prism refuses to issue the
115
+ download at all when that lease's trust class is below the workspace's floor.
116
+ The check is server-side deliberately: a client-side one would be a courtesy
117
+ that a modified client could skip.
118
+
119
+ Bulk data never passes through Prism. Uploads and downloads use presigned URLs
120
+ that live fifteen minutes, and they are used from your process, never handed to
121
+ the leased machine. The machine only ever sees `tar` and `base64`, which is all
122
+ this needs from it.
123
+
124
+ Snapshots travel over the lease's SSH channel, which caps a single save at 64
125
+ MiB of archive; a larger directory is refused on the machine before anything is
126
+ transferred. New workspaces default to the `open` trust floor, unlike vault
127
+ items: their contents are the files you are already handing to a rented box.
128
+ Raise it at creation when they deserve more:
129
+
130
+ ```js
131
+ await agent.workspace.create("model-weights", { minTrustClass: "isolated" });
132
+ ```
133
+
68
134
  ## Auth
69
135
 
70
136
  `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 +145,7 @@ The wallet needs two balances on Robinhood Chain (id 4663): USDG (`0x5fc5360D040
79
145
 
80
146
  ## Requirements
81
147
 
82
- Node >= 20, `viem` ^2 (peer), and `ssh` + `ssh-keygen` on PATH for `run()`.
148
+ Node >= 20, `viem` ^2 (peer), and `ssh` + `ssh-keygen` on PATH for `run()` and
149
+ for workspace save and restore.
83
150
 
84
151
  See `example.mjs` for a full run.
package/package.json CHANGED
@@ -1,20 +1,28 @@
1
1
  {
2
2
  "name": "@prismnetwork/agent-sdk",
3
- "version": "0.3.1",
3
+ "version": "0.4.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
  ".": "./prism.mjs",
9
+ "./toolset": "./toolset.mjs",
9
10
  "./vault": {
10
11
  "types": "./vault.d.ts",
11
12
  "default": "./vault.mjs"
13
+ },
14
+ "./workspace": {
15
+ "types": "./workspace.d.ts",
16
+ "default": "./workspace.mjs"
12
17
  }
13
18
  },
14
19
  "files": [
15
20
  "prism.mjs",
21
+ "toolset.mjs",
16
22
  "vault.mjs",
17
23
  "vault.d.ts",
24
+ "workspace.mjs",
25
+ "workspace.d.ts",
18
26
  "README.md"
19
27
  ],
20
28
  "engines": {
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,
@@ -108,14 +115,16 @@ export class PrismAgent {
108
115
  this.walletClient = createWalletClient({ account: this.account, chain: robinhoodChain, transport });
109
116
  this.session = null;
110
117
  this.vault = new PrismVault(this);
118
+ this.workspace = new PrismWorkspace(this);
111
119
  }
112
120
 
113
121
  get address() {
114
122
  return this.account.address;
115
123
  }
116
124
 
117
- // The vault key is derived from this signature on the caller's machine. It is
118
- // returned to the vault client and never sent anywhere.
125
+ // The vault and workspace keys are derived from this signature on the
126
+ // caller's machine. It is returned to the client that asked and never sent
127
+ // anywhere.
119
128
  async signVaultStatement(statement) {
120
129
  return this.account.signMessage({ message: statement });
121
130
  }
@@ -124,6 +133,10 @@ export class PrismAgent {
124
133
  return this.#proxy(method, ["vault", ...segments], { body });
125
134
  }
126
135
 
136
+ async workspaceRequest(method, segments, { body = null } = {}) {
137
+ return this.#proxy(method, ["workspaces", ...segments], { body });
138
+ }
139
+
127
140
  async authenticate() {
128
141
  const challenge = await this.#json(`/api/agent/challenge?address=${this.address}`);
129
142
  const signature = await this.account.signMessage({ message: challenge.message });
@@ -331,8 +344,9 @@ export class PrismAgent {
331
344
 
332
345
  // Run a command in the remote login shell over SSH (so pipes, redirects, and
333
346
  // $(...) 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
- async run(lease, command, { timeoutMs = 120_000, connectRetries = 24, connectDelayMs = 10_000 } = {}) {
347
+ // can lag a few minutes after the box reports ready. `stdin` feeds the command
348
+ // its input, which keeps anything sensitive out of the remote process table.
349
+ async run(lease, command, { timeoutMs = 120_000, connectRetries = 24, connectDelayMs = 10_000, stdin = null } = {}) {
336
350
  if (!lease?.access?.ssh_host || !lease.access.ssh_port || !lease.keyPath) {
337
351
  throw new PrismError(400, "invalid_lease_handle");
338
352
  }
@@ -345,7 +359,7 @@ export class PrismAgent {
345
359
  };
346
360
  let last;
347
361
  for (let attempt = 0; attempt <= connectRetries; attempt++) {
348
- const res = await this.#ssh(target, command, timeoutMs);
362
+ const res = await this.#ssh(target, command, timeoutMs, stdin);
349
363
  if (!isSshWarmup(res)) return res;
350
364
  last = res;
351
365
  if (attempt < connectRetries) await sleep(connectDelayMs);
@@ -376,7 +390,7 @@ export class PrismAgent {
376
390
  }
377
391
  }
378
392
 
379
- #ssh(target, command, timeoutMs) {
393
+ #ssh(target, command, timeoutMs, stdin = null) {
380
394
  const args = [
381
395
  "-i", target.keyPath,
382
396
  "-p", String(target.port),
@@ -398,6 +412,12 @@ export class PrismAgent {
398
412
  }, timeoutMs);
399
413
  child.stdout.on("data", (d) => (stdout += d));
400
414
  child.stderr.on("data", (d) => (stderr += d));
415
+ if (stdin !== null) {
416
+ // A command that exits before reading its input closes the pipe, which
417
+ // is a normal end to the transfer and not a failure to report.
418
+ child.stdin.on("error", () => {});
419
+ child.stdin.end(stdin);
420
+ }
401
421
  child.on("close", (code) => {
402
422
  clearTimeout(timer);
403
423
  resolve({ code: code ?? -1, stdout: stdout.trim(), stderr: stderr.trim(), timedOut });
package/toolset.mjs ADDED
@@ -0,0 +1,108 @@
1
+ // A framework-neutral tool surface over PrismAgent. Agent frameworks disagree
2
+ // about how a tool is declared but agree about what one is: a named function
3
+ // with typed arguments that returns text. PrismToolset holds the wallet, the
4
+ // open leases, and the spending cap in one place so framework plugins stay
5
+ // thin wrappers instead of diverging copies of the same logic.
6
+ //
7
+ // Without a wallet it still answers the read-only questions (capacity, prices)
8
+ // from the public API, the same degradation the MCP server offers.
9
+ import { DEFAULT_IMAGE, PrismAgent, TRUST_CLASSES } from "./prism.mjs";
10
+
11
+ export const DEFAULT_ESCROW = "0x62C042265991bEa17B07229322A01850974626dA";
12
+ export const PUBLIC_API = "https://api.prismnetwork.tech";
13
+
14
+ const MICROS = 1_000_000;
15
+ const usdg = (micros) => `${(Number(micros) / MICROS).toFixed(6)} USDG`;
16
+
17
+ export function agentFromEnv() {
18
+ const privateKey = process.env.PRISM_AGENT_KEY;
19
+ if (!privateKey) return null;
20
+ return new PrismAgent({ privateKey, escrow: process.env.PRISM_ESCROW ?? DEFAULT_ESCROW });
21
+ }
22
+
23
+ const NO_WALLET =
24
+ "No wallet is configured, so this needs PRISM_AGENT_KEY (a funded wallet on Robinhood Chain). " +
25
+ "Looking at capacity and prices works without one.";
26
+
27
+ export class PrismToolset {
28
+ #agent;
29
+ #leases = new Map();
30
+ #publicApi;
31
+
32
+ constructor({ agent, publicApi = PUBLIC_API } = {}) {
33
+ this.#agent = agent === undefined ? agentFromEnv() : agent;
34
+ this.#publicApi = publicApi;
35
+ }
36
+
37
+ get agent() {
38
+ return this.#agent;
39
+ }
40
+
41
+ async wallet() {
42
+ if (!this.#agent) return NO_WALLET;
43
+ const b = await this.#agent.balances();
44
+ return `address: ${b.address}\nusdg: ${usdg(b.usdg)}\neth: ${(Number(b.eth) / 1e18).toFixed(6)} for gas`;
45
+ }
46
+
47
+ async listGpus(minTrust = "open") {
48
+ if (!TRUST_CLASSES.includes(minTrust)) {
49
+ return `min_trust must be one of ${TRUST_CLASSES.join(", ")}`;
50
+ }
51
+ let offers;
52
+ if (this.#agent) {
53
+ offers = await this.#agent.offers({ minTrust });
54
+ } else {
55
+ const url = new URL("/v1/offers", this.#publicApi);
56
+ url.searchParams.set("min_trust", minTrust);
57
+ const res = await fetch(url, { headers: { accept: "application/json" } });
58
+ if (!res.ok) return `Prism capacity is unreachable right now (${res.status}).`;
59
+ offers = await res.json();
60
+ }
61
+ if (!offers.length) return "No GPUs are online to rent right now.";
62
+ return offers
63
+ .map((o) => {
64
+ const perHr = ((Number(o.rate_per_second) * 3600) / MICROS).toFixed(2);
65
+ return `${o.gpu.model} · ${o.gpu.vram_mib} MiB · $${perHr}/hr · ${o.trust_class ?? "open"}`;
66
+ })
67
+ .join("\n");
68
+ }
69
+
70
+ async leaseAndRun({
71
+ command,
72
+ durationSeconds = 600,
73
+ minVramMib = 16000,
74
+ image = DEFAULT_IMAGE,
75
+ maxUsdg = 1,
76
+ minTrustClass = "open",
77
+ }) {
78
+ if (!this.#agent) return NO_WALLET;
79
+ const lease = await this.#agent.lease({
80
+ image,
81
+ durationSeconds,
82
+ minVramMib,
83
+ maxDeposit: Math.round(maxUsdg * MICROS),
84
+ minTrustClass,
85
+ });
86
+ this.#leases.set(lease.leaseId, lease);
87
+ const res = await this.#agent.run(lease, command);
88
+ const out = res.stdout || res.stderr || "";
89
+ return `lease ${lease.leaseId} funded onchain (tx ${lease.fundingHash}), exit ${res.code}:\n${out}`;
90
+ }
91
+
92
+ async run(leaseId, command) {
93
+ if (!this.#agent) return NO_WALLET;
94
+ const lease = this.#leases.get(leaseId);
95
+ if (!lease) return `No active lease ${leaseId} in this session.`;
96
+ const res = await this.#agent.run(lease, command);
97
+ return `exit ${res.code}:\n${res.stdout || res.stderr || ""}`;
98
+ }
99
+
100
+ endLease(leaseId) {
101
+ if (!this.#agent) return NO_WALLET;
102
+ const lease = this.#leases.get(leaseId);
103
+ if (!lease) return `No active lease ${leaseId} in this session.`;
104
+ this.#agent.endLease(lease);
105
+ this.#leases.delete(leaseId);
106
+ return `released lease ${leaseId}`;
107
+ }
108
+ }
package/workspace.d.ts ADDED
@@ -0,0 +1,101 @@
1
+ export type TrustFloor = "open" | "isolated" | "attested" | "confidential";
2
+
3
+ export type WorkspaceSnapshot = {
4
+ wrapped_key: string;
5
+ nonce: string;
6
+ ciphertext_digest: string;
7
+ size_bytes: number;
8
+ };
9
+
10
+ export type Workspace = {
11
+ workspace_id: string;
12
+ name: string;
13
+ version: number;
14
+ snapshot?: WorkspaceSnapshot;
15
+ min_trust_class: TrustFloor;
16
+ created_at: string;
17
+ updated_at: string;
18
+ };
19
+
20
+ /// A presigned upload, valid for fifteen minutes. `version` is the one storage
21
+ /// will accept, and it is authenticated into the ciphertext, so the snapshot is
22
+ /// sealed after this arrives rather than before.
23
+ export type WorkspaceUploadGrant = {
24
+ url: string;
25
+ version: number;
26
+ key: string;
27
+ };
28
+
29
+ export type WorkspaceDownloadGrant = WorkspaceSnapshot & {
30
+ url: string;
31
+ version: number;
32
+ };
33
+
34
+ /// The handle an interactive `lease()` returns. `save` and `restore` reach the
35
+ /// machine over SSH, which a batch lease has no key for.
36
+ export type LeaseHandle = {
37
+ access: { ssh_host: string; ssh_port: number; ssh_user?: string };
38
+ keyPath: string;
39
+ };
40
+
41
+ /// What the workspace client needs from its caller: an address, a way to sign,
42
+ /// a way to reach the control plane, and, for `save` and `restore`, a way to
43
+ /// reach the leased machine. `signVaultStatement` is the generic "sign this
44
+ /// statement" hook the vault also uses; the statement differs, so the keys do.
45
+ export type WorkspaceTransport = {
46
+ address: string;
47
+ session: unknown;
48
+ authenticate: () => Promise<unknown>;
49
+ signVaultStatement: (statement: string) => Promise<string>;
50
+ workspaceRequest: (method: string, segments: string[], options?: { body?: unknown }) => Promise<any>;
51
+ run?: (
52
+ lease: LeaseHandle,
53
+ command: string,
54
+ options?: { timeoutMs?: number; stdin?: string | null },
55
+ ) => Promise<{ code: number; stdout: string; stderr: string; timedOut: boolean }>;
56
+ };
57
+
58
+ export declare const WORKSPACE_ENVELOPE_DOMAIN: string;
59
+ export declare const WORKSPACE_KEY_STATEMENT: string;
60
+ export declare const DEFAULT_WORKSPACE_TRUST_FLOOR: TrustFloor;
61
+
62
+ export declare function workspaceAssociatedData(
63
+ wallet: string,
64
+ workspaceId: string,
65
+ version: number,
66
+ trustFloor: TrustFloor,
67
+ ): Uint8Array;
68
+
69
+ export declare class PrismWorkspace {
70
+ constructor(transport: WorkspaceTransport);
71
+ readonly unlocked: boolean;
72
+ readonly wallet: string | null;
73
+ unlock(options?: { passphrase?: string | null }): Promise<this>;
74
+ lock(): void;
75
+ list(): Promise<Workspace[]>;
76
+ get(workspaceId: string | Workspace): Promise<Workspace>;
77
+ create(name: string, options?: { minTrustClass?: TrustFloor }): Promise<Workspace>;
78
+ remove(workspaceId: string | Workspace): Promise<null>;
79
+ save(
80
+ lease: LeaseHandle,
81
+ workspaceId: string | Workspace,
82
+ remotePath: string,
83
+ options?: { timeoutMs?: number },
84
+ ): Promise<Workspace>;
85
+ restore(
86
+ lease: LeaseHandle,
87
+ workspaceId: string | Workspace,
88
+ remotePath: string,
89
+ options?: {
90
+ expectVersion?: number | null;
91
+ expectTrustClass?: TrustFloor | null;
92
+ timeoutMs?: number;
93
+ },
94
+ ): Promise<Workspace>;
95
+ static permits(trustFloor: TrustFloor, leaseTrustClass: TrustFloor): boolean;
96
+ }
97
+
98
+ export declare class WorkspaceError extends Error {
99
+ readonly code: string;
100
+ readonly body?: unknown;
101
+ }
package/workspace.mjs ADDED
@@ -0,0 +1,503 @@
1
+ // Durable storage for a renter. A lease destroys its machine, so anything worth
2
+ // keeping is archived off it, sealed here on the renter's own machine, and
3
+ // pushed to object storage as ciphertext. Prism records how large a snapshot is
4
+ // and which version it is, and holds nothing that opens it.
5
+ //
6
+ // The bulk path runs through this process rather than through the leased box on
7
+ // purpose. A presigned URL is a bearer capability with a fifteen minute life;
8
+ // handing one to a rented machine would put it in that host's process table and
9
+ // give its operator read and write on the object for the rest of the window.
10
+ import { vaultWallet } from "./vault.mjs";
11
+
12
+ const { subtle } = globalThis.crypto;
13
+
14
+ export const WORKSPACE_ENVELOPE_DOMAIN = "prism.workspace.v1\0";
15
+
16
+ // A signature over this exact string is the workspace key. It is not the vault
17
+ // statement and it does not salt the same way, so a wallet that has opened a
18
+ // vault has not opened its workspaces, and neither signature derives the other.
19
+ export const WORKSPACE_KEY_STATEMENT = [
20
+ "Prism Network workspace key",
21
+ "",
22
+ "Signing this derives the key that encrypts your Prism workspaces. It is computed",
23
+ "on this machine and never sent. Anyone who gets this signature can read every",
24
+ "snapshot you have stored, so only sign it in software you trust.",
25
+ "",
26
+ "domain: prism.workspace.kdf.v1",
27
+ ].join("\n");
28
+
29
+ // A workspace holds the working files a renter is already handing to a rented
30
+ // machine. The vault's default of a class no live capacity meets would mean a
31
+ // workspace could never be restored, so this one starts at the floor and is
32
+ // raised per workspace when the contents deserve it.
33
+ export const DEFAULT_WORKSPACE_TRUST_FLOOR = "open";
34
+
35
+ const TRUST_ORDER = ["open", "isolated", "attested", "confidential"];
36
+
37
+ // The protocol allows 64 GiB per snapshot. This client carries the archive over
38
+ // the lease's SSH channel and holds it in memory to seal it, so it stops well
39
+ // short of that and says so rather than dying of a failed allocation.
40
+ const MAX_TRANSFER_BYTES = 64 * 1024 * 1024;
41
+ const MAX_NAME_BYTES = 64;
42
+
43
+ // Matches the presigned URL's own life: a transfer that has not finished by
44
+ // then cannot finish at all.
45
+ const TRANSFER_TIMEOUT_MS = 900_000;
46
+ const REMOTE_TIMEOUT_MS = 900_000;
47
+
48
+ const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
49
+
50
+ const encoder = new TextEncoder();
51
+
52
+ // Chunked because a large archive would otherwise spread into more arguments
53
+ // than an engine will accept in one call.
54
+ function base64(bytes) {
55
+ let binary = "";
56
+ for (let index = 0; index < bytes.length; index += 0x8000) {
57
+ binary += String.fromCharCode(...bytes.subarray(index, index + 0x8000));
58
+ }
59
+ return btoa(binary);
60
+ }
61
+
62
+ function b64url(bytes) {
63
+ return base64(bytes).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
64
+ }
65
+
66
+ // Takes both alphabets: the control plane sends base64url, and `base64` on the
67
+ // leased machine wraps standard base64 across lines.
68
+ function fromB64(value) {
69
+ const binary = atob(value.replace(/\s+/g, "").replace(/-/g, "+").replace(/_/g, "/"));
70
+ const bytes = new Uint8Array(binary.length);
71
+ for (let index = 0; index < binary.length; index += 1) {
72
+ bytes[index] = binary.charCodeAt(index);
73
+ }
74
+ return bytes;
75
+ }
76
+
77
+ function fromHex(value) {
78
+ const digits = value.replace(/^0x/, "");
79
+ if (digits.length % 2 !== 0 || !/^[0-9a-fA-F]*$/.test(digits)) {
80
+ throw new WorkspaceError("invalid_signature_encoding");
81
+ }
82
+ const bytes = new Uint8Array(digits.length / 2);
83
+ for (let index = 0; index < bytes.length; index += 1) {
84
+ bytes[index] = Number.parseInt(digits.slice(index * 2, index * 2 + 2), 16);
85
+ }
86
+ return bytes;
87
+ }
88
+
89
+ async function sha256Hex(bytes) {
90
+ const digest = new Uint8Array(await subtle.digest("SHA-256", bytes));
91
+ return Array.from(digest, (byte) => byte.toString(16).padStart(2, "0")).join("");
92
+ }
93
+
94
+ /// Mirrors `workspace_associated_data` in prism-protocol byte for byte. A
95
+ /// shared test vector pins both; if they drift, stored snapshots stop opening.
96
+ export function workspaceAssociatedData(wallet, workspaceId, version, trustFloor) {
97
+ return encoder.encode(
98
+ `${WORKSPACE_ENVELOPE_DOMAIN}${wallet}\0${workspaceId}\0${version}\0${trustFloor}\0`,
99
+ );
100
+ }
101
+
102
+ function assertTrustFloor(value) {
103
+ if (!TRUST_ORDER.includes(value)) {
104
+ throw new WorkspaceError("invalid_trust_floor", { expected: TRUST_ORDER });
105
+ }
106
+ return value;
107
+ }
108
+
109
+ function meetsFloor(floor, leaseClass) {
110
+ return TRUST_ORDER.indexOf(leaseClass) >= TRUST_ORDER.indexOf(floor);
111
+ }
112
+
113
+ // Accepts a workspace record or a bare id. Lowercased because the Rust side
114
+ // builds the associated data from a hyphenated lowercase UUID.
115
+ function workspaceIdOf(value) {
116
+ const id = typeof value === "string" ? value : value?.workspace_id;
117
+ if (typeof id !== "string" || !UUID.test(id.toLowerCase())) {
118
+ throw new WorkspaceError("invalid_workspace_id");
119
+ }
120
+ return id.toLowerCase();
121
+ }
122
+
123
+ // Single quotes so nothing in a caller's path reaches the remote shell as
124
+ // syntax. A path containing one has no safe quoting, so it is refused.
125
+ function quote(path) {
126
+ if (typeof path !== "string" || path.trim() === "" || /['\n]/.test(path)) {
127
+ throw new WorkspaceError("invalid_remote_path", {
128
+ hint: "a path without single quotes or newlines",
129
+ });
130
+ }
131
+ return `'${path}'`;
132
+ }
133
+
134
+ // HKDF over the wallet signature. Ethereum's ECDSA is deterministic (RFC 6979),
135
+ // so the same wallet reproduces the same key on any machine and a workspace
136
+ // survives a lost laptop without Prism holding an escrow copy. A passphrase,
137
+ // when given, is mixed into the salt, so a leaked signature alone is not enough.
138
+ async function deriveRootKey(signature, wallet, passphrase) {
139
+ const material = await subtle.importKey("raw", fromHex(signature), "HKDF", false, ["deriveKey"]);
140
+ const salt = await subtle.digest(
141
+ "SHA-256",
142
+ encoder.encode(`prism.workspace.kdf.v1\0${wallet}\0${passphrase ?? ""}`),
143
+ );
144
+ return subtle.deriveKey(
145
+ { name: "HKDF", hash: "SHA-256", salt: new Uint8Array(salt), info: encoder.encode("root") },
146
+ material,
147
+ { name: "AES-KW", length: 256 },
148
+ false,
149
+ ["wrapKey", "unwrapKey"],
150
+ );
151
+ }
152
+
153
+ // Per-snapshot data key, wrapped under the root. Storage holds the wrapped 40
154
+ // bytes next to the object; the root key that opens it never leaves here.
155
+ async function seal(rootKey, plaintext, aad) {
156
+ const dataKey = await subtle.generateKey({ name: "AES-GCM", length: 256 }, true, [
157
+ "encrypt",
158
+ "decrypt",
159
+ ]);
160
+ const nonce = globalThis.crypto.getRandomValues(new Uint8Array(12));
161
+ const [ciphertext, wrapped] = await Promise.all([
162
+ subtle.encrypt({ name: "AES-GCM", iv: nonce, additionalData: aad }, dataKey, plaintext),
163
+ subtle.wrapKey("raw", dataKey, rootKey, "AES-KW"),
164
+ ]);
165
+ return {
166
+ nonce,
167
+ ciphertext: new Uint8Array(ciphertext),
168
+ wrappedKey: new Uint8Array(wrapped),
169
+ };
170
+ }
171
+
172
+ async function unseal(rootKey, wrappedKey, nonce, ciphertext, aad) {
173
+ let dataKey;
174
+ try {
175
+ dataKey = await subtle.unwrapKey(
176
+ "raw",
177
+ wrappedKey,
178
+ rootKey,
179
+ "AES-KW",
180
+ { name: "AES-GCM", length: 256 },
181
+ false,
182
+ ["decrypt"],
183
+ );
184
+ } catch {
185
+ throw new WorkspaceError("workspace_key_mismatch", {
186
+ hint: "this workspace key does not open that snapshot; check the wallet and passphrase used to unlock",
187
+ });
188
+ }
189
+ try {
190
+ return new Uint8Array(
191
+ await subtle.decrypt({ name: "AES-GCM", iv: nonce, additionalData: aad }, dataKey, ciphertext),
192
+ );
193
+ } catch {
194
+ throw new WorkspaceError("workspace_authentication_failed", {
195
+ hint: "the stored snapshot does not match the account, workspace, version and trust floor it was sealed with",
196
+ });
197
+ }
198
+ }
199
+
200
+ // Straight between this process and storage. The URL is never logged and never
201
+ // reported in an error, because for the next fifteen minutes it is the object.
202
+ async function transfer(url, init, code, workspaceId) {
203
+ let res;
204
+ try {
205
+ res = await fetch(url, { ...init, signal: AbortSignal.timeout(TRANSFER_TIMEOUT_MS) });
206
+ } catch (err) {
207
+ // The cause code rather than the message: a fetch failure can quote the
208
+ // request URL back at you.
209
+ throw new WorkspaceError(code, {
210
+ workspace_id: workspaceId,
211
+ cause: err?.cause?.code ?? err?.name ?? "fetch_failed",
212
+ });
213
+ }
214
+ if (!res.ok) throw new WorkspaceError(code, { workspace_id: workspaceId, status: res.status });
215
+ return res;
216
+ }
217
+
218
+ export class PrismWorkspace {
219
+ #agent;
220
+ #rootKey = null;
221
+ #wallet = null;
222
+
223
+ constructor(agent) {
224
+ this.#agent = agent;
225
+ }
226
+
227
+ get unlocked() {
228
+ return this.#rootKey !== null;
229
+ }
230
+
231
+ /// Derives the workspace key from a wallet signature. Nothing leaves this
232
+ /// process; the signature itself is discarded once the key exists.
233
+ async unlock({ passphrase = null } = {}) {
234
+ if (!this.#agent.session) await this.#agent.authenticate();
235
+ const wallet = vaultWallet(this.#agent.address);
236
+ const signature = await this.#agent.signVaultStatement(WORKSPACE_KEY_STATEMENT);
237
+ this.#rootKey = await deriveRootKey(signature, wallet, passphrase);
238
+ this.#wallet = wallet;
239
+ return this;
240
+ }
241
+
242
+ /// The wallet whose workspaces are open. One wallet, one set of workspaces,
243
+ /// whether they are reached from a browser or from an agent.
244
+ get wallet() {
245
+ return this.#wallet;
246
+ }
247
+
248
+ lock() {
249
+ this.#rootKey = null;
250
+ this.#wallet = null;
251
+ }
252
+
253
+ /// Names, versions and sizes. The contents are ciphertext in object storage
254
+ /// and are not part of a listing.
255
+ async list() {
256
+ return this.#agent.workspaceRequest("GET", []);
257
+ }
258
+
259
+ async get(workspaceId) {
260
+ return this.#record(workspaceIdOf(workspaceId));
261
+ }
262
+
263
+ /// Creates an empty workspace. The name is stored unencrypted, like a vault
264
+ /// label, and is the one thing a listing discloses.
265
+ async create(name, { minTrustClass = DEFAULT_WORKSPACE_TRUST_FLOOR } = {}) {
266
+ assertTrustFloor(minTrustClass);
267
+ if (typeof name !== "string" || name.trim() === "" || encoder.encode(name).length > MAX_NAME_BYTES) {
268
+ throw new WorkspaceError("invalid_workspace_name", {
269
+ hint: `a name of 1 to ${MAX_NAME_BYTES} bytes`,
270
+ });
271
+ }
272
+ return this.#agent.workspaceRequest("POST", [], {
273
+ body: { name, min_trust_class: minTrustClass },
274
+ });
275
+ }
276
+
277
+ /// Drops the workspace and every snapshot stored under it.
278
+ async remove(workspaceId) {
279
+ return this.#agent.workspaceRequest("DELETE", [workspaceIdOf(workspaceId)]);
280
+ }
281
+
282
+ /// Archives `remotePath` on the leased machine and stores it as a new
283
+ /// version. The archive is sealed here before it is uploaded, so the object
284
+ /// storage that serves it and the control plane that indexes it both hold
285
+ /// ciphertext they cannot open.
286
+ async save(lease, workspaceId, remotePath, { timeoutMs = REMOTE_TIMEOUT_MS } = {}) {
287
+ this.#require();
288
+ const id = workspaceIdOf(workspaceId);
289
+ this.#requireMachine(lease);
290
+ const archive = await this.#archive(lease, id, remotePath, timeoutMs);
291
+
292
+ // Read rather than remembered: the floor is authenticated into the
293
+ // ciphertext, so sealing against a stale copy of it stores a snapshot that
294
+ // will not open.
295
+ const floor = assertTrustFloor((await this.#record(id)).min_trust_class);
296
+ // Storage signs the length, so the size is declared before the ciphertext
297
+ // exists. GCM adds a 16 byte tag and nothing else.
298
+ const grant = await this.#agent.workspaceRequest("POST", [id, "upload"], {
299
+ body: { size_bytes: archive.length + 16 },
300
+ });
301
+ if (typeof grant?.url !== "string" || !Number.isInteger(grant?.version) || grant.version < 1) {
302
+ throw new WorkspaceError("invalid_upload_grant");
303
+ }
304
+ const aad = workspaceAssociatedData(this.#wallet, id, grant.version, floor);
305
+ const { nonce, ciphertext, wrappedKey } = await seal(this.#rootKey, archive, aad);
306
+ const digest = await sha256Hex(ciphertext);
307
+
308
+ // Signed into the URL, so it has to be sent. It makes the object
309
+ // write-once: a stalled upload that lands after a retry has already
310
+ // committed is refused rather than replacing bytes the metadata describes.
311
+ await transfer(
312
+ grant.url,
313
+ { method: "PUT", body: ciphertext, headers: { "If-None-Match": "*" } },
314
+ "workspace_upload_failed",
315
+ id,
316
+ );
317
+
318
+ return this.#agent.workspaceRequest("POST", [id, "commit"], {
319
+ body: {
320
+ version: grant.version,
321
+ wrapped_key: b64url(wrappedKey),
322
+ nonce: b64url(nonce),
323
+ ciphertext_digest: digest,
324
+ size_bytes: ciphertext.length,
325
+ },
326
+ });
327
+ }
328
+
329
+ /// Fetches a snapshot, checks it hashes to what was stored, opens it here,
330
+ /// and unpacks it into `remotePath` on the leased machine. Pass the version
331
+ /// you last saved as `expectVersion` to refuse an older one.
332
+ async restore(
333
+ lease,
334
+ workspaceId,
335
+ remotePath,
336
+ { expectVersion = null, expectTrustClass = null, timeoutMs = REMOTE_TIMEOUT_MS } = {},
337
+ ) {
338
+ this.#require();
339
+ const id = workspaceIdOf(workspaceId);
340
+ this.#requireMachine(lease);
341
+ const workspace = await this.#record(id);
342
+ if (workspace.version < 1) {
343
+ throw new WorkspaceError("workspace_empty", { hint: "nothing has been saved here yet" });
344
+ }
345
+ const floor = assertTrustFloor(workspace.min_trust_class);
346
+ if (expectTrustClass !== null && floor !== expectTrustClass) {
347
+ throw new WorkspaceError("workspace_trust_floor_changed", {
348
+ expected: expectTrustClass,
349
+ served: floor,
350
+ });
351
+ }
352
+
353
+ const grant = await this.#agent.workspaceRequest("POST", [id, "download"], {
354
+ body: { lease_id: lease.leaseId },
355
+ });
356
+ if (typeof grant?.url !== "string" || !Number.isInteger(grant?.version)) {
357
+ throw new WorkspaceError("invalid_download_grant");
358
+ }
359
+ const { url, version, ...snapshot } = grant;
360
+ // The record said which version is current. A grant for anything else is a
361
+ // rollback, and it decrypts cleanly because an older snapshot's associated
362
+ // data is genuine for its own version, so nothing downstream would notice.
363
+ if (version !== workspace.version) {
364
+ throw new WorkspaceError("workspace_version_rollback", {
365
+ expected: workspace.version,
366
+ served: version,
367
+ });
368
+ }
369
+ if (expectVersion !== null && version !== expectVersion) {
370
+ throw new WorkspaceError("workspace_version_rollback", { expected: expectVersion, served: version });
371
+ }
372
+
373
+ const res = await transfer(url, { method: "GET" }, "workspace_download_failed", id);
374
+ const ciphertext = new Uint8Array(await res.arrayBuffer());
375
+ // Checked before anything is decrypted. Bytes that hash to something else
376
+ // were altered in storage, and that deserves a clear answer rather than an
377
+ // authentication failure that reads like a wrong key.
378
+ const digest = await sha256Hex(ciphertext);
379
+ if (digest !== snapshot.ciphertext_digest) {
380
+ throw new WorkspaceError("workspace_digest_mismatch", {
381
+ expected: snapshot.ciphertext_digest,
382
+ computed: digest,
383
+ expected_bytes: snapshot.size_bytes,
384
+ served_bytes: ciphertext.length,
385
+ });
386
+ }
387
+
388
+ const aad = workspaceAssociatedData(this.#wallet, id, version, floor);
389
+ const archive = await unseal(
390
+ this.#rootKey,
391
+ fromB64(snapshot.wrapped_key),
392
+ fromB64(snapshot.nonce),
393
+ ciphertext,
394
+ aad,
395
+ );
396
+ await this.#extract(lease, archive, remotePath, timeoutMs);
397
+ return { ...workspace, version, snapshot };
398
+ }
399
+
400
+ /// Whether a workspace at this floor may be restored onto a lease of that
401
+ /// trust class, without asking the control plane.
402
+ static permits(trustFloor, leaseTrustClass) {
403
+ return meetsFloor(assertTrustFloor(trustFloor), assertTrustFloor(leaseTrustClass));
404
+ }
405
+
406
+ #require() {
407
+ if (!this.#rootKey) throw new WorkspaceError("workspace_locked", { hint: "call unlock() first" });
408
+ }
409
+
410
+ // The control plane lists workspaces and does not serve one on its own, so
411
+ // this is where a single record comes from.
412
+ async #record(id) {
413
+ const workspaces = await this.#agent.workspaceRequest("GET", []);
414
+ const found = workspaces?.find?.((workspace) => workspace?.workspace_id?.toLowerCase() === id);
415
+ if (!found) throw new WorkspaceError("workspace_not_found", { workspace_id: id });
416
+ return found;
417
+ }
418
+
419
+ #requireMachine(lease) {
420
+ if (typeof this.#agent.run !== "function") {
421
+ throw new WorkspaceError("no_lease_transport", {
422
+ hint: "save and restore need an agent that can reach the machine over SSH",
423
+ });
424
+ }
425
+ // What `run` needs. A batch lease reports its output and keeps no key, so
426
+ // it cannot carry a workspace either way.
427
+ if (!lease?.access?.ssh_host || !lease.keyPath) {
428
+ throw new WorkspaceError("invalid_lease_handle", {
429
+ hint: "the handle from an interactive lease(), which a batch lease does not produce",
430
+ });
431
+ }
432
+ }
433
+
434
+ // tar and base64 are all this needs from the machine. The archive is staged
435
+ // to a file first so its size is known before it crosses the wire: storage
436
+ // signs the length of the upload, and a directory too large to carry should
437
+ // fail on the machine rather than halfway through a transfer.
438
+ async #archive(lease, id, remotePath, timeoutMs) {
439
+ const staged = `/tmp/prism-workspace-${id}.tar.gz`;
440
+ const res = await this.#agent.run(
441
+ lease,
442
+ [
443
+ "set -e",
444
+ `f='${staged}'`,
445
+ `trap 'rm -f "$f"' EXIT`,
446
+ // tar exits 1 for "file changed as we read it" and still writes a
447
+ // complete archive. A training job that has not stopped writing hits
448
+ // that on almost every save, and under set -e it would throw away a
449
+ // good snapshot. Only a fatal tar, exit 2, aborts.
450
+ `tar -C ${quote(remotePath)} -czf "$f" . || [ "$?" -le 1 ]`,
451
+ 'n=$(wc -c < "$f")',
452
+ `[ "$n" -le ${MAX_TRANSFER_BYTES} ] || { echo "prism_snapshot_too_large:$n" >&2; exit 3; }`,
453
+ // Redirected rather than named: every base64 reads stdin, and not all
454
+ // of them take a file argument.
455
+ 'base64 < "$f"',
456
+ ].join("\n"),
457
+ { timeoutMs },
458
+ );
459
+ if (res.code !== 0 || res.timedOut) {
460
+ // Some wc implementations pad their output, so the count is not
461
+ // necessarily flush against the marker.
462
+ const oversize = /prism_snapshot_too_large:\s*(\d+)/.exec(res.stderr);
463
+ if (oversize) {
464
+ throw new WorkspaceError("workspace_snapshot_too_large", {
465
+ bytes: Number(oversize[1]),
466
+ limit: MAX_TRANSFER_BYTES,
467
+ });
468
+ }
469
+ throw new WorkspaceError("workspace_archive_failed", {
470
+ code: res.code,
471
+ stderr: res.stderr,
472
+ timed_out: res.timedOut,
473
+ });
474
+ }
475
+ return fromB64(res.stdout);
476
+ }
477
+
478
+ // Over stdin rather than in the command, so the plaintext archive is not in
479
+ // the machine's process table or its shell history.
480
+ async #extract(lease, archive, remotePath, timeoutMs) {
481
+ const path = quote(remotePath);
482
+ const res = await this.#agent.run(
483
+ lease,
484
+ ["set -e", `mkdir -p ${path}`, `base64 -d | tar -C ${path} -xzf -`].join("\n"),
485
+ { timeoutMs, stdin: base64(archive) },
486
+ );
487
+ if (res.code !== 0 || res.timedOut) {
488
+ throw new WorkspaceError("workspace_extract_failed", {
489
+ code: res.code,
490
+ stderr: res.stderr,
491
+ timed_out: res.timedOut,
492
+ });
493
+ }
494
+ }
495
+ }
496
+
497
+ export class WorkspaceError extends Error {
498
+ constructor(code, body) {
499
+ super(`prism workspace: ${code}`);
500
+ this.code = code;
501
+ this.body = body;
502
+ }
503
+ }