@prismnetwork/agent-sdk 0.3.1 → 0.5.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 +73 -4
- package/package.json +20 -3
- package/prism.d.mts +84 -0
- package/prism.mjs +119 -22
- package/toolset.d.mts +30 -0
- package/toolset.mjs +223 -0
- package/vault.mjs +1 -1
- package/workspace.d.ts +101 -0
- package/workspace.mjs +503 -0
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
|
+
}
|