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