@fentaris/edge 0.2.2 → 0.3.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/dist/agent.d.ts +8 -3
- package/dist/agent.d.ts.map +1 -1
- package/dist/agent.js +99 -17
- package/dist/agent.js.map +1 -1
- package/dist/cli.d.ts +7 -1
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +90 -5
- package/dist/cli.js.map +1 -1
- package/dist/daemon.d.ts +67 -0
- package/dist/daemon.d.ts.map +1 -0
- package/dist/daemon.js +244 -0
- package/dist/daemon.js.map +1 -0
- package/dist/enrollment.d.ts +12 -1
- package/dist/enrollment.d.ts.map +1 -1
- package/dist/enrollment.js +27 -3
- package/dist/enrollment.js.map +1 -1
- package/dist/index.d.ts +12 -4
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +58 -3
- package/dist/index.js.map +1 -1
- package/dist/installation.d.ts +232 -0
- package/dist/installation.d.ts.map +1 -0
- package/dist/installation.js +897 -0
- package/dist/installation.js.map +1 -0
- package/dist/localControl.d.ts +50 -0
- package/dist/localControl.d.ts.map +1 -0
- package/dist/localControl.js +148 -0
- package/dist/localControl.js.map +1 -0
- package/dist/platform.d.ts +3 -0
- package/dist/platform.d.ts.map +1 -1
- package/dist/platform.js +19 -8
- package/dist/platform.js.map +1 -1
- package/dist/redaction.d.ts +2 -0
- package/dist/redaction.d.ts.map +1 -1
- package/dist/redaction.js +8 -0
- package/dist/redaction.js.map +1 -1
- package/dist/runtime.d.ts +27 -2
- package/dist/runtime.d.ts.map +1 -1
- package/dist/runtime.js +189 -15
- package/dist/runtime.js.map +1 -1
- package/dist/service.d.ts +96 -0
- package/dist/service.d.ts.map +1 -0
- package/dist/service.js +144 -0
- package/dist/service.js.map +1 -0
- package/dist/setup.d.ts +1 -0
- package/dist/setup.d.ts.map +1 -1
- package/dist/setup.js +9 -2
- package/dist/setup.js.map +1 -1
- package/package.json +3 -3
|
@@ -0,0 +1,897 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
3
|
+
import { chmod, copyFile, cp, lstat, mkdir, open, readFile, readdir, realpath, rm, stat, writeFile, } from "node:fs/promises";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { computeInstallationDigest, edgeError, installationApprovalDigest, normalizeInstallationReadiness, validateInstallationRecipe, } from "@fentaris/core";
|
|
6
|
+
import { redactInstallerText } from "./redaction.js";
|
|
7
|
+
const emptyDocument = () => ({
|
|
8
|
+
schemaVersion: 1,
|
|
9
|
+
attempts: {},
|
|
10
|
+
approvals: {},
|
|
11
|
+
lifecycle: {},
|
|
12
|
+
artifacts: {},
|
|
13
|
+
activePointers: {},
|
|
14
|
+
retentionReferences: {},
|
|
15
|
+
});
|
|
16
|
+
/** Protected durable installation state persisted atomically by the platform store. */
|
|
17
|
+
export class ProtectedInstallationState {
|
|
18
|
+
store;
|
|
19
|
+
queue = Promise.resolve();
|
|
20
|
+
constructor(store) {
|
|
21
|
+
this.store = store;
|
|
22
|
+
}
|
|
23
|
+
async snapshot() {
|
|
24
|
+
return structuredClone(await this.store.load() ?? emptyDocument());
|
|
25
|
+
}
|
|
26
|
+
async update(mutation) {
|
|
27
|
+
let result;
|
|
28
|
+
const operation = this.queue.then(async () => {
|
|
29
|
+
const [next, value] = mutation(await this.snapshot());
|
|
30
|
+
await this.store.save(next);
|
|
31
|
+
result = value;
|
|
32
|
+
});
|
|
33
|
+
this.queue = operation.catch(() => undefined);
|
|
34
|
+
await operation;
|
|
35
|
+
return result;
|
|
36
|
+
}
|
|
37
|
+
putAttempt(attempt) {
|
|
38
|
+
return this.update((document) => [{ ...document, attempts: { ...document.attempts, [attempt.attemptId]: freeze(attempt) } }, undefined]);
|
|
39
|
+
}
|
|
40
|
+
putApproval(approval) {
|
|
41
|
+
return this.update((document) => [{ ...document, approvals: { ...document.approvals, [approvalKey(approval.approvalDigest, approval.cleanup)]: freeze(approval) } }, undefined]);
|
|
42
|
+
}
|
|
43
|
+
putLifecycle(value) {
|
|
44
|
+
return this.update((document) => {
|
|
45
|
+
const current = document.lifecycle[value.deploymentId];
|
|
46
|
+
if (current && (value.desiredVersion < current.desiredVersion
|
|
47
|
+
|| (value.desiredVersion === current.desiredVersion && value.recipeDigest !== current.recipeDigest)
|
|
48
|
+
|| value.observedAt < current.observedAt)) {
|
|
49
|
+
throw edgeError("EDGE_PROTOCOL", "Installation lifecycle update is stale.");
|
|
50
|
+
}
|
|
51
|
+
return [{ ...document, lifecycle: { ...document.lifecycle, [value.deploymentId]: freeze(value) } }, undefined];
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
putArtifact(value) {
|
|
55
|
+
return this.update((document) => [{ ...document, artifacts: { ...document.artifacts, [value.recipeDigest]: freeze(value) } }, undefined]);
|
|
56
|
+
}
|
|
57
|
+
async approval(digest, cleanup = false) {
|
|
58
|
+
return (await this.snapshot()).approvals[approvalKey(digest, cleanup)];
|
|
59
|
+
}
|
|
60
|
+
async lifecycle(deploymentId) {
|
|
61
|
+
return (await this.snapshot()).lifecycle[deploymentId];
|
|
62
|
+
}
|
|
63
|
+
async artifact(digest) {
|
|
64
|
+
return (await this.snapshot()).artifacts[digest];
|
|
65
|
+
}
|
|
66
|
+
async attempts(digest) {
|
|
67
|
+
return Object.values((await this.snapshot()).attempts)
|
|
68
|
+
.filter((attempt) => attempt.recipeDigest === digest)
|
|
69
|
+
.sort((left, right) => left.startedAt - right.startedAt);
|
|
70
|
+
}
|
|
71
|
+
async activate(deploymentId, artifact, retain) {
|
|
72
|
+
await this.update((document) => {
|
|
73
|
+
const previous = document.activePointers[deploymentId];
|
|
74
|
+
const retained = [previous, ...(document.retentionReferences[deploymentId] ?? [])]
|
|
75
|
+
.filter((digest) => digest !== undefined && digest !== artifact.recipeDigest)
|
|
76
|
+
.slice(0, retain);
|
|
77
|
+
const artifacts = { ...document.artifacts };
|
|
78
|
+
if (previous && previous !== artifact.recipeDigest && artifacts[previous]) {
|
|
79
|
+
const references = Math.max(0, artifacts[previous].references - 1);
|
|
80
|
+
artifacts[previous] = { ...artifacts[previous], active: references > 0, references };
|
|
81
|
+
}
|
|
82
|
+
artifacts[artifact.recipeDigest] = { ...artifact, active: true };
|
|
83
|
+
return [{
|
|
84
|
+
...document,
|
|
85
|
+
artifacts,
|
|
86
|
+
activePointers: { ...document.activePointers, [deploymentId]: artifact.recipeDigest },
|
|
87
|
+
retentionReferences: { ...document.retentionReferences, [deploymentId]: retained },
|
|
88
|
+
}, undefined];
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
async removeReference(deploymentId, digest) {
|
|
92
|
+
return this.update((document) => {
|
|
93
|
+
const current = document.artifacts[digest];
|
|
94
|
+
if (!current)
|
|
95
|
+
return [document, undefined];
|
|
96
|
+
const references = Math.max(0, current.references - 1);
|
|
97
|
+
const artifact = { ...current, active: references > 0, references };
|
|
98
|
+
const activePointers = { ...document.activePointers };
|
|
99
|
+
delete activePointers[deploymentId];
|
|
100
|
+
return [{ ...document, artifacts: { ...document.artifacts, [digest]: artifact }, activePointers }, artifact];
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
async rollbackCandidate(deploymentId) {
|
|
104
|
+
const document = await this.snapshot();
|
|
105
|
+
const digest = document.retentionReferences[deploymentId]?.[0];
|
|
106
|
+
return digest ? document.artifacts[digest] : undefined;
|
|
107
|
+
}
|
|
108
|
+
clear() { return this.store.delete(); }
|
|
109
|
+
}
|
|
110
|
+
/** Build exact bounded custom-install review material without credential values. */
|
|
111
|
+
export function buildInstallationReview(recipe, localPolicy) {
|
|
112
|
+
const provider = recipe.provider;
|
|
113
|
+
const source = provider.kind === "custom" ? sanitizeSource(provider.source) : undefined;
|
|
114
|
+
return freeze({
|
|
115
|
+
approvalDigest: installationApprovalDigest(recipe, localPolicy),
|
|
116
|
+
recipeDigest: recipe.digest,
|
|
117
|
+
provider: provider.kind,
|
|
118
|
+
providerDetails: sanitizeProvider(provider),
|
|
119
|
+
...(source ? { source } : {}),
|
|
120
|
+
...(provider.kind === "custom" ? { entrypoint: provider.entrypoint, interpreter: provider.interpreter } : {}),
|
|
121
|
+
arguments: provider.kind === "custom" ? [...(provider.args ?? [])] : [],
|
|
122
|
+
permissions: recipe.permissions,
|
|
123
|
+
outputs: recipe.outputs,
|
|
124
|
+
verification: recipe.verification,
|
|
125
|
+
cleanup: recipe.cleanup,
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
export class InstallationConsentManager {
|
|
129
|
+
state;
|
|
130
|
+
now;
|
|
131
|
+
constructor(state, now = Date.now) {
|
|
132
|
+
this.state = state;
|
|
133
|
+
this.now = now;
|
|
134
|
+
}
|
|
135
|
+
review(recipe, localPolicy) { return buildInstallationReview(recipe, localPolicy); }
|
|
136
|
+
async decide(review, decision, cleanup = false) {
|
|
137
|
+
await this.state.putApproval({ approvalDigest: review.approvalDigest, recipeDigest: review.recipeDigest, decision, cleanup, decidedAt: this.now() });
|
|
138
|
+
}
|
|
139
|
+
async decision(review, cleanup = false) { return this.state.approval(review.approvalDigest, cleanup); }
|
|
140
|
+
}
|
|
141
|
+
/** Interactive local approval flow that presents the complete bounded review. */
|
|
142
|
+
export class TerminalInstallationConsent {
|
|
143
|
+
consent;
|
|
144
|
+
prompt;
|
|
145
|
+
constructor(consent, prompt) {
|
|
146
|
+
this.consent = consent;
|
|
147
|
+
this.prompt = prompt;
|
|
148
|
+
}
|
|
149
|
+
async reviewAndDecide(recipe, localPolicy, cleanup = false) {
|
|
150
|
+
const review = this.consent.review(recipe, cleanup ? { localPolicy, cleanup: true } : localPolicy);
|
|
151
|
+
await this.prompt.present(review);
|
|
152
|
+
const approved = await this.prompt.confirm(cleanup
|
|
153
|
+
? `Approve custom cleanup ${review.approvalDigest}?`
|
|
154
|
+
: `Approve custom installation ${review.approvalDigest}?`);
|
|
155
|
+
await this.consent.decide(review, approved ? "approved" : "denied", cleanup);
|
|
156
|
+
return (await this.consent.decision(review, cleanup));
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
/** Build an enforceable platform adapter when an approved sandbox command is configured. */
|
|
160
|
+
export function commandIsolationAdapter(options = {}) {
|
|
161
|
+
const platform = options.platform ?? process.platform;
|
|
162
|
+
const executable = options.executable;
|
|
163
|
+
if (!executable)
|
|
164
|
+
return noIsolationAdapter;
|
|
165
|
+
if (platform === "linux")
|
|
166
|
+
return {
|
|
167
|
+
filesystem: true,
|
|
168
|
+
network: true,
|
|
169
|
+
wrap: async (input) => ({
|
|
170
|
+
...input,
|
|
171
|
+
command: executable,
|
|
172
|
+
args: ["--die-with-parent", "--unshare-all", "--new-session", "--bind", input.cwd, input.cwd, "--chdir", input.cwd, input.command, ...(input.args ?? [])],
|
|
173
|
+
}),
|
|
174
|
+
};
|
|
175
|
+
if (platform === "darwin")
|
|
176
|
+
return {
|
|
177
|
+
filesystem: true,
|
|
178
|
+
network: true,
|
|
179
|
+
wrap: async (input) => ({
|
|
180
|
+
...input,
|
|
181
|
+
command: executable,
|
|
182
|
+
args: ["-p", `(version 1) (deny default) (allow process*) (allow file-read*) (allow file-write* (subpath "${escapeSandbox(input.cwd)}"))${input.requireNetworkIsolation ? "" : " (allow network*)"}`, input.command, ...(input.args ?? [])],
|
|
183
|
+
}),
|
|
184
|
+
};
|
|
185
|
+
return noIsolationAdapter;
|
|
186
|
+
}
|
|
187
|
+
/** Process-tree supervised, bounded, no-elevation installer execution. */
|
|
188
|
+
export class BoundedInstallerRunner {
|
|
189
|
+
isolation;
|
|
190
|
+
now;
|
|
191
|
+
constructor(isolation = noIsolationAdapter, now = Date.now) {
|
|
192
|
+
this.isolation = isolation;
|
|
193
|
+
this.now = now;
|
|
194
|
+
}
|
|
195
|
+
async run(input) {
|
|
196
|
+
denyElevation(input.command, input.args ?? []);
|
|
197
|
+
if (input.allowedExecutables && !input.allowedExecutables.includes(path.basename(input.command))) {
|
|
198
|
+
throw installationFailure("elevation-denied", "Installer executable is not allowed by local policy.");
|
|
199
|
+
}
|
|
200
|
+
if ((input.requireFilesystemIsolation && !this.isolation.filesystem) || (input.requireNetworkIsolation && !this.isolation.network)) {
|
|
201
|
+
throw installationFailure("isolation-unavailable", "Required installer isolation is unavailable on this platform.");
|
|
202
|
+
}
|
|
203
|
+
const wrapped = await this.isolation.wrap(input);
|
|
204
|
+
await mkdir(wrapped.cwd, { recursive: true, mode: 0o700 });
|
|
205
|
+
const startedAt = this.now();
|
|
206
|
+
return new Promise((resolve, reject) => {
|
|
207
|
+
const child = spawn(wrapped.command, [...(wrapped.args ?? [])], {
|
|
208
|
+
cwd: wrapped.cwd,
|
|
209
|
+
env: allowlistedEnvironment(wrapped.env),
|
|
210
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
211
|
+
detached: process.platform !== "win32",
|
|
212
|
+
windowsHide: true,
|
|
213
|
+
});
|
|
214
|
+
let bytes = 0;
|
|
215
|
+
let output = "";
|
|
216
|
+
let settled = false;
|
|
217
|
+
const terminate = () => terminateTree(child.pid);
|
|
218
|
+
const timer = setTimeout(() => {
|
|
219
|
+
terminate();
|
|
220
|
+
finishError(installationFailure("limit-exceeded", "Installer timed out."));
|
|
221
|
+
}, wrapped.timeoutMs);
|
|
222
|
+
const finishError = (error) => {
|
|
223
|
+
if (settled)
|
|
224
|
+
return;
|
|
225
|
+
settled = true;
|
|
226
|
+
clearTimeout(timer);
|
|
227
|
+
reject(error);
|
|
228
|
+
};
|
|
229
|
+
const collect = (chunk) => {
|
|
230
|
+
bytes += chunk.length;
|
|
231
|
+
if (bytes > wrapped.maxOutputBytes) {
|
|
232
|
+
terminate();
|
|
233
|
+
finishError(installationFailure("limit-exceeded", "Installer output limit exceeded."));
|
|
234
|
+
return;
|
|
235
|
+
}
|
|
236
|
+
output += chunk.toString("utf8");
|
|
237
|
+
};
|
|
238
|
+
child.stdout?.on("data", collect);
|
|
239
|
+
child.stderr?.on("data", collect);
|
|
240
|
+
child.once("error", finishError);
|
|
241
|
+
child.once("exit", async (code) => {
|
|
242
|
+
if (settled)
|
|
243
|
+
return;
|
|
244
|
+
try {
|
|
245
|
+
if (await directoryBytes(wrapped.cwd) > wrapped.maxDiskBytes)
|
|
246
|
+
throw installationFailure("limit-exceeded", "Installer disk limit exceeded.");
|
|
247
|
+
const sanitized = redactInstallerText(output, wrapped.secrets ?? []);
|
|
248
|
+
if (code !== 0)
|
|
249
|
+
throw installationFailure("installation-failed", `Installer exited with code ${code ?? -1}: ${sanitized}`);
|
|
250
|
+
settled = true;
|
|
251
|
+
clearTimeout(timer);
|
|
252
|
+
resolve({ code: 0, output: sanitized, startedAt, finishedAt: this.now(), ...(child.pid ? { pid: child.pid } : {}) });
|
|
253
|
+
}
|
|
254
|
+
catch (error) {
|
|
255
|
+
finishError(error);
|
|
256
|
+
}
|
|
257
|
+
});
|
|
258
|
+
});
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
export class NodeInstallationCommandExecutor {
|
|
262
|
+
runner;
|
|
263
|
+
constructor(runner = new BoundedInstallerRunner()) {
|
|
264
|
+
this.runner = runner;
|
|
265
|
+
}
|
|
266
|
+
async run(command, args, options) {
|
|
267
|
+
return (await this.runner.run({ command, args, cwd: options.cwd, env: options.env, timeoutMs: 300_000, maxOutputBytes: 1_048_576, maxDiskBytes: 1_073_741_824 })).output;
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
export class FetchInstallationDownloader {
|
|
271
|
+
async download(url, destination, credential, maxBytes = 536_870_912) {
|
|
272
|
+
const response = await fetch(url, { redirect: "error", headers: credential ? { authorization: `Bearer ${credential}` } : {} });
|
|
273
|
+
if (!response.ok || !response.body)
|
|
274
|
+
throw installationFailure("source-unavailable", `Archive download failed with status ${response.status}.`);
|
|
275
|
+
const declared = Number(response.headers.get("content-length") ?? 0);
|
|
276
|
+
if (declared > maxBytes)
|
|
277
|
+
throw installationFailure("limit-exceeded", "Archive exceeds the source size limit.");
|
|
278
|
+
const handle = await open(destination, "wx", 0o600);
|
|
279
|
+
let total = 0;
|
|
280
|
+
try {
|
|
281
|
+
for await (const chunk of response.body) {
|
|
282
|
+
total += chunk.byteLength;
|
|
283
|
+
if (total > maxBytes)
|
|
284
|
+
throw installationFailure("limit-exceeded", "Archive exceeds the source size limit.");
|
|
285
|
+
await handle.write(chunk);
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
finally {
|
|
289
|
+
await handle.close();
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
/** Tar archive extraction with preflight type/path checks and post-extract containment. */
|
|
294
|
+
export class SecureTarExtractor {
|
|
295
|
+
commands;
|
|
296
|
+
constructor(commands = new NodeInstallationCommandExecutor()) {
|
|
297
|
+
this.commands = commands;
|
|
298
|
+
}
|
|
299
|
+
async extract(archive, destination) {
|
|
300
|
+
await mkdir(destination, { recursive: true, mode: 0o700 });
|
|
301
|
+
const listing = await this.commands.run("tar", ["-tvf", archive], { cwd: destination });
|
|
302
|
+
for (const line of listing.split("\n").filter(Boolean)) {
|
|
303
|
+
const type = line[0];
|
|
304
|
+
if (type !== "-" && type !== "d")
|
|
305
|
+
throw installationFailure("source-integrity-failed", "Archive contains links or special files.");
|
|
306
|
+
const name = line.trim().split(/\s+/).at(-1) ?? "";
|
|
307
|
+
assertContainedRelative(name, "archive entry");
|
|
308
|
+
}
|
|
309
|
+
await this.commands.run("tar", ["-xf", archive, "--no-same-owner", "--no-same-permissions", "-C", destination], { cwd: destination });
|
|
310
|
+
await assertTreeSafe(destination);
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
/** Immutable Git/archive/inline/local/enterprise staging implementation. */
|
|
314
|
+
export class ManagedInstallationSourceResolver {
|
|
315
|
+
options;
|
|
316
|
+
constructor(options = {}) {
|
|
317
|
+
this.options = options;
|
|
318
|
+
}
|
|
319
|
+
async resolve(source, destination) {
|
|
320
|
+
await rm(destination, { recursive: true, force: true });
|
|
321
|
+
await mkdir(destination, { recursive: true, mode: 0o700 });
|
|
322
|
+
if (source.kind === "git")
|
|
323
|
+
await this.git(source, destination);
|
|
324
|
+
else if (source.kind === "archive")
|
|
325
|
+
await this.archive(source, destination);
|
|
326
|
+
else if (source.kind === "inline")
|
|
327
|
+
await this.inline(source, destination);
|
|
328
|
+
else if (source.kind === "local")
|
|
329
|
+
await this.local(source, destination);
|
|
330
|
+
else
|
|
331
|
+
await this.enterprise(source, destination);
|
|
332
|
+
const digest = await hashTree(destination);
|
|
333
|
+
const expected = source.kind === "local" || source.kind === "enterprise" ? source.integrity : undefined;
|
|
334
|
+
if (expected && digest !== expected) {
|
|
335
|
+
await rm(destination, { recursive: true, force: true });
|
|
336
|
+
throw installationFailure("source-integrity-failed", "Resolved source digest does not match the recipe.");
|
|
337
|
+
}
|
|
338
|
+
return { root: destination, digest };
|
|
339
|
+
}
|
|
340
|
+
async git(source, destination) {
|
|
341
|
+
const commands = this.options.commands ?? new NodeInstallationCommandExecutor();
|
|
342
|
+
const credential = source.credentialRef ? await this.options.credentials?.get(source.credentialRef) : undefined;
|
|
343
|
+
if (source.credentialRef && !credential)
|
|
344
|
+
throw installationFailure("source-credential-required", "Private Git source credential is unavailable locally.");
|
|
345
|
+
const askpass = path.join(destination, process.platform === "win32" ? ".fentaris-askpass.cmd" : ".fentaris-askpass");
|
|
346
|
+
if (credential) {
|
|
347
|
+
const contents = process.platform === "win32"
|
|
348
|
+
? "@echo off\r\necho %FENTARIS_SOURCE_CREDENTIAL%\r\n"
|
|
349
|
+
: "#!/bin/sh\nprintf '%s\\n' \"$FENTARIS_SOURCE_CREDENTIAL\"\n";
|
|
350
|
+
await writeFile(askpass, contents, { encoding: "utf8", mode: 0o700 });
|
|
351
|
+
await chmod(askpass, 0o700).catch(() => undefined);
|
|
352
|
+
}
|
|
353
|
+
const env = credential ? {
|
|
354
|
+
GIT_TERMINAL_PROMPT: "0",
|
|
355
|
+
GIT_ASKPASS: askpass,
|
|
356
|
+
GIT_ASKPASS_REQUIRE: "force",
|
|
357
|
+
FENTARIS_SOURCE_CREDENTIAL: credential,
|
|
358
|
+
} : { GIT_TERMINAL_PROMPT: "0" };
|
|
359
|
+
await commands.run("git", ["init", "--quiet"], { cwd: destination });
|
|
360
|
+
await commands.run("git", ["remote", "add", "origin", source.repository], { cwd: destination });
|
|
361
|
+
try {
|
|
362
|
+
await commands.run("git", ["fetch", "--depth", "1", "origin", source.commit], { cwd: destination, env });
|
|
363
|
+
}
|
|
364
|
+
finally {
|
|
365
|
+
await rm(askpass, { force: true });
|
|
366
|
+
}
|
|
367
|
+
await commands.run("git", ["checkout", "--detach", "--quiet", "FETCH_HEAD"], { cwd: destination });
|
|
368
|
+
const actual = (await commands.run("git", ["rev-parse", "HEAD"], { cwd: destination })).trim();
|
|
369
|
+
if (actual !== source.commit)
|
|
370
|
+
throw installationFailure("source-integrity-failed", "Git checkout did not resolve to the exact requested commit.");
|
|
371
|
+
const gitmodules = path.join(destination, ".gitmodules");
|
|
372
|
+
if (await exists(gitmodules))
|
|
373
|
+
throw installationFailure("source-integrity-failed", "Git submodules are disabled by the bounded source policy.");
|
|
374
|
+
await rm(path.join(destination, ".git"), { recursive: true, force: true });
|
|
375
|
+
}
|
|
376
|
+
async archive(source, destination) {
|
|
377
|
+
const archive = `${destination}.archive`;
|
|
378
|
+
const credential = source.credentialRef ? await this.options.credentials?.get(source.credentialRef) : undefined;
|
|
379
|
+
if (source.credentialRef && !credential)
|
|
380
|
+
throw installationFailure("source-credential-required", "Private source credential is unavailable locally.");
|
|
381
|
+
try {
|
|
382
|
+
await (this.options.downloader ?? new FetchInstallationDownloader()).download(source.url, archive, credential, source.maxBytes);
|
|
383
|
+
if (await hashFile(archive) !== source.integrity)
|
|
384
|
+
throw installationFailure("source-integrity-failed", "Archive digest mismatch.");
|
|
385
|
+
await (this.options.extractor ?? new SecureTarExtractor()).extract(archive, destination);
|
|
386
|
+
}
|
|
387
|
+
finally {
|
|
388
|
+
await rm(archive, { force: true });
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
async inline(source, destination) {
|
|
392
|
+
const filename = source.filename ?? "installer";
|
|
393
|
+
assertContainedRelative(filename, "inline filename");
|
|
394
|
+
await writeFile(path.join(destination, filename), source.content, { encoding: "utf8", mode: 0o700 });
|
|
395
|
+
}
|
|
396
|
+
async local(source, destination) {
|
|
397
|
+
const granted = await this.options.local?.resolve(source.grantRef);
|
|
398
|
+
if (!granted)
|
|
399
|
+
throw installationFailure("source-unavailable", "Approved local source grant is unavailable.");
|
|
400
|
+
const info = await lstat(granted);
|
|
401
|
+
if (info.isSymbolicLink() || (!info.isFile() && !info.isDirectory()))
|
|
402
|
+
throw installationFailure("source-integrity-failed", "Local source is a link or special file.");
|
|
403
|
+
if (info.isDirectory())
|
|
404
|
+
await cp(granted, destination, { recursive: true, dereference: false, errorOnExist: false });
|
|
405
|
+
else
|
|
406
|
+
await copyFile(granted, path.join(destination, path.basename(granted)));
|
|
407
|
+
await assertTreeSafe(destination);
|
|
408
|
+
}
|
|
409
|
+
async enterprise(source, destination) {
|
|
410
|
+
const adapter = this.options.enterprise?.find((candidate) => candidate.adapter === source.adapter);
|
|
411
|
+
if (!adapter)
|
|
412
|
+
throw installationFailure("source-unavailable", "Enterprise source adapter is unavailable.");
|
|
413
|
+
const credential = source.credentialRef ? await this.options.credentials?.get(source.credentialRef) : undefined;
|
|
414
|
+
if (source.credentialRef && !credential)
|
|
415
|
+
throw installationFailure("source-credential-required", "Enterprise source credential is unavailable locally.");
|
|
416
|
+
await adapter.resolve(source.artifactRef, destination, credential);
|
|
417
|
+
await assertTreeSafe(destination);
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
export class NodeInstallationVerificationService {
|
|
421
|
+
async verify(recipe, root) {
|
|
422
|
+
const canonicalRoot = await realpath(root);
|
|
423
|
+
const outputs = {};
|
|
424
|
+
for (const output of recipe.outputs) {
|
|
425
|
+
const candidate = await containedPath(canonicalRoot, output.path);
|
|
426
|
+
const info = await stat(candidate);
|
|
427
|
+
if (output.kind === "directory" ? !info.isDirectory() : !info.isFile())
|
|
428
|
+
throw installationFailure("verification-failed", `Declared output ${output.name} has the wrong type.`);
|
|
429
|
+
if (output.kind === "executable" && process.platform !== "win32" && (info.mode & 0o111) === 0)
|
|
430
|
+
throw installationFailure("verification-failed", `Declared output ${output.name} is not executable.`);
|
|
431
|
+
outputs[output.name] = candidate;
|
|
432
|
+
}
|
|
433
|
+
for (const verification of recipe.verification)
|
|
434
|
+
await verifyOne(verification, canonicalRoot);
|
|
435
|
+
return freeze(outputs);
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
export class SharedInstallationProvider {
|
|
439
|
+
kind;
|
|
440
|
+
source;
|
|
441
|
+
runner;
|
|
442
|
+
verifier;
|
|
443
|
+
constructor(kind, source, runner, verifier = new NodeInstallationVerificationService()) {
|
|
444
|
+
this.kind = kind;
|
|
445
|
+
this.source = source;
|
|
446
|
+
this.runner = runner;
|
|
447
|
+
this.verifier = verifier;
|
|
448
|
+
}
|
|
449
|
+
async preflight(context) {
|
|
450
|
+
if (context.recipe.provider.kind === "manual") {
|
|
451
|
+
const detect = context.recipe.provider.detect;
|
|
452
|
+
try {
|
|
453
|
+
await this.runner.run(runRequest(context.recipe, detect.target, detect.args ?? [], context.stagingRoot));
|
|
454
|
+
return { ready: true };
|
|
455
|
+
}
|
|
456
|
+
catch {
|
|
457
|
+
return { ready: false, reasonCode: "manual-prerequisite-required" };
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
return { ready: await exists(context.installationRoot) };
|
|
461
|
+
}
|
|
462
|
+
async install(context) {
|
|
463
|
+
const provider = context.recipe.provider;
|
|
464
|
+
await mkdir(context.installationRoot, { recursive: true, mode: 0o700 });
|
|
465
|
+
if (provider.kind === "custom") {
|
|
466
|
+
const staged = await this.source.resolve(provider.source, context.stagingRoot);
|
|
467
|
+
const entrypoint = await containedPath(staged.root, provider.entrypoint);
|
|
468
|
+
const command = interpreterCommand(provider.interpreter, entrypoint);
|
|
469
|
+
const args = provider.interpreter === "executable" ? [...(provider.args ?? [])] : [entrypoint, ...(provider.args ?? [])];
|
|
470
|
+
await this.runner.run(runRequest(context.recipe, command, args, staged.root));
|
|
471
|
+
await cp(staged.root, context.installationRoot, { recursive: true, force: true });
|
|
472
|
+
return;
|
|
473
|
+
}
|
|
474
|
+
if (provider.kind === "binary") {
|
|
475
|
+
const staged = await this.source.resolve(provider.source, context.stagingRoot);
|
|
476
|
+
await cp(staged.root, context.installationRoot, { recursive: true, force: true });
|
|
477
|
+
return;
|
|
478
|
+
}
|
|
479
|
+
if (provider.kind === "node-package") {
|
|
480
|
+
const spec = `${provider.package}@${provider.version}`;
|
|
481
|
+
await mkdir(context.stagingRoot, { recursive: true, mode: 0o700 });
|
|
482
|
+
const packed = await this.runner.run(runRequest(context.recipe, "npm", ["pack", "--json", "--pack-destination", context.stagingRoot, spec], context.stagingRoot));
|
|
483
|
+
const metadata = JSON.parse(packed.output);
|
|
484
|
+
const filename = metadata[0]?.filename;
|
|
485
|
+
if (!filename)
|
|
486
|
+
throw installationFailure("source-integrity-failed", "npm did not return a packed artifact identity.");
|
|
487
|
+
const tarball = path.join(context.stagingRoot, filename);
|
|
488
|
+
if (await hashFile(tarball) !== provider.integrity)
|
|
489
|
+
throw installationFailure("source-integrity-failed", "Node package integrity mismatch.");
|
|
490
|
+
await this.runner.run(runRequest(context.recipe, "npm", ["install", "--prefix", context.installationRoot, "--save-exact", provider.allowLifecycleScripts ? "--foreground-scripts" : "--ignore-scripts", tarball], context.installationRoot));
|
|
491
|
+
return;
|
|
492
|
+
}
|
|
493
|
+
if (provider.kind === "python") {
|
|
494
|
+
const python = provider.python ?? "python3";
|
|
495
|
+
await mkdir(context.stagingRoot, { recursive: true, mode: 0o700 });
|
|
496
|
+
await this.runner.run(runRequest(context.recipe, python, ["-m", "pip", "download", "--no-deps", "--dest", context.stagingRoot, `${provider.package}==${provider.version}`], context.stagingRoot));
|
|
497
|
+
const distributions = (await readdir(context.stagingRoot, { withFileTypes: true })).filter((entry) => entry.isFile());
|
|
498
|
+
if (distributions.length === 0)
|
|
499
|
+
throw installationFailure("source-integrity-failed", "Python resolver returned no distribution.");
|
|
500
|
+
for (const distribution of distributions) {
|
|
501
|
+
if (!provider.hashes.includes(await hashFile(path.join(context.stagingRoot, distribution.name))))
|
|
502
|
+
throw installationFailure("source-integrity-failed", "Python distribution hash is not declared by the recipe.");
|
|
503
|
+
}
|
|
504
|
+
await this.runner.run(runRequest(context.recipe, python, ["-m", "venv", context.installationRoot], context.installationRoot));
|
|
505
|
+
const pip = process.platform === "win32" ? path.join(context.installationRoot, "Scripts", "pip.exe") : path.join(context.installationRoot, "bin", "pip");
|
|
506
|
+
await this.runner.run(runRequest(context.recipe, pip, ["install", "--no-index", "--no-deps", ...distributions.map((entry) => path.join(context.stagingRoot, entry.name))], context.installationRoot));
|
|
507
|
+
return;
|
|
508
|
+
}
|
|
509
|
+
if (provider.kind === "container") {
|
|
510
|
+
const runtime = provider.runtime ?? "docker";
|
|
511
|
+
await this.runner.run(runRequest(context.recipe, runtime, ["pull", `${provider.image}@${provider.digest}`], context.installationRoot));
|
|
512
|
+
const inspected = await this.runner.run(runRequest(context.recipe, runtime, ["image", "inspect", "--format", "{{json .RepoDigests}}", `${provider.image}@${provider.digest}`], context.installationRoot));
|
|
513
|
+
if (!inspected.output.includes(`@${provider.digest}`))
|
|
514
|
+
throw installationFailure("verification-failed", "Container runtime did not confirm the requested immutable digest.");
|
|
515
|
+
await writeFile(path.join(context.installationRoot, "image.json"), JSON.stringify({ image: provider.image, digest: provider.digest }), { mode: 0o600 });
|
|
516
|
+
return;
|
|
517
|
+
}
|
|
518
|
+
throw installationFailure("manual-prerequisite-required", provider.nextAction);
|
|
519
|
+
}
|
|
520
|
+
verify(context) {
|
|
521
|
+
if (context.recipe.provider.kind === "manual") {
|
|
522
|
+
return Promise.resolve(Object.fromEntries(context.recipe.outputs.map((output) => [output.name, context.recipe.provider.kind === "manual" ? context.recipe.provider.detect.target : output.path])));
|
|
523
|
+
}
|
|
524
|
+
return this.verifier.verify(context.recipe, context.installationRoot);
|
|
525
|
+
}
|
|
526
|
+
async cleanup(context) {
|
|
527
|
+
const cleanup = context.recipe.cleanup;
|
|
528
|
+
if (cleanup.kind === "manual")
|
|
529
|
+
throw installationFailure("manual-prerequisite-required", "Complete the documented cleanup locally.");
|
|
530
|
+
if (cleanup.kind === "custom") {
|
|
531
|
+
if (!cleanup.entrypoint || !cleanup.interpreter)
|
|
532
|
+
throw installationFailure("verification-failed", "Custom cleanup is missing an entrypoint or interpreter.");
|
|
533
|
+
const entrypoint = await containedPath(context.installationRoot, cleanup.entrypoint);
|
|
534
|
+
const command = interpreterCommand(cleanup.interpreter, entrypoint);
|
|
535
|
+
const args = cleanup.interpreter === "executable" ? [...(cleanup.args ?? [])] : [entrypoint, ...(cleanup.args ?? [])];
|
|
536
|
+
await this.runner.run(runRequest(context.recipe, command, args, context.installationRoot));
|
|
537
|
+
}
|
|
538
|
+
await rm(context.installationRoot, { recursive: true, force: true });
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
/** Idempotent, crash-aware installation lifecycle coordinator. */
|
|
542
|
+
export class InstallationCoordinator {
|
|
543
|
+
options;
|
|
544
|
+
now;
|
|
545
|
+
attemptId;
|
|
546
|
+
constructor(options) {
|
|
547
|
+
this.options = options;
|
|
548
|
+
this.now = options.now ?? Date.now;
|
|
549
|
+
this.attemptId = options.attemptId ?? randomUUID;
|
|
550
|
+
}
|
|
551
|
+
async reconcile(request) {
|
|
552
|
+
const recipe = validateInstallationRecipe(request.recipe);
|
|
553
|
+
const root = path.join(this.options.installationRoot, digestDirectory(recipe.digest));
|
|
554
|
+
return this.options.lock.runExclusive(root, async () => {
|
|
555
|
+
const existing = await this.options.state.lifecycle(request.deploymentId);
|
|
556
|
+
if (existing && existing.desiredVersion > request.desiredVersion)
|
|
557
|
+
throw edgeError("EDGE_PROTOCOL", "Installation desired state is stale.");
|
|
558
|
+
if (existing && existing.desiredVersion === request.desiredVersion && existing.recipeDigest === recipe.digest) {
|
|
559
|
+
if (["ready", "installed"].includes(existing.state))
|
|
560
|
+
return existing;
|
|
561
|
+
if (!request.explicitRetry && existing.state === "failed")
|
|
562
|
+
return existing;
|
|
563
|
+
}
|
|
564
|
+
if (!platformSupported(recipe, this.options.platform ?? process.platform, this.options.architecture ?? process.arch)) {
|
|
565
|
+
return this.transition(request, "blocked", { reasonCode: "unsupported-platform", retryable: false });
|
|
566
|
+
}
|
|
567
|
+
const provider = this.options.providers.get(recipe.provider.kind);
|
|
568
|
+
if (!provider)
|
|
569
|
+
return this.transition(request, "blocked", { reasonCode: "agent-upgrade-required", retryable: false });
|
|
570
|
+
if (recipe.provider.kind === "custom") {
|
|
571
|
+
const review = this.options.consent.review(recipe, request.localPolicy ?? {});
|
|
572
|
+
const approval = await this.options.consent.decision(review);
|
|
573
|
+
if (!approval) {
|
|
574
|
+
await this.emit("edge.installation.approval", request, "required", { approvalDigest: review.approvalDigest, sourceKind: recipe.provider.source.kind });
|
|
575
|
+
return this.transition(request, "approval-required", { reasonCode: "approval-required", retryable: false, nextAction: `Review installation ${review.approvalDigest}.` });
|
|
576
|
+
}
|
|
577
|
+
if (approval.decision !== "approved")
|
|
578
|
+
return this.transition(request, "blocked", { reasonCode: approval.decision === "revoked" ? "approval-revoked" : "approval-denied", retryable: false });
|
|
579
|
+
}
|
|
580
|
+
const artifact = await this.options.state.artifact(recipe.digest);
|
|
581
|
+
if (artifact && await exists(artifact.root)) {
|
|
582
|
+
await this.options.state.activate(request.deploymentId, { ...artifact, active: true, references: artifact.references + 1 }, recipe.retention.previousVersions);
|
|
583
|
+
return this.transition(request, "installed", { retryable: false });
|
|
584
|
+
}
|
|
585
|
+
const prior = await this.options.state.attempts(recipe.digest);
|
|
586
|
+
if (!request.explicitRetry && prior.some((attempt) => attempt.state === "failed")) {
|
|
587
|
+
return this.transition(request, "failed", { reasonCode: prior.at(-1)?.reasonCode ?? "installation-failed", retryable: true, attempt: prior.at(-1) });
|
|
588
|
+
}
|
|
589
|
+
const attemptId = this.attemptId();
|
|
590
|
+
const startedAt = this.now();
|
|
591
|
+
const attempt = { attemptId, deploymentId: request.deploymentId, desiredVersion: request.desiredVersion, recipeDigest: recipe.digest, state: "installing", startedAt, retryable: false };
|
|
592
|
+
await this.options.state.putAttempt(attempt);
|
|
593
|
+
await this.transition(request, "installing", { retryable: false, attempt });
|
|
594
|
+
const stagingRoot = path.join(this.options.stagingRoot, attemptId);
|
|
595
|
+
const context = { recipe, attemptId, stagingRoot, installationRoot: root };
|
|
596
|
+
try {
|
|
597
|
+
const preflight = await provider.preflight(context);
|
|
598
|
+
if (!preflight.ready) {
|
|
599
|
+
await this.emit("edge.installation.source", request, "resolving", { provider: recipe.provider.kind, sourceKind: recipe.provider.kind === "custom" ? recipe.provider.source.kind : recipe.provider.kind });
|
|
600
|
+
await provider.install(context);
|
|
601
|
+
}
|
|
602
|
+
const outputs = await provider.verify(context);
|
|
603
|
+
await this.emit("edge.installation.verification", request, "verified", { attemptId, outputNames: Object.keys(outputs).sort() });
|
|
604
|
+
const verified = { recipeDigest: recipe.digest, root, outputs, verifiedAt: this.now(), active: true, references: 1, externalSideEffects: recipe.cleanup.externalSideEffects };
|
|
605
|
+
await this.options.state.putArtifact(verified);
|
|
606
|
+
await this.options.state.activate(request.deploymentId, verified, recipe.retention.previousVersions);
|
|
607
|
+
await this.emit("edge.installation.activation", request, "activated", { attemptId, rollbackAvailable: recipe.retention.previousVersions > 0 });
|
|
608
|
+
const complete = { ...attempt, state: "installed", finishedAt: this.now() };
|
|
609
|
+
await this.options.state.putAttempt(complete);
|
|
610
|
+
return this.transition(request, "installed", { retryable: false, attempt: complete });
|
|
611
|
+
}
|
|
612
|
+
catch (error) {
|
|
613
|
+
const reasonCode = installationReason(error);
|
|
614
|
+
const failed = { ...attempt, state: "failed", finishedAt: this.now(), reasonCode, retryable: retryableReason(reasonCode), diagnostics: [redactInstallerText(error instanceof Error ? error.message : String(error))] };
|
|
615
|
+
await this.options.state.putAttempt(failed);
|
|
616
|
+
await this.emit("edge.installation.attempt", request, "failed", { attemptId, reasonCode, retryable: failed.retryable });
|
|
617
|
+
return this.transition(request, "failed", { reasonCode, retryable: failed.retryable, attempt: failed });
|
|
618
|
+
}
|
|
619
|
+
finally {
|
|
620
|
+
await rm(stagingRoot, { recursive: true, force: true });
|
|
621
|
+
}
|
|
622
|
+
});
|
|
623
|
+
}
|
|
624
|
+
status(deploymentId) {
|
|
625
|
+
return this.options.state.lifecycle(deploymentId);
|
|
626
|
+
}
|
|
627
|
+
review(recipe, localPolicy = {}, cleanup = false) {
|
|
628
|
+
return this.options.consent.review(recipe, cleanup ? { localPolicy, cleanup: true } : localPolicy);
|
|
629
|
+
}
|
|
630
|
+
async decide(recipe, decision, localPolicy = {}, cleanup = false) {
|
|
631
|
+
const review = this.review(recipe, localPolicy, cleanup);
|
|
632
|
+
await this.options.consent.decide(review, decision, cleanup);
|
|
633
|
+
return (await this.options.consent.decision(review, cleanup));
|
|
634
|
+
}
|
|
635
|
+
async recoverInterrupted() {
|
|
636
|
+
const document = await this.options.state.snapshot();
|
|
637
|
+
const interrupted = [];
|
|
638
|
+
for (const attempt of Object.values(document.attempts).filter((candidate) => candidate.state === "installing")) {
|
|
639
|
+
for (const pid of attempt.processIds ?? [])
|
|
640
|
+
await this.options.terminateProcess?.(pid);
|
|
641
|
+
const updated = { ...attempt, state: "failed", finishedAt: this.now(), reasonCode: "attempt-interrupted", retryable: true };
|
|
642
|
+
await this.options.state.putAttempt(updated);
|
|
643
|
+
interrupted.push(updated);
|
|
644
|
+
}
|
|
645
|
+
return interrupted;
|
|
646
|
+
}
|
|
647
|
+
async remove(deploymentId, recipe, approveCleanup = false) {
|
|
648
|
+
const existing = await this.options.state.lifecycle(deploymentId);
|
|
649
|
+
const request = { deploymentId, desiredVersion: existing?.desiredVersion ?? 0, launchDigest: existing?.launchDigest ?? "sha256:removed", recipe };
|
|
650
|
+
if (recipe.cleanup.kind === "custom" && recipe.cleanup.externalSideEffects) {
|
|
651
|
+
const review = this.options.consent.review(recipe, { cleanup: true });
|
|
652
|
+
const approval = await this.options.consent.decision(review, true);
|
|
653
|
+
if (!approveCleanup || approval?.decision !== "approved")
|
|
654
|
+
return this.transition(request, "blocked", { reasonCode: "cleanup-approval-required", retryable: false });
|
|
655
|
+
}
|
|
656
|
+
await this.transition(request, "removing", { retryable: false });
|
|
657
|
+
const current = await this.options.state.artifact(recipe.digest);
|
|
658
|
+
if (current && current.references <= 1) {
|
|
659
|
+
const provider = this.options.providers.get(recipe.provider.kind);
|
|
660
|
+
if (!provider)
|
|
661
|
+
return this.transition(request, "blocked", { reasonCode: "agent-upgrade-required", retryable: false });
|
|
662
|
+
try {
|
|
663
|
+
await provider.cleanup({
|
|
664
|
+
recipe,
|
|
665
|
+
attemptId: `cleanup-${randomUUID()}`,
|
|
666
|
+
stagingRoot: this.options.stagingRoot,
|
|
667
|
+
installationRoot: current.root,
|
|
668
|
+
});
|
|
669
|
+
}
|
|
670
|
+
catch (error) {
|
|
671
|
+
const reasonCode = installationReason(error);
|
|
672
|
+
return this.transition(request, "failed", { reasonCode, retryable: retryableReason(reasonCode) });
|
|
673
|
+
}
|
|
674
|
+
}
|
|
675
|
+
const artifact = await this.options.state.removeReference(deploymentId, recipe.digest);
|
|
676
|
+
if (artifact?.references === 0)
|
|
677
|
+
await rm(artifact.root, { recursive: true, force: true });
|
|
678
|
+
await this.emit("edge.installation.cleanup", request, "removed", { managedOnly: true, remainingReferences: artifact?.references ?? 0 });
|
|
679
|
+
return this.transition(request, "removed", { retryable: false });
|
|
680
|
+
}
|
|
681
|
+
async rollback(deploymentId, request) {
|
|
682
|
+
const candidate = await this.options.state.rollbackCandidate(deploymentId);
|
|
683
|
+
if (!candidate || candidate.externalSideEffects || !await exists(candidate.root)) {
|
|
684
|
+
return this.transition(request, "failed", { reasonCode: "artifact-missing", retryable: false, nextAction: "Perform manual recovery; no safe verified rollback is available." });
|
|
685
|
+
}
|
|
686
|
+
await this.options.state.activate(deploymentId, { ...candidate, active: true, references: candidate.references + 1 }, request.recipe.retention.previousVersions);
|
|
687
|
+
await this.emit("edge.installation.rollback", request, "activated", { rollbackDigest: candidate.recipeDigest });
|
|
688
|
+
return this.transition({ ...request, recipe: { ...request.recipe, digest: candidate.recipeDigest } }, "installed", { retryable: false });
|
|
689
|
+
}
|
|
690
|
+
async resolveArtifact(reference) {
|
|
691
|
+
const artifact = await this.options.state.artifact(reference.installationDigest);
|
|
692
|
+
const output = artifact?.outputs[reference.output];
|
|
693
|
+
if (!artifact?.active || !output)
|
|
694
|
+
throw installationFailure("artifact-missing", "Installed artifact output is unavailable.");
|
|
695
|
+
return containedPath(await realpath(artifact.root), path.relative(artifact.root, output));
|
|
696
|
+
}
|
|
697
|
+
async transition(request, state, options) {
|
|
698
|
+
const lifecycle = {
|
|
699
|
+
deploymentId: request.deploymentId,
|
|
700
|
+
desiredVersion: request.desiredVersion,
|
|
701
|
+
recipeDigest: request.recipe.digest,
|
|
702
|
+
launchDigest: request.launchDigest,
|
|
703
|
+
state,
|
|
704
|
+
readiness: normalizeInstallationReadiness(state),
|
|
705
|
+
observedAt: this.now(),
|
|
706
|
+
...(options.reasonCode ? { reasonCode: options.reasonCode } : {}),
|
|
707
|
+
...(options.attempt ? { attempt: options.attempt } : {}),
|
|
708
|
+
...(options.nextAction ? { nextAction: options.nextAction } : {}),
|
|
709
|
+
};
|
|
710
|
+
await this.options.state.putLifecycle(lifecycle);
|
|
711
|
+
return lifecycle;
|
|
712
|
+
}
|
|
713
|
+
async emit(name, request, outcome, metadata) {
|
|
714
|
+
await this.options.telemetry?.emit({ name, deploymentId: request.deploymentId, outcome, metadata: { recipeDigest: request.recipe.digest, desiredVersion: request.desiredVersion, ...metadata } }).catch(() => undefined);
|
|
715
|
+
}
|
|
716
|
+
}
|
|
717
|
+
export function createDefaultInstallationProviders(options = {}) {
|
|
718
|
+
const source = options.source ?? new ManagedInstallationSourceResolver();
|
|
719
|
+
const runner = options.runner ?? new BoundedInstallerRunner();
|
|
720
|
+
const kinds = ["custom", "node-package", "python", "binary", "container", "manual"];
|
|
721
|
+
return new Map(kinds.map((kind) => [kind, new SharedInstallationProvider(kind, source, runner, options.verifier)]));
|
|
722
|
+
}
|
|
723
|
+
const noIsolationAdapter = Object.freeze({
|
|
724
|
+
filesystem: false,
|
|
725
|
+
network: false,
|
|
726
|
+
wrap: async (input) => input,
|
|
727
|
+
});
|
|
728
|
+
function runRequest(recipe, command, args, cwd) {
|
|
729
|
+
return {
|
|
730
|
+
command, args, cwd,
|
|
731
|
+
timeoutMs: recipe.permissions.limits.timeoutMs,
|
|
732
|
+
maxOutputBytes: recipe.permissions.limits.maxOutputBytes,
|
|
733
|
+
maxDiskBytes: recipe.permissions.limits.maxDiskBytes,
|
|
734
|
+
allowedExecutables: recipe.permissions.executables,
|
|
735
|
+
requireFilesystemIsolation: recipe.permissions.requireFilesystemIsolation,
|
|
736
|
+
requireNetworkIsolation: recipe.permissions.requireNetworkIsolation,
|
|
737
|
+
};
|
|
738
|
+
}
|
|
739
|
+
function sanitizeSource(source) {
|
|
740
|
+
const copy = { ...source };
|
|
741
|
+
delete copy.credentialRef;
|
|
742
|
+
if (source.kind === "inline")
|
|
743
|
+
copy.content = source.content.slice(0, 32_768);
|
|
744
|
+
return freeze(copy);
|
|
745
|
+
}
|
|
746
|
+
function sanitizeProvider(provider) {
|
|
747
|
+
const copy = structuredClone(provider);
|
|
748
|
+
if (provider.kind === "custom")
|
|
749
|
+
copy.source = sanitizeSource(provider.source);
|
|
750
|
+
return freeze(copy);
|
|
751
|
+
}
|
|
752
|
+
function allowlistedEnvironment(input = {}) {
|
|
753
|
+
const env = {};
|
|
754
|
+
for (const key of ["PATH", "SystemRoot", "WINDIR", "TMPDIR", "TEMP", "TMP"])
|
|
755
|
+
if (process.env[key])
|
|
756
|
+
env[key] = process.env[key];
|
|
757
|
+
for (const [key, value] of Object.entries(input))
|
|
758
|
+
if (/^[A-Z_][A-Z0-9_]*$/.test(key))
|
|
759
|
+
env[key] = value;
|
|
760
|
+
return env;
|
|
761
|
+
}
|
|
762
|
+
function denyElevation(command, args) {
|
|
763
|
+
const tokens = [path.basename(command), ...args].map((value) => value.toLowerCase());
|
|
764
|
+
if (tokens.some((value) => ["sudo", "su", "doas", "runas", "pkexec", "start-process -verb runas"].some((term) => value === term || value.includes(term)))) {
|
|
765
|
+
throw installationFailure("elevation-denied", "Installer elevation is not permitted.");
|
|
766
|
+
}
|
|
767
|
+
}
|
|
768
|
+
function terminateTree(pid) {
|
|
769
|
+
if (!pid)
|
|
770
|
+
return;
|
|
771
|
+
try {
|
|
772
|
+
process.kill(process.platform === "win32" ? pid : -pid, "SIGTERM");
|
|
773
|
+
}
|
|
774
|
+
catch { /* already exited */ }
|
|
775
|
+
const timer = setTimeout(() => { try {
|
|
776
|
+
process.kill(process.platform === "win32" ? pid : -pid, "SIGKILL");
|
|
777
|
+
}
|
|
778
|
+
catch { /* already exited */ } }, 2_000);
|
|
779
|
+
timer.unref();
|
|
780
|
+
}
|
|
781
|
+
async function verifyOne(verification, root) {
|
|
782
|
+
if (verification.kind === "container-image")
|
|
783
|
+
return;
|
|
784
|
+
const target = await containedPath(root, verification.target);
|
|
785
|
+
const info = await stat(target);
|
|
786
|
+
if (!info.isFile())
|
|
787
|
+
throw installationFailure("verification-failed", "Verification target is not a file.");
|
|
788
|
+
if (verification.expectedDigest && await hashFile(target) !== verification.expectedDigest)
|
|
789
|
+
throw installationFailure("verification-failed", "Verification digest mismatch.");
|
|
790
|
+
}
|
|
791
|
+
async function containedPath(root, relative) {
|
|
792
|
+
assertContainedRelative(relative, "managed path");
|
|
793
|
+
const candidate = path.resolve(root, relative);
|
|
794
|
+
const parent = await realpath(path.dirname(candidate));
|
|
795
|
+
if (parent !== root && !parent.startsWith(`${root}${path.sep}`))
|
|
796
|
+
throw installationFailure("artifact-escape", "Managed path escapes the artifact root.");
|
|
797
|
+
return candidate;
|
|
798
|
+
}
|
|
799
|
+
function assertContainedRelative(value, field) {
|
|
800
|
+
if (!value || path.isAbsolute(value) || value.split(/[\\/]/).includes(".."))
|
|
801
|
+
throw installationFailure("source-integrity-failed", `${field} escapes the managed root.`);
|
|
802
|
+
}
|
|
803
|
+
async function assertTreeSafe(root) {
|
|
804
|
+
const canonical = await realpath(root);
|
|
805
|
+
const visit = async (directory) => {
|
|
806
|
+
for (const entry of await readdir(directory, { withFileTypes: true })) {
|
|
807
|
+
const candidate = path.join(directory, entry.name);
|
|
808
|
+
const info = await lstat(candidate);
|
|
809
|
+
if (info.isSymbolicLink() || (!info.isFile() && !info.isDirectory()))
|
|
810
|
+
throw installationFailure("source-integrity-failed", "Source contains a symlink or special file.");
|
|
811
|
+
const resolved = await realpath(candidate);
|
|
812
|
+
if (resolved !== canonical && !resolved.startsWith(`${canonical}${path.sep}`))
|
|
813
|
+
throw installationFailure("source-integrity-failed", "Source escapes its staging root.");
|
|
814
|
+
if (info.isDirectory())
|
|
815
|
+
await visit(candidate);
|
|
816
|
+
}
|
|
817
|
+
};
|
|
818
|
+
await visit(root);
|
|
819
|
+
}
|
|
820
|
+
async function hashFile(file) {
|
|
821
|
+
return `sha256:${createHash("sha256").update(await readFile(file)).digest("hex")}`;
|
|
822
|
+
}
|
|
823
|
+
async function hashTree(root) {
|
|
824
|
+
const entries = [];
|
|
825
|
+
const walk = async (directory) => {
|
|
826
|
+
for (const entry of (await readdir(directory, { withFileTypes: true })).sort((a, b) => a.name.localeCompare(b.name))) {
|
|
827
|
+
if (entry.name === ".git")
|
|
828
|
+
continue;
|
|
829
|
+
const candidate = path.join(directory, entry.name);
|
|
830
|
+
if (entry.isDirectory())
|
|
831
|
+
await walk(candidate);
|
|
832
|
+
else if (entry.isFile())
|
|
833
|
+
entries.push({ path: path.relative(root, candidate).split(path.sep).join("/"), digest: await hashFile(candidate) });
|
|
834
|
+
else
|
|
835
|
+
throw installationFailure("source-integrity-failed", "Source contains a link or special file.");
|
|
836
|
+
}
|
|
837
|
+
};
|
|
838
|
+
await walk(root);
|
|
839
|
+
return computeInstallationDigest(entries);
|
|
840
|
+
}
|
|
841
|
+
async function directoryBytes(root) {
|
|
842
|
+
let total = 0;
|
|
843
|
+
const walk = async (directory) => {
|
|
844
|
+
for (const entry of await readdir(directory, { withFileTypes: true })) {
|
|
845
|
+
const candidate = path.join(directory, entry.name);
|
|
846
|
+
if (entry.isDirectory())
|
|
847
|
+
await walk(candidate);
|
|
848
|
+
else if (entry.isFile())
|
|
849
|
+
total += (await stat(candidate)).size;
|
|
850
|
+
}
|
|
851
|
+
};
|
|
852
|
+
await walk(root);
|
|
853
|
+
return total;
|
|
854
|
+
}
|
|
855
|
+
function interpreterCommand(interpreter, entrypoint) {
|
|
856
|
+
if (interpreter === "executable")
|
|
857
|
+
return entrypoint;
|
|
858
|
+
if (interpreter === "powershell")
|
|
859
|
+
return process.platform === "win32" ? "powershell.exe" : "pwsh";
|
|
860
|
+
if (interpreter === "python")
|
|
861
|
+
return "python3";
|
|
862
|
+
return interpreter;
|
|
863
|
+
}
|
|
864
|
+
function platformSupported(recipe, platform, architecture) {
|
|
865
|
+
return (!recipe.platforms?.platforms || recipe.platforms.platforms.includes(platform))
|
|
866
|
+
&& (!recipe.platforms?.architectures || recipe.platforms.architectures.includes(architecture));
|
|
867
|
+
}
|
|
868
|
+
function digestDirectory(digest) { return digest.slice("sha256:".length); }
|
|
869
|
+
function escapeSandbox(value) { return value.replaceAll("\\", "\\\\").replaceAll('"', '\\"'); }
|
|
870
|
+
function approvalKey(digest, cleanup = false) { return `${digest}:${cleanup}`; }
|
|
871
|
+
async function exists(file) { try {
|
|
872
|
+
await lstat(file);
|
|
873
|
+
return true;
|
|
874
|
+
}
|
|
875
|
+
catch (error) {
|
|
876
|
+
if (error.code === "ENOENT")
|
|
877
|
+
return false;
|
|
878
|
+
throw error;
|
|
879
|
+
} }
|
|
880
|
+
function retryableReason(reason) { return ["source-unavailable", "limit-exceeded", "installation-failed", "attempt-interrupted"].includes(reason); }
|
|
881
|
+
function installationReason(error) { return error instanceof InstallationExecutionError ? error.reasonCode : "installation-failed"; }
|
|
882
|
+
export class InstallationExecutionError extends Error {
|
|
883
|
+
reasonCode;
|
|
884
|
+
constructor(reasonCode, message) {
|
|
885
|
+
super(message);
|
|
886
|
+
this.reasonCode = reasonCode;
|
|
887
|
+
this.name = `InstallationExecutionError[${reasonCode}]`;
|
|
888
|
+
}
|
|
889
|
+
}
|
|
890
|
+
function installationFailure(reason, message) { return new InstallationExecutionError(reason, message); }
|
|
891
|
+
function freeze(value) { if (value && typeof value === "object") {
|
|
892
|
+
Object.freeze(value);
|
|
893
|
+
for (const child of Object.values(value))
|
|
894
|
+
if (child && typeof child === "object")
|
|
895
|
+
freeze(child);
|
|
896
|
+
} return value; }
|
|
897
|
+
//# sourceMappingURL=installation.js.map
|