@qilin-agent/cli 0.0.0-stage → 3.1.4
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/LICENSE +21 -0
- package/README.i18n.yaml +22 -0
- package/README.md +78 -2
- package/README.zh.md +79 -0
- package/lib/bin.js +349 -0
- package/lib/dump-config-_HRfhhWz.js +53 -0
- package/lib/plugin-BQ1sn-63.js +239 -0
- package/lib/profile-boot-DSy6PUHQ.js +315 -0
- package/lib/profile-boot.js +2 -0
- package/lib/tenant-runtime-ChD53FKq.js +489 -0
- package/lib/tenants-roster-Bhr9x2Jw.js +252 -0
- package/lib/types/args.d.ts +65 -0
- package/lib/types/bin.d.ts +11 -0
- package/lib/types/dump-config.d.ts +18 -0
- package/lib/types/plugin.d.ts +29 -0
- package/lib/types/process-shutdown.d.ts +20 -0
- package/lib/types/profile-boot.d.ts +92 -0
- package/lib/types/scheduler-yield.d.ts +19 -0
- package/lib/types/startup-diagnostics.d.ts +19 -0
- package/lib/types/tenant-proxy.d.ts +101 -0
- package/lib/types/tenant-runtime.d.ts +338 -0
- package/lib/types/tenants-roster.d.ts +138 -0
- package/lib/types/tenants.d.ts +175 -0
- package/lib/types/usage-ledger.d.ts +143 -0
- package/lib/types/web-entry.d.ts +97 -0
- package/lib/web-entry-C--0C6wG.js +1500 -0
- package/package.json +179 -3
|
@@ -0,0 +1,1500 @@
|
|
|
1
|
+
import { a as isTenantIdentity, i as TenantRuntime, n as LOOPBACK_HOSTS, r as TENANT_VARIABLE, t as BOOTSTRAP_DIRECTORY } from "./tenant-runtime-ChD53FKq.js";
|
|
2
|
+
import { resolveQilinHome } from "@qilin-agent/home-paths";
|
|
3
|
+
import { randomBytes, timingSafeEqual } from "node:crypto";
|
|
4
|
+
import { appendFile, mkdir, readFile, readdir } from "node:fs/promises";
|
|
5
|
+
import { join, resolve } from "node:path";
|
|
6
|
+
import { readFileSync } from "node:fs";
|
|
7
|
+
import { spawn } from "node:child_process";
|
|
8
|
+
import { Readable } from "node:stream";
|
|
9
|
+
import { AccountStore, DEPLOYMENT_AUTHORITY_HEADER, accountsFilePath } from "@qilin-agent/accounts-local";
|
|
10
|
+
import { createServer, request } from "node:http";
|
|
11
|
+
import { createServer as createServer$1 } from "node:https";
|
|
12
|
+
import { connect } from "node:net";
|
|
13
|
+
//#region lib/types/tenant-proxy.js
|
|
14
|
+
/**
|
|
15
|
+
* The hub's transport hop into one tenant runtime: an HTTP request forwarded
|
|
16
|
+
* with that runtime's own authority, and an upgraded socket piped the same way.
|
|
17
|
+
*
|
|
18
|
+
* The hop changes transport facts only. It rewrites `Host` to the tenant's
|
|
19
|
+
* authority — the tenant's session cookie is named after the authority it was
|
|
20
|
+
* issued for, so one browser can hold one cookie per tenant on one origin —
|
|
21
|
+
* and rewrites `Origin` to that same authority after checking the browser's
|
|
22
|
+
* own Origin names the hub, which keeps the tenant's same-origin fence as
|
|
23
|
+
* strong as it is without a proxy. Everything else, including `Range`,
|
|
24
|
+
* `Set-Cookie`, `Location`, and partial-content answers, crosses unchanged.
|
|
25
|
+
*
|
|
26
|
+
* The hop decides no access: the tenant runtime's own account gate answers
|
|
27
|
+
* every request, so a forged routing cookie reaches a runtime that does not
|
|
28
|
+
* recognize the caller.
|
|
29
|
+
* @module @qilin-agent/cli/tenant-proxy
|
|
30
|
+
*/
|
|
31
|
+
/**
|
|
32
|
+
* Headers that name one connection rather than one message: forwarded, they
|
|
33
|
+
* would promise the browser a connection the hub does not have.
|
|
34
|
+
*/
|
|
35
|
+
const HOP_BY_HOP = new Set([
|
|
36
|
+
"connection",
|
|
37
|
+
"keep-alive",
|
|
38
|
+
"proxy-authenticate",
|
|
39
|
+
"proxy-authorization",
|
|
40
|
+
"te",
|
|
41
|
+
"trailer",
|
|
42
|
+
"transfer-encoding",
|
|
43
|
+
"upgrade"
|
|
44
|
+
]);
|
|
45
|
+
/**
|
|
46
|
+
* Whether a browser-labeled request may be forwarded at all.
|
|
47
|
+
*
|
|
48
|
+
* Over plain HTTP a read carries neither Origin nor Fetch-Metadata, so an
|
|
49
|
+
* absent Origin is accepted and the tenant's own Host fence still binds the
|
|
50
|
+
* request. A present Origin must name the hub exactly, and an explicit
|
|
51
|
+
* cross-site marker is refused: the hub is the outermost hop, so this is the
|
|
52
|
+
* only place those markers can be read before the tenant's authority replaces
|
|
53
|
+
* them.
|
|
54
|
+
* @param headers - browser headers, verbatim.
|
|
55
|
+
* @param hubAuthority - authority the browser used to reach the hub.
|
|
56
|
+
* @returns true when the request may be forwarded.
|
|
57
|
+
*/
|
|
58
|
+
function hubAcceptsOrigin(headers, hubAuthority) {
|
|
59
|
+
if (headers["sec-fetch-site"] === "cross-site") return false;
|
|
60
|
+
const origin = headers.origin;
|
|
61
|
+
if (origin === void 0) return true;
|
|
62
|
+
try {
|
|
63
|
+
return new URL(origin).host === hubAuthority;
|
|
64
|
+
} catch {
|
|
65
|
+
return false;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* Headers to forward upstream: the browser's own, minus connection-scoped
|
|
70
|
+
* entries, with the authority-bearing ones rewritten to the tenant.
|
|
71
|
+
* @param headers - browser headers, verbatim.
|
|
72
|
+
* @param tenantAuthority - the runtime's own `host:port`.
|
|
73
|
+
* @param deploymentAuthority - authority the browser used to reach the hub, and
|
|
74
|
+
* therefore the one the runtime names its sessions for; empty when the request
|
|
75
|
+
* reached a runtime directly, which then keeps its own authority as the name.
|
|
76
|
+
* Required rather than optional because every hop of this deployment knows it,
|
|
77
|
+
* and an upgrade that forgot it authenticates against a cookie no browser holds.
|
|
78
|
+
* @returns the forwarded header set.
|
|
79
|
+
*/
|
|
80
|
+
function forwardedHeaders(headers, tenantAuthority, deploymentAuthority) {
|
|
81
|
+
const forwarded = {};
|
|
82
|
+
for (const [name, value] of Object.entries(headers)) {
|
|
83
|
+
const lower = name.toLowerCase();
|
|
84
|
+
if (HOP_BY_HOP.has(lower) || lower === "host" || lower === "origin" || lower === DEPLOYMENT_AUTHORITY_HEADER) continue;
|
|
85
|
+
forwarded[name] = value;
|
|
86
|
+
}
|
|
87
|
+
forwarded.host = tenantAuthority;
|
|
88
|
+
if (headers.origin !== void 0) forwarded.origin = `http://${tenantAuthority}`;
|
|
89
|
+
if (deploymentAuthority !== "") forwarded[DEPLOYMENT_AUTHORITY_HEADER] = deploymentAuthority;
|
|
90
|
+
return forwarded;
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* Send one request to a tenant runtime and hand back its streaming answer.
|
|
94
|
+
*
|
|
95
|
+
* The hub's own origin fence runs here, in the operation that would forward the
|
|
96
|
+
* request, so no caller can forward a browser request whose Origin names
|
|
97
|
+
* another site.
|
|
98
|
+
* @param options - the runtime, the path, headers, and body to forward.
|
|
99
|
+
* @returns the runtime's status, headers, and body, or the refusal.
|
|
100
|
+
* @throws Error when the runtime is unreachable or refuses the connection.
|
|
101
|
+
*/
|
|
102
|
+
async function proxyRequest(options) {
|
|
103
|
+
const origin = new URL(options.origin);
|
|
104
|
+
if (!hubAcceptsOrigin(options.headers, options.hubAuthority)) return {
|
|
105
|
+
status: 403,
|
|
106
|
+
headers: { "content-type": "text/plain; charset=utf-8" },
|
|
107
|
+
stream: Readable.from("qilin tenants: that origin is not this hub\n")
|
|
108
|
+
};
|
|
109
|
+
const answered = Promise.withResolvers();
|
|
110
|
+
const upstream = request({
|
|
111
|
+
protocol: origin.protocol,
|
|
112
|
+
hostname: origin.hostname,
|
|
113
|
+
port: origin.port,
|
|
114
|
+
method: options.method,
|
|
115
|
+
path: options.path,
|
|
116
|
+
headers: forwardedHeaders(options.headers, origin.host, options.hubAuthority)
|
|
117
|
+
});
|
|
118
|
+
upstream.on("response", (response) => {
|
|
119
|
+
answered.resolve({
|
|
120
|
+
status: response.statusCode ?? 502,
|
|
121
|
+
headers: response.headers,
|
|
122
|
+
stream: response
|
|
123
|
+
});
|
|
124
|
+
});
|
|
125
|
+
upstream.on("error", (error) => {
|
|
126
|
+
answered.reject(error);
|
|
127
|
+
});
|
|
128
|
+
options.body?.pipe(upstream);
|
|
129
|
+
if (options.body === void 0) upstream.end();
|
|
130
|
+
return await answered.promise;
|
|
131
|
+
}
|
|
132
|
+
/**
|
|
133
|
+
* Write one upstream answer to the browser, streaming the body.
|
|
134
|
+
* @param answer - the runtime's status, headers, and body.
|
|
135
|
+
* @param response - the browser's response.
|
|
136
|
+
*/
|
|
137
|
+
function relayAnswer(answer, response) {
|
|
138
|
+
const headers = {};
|
|
139
|
+
for (const [name, value] of Object.entries(answer.headers)) {
|
|
140
|
+
if (HOP_BY_HOP.has(name.toLowerCase())) continue;
|
|
141
|
+
headers[name] = value;
|
|
142
|
+
}
|
|
143
|
+
response.writeHead(answer.status, headers);
|
|
144
|
+
answer.stream.pipe(response);
|
|
145
|
+
}
|
|
146
|
+
/**
|
|
147
|
+
* Pipe one upgraded browser socket to the same path on a tenant runtime, with
|
|
148
|
+
* the tenant's authority in place of the hub's.
|
|
149
|
+
*
|
|
150
|
+
* The upgrade is piped, never terminated: the hub holds no copy of the frames,
|
|
151
|
+
* so the tenant's own mux and its trust fence stay in charge of the stream.
|
|
152
|
+
* @param request - the browser's upgrade request.
|
|
153
|
+
* @param socket - the browser's socket.
|
|
154
|
+
* @param head - bytes already read from the browser.
|
|
155
|
+
* @param target - origin of the runtime the socket belongs to.
|
|
156
|
+
* @param headers - already-forwarded headers, with the tenant's authority set.
|
|
157
|
+
*/
|
|
158
|
+
function relayUpgrade(request, socket, head, target, headers) {
|
|
159
|
+
const origin = new URL(target);
|
|
160
|
+
const upstream = connect({
|
|
161
|
+
host: origin.hostname,
|
|
162
|
+
port: Number(origin.port)
|
|
163
|
+
});
|
|
164
|
+
const lines = [`${request.method ?? "GET"} ${request.url ?? "/"} HTTP/1.1`];
|
|
165
|
+
lines.push("connection: Upgrade");
|
|
166
|
+
const upgrade = request.headers.upgrade;
|
|
167
|
+
lines.push(`upgrade: ${typeof upgrade === "string" && upgrade !== "" ? upgrade : "websocket"}`);
|
|
168
|
+
for (const [name, value] of Object.entries(headers)) {
|
|
169
|
+
if (value === void 0) continue;
|
|
170
|
+
for (const entry of Array.isArray(value) ? value : [value]) lines.push(`${name}: ${entry}`);
|
|
171
|
+
}
|
|
172
|
+
upstream.on("connect", () => {
|
|
173
|
+
upstream.write(`${lines.join("\r\n")}\r\n\r\n`);
|
|
174
|
+
if (head.length > 0) upstream.write(head);
|
|
175
|
+
upstream.pipe(socket);
|
|
176
|
+
socket.pipe(upstream);
|
|
177
|
+
});
|
|
178
|
+
const closeBoth = () => {
|
|
179
|
+
upstream.destroy();
|
|
180
|
+
socket.destroy();
|
|
181
|
+
};
|
|
182
|
+
upstream.on("error", closeBoth);
|
|
183
|
+
socket.on("error", closeBoth);
|
|
184
|
+
socket.on("close", () => {
|
|
185
|
+
upstream.destroy();
|
|
186
|
+
});
|
|
187
|
+
}
|
|
188
|
+
function usageDirectory(home) {
|
|
189
|
+
return join(home, "tenants", ".usage");
|
|
190
|
+
}
|
|
191
|
+
/** Absolute path of one account's ledger file. */
|
|
192
|
+
function usageFilePath(home, accountId) {
|
|
193
|
+
return join(usageDirectory(home), `${accountId}.jsonl`);
|
|
194
|
+
}
|
|
195
|
+
/**
|
|
196
|
+
* Append one report to an account's ledger, creating the directory on first use.
|
|
197
|
+
* @param home - deployment home.
|
|
198
|
+
* @param accountId - the account the reporting runtime serves.
|
|
199
|
+
* @param report - the values it reported.
|
|
200
|
+
*/
|
|
201
|
+
async function appendUsage(home, accountId, report) {
|
|
202
|
+
await mkdir(usageDirectory(home), {
|
|
203
|
+
recursive: true,
|
|
204
|
+
mode: 448
|
|
205
|
+
});
|
|
206
|
+
await appendFile(usageFilePath(home, accountId), `${JSON.stringify(report)}\n`, {
|
|
207
|
+
encoding: "utf8",
|
|
208
|
+
mode: 384
|
|
209
|
+
});
|
|
210
|
+
}
|
|
211
|
+
/** Whether one report's time falls inside a window. */
|
|
212
|
+
function insideWindow(report, window) {
|
|
213
|
+
if (window?.from !== void 0 && report.at < window.from) return false;
|
|
214
|
+
if (window?.to !== void 0 && report.at > window.to) return false;
|
|
215
|
+
return true;
|
|
216
|
+
}
|
|
217
|
+
/**
|
|
218
|
+
* Read one account's records, latest line per Turn, oldest first.
|
|
219
|
+
*
|
|
220
|
+
* A Turn reported twice keeps the later line and an out-of-order earlier line
|
|
221
|
+
* never replaces it; a line that is not a report is skipped rather than failing
|
|
222
|
+
* the read, because runtimes append to this file while the hub reads it.
|
|
223
|
+
* @param home - deployment home.
|
|
224
|
+
* @param accountId - the account whose ledger to read.
|
|
225
|
+
* @param window - report times to include.
|
|
226
|
+
* @returns the records inside the window, sorted by report time.
|
|
227
|
+
*/
|
|
228
|
+
async function readUsageRecords(home, accountId, window) {
|
|
229
|
+
let source;
|
|
230
|
+
try {
|
|
231
|
+
source = await readFile(usageFilePath(home, accountId), "utf8");
|
|
232
|
+
} catch (error) {
|
|
233
|
+
if (error.code === "ENOENT") return [];
|
|
234
|
+
throw error;
|
|
235
|
+
}
|
|
236
|
+
const perTurn = /* @__PURE__ */ new Map();
|
|
237
|
+
for (const line of source.split("\n")) {
|
|
238
|
+
const report = parseUsageReport(line);
|
|
239
|
+
if (report === void 0 || !insideWindow(report, window)) continue;
|
|
240
|
+
const key = `${report.sessionId}\n${String(report.turn)}`;
|
|
241
|
+
const previous = perTurn.get(key);
|
|
242
|
+
if (previous === void 0 || previous.at <= report.at) perTurn.set(key, report);
|
|
243
|
+
}
|
|
244
|
+
return [...perTurn.values()].sort((left, right) => left.at - right.at);
|
|
245
|
+
}
|
|
246
|
+
/**
|
|
247
|
+
* Total the records of one account's ledger inside a window.
|
|
248
|
+
* @param home - deployment home.
|
|
249
|
+
* @param accountId - the account whose ledger to total.
|
|
250
|
+
* @param window - report times to include.
|
|
251
|
+
* @returns the total, zeroes when nothing was reported in the window.
|
|
252
|
+
*/
|
|
253
|
+
async function readUsageTotal(home, accountId, window) {
|
|
254
|
+
return totalUsage(await readUsageRecords(home, accountId, window));
|
|
255
|
+
}
|
|
256
|
+
/**
|
|
257
|
+
* The report-time window one read asks for.
|
|
258
|
+
* @param params - query parameters of the read.
|
|
259
|
+
* @returns the window, or undefined when a bound is not a whole non-negative
|
|
260
|
+
* number of epoch milliseconds or the bounds are inverted.
|
|
261
|
+
*/
|
|
262
|
+
function readUsageWindow(params) {
|
|
263
|
+
const bound = (name) => {
|
|
264
|
+
const raw = params.get(name);
|
|
265
|
+
if (raw === null || raw === "") return void 0;
|
|
266
|
+
const value = Number(raw);
|
|
267
|
+
return Number.isSafeInteger(value) && value >= 0 ? value : "invalid";
|
|
268
|
+
};
|
|
269
|
+
const from = bound("from");
|
|
270
|
+
const to = bound("to");
|
|
271
|
+
if (from === "invalid" || to === "invalid") return void 0;
|
|
272
|
+
if (from !== void 0 && to !== void 0 && to < from) return void 0;
|
|
273
|
+
return {
|
|
274
|
+
...from === void 0 ? {} : { from },
|
|
275
|
+
...to === void 0 ? {} : { to }
|
|
276
|
+
};
|
|
277
|
+
}
|
|
278
|
+
/**
|
|
279
|
+
* The newest records one answer carries, and whether older ones were left out.
|
|
280
|
+
*
|
|
281
|
+
* Records are sorted by report time, so a chart drawn from the newest slice is
|
|
282
|
+
* missing its oldest days rather than scattered holes; the flag says so.
|
|
283
|
+
* @param records - records in report-time order.
|
|
284
|
+
* @param cap - most records one answer may carry.
|
|
285
|
+
* @returns the records to send and whether the cap dropped any.
|
|
286
|
+
*/
|
|
287
|
+
function capUsageRecords(records, cap) {
|
|
288
|
+
if (cap <= 0) return {
|
|
289
|
+
records: [],
|
|
290
|
+
truncated: records.length > 0
|
|
291
|
+
};
|
|
292
|
+
return records.length <= cap ? {
|
|
293
|
+
records: [...records],
|
|
294
|
+
truncated: false
|
|
295
|
+
} : {
|
|
296
|
+
records: records.slice(records.length - cap),
|
|
297
|
+
truncated: true
|
|
298
|
+
};
|
|
299
|
+
}
|
|
300
|
+
/**
|
|
301
|
+
* Sum one account's records into a total.
|
|
302
|
+
* @param records - records already deduplicated per Turn.
|
|
303
|
+
* @returns the total they add up to.
|
|
304
|
+
*/
|
|
305
|
+
function totalUsage(records) {
|
|
306
|
+
const sessions = /* @__PURE__ */ new Set();
|
|
307
|
+
let inputTokens = 0;
|
|
308
|
+
let outputTokens = 0;
|
|
309
|
+
let cacheReadTokens = 0;
|
|
310
|
+
let cacheWriteTokens = 0;
|
|
311
|
+
let at = 0;
|
|
312
|
+
for (const record of records) {
|
|
313
|
+
sessions.add(record.sessionId);
|
|
314
|
+
inputTokens += record.inputTokens;
|
|
315
|
+
outputTokens += record.outputTokens;
|
|
316
|
+
cacheReadTokens += record.cacheReadTokens ?? 0;
|
|
317
|
+
cacheWriteTokens += record.cacheWriteTokens ?? 0;
|
|
318
|
+
at = Math.max(at, record.at);
|
|
319
|
+
}
|
|
320
|
+
const prompt = inputTokens + cacheReadTokens;
|
|
321
|
+
return {
|
|
322
|
+
sessions: sessions.size,
|
|
323
|
+
turns: records.length,
|
|
324
|
+
inputTokens,
|
|
325
|
+
outputTokens,
|
|
326
|
+
cacheReadTokens,
|
|
327
|
+
cacheWriteTokens,
|
|
328
|
+
cacheHitRate: prompt === 0 ? 0 : cacheReadTokens / prompt,
|
|
329
|
+
at
|
|
330
|
+
};
|
|
331
|
+
}
|
|
332
|
+
/**
|
|
333
|
+
* Total every account's ledger inside a window.
|
|
334
|
+
* @param home - deployment home.
|
|
335
|
+
* @param window - report times to include.
|
|
336
|
+
* @returns one total per account that reported in the window.
|
|
337
|
+
*/
|
|
338
|
+
async function readUsageTotals(home, window) {
|
|
339
|
+
const directory = usageDirectory(home);
|
|
340
|
+
let entries;
|
|
341
|
+
try {
|
|
342
|
+
entries = await readdir(directory);
|
|
343
|
+
} catch (error) {
|
|
344
|
+
if (error.code === "ENOENT") return {};
|
|
345
|
+
throw error;
|
|
346
|
+
}
|
|
347
|
+
const totals = {};
|
|
348
|
+
for (const entry of entries) {
|
|
349
|
+
if (!entry.endsWith(".jsonl")) continue;
|
|
350
|
+
totals[entry.slice(0, -6)] = await readUsageTotal(home, entry.slice(0, -6), window);
|
|
351
|
+
}
|
|
352
|
+
return totals;
|
|
353
|
+
}
|
|
354
|
+
/**
|
|
355
|
+
* Sum every account's total into the deployment's own.
|
|
356
|
+
*
|
|
357
|
+
* The session and turn counts add up rather than deduplicating, because one
|
|
358
|
+
* session belongs to one account: two accounts reporting a session each
|
|
359
|
+
* reported two.
|
|
360
|
+
* @param totals - totals keyed by account identity.
|
|
361
|
+
* @returns the summed total, zeroes when no account has reported.
|
|
362
|
+
*/
|
|
363
|
+
function sumUsageTotals(totals) {
|
|
364
|
+
let sessions = 0;
|
|
365
|
+
let turns = 0;
|
|
366
|
+
let inputTokens = 0;
|
|
367
|
+
let outputTokens = 0;
|
|
368
|
+
let cacheReadTokens = 0;
|
|
369
|
+
let cacheWriteTokens = 0;
|
|
370
|
+
let at = 0;
|
|
371
|
+
for (const total of Object.values(totals)) {
|
|
372
|
+
sessions += total.sessions;
|
|
373
|
+
turns += total.turns;
|
|
374
|
+
inputTokens += total.inputTokens;
|
|
375
|
+
outputTokens += total.outputTokens;
|
|
376
|
+
cacheReadTokens += total.cacheReadTokens;
|
|
377
|
+
cacheWriteTokens += total.cacheWriteTokens;
|
|
378
|
+
at = Math.max(at, total.at);
|
|
379
|
+
}
|
|
380
|
+
const prompt = inputTokens + cacheReadTokens;
|
|
381
|
+
return {
|
|
382
|
+
sessions,
|
|
383
|
+
turns,
|
|
384
|
+
inputTokens,
|
|
385
|
+
outputTokens,
|
|
386
|
+
cacheReadTokens,
|
|
387
|
+
cacheWriteTokens,
|
|
388
|
+
cacheHitRate: prompt === 0 ? 0 : cacheReadTokens / prompt,
|
|
389
|
+
at
|
|
390
|
+
};
|
|
391
|
+
}
|
|
392
|
+
/**
|
|
393
|
+
* Read one ledger line as a report.
|
|
394
|
+
* @param line - one line of an account's ledger.
|
|
395
|
+
* @returns the report, or undefined when the line is empty, torn, or not a report.
|
|
396
|
+
*/
|
|
397
|
+
function parseUsageReport(line) {
|
|
398
|
+
if (line === "") return void 0;
|
|
399
|
+
let parsed;
|
|
400
|
+
try {
|
|
401
|
+
parsed = JSON.parse(line);
|
|
402
|
+
} catch (error) {
|
|
403
|
+
if (error instanceof SyntaxError) return void 0;
|
|
404
|
+
throw error;
|
|
405
|
+
}
|
|
406
|
+
return isUsageReport(parsed) ? parsed : void 0;
|
|
407
|
+
}
|
|
408
|
+
/**
|
|
409
|
+
* Whether a parsed value carries the report fields with usable values.
|
|
410
|
+
* @param value - the parsed line.
|
|
411
|
+
* @returns true when it is one report.
|
|
412
|
+
*/
|
|
413
|
+
function isUsageReport(value) {
|
|
414
|
+
if (typeof value !== "object" || value === null) return false;
|
|
415
|
+
const report = value;
|
|
416
|
+
if (typeof report["sessionId"] !== "string" || report["sessionId"] === "") return false;
|
|
417
|
+
if (!isCount(report["inputTokens"]) || !isCount(report["outputTokens"])) return false;
|
|
418
|
+
if (!isCount(report["turn"])) return false;
|
|
419
|
+
if (report["cacheReadTokens"] !== void 0 && !isCount(report["cacheReadTokens"])) return false;
|
|
420
|
+
if (report["cacheWriteTokens"] !== void 0 && !isCount(report["cacheWriteTokens"])) return false;
|
|
421
|
+
return typeof report["at"] === "number" && Number.isSafeInteger(report["at"]) && report["at"] >= 0;
|
|
422
|
+
}
|
|
423
|
+
/** Whether a value is a whole, non-negative count. */
|
|
424
|
+
function isCount(value) {
|
|
425
|
+
return typeof value === "number" && Number.isSafeInteger(value) && value >= 0;
|
|
426
|
+
}
|
|
427
|
+
//#endregion
|
|
428
|
+
//#region lib/types/tenants.js
|
|
429
|
+
/**
|
|
430
|
+
* `qilin tenants serve`: one hub over a shared Harness home, one isolated Web
|
|
431
|
+
* runtime per account. Sessions, tasks, and workspaces stay with the account
|
|
432
|
+
* that owns them; plugins, model settings, credentials, and the account file
|
|
433
|
+
* stay shared, so every tenant runtime is that one deployment.
|
|
434
|
+
*
|
|
435
|
+
* The hub authenticates nobody: a handoff is a redirect, not a credential.
|
|
436
|
+
* Each tenant runtime keeps the shared home's account gate and admits only its
|
|
437
|
+
* own account, so the sign-in happens at the runtime and the runtime is the
|
|
438
|
+
* boundary. The hub must be reachable where its account list is not a secret —
|
|
439
|
+
* it binds loopback by default and lists usernames.
|
|
440
|
+
*
|
|
441
|
+
* Idle reclamation counts from the last handoff. A browser that reached a tenant
|
|
442
|
+
* origin keeps talking to that origin directly, which the hub cannot observe, so
|
|
443
|
+
* a deployment that leaves long-lived tabs open raises `--idle-ms` or disables
|
|
444
|
+
* reclamation with 0; `--single-origin` moves that same traffic through the hub
|
|
445
|
+
* instead, and reclamation then counts proxied requests.
|
|
446
|
+
*
|
|
447
|
+
* With `--single-origin` the hub serves one origin and routes by a routing
|
|
448
|
+
* cookie it sets at the handoff: `/api`, the mux upgrade, and media requests are
|
|
449
|
+
* absolute paths on the page origin, so only cookie routing can attribute them
|
|
450
|
+
* to a tenant. The cookie selects a runtime, it does not authorize: the runtime
|
|
451
|
+
* still answers with its own account gate, so a forged cookie reaches a
|
|
452
|
+
* runtime that does not recognize the caller.
|
|
453
|
+
*
|
|
454
|
+
* `--max-tenants` bounds how many runtimes live at once: a handoff that would
|
|
455
|
+
* exceed the bound reclaims the least recently used one, and every tenant's
|
|
456
|
+
* Sessions stay on disk. A runtime that fails to start is not retried on every
|
|
457
|
+
* visit: the hub doubles a wait per consecutive failure, and `/status` reports
|
|
458
|
+
* the count and the earliest retry.
|
|
459
|
+
* @module @qilin-agent/cli/tenants
|
|
460
|
+
*/
|
|
461
|
+
/** Routing cookie the hub sets so later requests, whatever their path, name their tenant. */
|
|
462
|
+
const TENANT_COOKIE = "qilin_tenant";
|
|
463
|
+
/** Largest usage report the ledger accepts; one report is a few dozen bytes. */
|
|
464
|
+
const MAX_USAGE_BODY_BYTES = 4 * 1024;
|
|
465
|
+
/**
|
|
466
|
+
* Most ledger records one read answers with. A bound on the response rather than
|
|
467
|
+
* a deployment choice: a long-lived account reports one record per Turn, so the
|
|
468
|
+
* newest slice is what a chart shows and `truncated` says the rest was left out.
|
|
469
|
+
*/
|
|
470
|
+
const USAGE_RECORDS_CAP = 5e3;
|
|
471
|
+
/**
|
|
472
|
+
* Whether every runtime of this deployment must mark its session cookies
|
|
473
|
+
* `Secure`.
|
|
474
|
+
*
|
|
475
|
+
* The browser reaches the deployment through the hub, so a deployment that
|
|
476
|
+
* terminates TLS itself is the one case where the client's own connection is
|
|
477
|
+
* encrypted; a runtime behind a plain-HTTP hub cannot know otherwise, and it
|
|
478
|
+
* never guesses.
|
|
479
|
+
* @param options - the deployment's own TLS material, when it terminates TLS.
|
|
480
|
+
* @returns true only when this deployment terminates TLS.
|
|
481
|
+
*/
|
|
482
|
+
function secureSessionCookies(options) {
|
|
483
|
+
return options.tls !== void 0;
|
|
484
|
+
}
|
|
485
|
+
/** The text one proxied answer carries. */
|
|
486
|
+
async function answerText(stream) {
|
|
487
|
+
const chunks = [];
|
|
488
|
+
for await (const chunk of stream) chunks.push(chunk);
|
|
489
|
+
return Buffer.concat(chunks).toString("utf8");
|
|
490
|
+
}
|
|
491
|
+
/** Store key of the runtime a deployment with no accounts uses. */
|
|
492
|
+
const BOOTSTRAP_ACCOUNT = "first-run";
|
|
493
|
+
/**
|
|
494
|
+
* The routing cookie one handoff sets.
|
|
495
|
+
*
|
|
496
|
+
* It selects a runtime; it authorizes nothing. The runtime still answers with
|
|
497
|
+
* its own account gate, so the value is an account identity and not a secret,
|
|
498
|
+
* and `HttpOnly` keeps it out of page scripts that have no use for it.
|
|
499
|
+
* @param accountId - account identity the browser should reach from now on.
|
|
500
|
+
* @returns one `Set-Cookie` value.
|
|
501
|
+
*/
|
|
502
|
+
function routingCookie(accountId) {
|
|
503
|
+
return `${TENANT_COOKIE}=${accountId}; Path=/; HttpOnly; SameSite=Lax; Max-Age=2592000`;
|
|
504
|
+
}
|
|
505
|
+
/** The routing cookie's removal, for a browser whose tenant no longer exists. */
|
|
506
|
+
function clearedRoutingCookie() {
|
|
507
|
+
return `${TENANT_COOKIE}=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0`;
|
|
508
|
+
}
|
|
509
|
+
/** Documents the deployment itself answers, whatever account a browser chose. */
|
|
510
|
+
const DEPLOYMENT_DOORS = new Set(["/login", "/setup"]);
|
|
511
|
+
/**
|
|
512
|
+
* Whether a path belongs to the deployment's own doors: the sign-in and setup
|
|
513
|
+
* documents, and the account surface they call.
|
|
514
|
+
*
|
|
515
|
+
* These are where a browser picks, or creates, the account it works as, so they
|
|
516
|
+
* are answered by the runtime that carries the deployment's registration policy
|
|
517
|
+
* rather than by whichever account's runtime this browser happens to hold.
|
|
518
|
+
* @param pathname - the request path.
|
|
519
|
+
* @returns true for the sign-in surface.
|
|
520
|
+
*/
|
|
521
|
+
function deploymentDoor(pathname) {
|
|
522
|
+
return DEPLOYMENT_DOORS.has(pathname) || pathname.startsWith("/api/auth/");
|
|
523
|
+
}
|
|
524
|
+
/**
|
|
525
|
+
* One cookie value from a request, decoded no further than substitution.
|
|
526
|
+
* @param header - the browser's `Cookie` header.
|
|
527
|
+
* @param name - cookie name to read.
|
|
528
|
+
* @returns the value, or undefined when the browser did not send it.
|
|
529
|
+
*/
|
|
530
|
+
function cookieValue(header, name) {
|
|
531
|
+
if (header === void 0) return void 0;
|
|
532
|
+
for (const part of header.split(";")) {
|
|
533
|
+
const separator = part.indexOf("=");
|
|
534
|
+
if (separator === -1) continue;
|
|
535
|
+
if (part.slice(0, separator).trim() === name) return part.slice(separator + 1).trim();
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
/**
|
|
539
|
+
* One path segment read as an account name.
|
|
540
|
+
* @param segment - the raw path segment.
|
|
541
|
+
* @returns the decoded segment, or undefined when it is not valid percent-encoding.
|
|
542
|
+
*/
|
|
543
|
+
function decodeIdentifier(segment) {
|
|
544
|
+
try {
|
|
545
|
+
return decodeURIComponent(segment);
|
|
546
|
+
} catch {
|
|
547
|
+
return;
|
|
548
|
+
}
|
|
549
|
+
}
|
|
550
|
+
/**
|
|
551
|
+
* One PEM file the deployment address serves with.
|
|
552
|
+
* @param path - the file the invocation named.
|
|
553
|
+
* @param flag - the flag that named it, for the refusal.
|
|
554
|
+
* @returns the file's bytes.
|
|
555
|
+
* @throws Error when the file cannot be read.
|
|
556
|
+
*/
|
|
557
|
+
function readPem(path, flag) {
|
|
558
|
+
try {
|
|
559
|
+
return readFileSync(path);
|
|
560
|
+
} catch (error) {
|
|
561
|
+
throw new Error(`qilin tenants: ${flag} ${path} is not readable: ${String(error)}`);
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
/** One answered request. */
|
|
565
|
+
function respond(response, status, contentType, body) {
|
|
566
|
+
response.writeHead(status, {
|
|
567
|
+
"cache-control": "no-store",
|
|
568
|
+
"content-type": contentType,
|
|
569
|
+
"content-length": Buffer.byteLength(body)
|
|
570
|
+
});
|
|
571
|
+
response.end(body);
|
|
572
|
+
}
|
|
573
|
+
/** One JSON answer. */
|
|
574
|
+
function json(response, status, body) {
|
|
575
|
+
respond(response, status, "application/json; charset=utf-8", `${JSON.stringify(body)}\n`);
|
|
576
|
+
}
|
|
577
|
+
/**
|
|
578
|
+
* Serve the hub until it is asked to stop.
|
|
579
|
+
* @param options - the interface, port, tenant profile, and idle window.
|
|
580
|
+
* @returns the process exit code, 0 after a clean shutdown.
|
|
581
|
+
*/
|
|
582
|
+
/**
|
|
583
|
+
* What the deployment's own address and its tenant interface mean for reaching
|
|
584
|
+
* them, as the lines a start prints.
|
|
585
|
+
*
|
|
586
|
+
* A hub on loopback is reachable from this machine only, and account runtimes
|
|
587
|
+
* bind loopback unless `--tenant-host` names another interface. Naming a
|
|
588
|
+
* reachable one exposes every runtime there directly — plain HTTP, no entry in
|
|
589
|
+
* between — which an operator has to know before treating the hub address as the
|
|
590
|
+
* only door.
|
|
591
|
+
* @param host - interface the entry bound.
|
|
592
|
+
* @param tenantHost - interface each account runtime binds, when the invocation named one.
|
|
593
|
+
* @param port - port the entry bound.
|
|
594
|
+
* @returns one line per fact the operator needs, in print order.
|
|
595
|
+
*/
|
|
596
|
+
function addressNotices(host, tenantHost, port) {
|
|
597
|
+
const runtimes = tenantHost === void 0 || LOOPBACK_HOSTS.includes(tenantHost) ? "every account runtime stays on loopback" : `each account runtime is reachable at ${tenantHost} over plain HTTP, with no entry in between`;
|
|
598
|
+
if (!LOOPBACK_HOSTS.includes(host)) return [`qilin tenants: this address is reachable at ${host}:${String(port)}; ${runtimes}`];
|
|
599
|
+
return tenantHost === void 0 || LOOPBACK_HOSTS.includes(tenantHost) ? [] : [`qilin tenants: ${runtimes}`];
|
|
600
|
+
}
|
|
601
|
+
/**
|
|
602
|
+
* The runtime a handoff stops to make room, or undefined when there is room or
|
|
603
|
+
* nothing may be reclaimed yet.
|
|
604
|
+
*
|
|
605
|
+
* Only a running runtime is a candidate: one that is still starting has no last
|
|
606
|
+
* use to compare, so the next handoff reclaims it once it is serving. The
|
|
607
|
+
* runtime about to start is never its own candidate.
|
|
608
|
+
* @param live - every runtime holding a child, in any state.
|
|
609
|
+
* @param target - the runtime about to start.
|
|
610
|
+
* @param maxTenants - cap on live runtimes; zero or less means no cap.
|
|
611
|
+
* @returns the runtime to stop, if any.
|
|
612
|
+
*/
|
|
613
|
+
function reclaimFor(live, target, maxTenants) {
|
|
614
|
+
if (maxTenants <= 0) return void 0;
|
|
615
|
+
const others = live.filter((runtime) => runtime !== target);
|
|
616
|
+
if (others.length < maxTenants) return void 0;
|
|
617
|
+
let oldest;
|
|
618
|
+
for (const runtime of others) {
|
|
619
|
+
if (runtime.status().state !== "running") continue;
|
|
620
|
+
if (oldest === void 0 || runtime.lastUsedAt <= oldest.lastUsedAt) oldest = runtime;
|
|
621
|
+
}
|
|
622
|
+
return oldest;
|
|
623
|
+
}
|
|
624
|
+
/**
|
|
625
|
+
* One queue that runs handoffs one at a time.
|
|
626
|
+
*
|
|
627
|
+
* Two handoffs that passed the cap together would both start a runtime, so each
|
|
628
|
+
* handoff's make-room-and-start runs behind the previous one. A handoff that
|
|
629
|
+
* fails settles its own promise and leaves the queue ready for the next.
|
|
630
|
+
* @returns a function that queues one handoff.
|
|
631
|
+
*/
|
|
632
|
+
function createHandoffQueue() {
|
|
633
|
+
let tail = Promise.resolve();
|
|
634
|
+
return (work) => {
|
|
635
|
+
const next = tail.then(work, work);
|
|
636
|
+
tail = next.then(() => void 0, () => void 0);
|
|
637
|
+
return next;
|
|
638
|
+
};
|
|
639
|
+
}
|
|
640
|
+
async function runTenantsServe(options) {
|
|
641
|
+
const home = options.home ?? resolveQilinHome();
|
|
642
|
+
const binPath = process.argv[1];
|
|
643
|
+
if (binPath === void 0) throw new Error("qilin tenants: the CLI entry path is unknown, so no tenant runtime can be started");
|
|
644
|
+
const startRuntime = options.createRuntime ?? ((runtimeOptions) => new TenantRuntime(runtimeOptions));
|
|
645
|
+
const runtimes = /* @__PURE__ */ new Map();
|
|
646
|
+
const usageTokens = /* @__PURE__ */ new Map();
|
|
647
|
+
let usageUrl = "";
|
|
648
|
+
/** Runtimes holding a child, whether or not it has printed its URL yet. */
|
|
649
|
+
const active = () => [...runtimes.values()].filter((runtime) => runtime.status().state === "running" || runtime.status().state === "starting");
|
|
650
|
+
const runtimeFor = (accountId) => {
|
|
651
|
+
const existing = runtimes.get(accountId);
|
|
652
|
+
if (existing !== void 0) return existing;
|
|
653
|
+
const usageToken = randomBytes(32).toString("hex");
|
|
654
|
+
usageTokens.set(usageToken, accountId);
|
|
655
|
+
const created = startRuntime({
|
|
656
|
+
usage: {
|
|
657
|
+
url: usageUrl,
|
|
658
|
+
token: usageToken
|
|
659
|
+
},
|
|
660
|
+
home,
|
|
661
|
+
accountId,
|
|
662
|
+
profile: options.profile,
|
|
663
|
+
binPath,
|
|
664
|
+
execArgv: process.execArgv,
|
|
665
|
+
host: options.tenantHost ?? void 0,
|
|
666
|
+
restartBackoffMilliseconds: options.restartBackoffMilliseconds,
|
|
667
|
+
patches: options.patches,
|
|
668
|
+
appArguments: options.appArguments,
|
|
669
|
+
sessionCookieSecure: secureSessionCookies(options),
|
|
670
|
+
log: (line) => {
|
|
671
|
+
process.stderr.write(`qilin tenants: [${accountId}] ${line}\n`);
|
|
672
|
+
}
|
|
673
|
+
});
|
|
674
|
+
runtimes.set(accountId, created);
|
|
675
|
+
return created;
|
|
676
|
+
};
|
|
677
|
+
/**
|
|
678
|
+
* The runtime a deployment serves the product's own surface from: the landing
|
|
679
|
+
* page, the sign-in form, registration, and first-run initialization.
|
|
680
|
+
*
|
|
681
|
+
* The registration mode is the deployment's own policy, not a consequence of
|
|
682
|
+
* holding an account: a deployment that accepts new accounts keeps accepting
|
|
683
|
+
* them once the first one exists, and only an operator asking for `closed`
|
|
684
|
+
* turns that off.
|
|
685
|
+
* @returns the first-run runtime.
|
|
686
|
+
*/
|
|
687
|
+
const bootstrapRuntime = () => {
|
|
688
|
+
const existing = runtimes.get(BOOTSTRAP_ACCOUNT);
|
|
689
|
+
if (existing !== void 0) return existing;
|
|
690
|
+
const created = startRuntime({
|
|
691
|
+
home,
|
|
692
|
+
accountId: BOOTSTRAP_ACCOUNT,
|
|
693
|
+
directoryName: BOOTSTRAP_DIRECTORY,
|
|
694
|
+
bootstrap: true,
|
|
695
|
+
registration: options.registration,
|
|
696
|
+
profile: options.profile,
|
|
697
|
+
binPath,
|
|
698
|
+
execArgv: process.execArgv,
|
|
699
|
+
host: options.tenantHost ?? void 0,
|
|
700
|
+
restartBackoffMilliseconds: options.restartBackoffMilliseconds,
|
|
701
|
+
patches: options.patches,
|
|
702
|
+
appArguments: options.appArguments,
|
|
703
|
+
sessionCookieSecure: secureSessionCookies(options),
|
|
704
|
+
log: (line) => {
|
|
705
|
+
process.stderr.write(`qilin tenants: [first-run] ${line}\n`);
|
|
706
|
+
}
|
|
707
|
+
});
|
|
708
|
+
runtimes.set(BOOTSTRAP_ACCOUNT, created);
|
|
709
|
+
return created;
|
|
710
|
+
};
|
|
711
|
+
/**
|
|
712
|
+
* Bring the live runtimes under the cap by reclaiming the least recently used
|
|
713
|
+
* ones. Every tenant's Sessions stay on disk, so reclaiming costs one boot.
|
|
714
|
+
* @param target - the runtime about to start, which is never reclaimed.
|
|
715
|
+
*/
|
|
716
|
+
const makeRoom = async (target) => {
|
|
717
|
+
for (;;) {
|
|
718
|
+
const oldest = reclaimFor(active(), target, options.maxTenants);
|
|
719
|
+
if (oldest === void 0) return;
|
|
720
|
+
process.stderr.write(`qilin tenants: ${String(options.maxTenants)} runtime(s) are already alive; reclaiming ${oldest.status().accountId} to start ${target.status().accountId}\n`);
|
|
721
|
+
await oldest.stop();
|
|
722
|
+
}
|
|
723
|
+
};
|
|
724
|
+
/** Handoffs run one at a time, so two of them cannot both pass the cap and start. */
|
|
725
|
+
const serialize = createHandoffQueue();
|
|
726
|
+
const roster = async () => await AccountStore.open(accountsFilePath(home));
|
|
727
|
+
const status = async () => {
|
|
728
|
+
const store = await roster();
|
|
729
|
+
return {
|
|
730
|
+
mode: options.singleOrigin ? "single-origin" : "handoff",
|
|
731
|
+
maxTenants: options.maxTenants,
|
|
732
|
+
alive: active().length,
|
|
733
|
+
tenants: [...runtimes.values()].map((runtime) => {
|
|
734
|
+
const reported = runtime.status();
|
|
735
|
+
return {
|
|
736
|
+
...reported,
|
|
737
|
+
username: store.byId(reported.accountId)?.username ?? null
|
|
738
|
+
};
|
|
739
|
+
})
|
|
740
|
+
};
|
|
741
|
+
};
|
|
742
|
+
/**
|
|
743
|
+
* Resolve the runtime one browser is driving, starting it when it is not serving.
|
|
744
|
+
* @param store - the roster this request read.
|
|
745
|
+
* @param accountId - the account the browser's routing cookie names.
|
|
746
|
+
* @returns the runtime, or undefined when the roster no longer holds that account.
|
|
747
|
+
*/
|
|
748
|
+
const routedRuntime = async (store, accountId) => {
|
|
749
|
+
if (store.byId(accountId) === void 0) return void 0;
|
|
750
|
+
const runtime = runtimeFor(accountId);
|
|
751
|
+
await serialize(async () => {
|
|
752
|
+
await makeRoom(runtime);
|
|
753
|
+
await runtime.ensure();
|
|
754
|
+
});
|
|
755
|
+
return runtime;
|
|
756
|
+
};
|
|
757
|
+
/**
|
|
758
|
+
* Forward one browser request to a runtime and relay its answer.
|
|
759
|
+
* @param request - the browser's request.
|
|
760
|
+
* @param response - the browser's response.
|
|
761
|
+
* @param runtime - the runtime the request belongs to.
|
|
762
|
+
* @param path - path and query to request upstream.
|
|
763
|
+
* @param extraCookies - cookies to add to the answer.
|
|
764
|
+
*/
|
|
765
|
+
const forward = async (request, response, runtime, path, extraCookies) => {
|
|
766
|
+
const origin = runtime.proxyTarget();
|
|
767
|
+
if (origin === void 0) throw new Error("the runtime is not serving");
|
|
768
|
+
runtime.lastUsedAt = Date.now();
|
|
769
|
+
const answered = await proxyRequest({
|
|
770
|
+
origin,
|
|
771
|
+
method: request.method ?? "GET",
|
|
772
|
+
path,
|
|
773
|
+
headers: request.headers,
|
|
774
|
+
hubAuthority: request.headers.host ?? "",
|
|
775
|
+
body: request
|
|
776
|
+
});
|
|
777
|
+
const upstreamCookies = answered.headers["set-cookie"] === void 0 ? [] : Array.isArray(answered.headers["set-cookie"]) ? answered.headers["set-cookie"] : [answered.headers["set-cookie"]];
|
|
778
|
+
relayAnswer({
|
|
779
|
+
...answered,
|
|
780
|
+
headers: {
|
|
781
|
+
...answered.headers,
|
|
782
|
+
"set-cookie": [...upstreamCookies, ...extraCookies]
|
|
783
|
+
}
|
|
784
|
+
}, response);
|
|
785
|
+
};
|
|
786
|
+
/**
|
|
787
|
+
* The runtime the product's own surface is served from, running.
|
|
788
|
+
*
|
|
789
|
+
* The product hides or shows its register entry from the answer this runtime
|
|
790
|
+
* reports, and the runtime refuses the call it would hide: both follow the
|
|
791
|
+
* deployment's registration policy, so an operator who accepts new accounts
|
|
792
|
+
* sees the entry for every account the roster already holds.
|
|
793
|
+
* @returns the first-run runtime, running.
|
|
794
|
+
*/
|
|
795
|
+
const runningFirstRun = async () => {
|
|
796
|
+
const runtime = bootstrapRuntime();
|
|
797
|
+
await serialize(async () => {
|
|
798
|
+
await makeRoom(runtime);
|
|
799
|
+
await runtime.ensure();
|
|
800
|
+
});
|
|
801
|
+
return runtime;
|
|
802
|
+
};
|
|
803
|
+
/**
|
|
804
|
+
* Hand a request to the runtime a deployment without accounts uses, so the
|
|
805
|
+
* product's own first-run form creates the first account instead of the
|
|
806
|
+
* deployment describing what an operator should type.
|
|
807
|
+
* @param request - the browser's request.
|
|
808
|
+
* @param response - the browser's answer.
|
|
809
|
+
* @param path - request path, preserved so the application sees its own route.
|
|
810
|
+
* @param extraCookies - cookies this deployment appends to the answer.
|
|
811
|
+
*/
|
|
812
|
+
const serveFirstRun = async (request, response, path, extraCookies = []) => {
|
|
813
|
+
try {
|
|
814
|
+
await forward(request, response, await runningFirstRun(), path, extraCookies);
|
|
815
|
+
} catch (error) {
|
|
816
|
+
process.stderr.write(`qilin tenants: the first-run runtime did not start: ${String(error)}\n`);
|
|
817
|
+
respond(response, 503, "text/plain; charset=utf-8", "qilin tenants: this deployment could not start its first-run runtime\n");
|
|
818
|
+
}
|
|
819
|
+
};
|
|
820
|
+
/**
|
|
821
|
+
* Which account the browser's session names, as the runtime that issued it
|
|
822
|
+
* reports it.
|
|
823
|
+
*
|
|
824
|
+
* The deployment never reads a session itself: the runtime owns that answer,
|
|
825
|
+
* and this asks it the same question the product's landing page asks.
|
|
826
|
+
* @param request - the browser's request, for the cookie that carries the session.
|
|
827
|
+
* @returns the account id, or undefined when no session names one.
|
|
828
|
+
*/
|
|
829
|
+
const signedInAccountId = async (request) => {
|
|
830
|
+
const origin = (await runningFirstRun()).proxyTarget();
|
|
831
|
+
if (origin === void 0) return void 0;
|
|
832
|
+
const answered = await proxyRequest({
|
|
833
|
+
origin,
|
|
834
|
+
method: "GET",
|
|
835
|
+
path: "/api/auth/status",
|
|
836
|
+
headers: {
|
|
837
|
+
cookie: request.headers.cookie ?? "",
|
|
838
|
+
host: request.headers.host ?? ""
|
|
839
|
+
},
|
|
840
|
+
hubAuthority: request.headers.host ?? ""
|
|
841
|
+
});
|
|
842
|
+
if (answered.status !== 200) {
|
|
843
|
+
answered.stream.resume();
|
|
844
|
+
return;
|
|
845
|
+
}
|
|
846
|
+
const body = JSON.parse(await answerText(answered.stream));
|
|
847
|
+
if (typeof body !== "object" || body === null) return void 0;
|
|
848
|
+
const user = body.user;
|
|
849
|
+
if (typeof user !== "object" || user === null) return void 0;
|
|
850
|
+
const id = user.id;
|
|
851
|
+
return typeof id === "string" ? id : void 0;
|
|
852
|
+
};
|
|
853
|
+
const bearerToken = (header) => {
|
|
854
|
+
if (header === void 0) return void 0;
|
|
855
|
+
const separator = header.indexOf(" ");
|
|
856
|
+
if (separator === -1) return void 0;
|
|
857
|
+
if (header.slice(0, separator).toLowerCase() !== "bearer") return void 0;
|
|
858
|
+
const value = header.slice(separator + 1).trim();
|
|
859
|
+
return value === "" ? void 0 : value;
|
|
860
|
+
};
|
|
861
|
+
/** The account one presented token was minted for, compared without leaking its prefix. */
|
|
862
|
+
const accountForToken = (presented) => {
|
|
863
|
+
const candidate = Buffer.from(presented, "utf8");
|
|
864
|
+
for (const [token, accountId] of usageTokens) {
|
|
865
|
+
const known = Buffer.from(token, "utf8");
|
|
866
|
+
if (known.length === candidate.length && timingSafeEqual(known, candidate)) return accountId;
|
|
867
|
+
}
|
|
868
|
+
};
|
|
869
|
+
/** Read one report body, bounded, or undefined when it is oversized or not JSON. */
|
|
870
|
+
const readReportBody = async (request) => {
|
|
871
|
+
const chunks = [];
|
|
872
|
+
let size = 0;
|
|
873
|
+
for await (const chunk of request) {
|
|
874
|
+
const buffer = chunk;
|
|
875
|
+
size += buffer.length;
|
|
876
|
+
if (size > MAX_USAGE_BODY_BYTES) return void 0;
|
|
877
|
+
chunks.push(buffer);
|
|
878
|
+
}
|
|
879
|
+
try {
|
|
880
|
+
return JSON.parse(Buffer.concat(chunks).toString("utf8"));
|
|
881
|
+
} catch (error) {
|
|
882
|
+
if (error instanceof SyntaxError) return void 0;
|
|
883
|
+
throw error;
|
|
884
|
+
}
|
|
885
|
+
};
|
|
886
|
+
/**
|
|
887
|
+
* Answer a usage read for one account: the administrator's seat widens the
|
|
888
|
+
* answer to every account's records, tagged; everyone else reads their own.
|
|
889
|
+
* Shared by the ledger channel (a runtime reporting under its token) and the
|
|
890
|
+
* browser's own read, which the hub answers directly from its ledger.
|
|
891
|
+
* @param response - the answer to write.
|
|
892
|
+
* @param accountId - the account the read belongs to.
|
|
893
|
+
* @param search - the read's query, carrying the window.
|
|
894
|
+
*/
|
|
895
|
+
const answerUsageRead = async (response, accountId, search) => {
|
|
896
|
+
const window = readUsageWindow(search);
|
|
897
|
+
if (window === void 0) {
|
|
898
|
+
json(response, 400, { error: "invalid-window" });
|
|
899
|
+
return;
|
|
900
|
+
}
|
|
901
|
+
const store = await roster();
|
|
902
|
+
if (store.byId(accountId)?.admin === true) {
|
|
903
|
+
const tagged = [];
|
|
904
|
+
for (const account of store.all()) for (const record of await readUsageRecords(home, account.id, window)) tagged.push({
|
|
905
|
+
accountId: account.id,
|
|
906
|
+
record
|
|
907
|
+
});
|
|
908
|
+
tagged.sort((left, right) => left.record.at - right.record.at);
|
|
909
|
+
const capped = capUsageRecords(tagged, USAGE_RECORDS_CAP);
|
|
910
|
+
const totals = await readUsageTotals(home);
|
|
911
|
+
json(response, 200, {
|
|
912
|
+
scope: "deployment",
|
|
913
|
+
totals,
|
|
914
|
+
total: sumUsageTotals(totals),
|
|
915
|
+
records: capped.records.map((entry) => ({
|
|
916
|
+
...entry.record,
|
|
917
|
+
accountId: entry.accountId
|
|
918
|
+
})),
|
|
919
|
+
truncated: capped.truncated
|
|
920
|
+
});
|
|
921
|
+
return;
|
|
922
|
+
}
|
|
923
|
+
const capped = capUsageRecords(await readUsageRecords(home, accountId, window), USAGE_RECORDS_CAP);
|
|
924
|
+
json(response, 200, {
|
|
925
|
+
accountId,
|
|
926
|
+
total: await readUsageTotal(home, accountId),
|
|
927
|
+
records: capped.records,
|
|
928
|
+
truncated: capped.truncated
|
|
929
|
+
});
|
|
930
|
+
};
|
|
931
|
+
/**
|
|
932
|
+
* Answer one runtime over the ledger channel: a report books spend, and a read
|
|
933
|
+
* answers what that same token's account spent. Only a token this hub minted
|
|
934
|
+
* is accepted, so neither verb can name another account.
|
|
935
|
+
*
|
|
936
|
+
* The account's own seat widens the read: the deployment's administrator reads
|
|
937
|
+
* every account's ledger, and the seat is read from the roster on each request
|
|
938
|
+
* so it follows the account it belongs to rather than the token's age.
|
|
939
|
+
*/
|
|
940
|
+
const handleUsage = async (request, response) => {
|
|
941
|
+
if (request.method !== "POST" && request.method !== "GET") {
|
|
942
|
+
respond(response, 405, "text/plain; charset=utf-8", "qilin tenants: the ledger reports and reads\n");
|
|
943
|
+
return;
|
|
944
|
+
}
|
|
945
|
+
const presented = bearerToken(request.headers.authorization);
|
|
946
|
+
const accountId = presented === void 0 ? void 0 : accountForToken(presented);
|
|
947
|
+
if (accountId === void 0 || !isTenantIdentity(accountId)) {
|
|
948
|
+
json(response, 401, { error: "unauthorized" });
|
|
949
|
+
return;
|
|
950
|
+
}
|
|
951
|
+
if (request.method === "GET") {
|
|
952
|
+
await answerUsageRead(response, accountId, new URL(request.url ?? "/", "http://qilin.invalid").searchParams);
|
|
953
|
+
return;
|
|
954
|
+
}
|
|
955
|
+
const body = await readReportBody(request);
|
|
956
|
+
if (!isUsageReport(body)) {
|
|
957
|
+
json(response, 400, { error: "invalid-body" });
|
|
958
|
+
return;
|
|
959
|
+
}
|
|
960
|
+
await appendUsage(home, accountId, body);
|
|
961
|
+
response.writeHead(204);
|
|
962
|
+
response.end();
|
|
963
|
+
};
|
|
964
|
+
const handle = async (request, response) => {
|
|
965
|
+
const url = new URL(request.url ?? "/", "http://qilin.invalid");
|
|
966
|
+
if (url.pathname === "/healthz") {
|
|
967
|
+
json(response, 200, { ok: true });
|
|
968
|
+
return;
|
|
969
|
+
}
|
|
970
|
+
if (url.pathname === "/status") {
|
|
971
|
+
json(response, 200, await status());
|
|
972
|
+
return;
|
|
973
|
+
}
|
|
974
|
+
const productPath = () => {
|
|
975
|
+
if (!url.pathname.startsWith("/t/")) return `${url.pathname}${url.search}`;
|
|
976
|
+
const rest = url.pathname.slice(3);
|
|
977
|
+
const separator = rest.indexOf("/");
|
|
978
|
+
return separator === -1 ? `/${url.search}` : `${rest.slice(separator)}${url.search}`;
|
|
979
|
+
};
|
|
980
|
+
const store = await roster();
|
|
981
|
+
if (store.all().length === 0) {
|
|
982
|
+
await serveFirstRun(request, response, productPath());
|
|
983
|
+
return;
|
|
984
|
+
}
|
|
985
|
+
const hubOwnRoute = url.pathname === "/";
|
|
986
|
+
if (request.method !== "GET" && !(options.singleOrigin && !hubOwnRoute)) {
|
|
987
|
+
respond(response, 405, "text/plain; charset=utf-8", "qilin tenants: the hub answers reads only\n");
|
|
988
|
+
return;
|
|
989
|
+
}
|
|
990
|
+
if (url.pathname.startsWith("/t/")) {
|
|
991
|
+
const rest = url.pathname.slice(3);
|
|
992
|
+
const separator = rest.indexOf("/");
|
|
993
|
+
const username = decodeIdentifier(separator === -1 ? rest : rest.slice(0, separator));
|
|
994
|
+
if (username === void 0) {
|
|
995
|
+
respond(response, 400, "text/plain; charset=utf-8", "qilin tenants: that account path is not valid UTF-8\n");
|
|
996
|
+
return;
|
|
997
|
+
}
|
|
998
|
+
const account = store.byIdentifier(username);
|
|
999
|
+
if (account === void 0) {
|
|
1000
|
+
await serveFirstRun(request, response, productPath());
|
|
1001
|
+
return;
|
|
1002
|
+
}
|
|
1003
|
+
try {
|
|
1004
|
+
const runtime = runtimeFor(account.id);
|
|
1005
|
+
const launchUrl = await serialize(async () => {
|
|
1006
|
+
await makeRoom(runtime);
|
|
1007
|
+
return await runtime.ensure();
|
|
1008
|
+
});
|
|
1009
|
+
const deeper = separator === -1 ? "/" : url.pathname.slice(3 + username.length);
|
|
1010
|
+
if (!options.singleOrigin) {
|
|
1011
|
+
response.writeHead(303, {
|
|
1012
|
+
"cache-control": "no-store",
|
|
1013
|
+
"location": `${new URL(launchUrl).origin}${deeper}${url.search}`,
|
|
1014
|
+
"referrer-policy": "no-referrer"
|
|
1015
|
+
});
|
|
1016
|
+
response.end();
|
|
1017
|
+
return;
|
|
1018
|
+
}
|
|
1019
|
+
await forward(request, response, runtime, `${deeper}${url.search}`, [routingCookie(account.id)]);
|
|
1020
|
+
} catch (error) {
|
|
1021
|
+
process.stderr.write(`qilin tenants: the runtime for ${account.username} did not start: ${String(error)}\n`);
|
|
1022
|
+
respond(response, 503, "text/plain; charset=utf-8", `qilin tenants: the runtime for ${account.username} did not start\n`);
|
|
1023
|
+
}
|
|
1024
|
+
return;
|
|
1025
|
+
}
|
|
1026
|
+
if (options.singleOrigin) {
|
|
1027
|
+
const accountId = cookieValue(request.headers.cookie, TENANT_COOKIE);
|
|
1028
|
+
if (deploymentDoor(url.pathname)) {
|
|
1029
|
+
await serveFirstRun(request, response, productPath(), [clearedRoutingCookie()]);
|
|
1030
|
+
return;
|
|
1031
|
+
}
|
|
1032
|
+
if (url.pathname === "/api/usage/me" && request.method === "GET") {
|
|
1033
|
+
const accountId = cookieValue(request.headers.cookie, TENANT_COOKIE) ?? await signedInAccountId(request);
|
|
1034
|
+
if (accountId === void 0 || store.byId(accountId) === void 0) {
|
|
1035
|
+
json(response, 401, { error: "unauthorized" });
|
|
1036
|
+
return;
|
|
1037
|
+
}
|
|
1038
|
+
await answerUsageRead(response, accountId, new URL(request.url ?? "/", "http://qilin.invalid").searchParams);
|
|
1039
|
+
return;
|
|
1040
|
+
}
|
|
1041
|
+
if (accountId !== void 0) {
|
|
1042
|
+
const runtime = await routedRuntime(store, accountId);
|
|
1043
|
+
if (runtime !== void 0) {
|
|
1044
|
+
try {
|
|
1045
|
+
await forward(request, response, runtime, `${url.pathname}${url.search}`, []);
|
|
1046
|
+
} catch (error) {
|
|
1047
|
+
process.stderr.write(`qilin tenants: no runtime served ${url.pathname}: ${String(error)}\n`);
|
|
1048
|
+
respond(response, 503, "text/plain; charset=utf-8", "qilin tenants: that tenant is not serving\n");
|
|
1049
|
+
}
|
|
1050
|
+
return;
|
|
1051
|
+
}
|
|
1052
|
+
response.setHeader("set-cookie", clearedRoutingCookie());
|
|
1053
|
+
}
|
|
1054
|
+
}
|
|
1055
|
+
if (options.singleOrigin && request.method === "GET" && request.headers.cookie !== void 0 && (request.headers.accept ?? "").includes("text/html")) {
|
|
1056
|
+
const accountId = await signedInAccountId(request);
|
|
1057
|
+
if (accountId !== void 0 && store.byId(accountId) !== void 0) {
|
|
1058
|
+
if ((await routedRuntime(store, accountId))?.proxyTarget() !== void 0) {
|
|
1059
|
+
response.writeHead(303, {
|
|
1060
|
+
"cache-control": "no-store",
|
|
1061
|
+
"location": `${url.pathname}${url.search}`,
|
|
1062
|
+
"referrer-policy": "no-referrer",
|
|
1063
|
+
"set-cookie": routingCookie(accountId)
|
|
1064
|
+
});
|
|
1065
|
+
response.end();
|
|
1066
|
+
return;
|
|
1067
|
+
}
|
|
1068
|
+
}
|
|
1069
|
+
}
|
|
1070
|
+
if (options.singleOrigin && request.headers.cookie !== void 0) {
|
|
1071
|
+
const signedIn = await signedInAccountId(request);
|
|
1072
|
+
if (signedIn !== void 0 && store.byId(signedIn) !== void 0) {
|
|
1073
|
+
const runtime = await routedRuntime(store, signedIn);
|
|
1074
|
+
if (runtime?.proxyTarget() !== void 0) {
|
|
1075
|
+
await forward(request, response, runtime, `${url.pathname}${url.search}`, [routingCookie(signedIn)]);
|
|
1076
|
+
return;
|
|
1077
|
+
}
|
|
1078
|
+
}
|
|
1079
|
+
}
|
|
1080
|
+
await serveFirstRun(request, response, productPath());
|
|
1081
|
+
};
|
|
1082
|
+
const onRequest = (request, response) => {
|
|
1083
|
+
handle(request, response).catch((error) => {
|
|
1084
|
+
process.stderr.write(`qilin tenants: request failed: ${String(error)}\n`);
|
|
1085
|
+
if (!response.headersSent) respond(response, 500, "text/plain; charset=utf-8", "qilin tenants: request failed\n");
|
|
1086
|
+
else response.end();
|
|
1087
|
+
});
|
|
1088
|
+
};
|
|
1089
|
+
const server = options.tls === void 0 ? createServer(onRequest) : createServer$1({
|
|
1090
|
+
cert: readPem(options.tls.certFile, "--tls-cert"),
|
|
1091
|
+
key: readPem(options.tls.keyFile, "--tls-key")
|
|
1092
|
+
}, onRequest);
|
|
1093
|
+
if (options.singleOrigin) server.on("upgrade", (request, socket, head) => {
|
|
1094
|
+
(async () => {
|
|
1095
|
+
if (!hubAcceptsOrigin(request.headers, request.headers.host ?? "")) {
|
|
1096
|
+
socket.destroy();
|
|
1097
|
+
return;
|
|
1098
|
+
}
|
|
1099
|
+
let accountId = cookieValue(request.headers.cookie, TENANT_COOKIE);
|
|
1100
|
+
if (accountId === void 0 && request.headers.cookie !== void 0) accountId = await signedInAccountId(request);
|
|
1101
|
+
const runtime = accountId === void 0 ? void 0 : await routedRuntime(await roster(), accountId);
|
|
1102
|
+
const target = runtime?.proxyTarget();
|
|
1103
|
+
if (runtime === void 0 || target === void 0) {
|
|
1104
|
+
socket.destroy();
|
|
1105
|
+
return;
|
|
1106
|
+
}
|
|
1107
|
+
runtime.lastUsedAt = Date.now();
|
|
1108
|
+
relayUpgrade(request, socket, head, target, forwardedHeaders(request.headers, new URL(target).host, request.headers.host ?? ""));
|
|
1109
|
+
})().catch((error) => {
|
|
1110
|
+
process.stderr.write(`qilin tenants: the upgrade to ${request.url ?? ""} failed: ${String(error)}\n`);
|
|
1111
|
+
socket.destroy();
|
|
1112
|
+
});
|
|
1113
|
+
});
|
|
1114
|
+
const listening = Promise.withResolvers();
|
|
1115
|
+
server.on("error", (error) => {
|
|
1116
|
+
listening.reject(error);
|
|
1117
|
+
});
|
|
1118
|
+
server.listen(options.port, options.host, () => {
|
|
1119
|
+
const address = server.address();
|
|
1120
|
+
listening.resolve(typeof address === "object" && address !== null ? address.port : options.port);
|
|
1121
|
+
});
|
|
1122
|
+
const port = await listening.promise;
|
|
1123
|
+
const usageServer = createServer((request, response) => {
|
|
1124
|
+
handleUsage(request, response).catch((error) => {
|
|
1125
|
+
process.stderr.write(`qilin tenants: the usage report failed: ${String(error)}\n`);
|
|
1126
|
+
if (response.headersSent) response.end();
|
|
1127
|
+
else json(response, 500, { error: "ledger-failed" });
|
|
1128
|
+
});
|
|
1129
|
+
});
|
|
1130
|
+
const usagePort = await new Promise((resolve, reject) => {
|
|
1131
|
+
usageServer.once("error", reject);
|
|
1132
|
+
usageServer.listen(0, "127.0.0.1", () => {
|
|
1133
|
+
const bound = usageServer.address();
|
|
1134
|
+
resolve(typeof bound === "object" && bound !== null ? bound.port : 0);
|
|
1135
|
+
});
|
|
1136
|
+
});
|
|
1137
|
+
usageUrl = `http://127.0.0.1:${String(usagePort)}/usage`;
|
|
1138
|
+
const printableHost = options.host === "0.0.0.0" || options.host === "::" ? "127.0.0.1" : options.host;
|
|
1139
|
+
const address = `${options.tls === void 0 ? "http" : "https"}://${printableHost}:${String(port)}/`;
|
|
1140
|
+
process.stdout.write(`qilin tenants: ${address}\n`);
|
|
1141
|
+
for (const notice of addressNotices(options.host, options.tenantHost, port)) process.stderr.write(`${notice}\n`);
|
|
1142
|
+
if ((await roster()).isEmpty && options.registration !== "open") process.stderr.write("qilin tenants: no account exists yet and this deployment does not accept new ones; create the first with `qilin tenants add <name>`\n");
|
|
1143
|
+
options.onListening?.(address);
|
|
1144
|
+
const sweep = Math.max(250, Math.min(Math.floor(options.idleMilliseconds / 4), 3e4));
|
|
1145
|
+
const idle = options.idleMilliseconds <= 0 ? void 0 : setInterval(() => {
|
|
1146
|
+
const now = Date.now();
|
|
1147
|
+
for (const runtime of runtimes.values()) {
|
|
1148
|
+
if (runtime.status().state !== "running") continue;
|
|
1149
|
+
if (now - runtime.lastUsedAt < options.idleMilliseconds) continue;
|
|
1150
|
+
process.stderr.write(`qilin tenants: reclaiming the idle runtime for ${runtime.status().accountId}\n`);
|
|
1151
|
+
runtime.stop();
|
|
1152
|
+
}
|
|
1153
|
+
}, sweep);
|
|
1154
|
+
const stopping = Promise.withResolvers();
|
|
1155
|
+
const shutdown = () => {
|
|
1156
|
+
(async () => {
|
|
1157
|
+
if (idle !== void 0) clearInterval(idle);
|
|
1158
|
+
server.closeAllConnections();
|
|
1159
|
+
usageServer.closeAllConnections();
|
|
1160
|
+
await new Promise((resolve) => {
|
|
1161
|
+
server.close(() => {
|
|
1162
|
+
resolve();
|
|
1163
|
+
});
|
|
1164
|
+
});
|
|
1165
|
+
await new Promise((resolve) => {
|
|
1166
|
+
usageServer.close(() => {
|
|
1167
|
+
resolve();
|
|
1168
|
+
});
|
|
1169
|
+
});
|
|
1170
|
+
usageTokens.clear();
|
|
1171
|
+
await Promise.all([...runtimes.values()].map(async (runtime) => {
|
|
1172
|
+
await runtime.stop();
|
|
1173
|
+
}));
|
|
1174
|
+
stopping.resolve(0);
|
|
1175
|
+
})();
|
|
1176
|
+
};
|
|
1177
|
+
process.once("SIGINT", shutdown);
|
|
1178
|
+
process.once("SIGTERM", shutdown);
|
|
1179
|
+
if (options.signal !== void 0) if (options.signal.aborted) shutdown();
|
|
1180
|
+
else options.signal.addEventListener("abort", shutdown, { once: true });
|
|
1181
|
+
return await stopping.promise;
|
|
1182
|
+
}
|
|
1183
|
+
//#endregion
|
|
1184
|
+
//#region lib/types/web-entry.js
|
|
1185
|
+
/**
|
|
1186
|
+
* The deployment entry: one public address, one supervisor, and one runtime per
|
|
1187
|
+
* account.
|
|
1188
|
+
*
|
|
1189
|
+
* Every profile that serves a browser address is a deployment: `qilin web` and
|
|
1190
|
+
* the product surface `qilin` both come here. This process binds the address a
|
|
1191
|
+
* browser reaches, terminates TLS when the invocation carries a certificate and
|
|
1192
|
+
* key, and starts one child runtime per account on demand. A child boots the same
|
|
1193
|
+
* profile with {@link TENANT_VARIABLE} set, so the application is what a runtime
|
|
1194
|
+
* process runs and the supervisor is what the deployment runs.
|
|
1195
|
+
*
|
|
1196
|
+
* Runtimes stay on loopback: the public address is the supervisor's. That is
|
|
1197
|
+
* why this entry serves every account through its own origin rather than
|
|
1198
|
+
* redirecting a browser to a runtime port — a redirect target a client cannot
|
|
1199
|
+
* reach is not a deployment.
|
|
1200
|
+
*
|
|
1201
|
+
* The entry reads the deployment's own flags and hands every other argument to
|
|
1202
|
+
* each runtime's command line, so an application flag reaches the process that
|
|
1203
|
+
* implements it instead of being dropped here. `--patch` is the exception it
|
|
1204
|
+
* forwards as a launcher flag, because an overlay applies to the composition
|
|
1205
|
+
* each runtime boots.
|
|
1206
|
+
*
|
|
1207
|
+
* The desktop shell boots the product profile in-process through
|
|
1208
|
+
* `profile-boot`, so it never reaches this module and keeps serving one
|
|
1209
|
+
* account directly.
|
|
1210
|
+
* @module @qilin-agent/cli/web-entry
|
|
1211
|
+
*/
|
|
1212
|
+
/** The profiles whose boot is a deployment of the product surface. */
|
|
1213
|
+
const ENTRY_PROFILES = ["qilin", "web"];
|
|
1214
|
+
/** Idle window a deployment gets before it sets one. */
|
|
1215
|
+
const DEFAULT_IDLE_MILLISECONDS = 18e5;
|
|
1216
|
+
/** Where the public address binds when the invocation names none. */
|
|
1217
|
+
const DEFAULT_HOST = "127.0.0.1";
|
|
1218
|
+
/** Which port the public address binds when the invocation names none. */
|
|
1219
|
+
const DEFAULT_PORT = 8790;
|
|
1220
|
+
/** First wait before retrying a runtime that failed to start. */
|
|
1221
|
+
const DEFAULT_RESTART_BACKOFF_MILLISECONDS = 1e3;
|
|
1222
|
+
/**
|
|
1223
|
+
* Addresses that mean "every interface". The served application refuses them
|
|
1224
|
+
* because binding every interface exposes remote execution to the network, and
|
|
1225
|
+
* the deployment address is the only address a browser reaches.
|
|
1226
|
+
*/
|
|
1227
|
+
const WILDCARD_HOSTS = [
|
|
1228
|
+
"0.0.0.0",
|
|
1229
|
+
"::",
|
|
1230
|
+
"[::]",
|
|
1231
|
+
"*"
|
|
1232
|
+
];
|
|
1233
|
+
/** Deployment flags that accumulate every occurrence instead of keeping the first. */
|
|
1234
|
+
const REPEATABLE_VALUE_FLAGS = ["--patch"];
|
|
1235
|
+
/** Deployment flags that carry a value. */
|
|
1236
|
+
const VALUE_FLAGS = [
|
|
1237
|
+
"--host",
|
|
1238
|
+
"--port",
|
|
1239
|
+
"--patch",
|
|
1240
|
+
"--tls-cert",
|
|
1241
|
+
"--tls-key",
|
|
1242
|
+
"--tenant-profile",
|
|
1243
|
+
"--idle-ms",
|
|
1244
|
+
"--max-tenants",
|
|
1245
|
+
"--restart-backoff-ms",
|
|
1246
|
+
"--tenant-host",
|
|
1247
|
+
"--registration"
|
|
1248
|
+
];
|
|
1249
|
+
/** Deployment flags that stand alone. */
|
|
1250
|
+
const SWITCH_FLAGS = [
|
|
1251
|
+
"--no-open",
|
|
1252
|
+
"--per-account-ports",
|
|
1253
|
+
"-h",
|
|
1254
|
+
"--help"
|
|
1255
|
+
];
|
|
1256
|
+
/**
|
|
1257
|
+
* Split one invocation's arguments into the deployment's own flags and the
|
|
1258
|
+
* application's.
|
|
1259
|
+
* @param args - the arguments after the launcher's own flags.
|
|
1260
|
+
* @returns the deployment values, its repeatable lists, its switches, and the application's arguments.
|
|
1261
|
+
* @throws Error when a deployment flag that carries a value names none.
|
|
1262
|
+
*/
|
|
1263
|
+
function splitArguments(args) {
|
|
1264
|
+
const values = /* @__PURE__ */ new Map();
|
|
1265
|
+
const lists = /* @__PURE__ */ new Map();
|
|
1266
|
+
const switches = /* @__PURE__ */ new Set();
|
|
1267
|
+
const appArguments = [];
|
|
1268
|
+
for (let index = 0; index < args.length; index += 1) {
|
|
1269
|
+
const token = args[index] ?? "";
|
|
1270
|
+
const separator = token.indexOf("=");
|
|
1271
|
+
const name = separator === -1 ? token : token.slice(0, separator);
|
|
1272
|
+
const inline = separator === -1 ? void 0 : token.slice(separator + 1);
|
|
1273
|
+
if (SWITCH_FLAGS.includes(name)) {
|
|
1274
|
+
if (inline !== void 0) throw new Error(`qilin web: ${name} takes no value, received ${JSON.stringify(token)}`);
|
|
1275
|
+
switches.add(name);
|
|
1276
|
+
continue;
|
|
1277
|
+
}
|
|
1278
|
+
if (VALUE_FLAGS.includes(name)) {
|
|
1279
|
+
const value = inline ?? args[index + 1];
|
|
1280
|
+
if (inline === void 0) index += 1;
|
|
1281
|
+
if (value === void 0 || inline === void 0 && value.startsWith("--")) throw new Error(`qilin web: ${name} needs a value`);
|
|
1282
|
+
if (REPEATABLE_VALUE_FLAGS.includes(name)) {
|
|
1283
|
+
lists.set(name, [...lists.get(name) ?? [], value]);
|
|
1284
|
+
continue;
|
|
1285
|
+
}
|
|
1286
|
+
if (!values.has(name)) values.set(name, value);
|
|
1287
|
+
continue;
|
|
1288
|
+
}
|
|
1289
|
+
appArguments.push(token);
|
|
1290
|
+
}
|
|
1291
|
+
return {
|
|
1292
|
+
values,
|
|
1293
|
+
lists,
|
|
1294
|
+
switches,
|
|
1295
|
+
appArguments
|
|
1296
|
+
};
|
|
1297
|
+
}
|
|
1298
|
+
/**
|
|
1299
|
+
* One whole-number deployment flag.
|
|
1300
|
+
* @param values - the deployment values the invocation named.
|
|
1301
|
+
* @param name - the flag to read.
|
|
1302
|
+
* @param fallback - the value to use when the flag is absent.
|
|
1303
|
+
* @param minimum - the smallest accepted value.
|
|
1304
|
+
* @returns the parsed value.
|
|
1305
|
+
* @throws Error when the flag is present but not a whole number at or above the floor.
|
|
1306
|
+
*/
|
|
1307
|
+
function wholeNumber(values, name, fallback, minimum) {
|
|
1308
|
+
const raw = values.get(name);
|
|
1309
|
+
if (raw === void 0) return fallback;
|
|
1310
|
+
if (!/^[0-9]+$/u.test(raw) || Number(raw) < minimum) throw new Error(`qilin web: ${name} needs a whole number of at least ${String(minimum)}, received ${JSON.stringify(raw)}`);
|
|
1311
|
+
return Number(raw);
|
|
1312
|
+
}
|
|
1313
|
+
/**
|
|
1314
|
+
* The certificate and key the public address serves with.
|
|
1315
|
+
* @param values - the deployment values the invocation named.
|
|
1316
|
+
* @returns the resolved pair, or undefined when the invocation named none.
|
|
1317
|
+
* @throws Error when only one of the two flags is present.
|
|
1318
|
+
*/
|
|
1319
|
+
function tlsOptions(values) {
|
|
1320
|
+
const certFile = values.get("--tls-cert");
|
|
1321
|
+
const keyFile = values.get("--tls-key");
|
|
1322
|
+
if (certFile === void 0 && keyFile === void 0) return void 0;
|
|
1323
|
+
if (certFile === void 0 || keyFile === void 0) throw new Error("qilin web: --tls-cert and --tls-key must be supplied together");
|
|
1324
|
+
return {
|
|
1325
|
+
certFile: resolve(certFile),
|
|
1326
|
+
keyFile: resolve(keyFile)
|
|
1327
|
+
};
|
|
1328
|
+
}
|
|
1329
|
+
/**
|
|
1330
|
+
* Read who may create accounts on this deployment.
|
|
1331
|
+
*
|
|
1332
|
+
* A loopback address is this machine's own, so the product's form may create the
|
|
1333
|
+
* accounts it is asked to; an address other machines reach answers the same
|
|
1334
|
+
* question with the operator, who runs `qilin tenants add` where the decision is
|
|
1335
|
+
* deliberate.
|
|
1336
|
+
* @param values - the deployment values the invocation named.
|
|
1337
|
+
* @param host - interface the public address binds.
|
|
1338
|
+
* @returns the effective policy.
|
|
1339
|
+
* @throws Error when the flag names a policy this deployment does not have.
|
|
1340
|
+
*/
|
|
1341
|
+
function registrationPolicy(values, host) {
|
|
1342
|
+
const named = values.get("--registration");
|
|
1343
|
+
if (named === void 0) return LOOPBACK_HOSTS.includes(host) ? "open" : "closed";
|
|
1344
|
+
if (named !== "open" && named !== "closed") throw new Error(`qilin web: --registration is open or closed, received ${JSON.stringify(named)}`);
|
|
1345
|
+
return named;
|
|
1346
|
+
}
|
|
1347
|
+
/**
|
|
1348
|
+
* Read one deployment invocation.
|
|
1349
|
+
* @param args - the arguments after the launcher's own flags.
|
|
1350
|
+
* @param servedProfile - profile this deployment serves when the invocation names none.
|
|
1351
|
+
* @returns what the entry should serve.
|
|
1352
|
+
* @throws Error when a named flag cannot be honoured.
|
|
1353
|
+
*/
|
|
1354
|
+
function deploymentOptions(args, servedProfile) {
|
|
1355
|
+
const { values, lists, switches, appArguments } = splitArguments(args);
|
|
1356
|
+
const host = values.get("--host") ?? DEFAULT_HOST;
|
|
1357
|
+
if (switches.has("-h") || switches.has("--help")) return {
|
|
1358
|
+
host,
|
|
1359
|
+
port: DEFAULT_PORT,
|
|
1360
|
+
tenantProfile: values.get("--tenant-profile") ?? servedProfile,
|
|
1361
|
+
idleMilliseconds: DEFAULT_IDLE_MILLISECONDS,
|
|
1362
|
+
maxTenants: 0,
|
|
1363
|
+
restartBackoffMilliseconds: DEFAULT_RESTART_BACKOFF_MILLISECONDS,
|
|
1364
|
+
singleOrigin: !switches.has("--per-account-ports"),
|
|
1365
|
+
tenantHost: values.get("--tenant-host"),
|
|
1366
|
+
registration: LOOPBACK_HOSTS.includes(host) ? "open" : "closed",
|
|
1367
|
+
tls: void 0,
|
|
1368
|
+
help: true,
|
|
1369
|
+
patches: (lists.get("--patch") ?? []).map((path) => resolve(path)),
|
|
1370
|
+
appArguments
|
|
1371
|
+
};
|
|
1372
|
+
if (WILDCARD_HOSTS.includes(host)) throw new Error(`qilin web: --host ${host} is an unspecified (wildcard) address, which is not supported: bind one concrete address instead`);
|
|
1373
|
+
const singleOrigin = !switches.has("--per-account-ports");
|
|
1374
|
+
const tls = tlsOptions(values);
|
|
1375
|
+
if (tls !== void 0 && !singleOrigin) throw new Error("qilin web: --tls-cert terminates TLS at this address, so it cannot be combined with --per-account-ports");
|
|
1376
|
+
return {
|
|
1377
|
+
host,
|
|
1378
|
+
port: wholeNumber(values, "--port", DEFAULT_PORT, 0),
|
|
1379
|
+
tenantProfile: values.get("--tenant-profile") ?? servedProfile,
|
|
1380
|
+
idleMilliseconds: wholeNumber(values, "--idle-ms", DEFAULT_IDLE_MILLISECONDS, 0),
|
|
1381
|
+
maxTenants: wholeNumber(values, "--max-tenants", 0, 0),
|
|
1382
|
+
restartBackoffMilliseconds: wholeNumber(values, "--restart-backoff-ms", DEFAULT_RESTART_BACKOFF_MILLISECONDS, 0),
|
|
1383
|
+
singleOrigin,
|
|
1384
|
+
tenantHost: values.get("--tenant-host"),
|
|
1385
|
+
registration: registrationPolicy(values, host),
|
|
1386
|
+
tls,
|
|
1387
|
+
help: false,
|
|
1388
|
+
patches: (lists.get("--patch") ?? []).map((path) => resolve(path)),
|
|
1389
|
+
appArguments
|
|
1390
|
+
};
|
|
1391
|
+
}
|
|
1392
|
+
/**
|
|
1393
|
+
* Whether this process is the deployment entry rather than one account's runtime.
|
|
1394
|
+
* @returns true when no tenant marker is present.
|
|
1395
|
+
*/
|
|
1396
|
+
function isWebEntry() {
|
|
1397
|
+
return process.env[TENANT_VARIABLE] === void 0;
|
|
1398
|
+
}
|
|
1399
|
+
/**
|
|
1400
|
+
* The deployment's own help, which the served application's help cannot answer
|
|
1401
|
+
* for: this process owns the address every account is reached through.
|
|
1402
|
+
* @returns the help text.
|
|
1403
|
+
*/
|
|
1404
|
+
function helpText() {
|
|
1405
|
+
return [
|
|
1406
|
+
"Usage: qilin --profile web [deployment options] [app options...]",
|
|
1407
|
+
" qilin [deployment options] [app options...]",
|
|
1408
|
+
"",
|
|
1409
|
+
"Serve one public address, with one isolated runtime per account.",
|
|
1410
|
+
"",
|
|
1411
|
+
"Deployment options:",
|
|
1412
|
+
` --host <host> address to bind (default ${DEFAULT_HOST}); wildcard addresses are refused`,
|
|
1413
|
+
` --port <port> listen port; 0 lets the OS pick one (default ${String(DEFAULT_PORT)})`,
|
|
1414
|
+
" --patch <path> extra patch-list overlay every runtime boots with (repeatable)",
|
|
1415
|
+
" --tls-cert <file> PEM server certificate chain; requires --tls-key",
|
|
1416
|
+
" --tls-key <file> unencrypted PEM private key; requires --tls-cert",
|
|
1417
|
+
" --tenant-profile <name> profile each account runtime boots (default: the served profile)",
|
|
1418
|
+
" --tenant-host <host> interface each account runtime binds (default loopback)",
|
|
1419
|
+
" --per-account-ports give every account an origin of its own",
|
|
1420
|
+
` --idle-ms <ms> reclaim an idle runtime after this long; 0 keeps every one (default ${String(DEFAULT_IDLE_MILLISECONDS)})`,
|
|
1421
|
+
" --max-tenants <count> most runtimes alive at once; 0 keeps every one",
|
|
1422
|
+
" --restart-backoff-ms <ms> first wait before retrying a runtime that failed to start",
|
|
1423
|
+
" --registration open|closed who may create accounts (default open on a loopback address, closed otherwise)",
|
|
1424
|
+
" --no-open do not open the address in the default browser",
|
|
1425
|
+
" -h, --help print this help",
|
|
1426
|
+
"",
|
|
1427
|
+
"Every other argument passes to the command line of each account runtime.",
|
|
1428
|
+
"",
|
|
1429
|
+
"Examples:",
|
|
1430
|
+
" qilin web serve the deployment on 127.0.0.1:8790",
|
|
1431
|
+
" qilin web --port 8080 --no-open serve another port without opening a browser",
|
|
1432
|
+
" qilin web --tls-cert ./chain.pem --tls-key ./key.pem",
|
|
1433
|
+
" serve HTTPS, terminating TLS here",
|
|
1434
|
+
" qilin web --patch ./overlay.yml compose every runtime with one overlay",
|
|
1435
|
+
""
|
|
1436
|
+
].join("\n");
|
|
1437
|
+
}
|
|
1438
|
+
/**
|
|
1439
|
+
* Hand the deployment's address to the operating system's default browser.
|
|
1440
|
+
*
|
|
1441
|
+
* The launcher is the platform's own, the same call the Web application makes
|
|
1442
|
+
* for its runtime; it is repeated here because this process is the deployment
|
|
1443
|
+
* rather than a runtime, and the CLI does not depend on that bundle.
|
|
1444
|
+
* @param url - the address to open.
|
|
1445
|
+
* @returns nothing once the launcher is started.
|
|
1446
|
+
*/
|
|
1447
|
+
function openAddress(url) {
|
|
1448
|
+
const [command, ...args] = process.platform === "darwin" ? ["open", url] : process.platform === "win32" ? [
|
|
1449
|
+
"cmd",
|
|
1450
|
+
"/c",
|
|
1451
|
+
"start",
|
|
1452
|
+
"",
|
|
1453
|
+
url
|
|
1454
|
+
] : ["xdg-open", url];
|
|
1455
|
+
const child = spawn(command, args, {
|
|
1456
|
+
stdio: "ignore",
|
|
1457
|
+
detached: true
|
|
1458
|
+
});
|
|
1459
|
+
child.once("error", (error) => {
|
|
1460
|
+
process.stderr.write(`qilin web: could not open ${url}: ${String(error)}\n`);
|
|
1461
|
+
});
|
|
1462
|
+
child.unref();
|
|
1463
|
+
}
|
|
1464
|
+
/**
|
|
1465
|
+
* Serve the deployment: the public address, and one runtime per account.
|
|
1466
|
+
* @param invocation - the served profile, its overlays, and the app's arguments.
|
|
1467
|
+
* @returns the process exit code.
|
|
1468
|
+
* @throws Error when the invocation names a flag the deployment cannot honour.
|
|
1469
|
+
*/
|
|
1470
|
+
async function runWebEntry(invocation) {
|
|
1471
|
+
if (invocation.fromDefaultProfile !== void 0) throw new Error("qilin web: --from-default-profile initializes a custom profile, and a deployment serves a shipped profile; omit it");
|
|
1472
|
+
const options = deploymentOptions(invocation.args, invocation.profile);
|
|
1473
|
+
if (options.help) {
|
|
1474
|
+
process.stdout.write(helpText());
|
|
1475
|
+
return 0;
|
|
1476
|
+
}
|
|
1477
|
+
return await runTenantsServe({
|
|
1478
|
+
host: options.host,
|
|
1479
|
+
port: options.port,
|
|
1480
|
+
profile: options.tenantProfile,
|
|
1481
|
+
idleMilliseconds: options.idleMilliseconds,
|
|
1482
|
+
maxTenants: options.maxTenants,
|
|
1483
|
+
restartBackoffMilliseconds: options.restartBackoffMilliseconds,
|
|
1484
|
+
singleOrigin: options.singleOrigin,
|
|
1485
|
+
tenantHost: options.tenantHost,
|
|
1486
|
+
registration: options.registration,
|
|
1487
|
+
patches: [...invocation.patches, ...options.patches].map((path) => resolve(path)),
|
|
1488
|
+
appArguments: options.appArguments,
|
|
1489
|
+
...options.tls === void 0 ? {} : { tls: options.tls },
|
|
1490
|
+
...invocation.args.includes("--no-open") ? {} : { onListening: (url) => {
|
|
1491
|
+
try {
|
|
1492
|
+
openAddress(url);
|
|
1493
|
+
} catch (error) {
|
|
1494
|
+
process.stderr.write(`qilin web: could not open ${url}: ${String(error)}\n`);
|
|
1495
|
+
}
|
|
1496
|
+
} }
|
|
1497
|
+
});
|
|
1498
|
+
}
|
|
1499
|
+
//#endregion
|
|
1500
|
+
export { ENTRY_PROFILES, isWebEntry, runWebEntry };
|