@prismnetwork/agent-sdk 0.3.0 → 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 +69 -2
- package/package.json +9 -1
- package/prism.mjs +26 -6
- package/toolset.mjs +108 -0
- package/workspace.d.ts +101 -0
- package/workspace.mjs +503 -0
package/README.md
CHANGED
|
@@ -17,7 +17,7 @@ import { PrismAgent, DEFAULT_IMAGE } from "@prismnetwork/agent-sdk";
|
|
|
17
17
|
|
|
18
18
|
const agent = new PrismAgent({
|
|
19
19
|
privateKey: process.env.AGENT_KEY, // agent's wallet
|
|
20
|
-
escrow: "
|
|
20
|
+
escrow: "0x62C042265991bEa17B07229322A01850974626dA",
|
|
21
21
|
});
|
|
22
22
|
|
|
23
23
|
await agent.authenticate();
|
|
@@ -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
|
+
"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
|
|
118
|
-
// returned to the
|
|
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
|
-
|
|
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
|
+
}
|