@forgezero/agent 0.1.39 → 0.1.40
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 +91 -18
- package/dist/agent-heartbeat.js +16 -14
- package/dist/agent-update-helper.js +15 -13
- package/dist/agent-update.js +15 -13
- package/dist/bootstrap.d.ts +13 -0
- package/dist/bootstrap.js +244 -832
- package/dist/cli/index.d.ts +0 -2
- package/dist/cloudflare-bootstrap.d.ts +21 -128
- package/dist/cloudflare-bootstrap.js +146 -788
- package/dist/cloudflare-edge.d.ts +3 -151
- package/dist/cloudflare-edge.js +28 -248
- package/dist/credential-schema.d.ts +60 -0
- package/dist/credential-schema.js +335 -0
- package/dist/definition.js +2 -2
- package/dist/deploy-file.js +2 -2
- package/dist/fz-agent.js +199 -47
- package/dist/fz.js +536 -1544
- package/dist/index.d.ts +33 -0
- package/dist/metal-bootstrap.js +1 -1
- package/dist/platform-bootstrap-runtime.d.ts +5 -0
- package/dist/platform-bootstrap-runtime.js +25 -6
- package/dist/project-context.js +1 -1
- package/dist/provision.js +18 -16
- package/dist/software-helper.js +2 -2
- package/dist/software.js +2 -2
- package/dist/version.d.ts +1 -1
- package/package.json +8 -4
- package/dist/cli/custody.d.ts +0 -35
|
@@ -0,0 +1,335 @@
|
|
|
1
|
+
// src/cache.ts
|
|
2
|
+
class CacheError extends Error {
|
|
3
|
+
code;
|
|
4
|
+
constructor(code, message) {
|
|
5
|
+
super(message);
|
|
6
|
+
this.code = code;
|
|
7
|
+
this.name = "CacheError";
|
|
8
|
+
}
|
|
9
|
+
}
|
|
10
|
+
var DEFAULT_TTL_MS = 60000;
|
|
11
|
+
var DEFAULT_MAX_STALE_MS = 300000;
|
|
12
|
+
function createSecretCache(options) {
|
|
13
|
+
let entries = new Map;
|
|
14
|
+
const now = options.now ?? (() => Date.now());
|
|
15
|
+
const ttlMs = options.ttlMs ?? DEFAULT_TTL_MS;
|
|
16
|
+
const maxStaleMs = options.maxStaleMs ?? DEFAULT_MAX_STALE_MS;
|
|
17
|
+
let cursor = 0;
|
|
18
|
+
let replicated = false;
|
|
19
|
+
let lastSyncOkMs = now();
|
|
20
|
+
const fetchScope = async () => {
|
|
21
|
+
const next = new Map;
|
|
22
|
+
if (!options.list)
|
|
23
|
+
return { entries: next, loaded: 0, failed: [] };
|
|
24
|
+
const names = await options.list();
|
|
25
|
+
const failed = [];
|
|
26
|
+
for (const name of names) {
|
|
27
|
+
try {
|
|
28
|
+
const result = await options.fetch(name);
|
|
29
|
+
next.set(name, { value: result.value, version: result.version, fetchedAtMs: now() });
|
|
30
|
+
} catch {
|
|
31
|
+
failed.push(name);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
return { entries: next, loaded: next.size, failed };
|
|
35
|
+
};
|
|
36
|
+
const loadScope = async () => {
|
|
37
|
+
if (!options.list)
|
|
38
|
+
return { loaded: 0, failed: [] };
|
|
39
|
+
const snapshot = await fetchScope();
|
|
40
|
+
entries = snapshot.entries;
|
|
41
|
+
replicated = snapshot.failed.length === 0;
|
|
42
|
+
return { loaded: snapshot.loaded, failed: snapshot.failed };
|
|
43
|
+
};
|
|
44
|
+
const refreshScope = async () => {
|
|
45
|
+
const snapshot = await fetchScope();
|
|
46
|
+
if (snapshot.failed.length > 0) {
|
|
47
|
+
throw new CacheError("FETCH_FAILED", `Could not refresh ${snapshot.failed.length} assigned vault ${snapshot.failed.length === 1 ? "entry" : "entries"}.`);
|
|
48
|
+
}
|
|
49
|
+
entries = snapshot.entries;
|
|
50
|
+
replicated = true;
|
|
51
|
+
};
|
|
52
|
+
return {
|
|
53
|
+
names: () => [...entries.keys()],
|
|
54
|
+
get replica() {
|
|
55
|
+
return replicated;
|
|
56
|
+
},
|
|
57
|
+
load: loadScope,
|
|
58
|
+
get cursor() {
|
|
59
|
+
return cursor;
|
|
60
|
+
},
|
|
61
|
+
async get(name) {
|
|
62
|
+
const staleFor = now() - lastSyncOkMs;
|
|
63
|
+
if (staleFor > maxStaleMs) {
|
|
64
|
+
throw new CacheError("STALE", `Synchronisation has not succeeded for ${Math.round(staleFor / 1000)}s, so this ` + "cache can no longer vouch for what it holds. Refusing rather than serving a value " + "that may already be revoked.");
|
|
65
|
+
}
|
|
66
|
+
const cached = entries.get(name);
|
|
67
|
+
if (cached && now() - cached.fetchedAtMs < ttlMs)
|
|
68
|
+
return cached.value;
|
|
69
|
+
let fetched;
|
|
70
|
+
try {
|
|
71
|
+
fetched = await options.fetch(name);
|
|
72
|
+
} catch (cause) {
|
|
73
|
+
if (cached)
|
|
74
|
+
return cached.value;
|
|
75
|
+
throw new CacheError("FETCH_FAILED", cause instanceof Error ? cause.message : `Could not fetch ${name}.`);
|
|
76
|
+
}
|
|
77
|
+
entries.set(name, { ...fetched, fetchedAtMs: now() });
|
|
78
|
+
return fetched.value;
|
|
79
|
+
},
|
|
80
|
+
async sync() {
|
|
81
|
+
const result = await options.changes(cursor);
|
|
82
|
+
if (result.resync) {
|
|
83
|
+
const dropped = [...entries.keys()];
|
|
84
|
+
if (options.list) {
|
|
85
|
+
await refreshScope();
|
|
86
|
+
} else {
|
|
87
|
+
entries.clear();
|
|
88
|
+
}
|
|
89
|
+
cursor = 0;
|
|
90
|
+
lastSyncOkMs = now();
|
|
91
|
+
return { invalidated: dropped, cursor: 0, resync: true };
|
|
92
|
+
}
|
|
93
|
+
if (options.list && result.changed.length > 0) {
|
|
94
|
+
const held = new Set(entries.keys());
|
|
95
|
+
await refreshScope();
|
|
96
|
+
cursor = result.version;
|
|
97
|
+
lastSyncOkMs = now();
|
|
98
|
+
return {
|
|
99
|
+
invalidated: result.changed.filter((name) => held.has(name) || entries.has(name)),
|
|
100
|
+
cursor,
|
|
101
|
+
resync: false
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
const invalidated = [];
|
|
105
|
+
for (const name of result.changed) {
|
|
106
|
+
if (entries.delete(name))
|
|
107
|
+
invalidated.push(name);
|
|
108
|
+
}
|
|
109
|
+
cursor = result.version;
|
|
110
|
+
lastSyncOkMs = now();
|
|
111
|
+
return { invalidated, cursor, resync: false };
|
|
112
|
+
},
|
|
113
|
+
clear() {
|
|
114
|
+
entries.clear();
|
|
115
|
+
cursor = 0;
|
|
116
|
+
},
|
|
117
|
+
staleForMs: () => now() - lastSyncOkMs
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// src/signed-node-http.ts
|
|
122
|
+
import {
|
|
123
|
+
encodeSignatureHeader,
|
|
124
|
+
generateResponseRecipient,
|
|
125
|
+
openResponse,
|
|
126
|
+
RESPONSE_KEY_HEADER,
|
|
127
|
+
signRequest
|
|
128
|
+
} from "@forgezero/runtime/identity";
|
|
129
|
+
|
|
130
|
+
class SignedNodeHttpError extends Error {
|
|
131
|
+
status;
|
|
132
|
+
constructor(status, message) {
|
|
133
|
+
super(message);
|
|
134
|
+
this.status = status;
|
|
135
|
+
this.name = "SignedNodeHttpError";
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
function signedNodeApiUrl(value) {
|
|
139
|
+
let url;
|
|
140
|
+
try {
|
|
141
|
+
url = new URL(value);
|
|
142
|
+
} catch {
|
|
143
|
+
throw new Error("Agent API URL must be an absolute HTTPS URL.");
|
|
144
|
+
}
|
|
145
|
+
const loopback = url.hostname === "localhost" || url.hostname === "127.0.0.1" || url.hostname === "::1";
|
|
146
|
+
if (url.protocol !== "https:" && !(loopback && url.protocol === "http:") || url.username || url.password || url.search || url.hash) {
|
|
147
|
+
throw new Error("Agent API URL must be HTTPS without credentials, query or fragment.");
|
|
148
|
+
}
|
|
149
|
+
return url;
|
|
150
|
+
}
|
|
151
|
+
async function postSignedNode(options, path, body) {
|
|
152
|
+
const url = signedNodeApiUrl(options.apiUrl);
|
|
153
|
+
url.pathname = `${url.pathname.replace(/\/$/, "")}/${path.replace(/^\//, "")}`.replace(/\/+/g, "/");
|
|
154
|
+
url.search = "";
|
|
155
|
+
url.hash = "";
|
|
156
|
+
const raw = JSON.stringify(body);
|
|
157
|
+
const recipient = generateResponseRecipient();
|
|
158
|
+
const envelope = signRequest(options.keys, options.nodeKey, {
|
|
159
|
+
method: "POST",
|
|
160
|
+
path: url.pathname,
|
|
161
|
+
query: "",
|
|
162
|
+
body: raw,
|
|
163
|
+
responseKey: recipient.publicKey
|
|
164
|
+
});
|
|
165
|
+
const signature = encodeSignatureHeader(envelope);
|
|
166
|
+
const response = await (options.fetch ?? globalThis.fetch)(url, {
|
|
167
|
+
method: "POST",
|
|
168
|
+
headers: {
|
|
169
|
+
"content-type": "application/json",
|
|
170
|
+
"x-fz-node": options.nodeKey,
|
|
171
|
+
"x-fz-signature": signature,
|
|
172
|
+
[RESPONSE_KEY_HEADER]: recipient.publicKey
|
|
173
|
+
},
|
|
174
|
+
body: raw,
|
|
175
|
+
signal: AbortSignal.timeout(options.requestTimeoutMs ?? 15000)
|
|
176
|
+
});
|
|
177
|
+
const payload = await response.json().catch(() => null);
|
|
178
|
+
if (!response.ok) {
|
|
179
|
+
const failure = payload;
|
|
180
|
+
const reason = failure ? failure.error?.message ?? failure.message : undefined;
|
|
181
|
+
throw new SignedNodeHttpError(response.status, reason || `signed node request returned HTTP ${response.status}`);
|
|
182
|
+
}
|
|
183
|
+
try {
|
|
184
|
+
return await openResponse(recipient.secretKey, signature, payload);
|
|
185
|
+
} catch {
|
|
186
|
+
throw new SignedNodeHttpError(502, "The node response was not sealed to this request.");
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// src/node-vault.ts
|
|
191
|
+
var SCOPE_PART = /^[A-Za-z0-9_][A-Za-z0-9_.-]{0,127}$/;
|
|
192
|
+
function projectVaultCacheKey(coordinate) {
|
|
193
|
+
if (!SCOPE_PART.test(coordinate.environment) || !SCOPE_PART.test(coordinate.name)) {
|
|
194
|
+
throw new Error("node vault coordinate is malformed");
|
|
195
|
+
}
|
|
196
|
+
return JSON.stringify([coordinate.environment, coordinate.name]);
|
|
197
|
+
}
|
|
198
|
+
function projectVaultCoordinate(key) {
|
|
199
|
+
let value;
|
|
200
|
+
try {
|
|
201
|
+
value = JSON.parse(key);
|
|
202
|
+
} catch {
|
|
203
|
+
throw new Error("node vault cache key is malformed");
|
|
204
|
+
}
|
|
205
|
+
if (!Array.isArray(value) || value.length !== 2 || typeof value[0] !== "string" || typeof value[1] !== "string" || !SCOPE_PART.test(value[0]) || !SCOPE_PART.test(value[1]))
|
|
206
|
+
throw new Error("node vault cache key is malformed");
|
|
207
|
+
return { environment: value[0], name: value[1] };
|
|
208
|
+
}
|
|
209
|
+
function tenantNodeApiUrl(apiUrl, tenantSlug) {
|
|
210
|
+
if (!/^[a-z0-9][a-z0-9-]{0,62}$/.test(tenantSlug))
|
|
211
|
+
throw new Error("tenant slug is malformed");
|
|
212
|
+
const url = signedNodeApiUrl(apiUrl);
|
|
213
|
+
url.pathname = `/api/t/${encodeURIComponent(tenantSlug)}`;
|
|
214
|
+
url.search = "";
|
|
215
|
+
url.hash = "";
|
|
216
|
+
return url.toString().replace(/\/$/, "");
|
|
217
|
+
}
|
|
218
|
+
function createNodeVaultCache(options) {
|
|
219
|
+
const post = (operation, body) => postSignedNode(options, `v1/node/vault/${operation}`, body);
|
|
220
|
+
return createSecretCache({
|
|
221
|
+
ttlMs: options.ttlMs,
|
|
222
|
+
maxStaleMs: options.maxStaleMs,
|
|
223
|
+
list: async () => {
|
|
224
|
+
const payload = await post("list", {});
|
|
225
|
+
if (!Array.isArray(payload.entries) || payload.entries.some((entry) => !entry || typeof entry.environment !== "string" || typeof entry.name !== "string")) {
|
|
226
|
+
throw new Error("node vault list response is malformed");
|
|
227
|
+
}
|
|
228
|
+
return payload.entries.map(projectVaultCacheKey);
|
|
229
|
+
},
|
|
230
|
+
fetch: async (key) => {
|
|
231
|
+
const coordinate = projectVaultCoordinate(key);
|
|
232
|
+
const payload = await post("read", coordinate);
|
|
233
|
+
if (typeof payload.value !== "string" || !Number.isSafeInteger(payload.version)) {
|
|
234
|
+
throw new Error("node vault read response is malformed");
|
|
235
|
+
}
|
|
236
|
+
return { value: payload.value, version: payload.version };
|
|
237
|
+
},
|
|
238
|
+
changes: async (since) => {
|
|
239
|
+
const payload = await post("changes", { since });
|
|
240
|
+
if (!Number.isSafeInteger(payload.version) || !Array.isArray(payload.changed) || payload.changed.some((entry) => !entry || typeof entry.environment !== "string" || typeof entry.name !== "string"))
|
|
241
|
+
throw new Error("node vault changes response is malformed");
|
|
242
|
+
return {
|
|
243
|
+
version: payload.version,
|
|
244
|
+
changed: payload.changed.map(projectVaultCacheKey),
|
|
245
|
+
resync: payload.resync
|
|
246
|
+
};
|
|
247
|
+
}
|
|
248
|
+
});
|
|
249
|
+
}
|
|
250
|
+
function startNodeVaultSync(cache, options = {}) {
|
|
251
|
+
const interval = Math.max(1000, options.intervalMs ?? 30000);
|
|
252
|
+
const setTimer = options.setTimer ?? ((callback, ms) => setTimeout(callback, ms));
|
|
253
|
+
const clearTimer = options.clearTimer ?? ((handle) => clearTimeout(handle));
|
|
254
|
+
let stopped = false;
|
|
255
|
+
let timer;
|
|
256
|
+
let active = null;
|
|
257
|
+
const schedule = () => {
|
|
258
|
+
if (!stopped)
|
|
259
|
+
timer = setTimer(tick, interval);
|
|
260
|
+
};
|
|
261
|
+
const tick = () => {
|
|
262
|
+
if (stopped || active)
|
|
263
|
+
return;
|
|
264
|
+
const sync = () => cache.sync();
|
|
265
|
+
active = (options.telemetry ? options.telemetry.observe("vault.sync", sync) : sync()).then((result) => options.onEvent?.("synced", result)).catch((cause) => options.onEvent?.("sync-failed", cause)).finally(() => {
|
|
266
|
+
active = null;
|
|
267
|
+
schedule();
|
|
268
|
+
});
|
|
269
|
+
};
|
|
270
|
+
schedule();
|
|
271
|
+
return {
|
|
272
|
+
async stop() {
|
|
273
|
+
stopped = true;
|
|
274
|
+
clearTimer(timer);
|
|
275
|
+
await active;
|
|
276
|
+
}
|
|
277
|
+
};
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
// src/credential-schema.ts
|
|
281
|
+
var AGENT_CREDENTIAL_LOCATIONS = [
|
|
282
|
+
"operator",
|
|
283
|
+
"metal",
|
|
284
|
+
"platform-compute",
|
|
285
|
+
"tenant-compute"
|
|
286
|
+
];
|
|
287
|
+
var AGENT_CREDENTIAL_POLICY = {
|
|
288
|
+
operator: { vault: false, systemdFallback: false, attendedFile: true },
|
|
289
|
+
metal: { vault: false, systemdFallback: true, attendedFile: false },
|
|
290
|
+
"platform-compute": { vault: true, systemdFallback: true, attendedFile: false },
|
|
291
|
+
"tenant-compute": { vault: true, systemdFallback: true, attendedFile: false }
|
|
292
|
+
};
|
|
293
|
+
var CREDENTIAL_NAME = /^[A-Z_][A-Z0-9_]*$/;
|
|
294
|
+
var SCOPE_PART2 = /^[A-Za-z0-9_][A-Za-z0-9_.-]{0,127}$/;
|
|
295
|
+
function deploymentCredentialSchema(input) {
|
|
296
|
+
if (!SCOPE_PART2.test(input.projectKey) || !SCOPE_PART2.test(input.environmentKey)) {
|
|
297
|
+
throw new Error("agent: deployment credential scope is malformed");
|
|
298
|
+
}
|
|
299
|
+
const systemd = [...new Set(input.systemdCredentials ?? [])].sort();
|
|
300
|
+
for (const name of systemd) {
|
|
301
|
+
if (!CREDENTIAL_NAME.test(name)) {
|
|
302
|
+
throw new Error(`agent: invalid pipeline credential name ${name}.`);
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
return {
|
|
306
|
+
version: 1,
|
|
307
|
+
location: input.realm === "platform" ? "platform-compute" : "tenant-compute",
|
|
308
|
+
projectKey: input.projectKey,
|
|
309
|
+
environmentKey: input.environmentKey,
|
|
310
|
+
credentials: systemd.map((name) => ({
|
|
311
|
+
name,
|
|
312
|
+
vaultCacheKey: projectVaultCacheKey({ environment: input.environmentKey, name }),
|
|
313
|
+
systemdCredential: name
|
|
314
|
+
}))
|
|
315
|
+
};
|
|
316
|
+
}
|
|
317
|
+
function credentialBinding(schema, name) {
|
|
318
|
+
if (!CREDENTIAL_NAME.test(name))
|
|
319
|
+
throw new Error(`agent: invalid pipeline credential name ${name}.`);
|
|
320
|
+
const declared = schema.credentials.find((entry) => entry.name === name);
|
|
321
|
+
return declared ?? {
|
|
322
|
+
name,
|
|
323
|
+
vaultCacheKey: projectVaultCacheKey({ environment: schema.environmentKey, name })
|
|
324
|
+
};
|
|
325
|
+
}
|
|
326
|
+
var METAL_SYSTEMD_CREDENTIALS = {
|
|
327
|
+
identity: "metal-agent-seed"
|
|
328
|
+
};
|
|
329
|
+
export {
|
|
330
|
+
deploymentCredentialSchema,
|
|
331
|
+
credentialBinding,
|
|
332
|
+
METAL_SYSTEMD_CREDENTIALS,
|
|
333
|
+
AGENT_CREDENTIAL_POLICY,
|
|
334
|
+
AGENT_CREDENTIAL_LOCATIONS
|
|
335
|
+
};
|
package/dist/definition.js
CHANGED
|
@@ -28,8 +28,8 @@ var UBUNTU_2604_X64 = [
|
|
|
28
28
|
},
|
|
29
29
|
{
|
|
30
30
|
requirement: { id: "arangodb", version: "3.11.14" },
|
|
31
|
-
check: `arangod --version 2>/dev/null | head -1 | grep -q '3.11.14'`,
|
|
32
|
-
install: `tmp=$(mktemp -d); trap 'rm -rf "$tmp"' EXIT; ` + `curl -fsSL 'https://download.arangodb.com/arangodb311/DEBIAN/amd64/arangodb3_3.11.14-1_amd64.deb' -o "$tmp/arangodb.deb"; ` + `echo "${ARANGO_SHA256} $tmp/arangodb.deb" | sha256sum -c -; ` + `DEBIAN_FRONTEND=noninteractive dpkg -i "$tmp/arangodb.deb" >/dev/null 2>&1 || ` + `DEBIAN_FRONTEND=noninteractive apt-get -y -f install`
|
|
31
|
+
check: `arangod --version 2>/dev/null | head -1 | grep -q '3.11.14' && ` + `! systemctl is-active --quiet arangodb3.service && ` + `! systemctl is-enabled --quiet arangodb3.service`,
|
|
32
|
+
install: `tmp=$(mktemp -d); trap 'rm -rf "$tmp"' EXIT; ` + `curl -fsSL 'https://download.arangodb.com/arangodb311/DEBIAN/amd64/arangodb3_3.11.14-1_amd64.deb' -o "$tmp/arangodb.deb"; ` + `echo "${ARANGO_SHA256} $tmp/arangodb.deb" | sha256sum -c -; ` + `DEBIAN_FRONTEND=noninteractive dpkg -i "$tmp/arangodb.deb" >/dev/null 2>&1 || ` + `DEBIAN_FRONTEND=noninteractive apt-get -y -f install; ` + `systemctl disable --now arangodb3.service`
|
|
33
33
|
},
|
|
34
34
|
{
|
|
35
35
|
requirement: { id: "cloudflared", version: "2026.7.3" },
|
package/dist/deploy-file.js
CHANGED
|
@@ -28,8 +28,8 @@ var UBUNTU_2604_X64 = [
|
|
|
28
28
|
},
|
|
29
29
|
{
|
|
30
30
|
requirement: { id: "arangodb", version: "3.11.14" },
|
|
31
|
-
check: `arangod --version 2>/dev/null | head -1 | grep -q '3.11.14'`,
|
|
32
|
-
install: `tmp=$(mktemp -d); trap 'rm -rf "$tmp"' EXIT; ` + `curl -fsSL 'https://download.arangodb.com/arangodb311/DEBIAN/amd64/arangodb3_3.11.14-1_amd64.deb' -o "$tmp/arangodb.deb"; ` + `echo "${ARANGO_SHA256} $tmp/arangodb.deb" | sha256sum -c -; ` + `DEBIAN_FRONTEND=noninteractive dpkg -i "$tmp/arangodb.deb" >/dev/null 2>&1 || ` + `DEBIAN_FRONTEND=noninteractive apt-get -y -f install`
|
|
31
|
+
check: `arangod --version 2>/dev/null | head -1 | grep -q '3.11.14' && ` + `! systemctl is-active --quiet arangodb3.service && ` + `! systemctl is-enabled --quiet arangodb3.service`,
|
|
32
|
+
install: `tmp=$(mktemp -d); trap 'rm -rf "$tmp"' EXIT; ` + `curl -fsSL 'https://download.arangodb.com/arangodb311/DEBIAN/amd64/arangodb3_3.11.14-1_amd64.deb' -o "$tmp/arangodb.deb"; ` + `echo "${ARANGO_SHA256} $tmp/arangodb.deb" | sha256sum -c -; ` + `DEBIAN_FRONTEND=noninteractive dpkg -i "$tmp/arangodb.deb" >/dev/null 2>&1 || ` + `DEBIAN_FRONTEND=noninteractive apt-get -y -f install; ` + `systemctl disable --now arangodb3.service`
|
|
33
33
|
},
|
|
34
34
|
{
|
|
35
35
|
requirement: { id: "cloudflared", version: "2026.7.3" },
|