@sys9/chord-ctl 0.0.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 +12 -0
- package/bin/chord-ctl +609 -0
- package/package.json +26 -0
package/README.md
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
# @sys9/chord-ctl
|
|
2
|
+
|
|
3
|
+
`@sys9/chord-ctl` is the transient bootstrap used to install or repair a Chord Host.
|
|
4
|
+
It obtains the server-selected native release, verifies it, and delegates setup to the
|
|
5
|
+
native runner.
|
|
6
|
+
|
|
7
|
+
```sh
|
|
8
|
+
npx -y @sys9/chord-ctl@latest setup --server https://chord-api.sys9.ai
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
The bootstrap exits after handing control to the native runner. It does not install or
|
|
12
|
+
invoke a package manager while acquiring the selected release.
|
package/bin/chord-ctl
ADDED
|
@@ -0,0 +1,609 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
"use strict";
|
|
4
|
+
|
|
5
|
+
const crypto = require("node:crypto");
|
|
6
|
+
const { spawn } = require("node:child_process");
|
|
7
|
+
const fs = require("node:fs");
|
|
8
|
+
const http = require("node:http");
|
|
9
|
+
const https = require("node:https");
|
|
10
|
+
const os = require("node:os");
|
|
11
|
+
const path = require("node:path");
|
|
12
|
+
const { TextDecoder } = require("node:util");
|
|
13
|
+
const zlib = require("node:zlib");
|
|
14
|
+
|
|
15
|
+
const PACKAGE_NAME = "@sys9/chord-cli";
|
|
16
|
+
const DEFAULT_NPM_REGISTRY = "https://registry.npmjs.org/";
|
|
17
|
+
const BOOTSTRAP_SCHEMA = 1;
|
|
18
|
+
const REQUEST_TIMEOUT_MS = 30_000;
|
|
19
|
+
const MAX_URL_BYTES = 4096;
|
|
20
|
+
const MAX_BOOTSTRAP_BYTES = 64 * 1024;
|
|
21
|
+
const MAX_METADATA_BYTES = 1024 * 1024;
|
|
22
|
+
const MAX_TARBALL_BYTES = 128 * 1024 * 1024;
|
|
23
|
+
const MAX_ARCHIVE_BYTES = 512 * 1024 * 1024;
|
|
24
|
+
const MAX_ENTRY_BYTES = 128 * 1024 * 1024;
|
|
25
|
+
const MAX_ARCHIVE_ENTRIES = 1024;
|
|
26
|
+
|
|
27
|
+
const VERSION_PATTERN = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/;
|
|
28
|
+
const ENVIRONMENT_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
|
|
29
|
+
|
|
30
|
+
const PLATFORM_TAGS = {
|
|
31
|
+
"linux:x64": "linux-x64",
|
|
32
|
+
"linux:arm64": "linux-arm64",
|
|
33
|
+
"darwin:x64": "darwin-x64",
|
|
34
|
+
"darwin:arm64": "darwin-arm64",
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
const textDecoder = new TextDecoder("utf-8", { fatal: true });
|
|
38
|
+
|
|
39
|
+
function isPlainObject(value) {
|
|
40
|
+
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function parseArguments(argv) {
|
|
44
|
+
if (!Array.isArray(argv) || argv.length === 0 || argv[0] !== "setup") {
|
|
45
|
+
throw new Error("expected: setup --server URL");
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
let server = null;
|
|
49
|
+
for (let index = 1; index < argv.length; index += 1) {
|
|
50
|
+
const argument = argv[index];
|
|
51
|
+
if (argument === "--server") {
|
|
52
|
+
if (server !== null || index + 1 >= argv.length) {
|
|
53
|
+
throw new Error("setup requires exactly one --server URL");
|
|
54
|
+
}
|
|
55
|
+
server = argv[index + 1];
|
|
56
|
+
index += 1;
|
|
57
|
+
continue;
|
|
58
|
+
}
|
|
59
|
+
if (argument.startsWith("--server=")) {
|
|
60
|
+
if (server !== null || argument.length === "--server=".length) {
|
|
61
|
+
throw new Error("setup requires exactly one --server URL");
|
|
62
|
+
}
|
|
63
|
+
server = argument.slice("--server=".length);
|
|
64
|
+
continue;
|
|
65
|
+
}
|
|
66
|
+
throw new Error("unsupported argument: " + argument);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
if (server === null) {
|
|
70
|
+
throw new Error("missing required --server URL");
|
|
71
|
+
}
|
|
72
|
+
return { server };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function parseHTTPURL(value, label, { allowPath = false, allowQuery = false } = {}) {
|
|
76
|
+
if (typeof value !== "string" || value.length === 0 || value.length > MAX_URL_BYTES) {
|
|
77
|
+
throw new Error(label + " must be a bounded URL");
|
|
78
|
+
}
|
|
79
|
+
if (value !== value.trim()) {
|
|
80
|
+
throw new Error(label + " must not contain surrounding whitespace");
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
let parsed;
|
|
84
|
+
try {
|
|
85
|
+
parsed = new URL(value);
|
|
86
|
+
} catch {
|
|
87
|
+
throw new Error(label + " must be a valid URL");
|
|
88
|
+
}
|
|
89
|
+
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
|
|
90
|
+
throw new Error(label + " must use http or https");
|
|
91
|
+
}
|
|
92
|
+
if (parsed.username || parsed.password || parsed.hash || (!allowQuery && parsed.search)) {
|
|
93
|
+
throw new Error(
|
|
94
|
+
label +
|
|
95
|
+
(allowQuery
|
|
96
|
+
? " must not contain credentials or fragment"
|
|
97
|
+
: " must not contain credentials, query, or fragment"),
|
|
98
|
+
);
|
|
99
|
+
}
|
|
100
|
+
if (!allowPath && parsed.pathname !== "/") {
|
|
101
|
+
throw new Error(label + " must be an HTTP origin");
|
|
102
|
+
}
|
|
103
|
+
return parsed;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function platformTag(platform = process.platform, arch = process.arch) {
|
|
107
|
+
const tag = PLATFORM_TAGS[platform + ":" + arch];
|
|
108
|
+
if (!tag) {
|
|
109
|
+
throw new Error("unsupported platform: " + platform + " (" + arch + ")");
|
|
110
|
+
}
|
|
111
|
+
return tag;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function isVersion(value) {
|
|
115
|
+
return typeof value === "string" && value.length <= 128 && VERSION_PATTERN.test(value);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function parseJSON(body, label) {
|
|
119
|
+
let text;
|
|
120
|
+
try {
|
|
121
|
+
text = textDecoder.decode(body);
|
|
122
|
+
} catch {
|
|
123
|
+
throw new Error(label + " is not valid UTF-8");
|
|
124
|
+
}
|
|
125
|
+
try {
|
|
126
|
+
return JSON.parse(text);
|
|
127
|
+
} catch {
|
|
128
|
+
throw new Error(label + " is not valid JSON");
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function validateBootstrapResponse(payload) {
|
|
133
|
+
if (!isPlainObject(payload)) {
|
|
134
|
+
throw new Error("bootstrap response must be an object");
|
|
135
|
+
}
|
|
136
|
+
const keys = Object.keys(payload).sort();
|
|
137
|
+
if (
|
|
138
|
+
keys.length !== 3 ||
|
|
139
|
+
keys[0] !== "approved_cli_version" ||
|
|
140
|
+
keys[1] !== "canonical_environment_id" ||
|
|
141
|
+
keys[2] !== "schema_version"
|
|
142
|
+
) {
|
|
143
|
+
throw new Error("bootstrap response has an invalid schema");
|
|
144
|
+
}
|
|
145
|
+
if (payload.schema_version !== BOOTSTRAP_SCHEMA) {
|
|
146
|
+
throw new Error("unsupported bootstrap schema");
|
|
147
|
+
}
|
|
148
|
+
if (
|
|
149
|
+
typeof payload.canonical_environment_id !== "string" ||
|
|
150
|
+
!ENVIRONMENT_ID_PATTERN.test(payload.canonical_environment_id)
|
|
151
|
+
) {
|
|
152
|
+
throw new Error("bootstrap response has an invalid canonical_environment_id");
|
|
153
|
+
}
|
|
154
|
+
if (!isVersion(payload.approved_cli_version)) {
|
|
155
|
+
throw new Error("bootstrap response has an invalid version");
|
|
156
|
+
}
|
|
157
|
+
return {
|
|
158
|
+
environmentID: payload.canonical_environment_id,
|
|
159
|
+
version: payload.approved_cli_version,
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function requestBytes(target, limit, label, accept) {
|
|
164
|
+
return new Promise((resolve, reject) => {
|
|
165
|
+
const client = target.protocol === "https:" ? https : http;
|
|
166
|
+
let settled = false;
|
|
167
|
+
let request;
|
|
168
|
+
|
|
169
|
+
const finish = (error, value) => {
|
|
170
|
+
if (settled) {
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
173
|
+
settled = true;
|
|
174
|
+
if (error) {
|
|
175
|
+
reject(error);
|
|
176
|
+
} else {
|
|
177
|
+
resolve(value);
|
|
178
|
+
}
|
|
179
|
+
};
|
|
180
|
+
|
|
181
|
+
try {
|
|
182
|
+
request = client.get(
|
|
183
|
+
target,
|
|
184
|
+
{
|
|
185
|
+
headers: {
|
|
186
|
+
accept: accept || "*/*",
|
|
187
|
+
"accept-encoding": "identity",
|
|
188
|
+
},
|
|
189
|
+
},
|
|
190
|
+
(response) => {
|
|
191
|
+
const status = response.statusCode || 0;
|
|
192
|
+
if (status !== 200) {
|
|
193
|
+
response.destroy();
|
|
194
|
+
finish(new Error(label + " returned HTTP " + status));
|
|
195
|
+
return;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
const lengthHeader = response.headers["content-length"];
|
|
199
|
+
if (lengthHeader !== undefined) {
|
|
200
|
+
if (!/^\d+$/.test(lengthHeader) || Number(lengthHeader) > limit) {
|
|
201
|
+
response.destroy();
|
|
202
|
+
finish(new Error(label + " exceeds the size limit"));
|
|
203
|
+
return;
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
const chunks = [];
|
|
208
|
+
let size = 0;
|
|
209
|
+
response.on("data", (chunk) => {
|
|
210
|
+
const bytes = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
211
|
+
size += bytes.length;
|
|
212
|
+
if (size > limit) {
|
|
213
|
+
response.destroy();
|
|
214
|
+
finish(new Error(label + " exceeds the size limit"));
|
|
215
|
+
return;
|
|
216
|
+
}
|
|
217
|
+
chunks.push(bytes);
|
|
218
|
+
});
|
|
219
|
+
response.on("end", () => finish(null, Buffer.concat(chunks, size)));
|
|
220
|
+
response.on("aborted", () => finish(new Error(label + " response was interrupted")));
|
|
221
|
+
response.on("error", (error) => finish(new Error(label + " failed: " + error.message)));
|
|
222
|
+
},
|
|
223
|
+
);
|
|
224
|
+
} catch (error) {
|
|
225
|
+
finish(new Error(label + " failed: " + error.message));
|
|
226
|
+
return;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
request.setTimeout(REQUEST_TIMEOUT_MS, () => {
|
|
230
|
+
request.destroy(new Error(label + " request timed out"));
|
|
231
|
+
});
|
|
232
|
+
request.on("error", (error) => finish(new Error(label + " failed: " + error.message)));
|
|
233
|
+
});
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
async function fetchBootstrap(serverURL) {
|
|
237
|
+
const server = parseHTTPURL(serverURL, "--server");
|
|
238
|
+
const endpoint = new URL("/api/bootstrap", server);
|
|
239
|
+
const body = await requestBytes(endpoint, MAX_BOOTSTRAP_BYTES, "bootstrap request", "application/json");
|
|
240
|
+
return validateBootstrapResponse(parseJSON(body, "bootstrap response"));
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
function registryURL(environment = process.env) {
|
|
244
|
+
const hasOverride = Object.prototype.hasOwnProperty.call(environment, "CHORD_NPM_REGISTRY");
|
|
245
|
+
const value = hasOverride ? environment.CHORD_NPM_REGISTRY : DEFAULT_NPM_REGISTRY;
|
|
246
|
+
const registry = parseHTTPURL(value, "CHORD_NPM_REGISTRY", { allowPath: true });
|
|
247
|
+
if (!registry.pathname.endsWith("/")) {
|
|
248
|
+
registry.pathname += "/";
|
|
249
|
+
}
|
|
250
|
+
return registry;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
function metadataURL(registry, version) {
|
|
254
|
+
return new URL("@sys9/chord-cli/" + encodeURIComponent(version), registry);
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
function parseIntegrity(value) {
|
|
258
|
+
if (typeof value !== "string" || value.trim() === "") {
|
|
259
|
+
throw new Error("package metadata has an invalid sha512 integrity");
|
|
260
|
+
}
|
|
261
|
+
const digests = [];
|
|
262
|
+
for (const token of value.trim().split(/\s+/)) {
|
|
263
|
+
if (!token.startsWith("sha512-")) {
|
|
264
|
+
continue;
|
|
265
|
+
}
|
|
266
|
+
const encoded = token.slice("sha512-".length);
|
|
267
|
+
if (!/^[A-Za-z0-9+/]+={0,2}$/.test(encoded)) {
|
|
268
|
+
throw new Error("package metadata has an invalid sha512 integrity");
|
|
269
|
+
}
|
|
270
|
+
const digest = Buffer.from(encoded, "base64");
|
|
271
|
+
if (digest.length !== 64 || digest.toString("base64") !== encoded) {
|
|
272
|
+
throw new Error("package metadata has an invalid sha512 integrity");
|
|
273
|
+
}
|
|
274
|
+
digests.push(digest);
|
|
275
|
+
}
|
|
276
|
+
if (digests.length === 0) {
|
|
277
|
+
throw new Error("package metadata has an invalid sha512 integrity");
|
|
278
|
+
}
|
|
279
|
+
return digests;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
function validatePackageMetadata(payload, expectedVersion, registry) {
|
|
283
|
+
if (!isPlainObject(payload) || payload.name !== PACKAGE_NAME || payload.version !== expectedVersion) {
|
|
284
|
+
throw new Error("package metadata does not describe the requested package version");
|
|
285
|
+
}
|
|
286
|
+
if (!isPlainObject(payload.dist)) {
|
|
287
|
+
throw new Error("package metadata has no dist object");
|
|
288
|
+
}
|
|
289
|
+
const integrity = parseIntegrity(payload.dist.integrity);
|
|
290
|
+
if (typeof payload.dist.tarball !== "string") {
|
|
291
|
+
throw new Error("package metadata has no tarball URL");
|
|
292
|
+
}
|
|
293
|
+
const tarball = parseHTTPURL(payload.dist.tarball, "package tarball", {
|
|
294
|
+
allowPath: true,
|
|
295
|
+
allowQuery: true,
|
|
296
|
+
});
|
|
297
|
+
if (tarball.origin !== registry.origin) {
|
|
298
|
+
throw new Error("package tarball origin does not match the registry");
|
|
299
|
+
}
|
|
300
|
+
return { integrity, tarball };
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
async function fetchPackage(registry, version) {
|
|
304
|
+
const metadata = metadataURL(registry, version);
|
|
305
|
+
const metadataBody = await requestBytes(
|
|
306
|
+
metadata,
|
|
307
|
+
MAX_METADATA_BYTES,
|
|
308
|
+
"package metadata request",
|
|
309
|
+
"application/json",
|
|
310
|
+
);
|
|
311
|
+
const packageInfo = validatePackageMetadata(parseJSON(metadataBody, "package metadata"), version, registry);
|
|
312
|
+
const tarballBody = await requestBytes(packageInfo.tarball, MAX_TARBALL_BYTES, "package tarball request");
|
|
313
|
+
const actual = crypto.createHash("sha512").update(tarballBody).digest();
|
|
314
|
+
if (!packageInfo.integrity.some((digest) => crypto.timingSafeEqual(actual, digest))) {
|
|
315
|
+
throw new Error("package tarball integrity mismatch");
|
|
316
|
+
}
|
|
317
|
+
return tarballBody;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
function tarText(bytes, label) {
|
|
321
|
+
const end = bytes.indexOf(0);
|
|
322
|
+
const field = end === -1 ? bytes : bytes.subarray(0, end);
|
|
323
|
+
try {
|
|
324
|
+
return textDecoder.decode(field);
|
|
325
|
+
} catch {
|
|
326
|
+
throw new Error("tar archive has an invalid " + label);
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
function tarNumber(bytes, label) {
|
|
331
|
+
const raw = tarText(bytes, label).trim();
|
|
332
|
+
if (!/^[0-7]+$/.test(raw)) {
|
|
333
|
+
throw new Error("tar archive has an invalid " + label);
|
|
334
|
+
}
|
|
335
|
+
const value = Number.parseInt(raw, 8);
|
|
336
|
+
if (!Number.isSafeInteger(value)) {
|
|
337
|
+
throw new Error("tar archive has an invalid " + label);
|
|
338
|
+
}
|
|
339
|
+
return value;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
function isZeroBlock(block) {
|
|
343
|
+
for (const byte of block) {
|
|
344
|
+
if (byte !== 0) {
|
|
345
|
+
return false;
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
return true;
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
function validateTarPath(value, directory) {
|
|
352
|
+
if (
|
|
353
|
+
typeof value !== "string" ||
|
|
354
|
+
value.length === 0 ||
|
|
355
|
+
value.length > 512 ||
|
|
356
|
+
value.includes("\\") ||
|
|
357
|
+
value.includes("\0") ||
|
|
358
|
+
value.startsWith("/") ||
|
|
359
|
+
/^[A-Za-z]:/.test(value)
|
|
360
|
+
) {
|
|
361
|
+
throw new Error("tar archive contains an unsafe path");
|
|
362
|
+
}
|
|
363
|
+
const pathValue = directory && value.endsWith("/") ? value.slice(0, -1) : value;
|
|
364
|
+
const components = pathValue.split("/");
|
|
365
|
+
if (
|
|
366
|
+
components.some(
|
|
367
|
+
(component) =>
|
|
368
|
+
component.length === 0 ||
|
|
369
|
+
component === "." ||
|
|
370
|
+
component === ".." ||
|
|
371
|
+
component.includes(":")
|
|
372
|
+
)
|
|
373
|
+
) {
|
|
374
|
+
throw new Error("tar archive contains an unsafe path");
|
|
375
|
+
}
|
|
376
|
+
return pathValue;
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
function targetDirectory(platform = process.platform, arch = process.arch) {
|
|
380
|
+
const goarch = arch === "x64" ? "amd64" : arch;
|
|
381
|
+
if (platform !== "linux" && platform !== "darwin") {
|
|
382
|
+
throw new Error("unsupported platform: " + platform + " (" + arch + ")");
|
|
383
|
+
}
|
|
384
|
+
if (goarch !== "amd64" && goarch !== "arm64") {
|
|
385
|
+
throw new Error("unsupported platform: " + platform + " (" + arch + ")");
|
|
386
|
+
}
|
|
387
|
+
return platform + "_" + goarch;
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
function parseTar(tar, target = targetDirectory()) {
|
|
391
|
+
if (!Buffer.isBuffer(tar) || tar.length === 0 || tar.length > MAX_ARCHIVE_BYTES) {
|
|
392
|
+
throw new Error("tar archive exceeds the size limit");
|
|
393
|
+
}
|
|
394
|
+
if (!/^(?:linux|darwin)_(?:amd64|arm64)$/.test(target)) {
|
|
395
|
+
throw new Error("invalid native release target");
|
|
396
|
+
}
|
|
397
|
+
const targetRoot = "package/vendor/" + target;
|
|
398
|
+
const expectedPaths = new Map([
|
|
399
|
+
[targetRoot + "/runner", "runner"],
|
|
400
|
+
[targetRoot + "/chord-daemon", "chord-daemon"],
|
|
401
|
+
[targetRoot + "/chord-bridge", "chord-bridge"],
|
|
402
|
+
]);
|
|
403
|
+
const entries = new Map();
|
|
404
|
+
const extractedNames = new Set();
|
|
405
|
+
let offset = 0;
|
|
406
|
+
let entryCount = 0;
|
|
407
|
+
let endBlocks = 0;
|
|
408
|
+
|
|
409
|
+
while (offset + 512 <= tar.length) {
|
|
410
|
+
const header = tar.subarray(offset, offset + 512);
|
|
411
|
+
offset += 512;
|
|
412
|
+
if (isZeroBlock(header)) {
|
|
413
|
+
endBlocks += 1;
|
|
414
|
+
if (endBlocks === 2) {
|
|
415
|
+
break;
|
|
416
|
+
}
|
|
417
|
+
continue;
|
|
418
|
+
}
|
|
419
|
+
if (endBlocks !== 0) {
|
|
420
|
+
throw new Error("tar archive has data after its end marker");
|
|
421
|
+
}
|
|
422
|
+
entryCount += 1;
|
|
423
|
+
if (entryCount > MAX_ARCHIVE_ENTRIES) {
|
|
424
|
+
throw new Error("tar archive has too many entries");
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
const checksum = tarNumber(header.subarray(148, 156), "checksum");
|
|
428
|
+
let computedChecksum = 0;
|
|
429
|
+
for (let index = 0; index < header.length; index += 1) {
|
|
430
|
+
computedChecksum += index >= 148 && index < 156 ? 32 : header[index];
|
|
431
|
+
}
|
|
432
|
+
if (checksum !== computedChecksum) {
|
|
433
|
+
throw new Error("tar archive has an invalid checksum");
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
const name = tarText(header.subarray(0, 100), "path");
|
|
437
|
+
const prefix = tarText(header.subarray(345, 500), "path");
|
|
438
|
+
const fullName = prefix ? prefix + "/" + name : name;
|
|
439
|
+
const type = header[156];
|
|
440
|
+
const directory = type === 53;
|
|
441
|
+
const safeName = validateTarPath(fullName, directory);
|
|
442
|
+
if (safeName !== "package" && !safeName.startsWith("package/")) {
|
|
443
|
+
throw new Error("tar archive entry is outside package/");
|
|
444
|
+
}
|
|
445
|
+
if (extractedNames.has(safeName)) {
|
|
446
|
+
throw new Error("tar archive contains a duplicate path");
|
|
447
|
+
}
|
|
448
|
+
extractedNames.add(safeName);
|
|
449
|
+
|
|
450
|
+
if (type !== 0 && type !== 48 && !directory) {
|
|
451
|
+
throw new Error("tar archive contains a link or special file");
|
|
452
|
+
}
|
|
453
|
+
const size = tarNumber(header.subarray(124, 136), "file size");
|
|
454
|
+
if (size > MAX_ENTRY_BYTES) {
|
|
455
|
+
throw new Error("tar archive entry exceeds the size limit");
|
|
456
|
+
}
|
|
457
|
+
const end = offset + size;
|
|
458
|
+
if (end < offset || end > tar.length) {
|
|
459
|
+
throw new Error("tar archive entry is truncated");
|
|
460
|
+
}
|
|
461
|
+
if (directory && size !== 0) {
|
|
462
|
+
throw new Error("tar archive directory has data");
|
|
463
|
+
}
|
|
464
|
+
const padding = (512 - (size % 512)) % 512;
|
|
465
|
+
if (end + padding > tar.length) {
|
|
466
|
+
throw new Error("tar archive entry padding is truncated");
|
|
467
|
+
}
|
|
468
|
+
const outputName = expectedPaths.get(safeName);
|
|
469
|
+
const inTarget = safeName === targetRoot || safeName.startsWith(targetRoot + "/");
|
|
470
|
+
if (inTarget && safeName !== targetRoot && !outputName) {
|
|
471
|
+
throw new Error("tar archive contains an unexpected target entry " + safeName);
|
|
472
|
+
}
|
|
473
|
+
if (type === 0 || type === 48) {
|
|
474
|
+
if (outputName) {
|
|
475
|
+
const mode = tarNumber(header.subarray(100, 108), "file mode");
|
|
476
|
+
if ((mode & 0o111) === 0 || size === 0) {
|
|
477
|
+
throw new Error("native release file " + safeName + " is not executable");
|
|
478
|
+
}
|
|
479
|
+
if (entries.has(outputName)) {
|
|
480
|
+
throw new Error("tar archive contains a duplicate binary");
|
|
481
|
+
}
|
|
482
|
+
entries.set(outputName, Buffer.from(tar.subarray(offset, end)));
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
offset = end + padding;
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
if (endBlocks < 2 || offset > tar.length || (tar.length - offset) % 512 !== 0) {
|
|
489
|
+
throw new Error("tar archive has no complete end marker");
|
|
490
|
+
}
|
|
491
|
+
for (const byte of tar.subarray(offset)) {
|
|
492
|
+
if (byte !== 0) {
|
|
493
|
+
throw new Error("tar archive has trailing data");
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
for (const name of ["runner", "chord-daemon", "chord-bridge"]) {
|
|
497
|
+
const content = entries.get(name);
|
|
498
|
+
if (!content || content.length === 0) {
|
|
499
|
+
throw new Error("tar archive is missing " + name);
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
return entries;
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
function unpackPackage(tarball, target = targetDirectory()) {
|
|
506
|
+
let tar;
|
|
507
|
+
try {
|
|
508
|
+
tar = zlib.gunzipSync(tarball, { maxOutputLength: MAX_ARCHIVE_BYTES });
|
|
509
|
+
} catch (error) {
|
|
510
|
+
if (error && error.code === "ERR_BUFFER_TOO_LARGE") {
|
|
511
|
+
throw new Error("package tarball exceeds the uncompressed size limit");
|
|
512
|
+
}
|
|
513
|
+
throw new Error("package tarball is not a valid gzip archive: " + error.message);
|
|
514
|
+
}
|
|
515
|
+
return parseTar(tar, target);
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
function materializeBinaries(entries) {
|
|
519
|
+
const root = fs.mkdtempSync(path.join(os.tmpdir(), "chord-ctl-"));
|
|
520
|
+
try {
|
|
521
|
+
for (const name of ["runner", "chord-daemon", "chord-bridge"]) {
|
|
522
|
+
const target = path.join(root, name);
|
|
523
|
+
fs.writeFileSync(target, entries.get(name), { encoding: null, mode: 0o700, flag: "wx" });
|
|
524
|
+
fs.chmodSync(target, 0o700);
|
|
525
|
+
if (!fs.lstatSync(target).isFile()) {
|
|
526
|
+
throw new Error("extracted " + name + " is not a regular file");
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
return root;
|
|
530
|
+
} catch (error) {
|
|
531
|
+
fs.rmSync(root, { recursive: true, force: true });
|
|
532
|
+
throw new Error("could not materialize package binaries: " + error.message);
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
function runRunner(root, serverURL, environmentID) {
|
|
537
|
+
const runnerPath = path.join(root, "runner");
|
|
538
|
+
return new Promise((resolve, reject) => {
|
|
539
|
+
let child;
|
|
540
|
+
try {
|
|
541
|
+
child = spawn(
|
|
542
|
+
runnerPath,
|
|
543
|
+
["setup", "--server", serverURL, "--environment-id", environmentID],
|
|
544
|
+
{ stdio: "inherit" },
|
|
545
|
+
);
|
|
546
|
+
} catch (error) {
|
|
547
|
+
reject(error);
|
|
548
|
+
return;
|
|
549
|
+
}
|
|
550
|
+
child.once("error", reject);
|
|
551
|
+
child.once("exit", (code, signal) => {
|
|
552
|
+
if (signal) {
|
|
553
|
+
resolve(1);
|
|
554
|
+
} else {
|
|
555
|
+
resolve(code === null ? 1 : code);
|
|
556
|
+
}
|
|
557
|
+
});
|
|
558
|
+
});
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
async function setup(serverURL, environment = process.env) {
|
|
562
|
+
const bootstrap = await fetchBootstrap(serverURL);
|
|
563
|
+
const version = bootstrap.version + "-" + platformTag();
|
|
564
|
+
const tarball = await fetchPackage(registryURL(environment), version);
|
|
565
|
+
const binaries = unpackPackage(tarball);
|
|
566
|
+
const root = materializeBinaries(binaries);
|
|
567
|
+
try {
|
|
568
|
+
return await runRunner(root, serverURL, bootstrap.environmentID);
|
|
569
|
+
} finally {
|
|
570
|
+
fs.rmSync(root, { recursive: true, force: true });
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
async function main(argv = process.argv.slice(2), environment = process.env) {
|
|
575
|
+
const args = parseArguments(argv);
|
|
576
|
+
return setup(args.server, environment);
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
if (require.main === module) {
|
|
580
|
+
main().then(
|
|
581
|
+
(exitCode) => {
|
|
582
|
+
if (exitCode) {
|
|
583
|
+
process.exitCode = exitCode;
|
|
584
|
+
}
|
|
585
|
+
},
|
|
586
|
+
(error) => {
|
|
587
|
+
console.error("chord-ctl: " + error.message);
|
|
588
|
+
process.exitCode = 1;
|
|
589
|
+
},
|
|
590
|
+
);
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
module.exports = {
|
|
594
|
+
BOOTSTRAP_SCHEMA,
|
|
595
|
+
DEFAULT_NPM_REGISTRY,
|
|
596
|
+
MAX_ARCHIVE_BYTES,
|
|
597
|
+
MAX_BOOTSTRAP_BYTES,
|
|
598
|
+
MAX_ENTRY_BYTES,
|
|
599
|
+
MAX_METADATA_BYTES,
|
|
600
|
+
MAX_TARBALL_BYTES,
|
|
601
|
+
parseArguments,
|
|
602
|
+
parseTar,
|
|
603
|
+
platformTag,
|
|
604
|
+
registryURL,
|
|
605
|
+
setup,
|
|
606
|
+
unpackPackage,
|
|
607
|
+
validateBootstrapResponse,
|
|
608
|
+
validatePackageMetadata,
|
|
609
|
+
};
|
package/package.json
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@sys9/chord-ctl",
|
|
3
|
+
"version": "0.0.0",
|
|
4
|
+
"description": "Transient Chord Host setup bootstrap",
|
|
5
|
+
"license": "UNLICENSED",
|
|
6
|
+
"repository": {
|
|
7
|
+
"type": "git",
|
|
8
|
+
"url": "git+https://github.com/sys9-ai/chord.git"
|
|
9
|
+
},
|
|
10
|
+
"publishConfig": {
|
|
11
|
+
"access": "public"
|
|
12
|
+
},
|
|
13
|
+
"bin": {
|
|
14
|
+
"chord-ctl": "bin/chord-ctl"
|
|
15
|
+
},
|
|
16
|
+
"files": [
|
|
17
|
+
"README.md",
|
|
18
|
+
"bin"
|
|
19
|
+
],
|
|
20
|
+
"scripts": {
|
|
21
|
+
"test": "node --test test/*.test.js"
|
|
22
|
+
},
|
|
23
|
+
"engines": {
|
|
24
|
+
"node": ">=16"
|
|
25
|
+
}
|
|
26
|
+
}
|