@qilin-agent/cli 3.1.3 → 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.
@@ -0,0 +1,489 @@
1
+ import { createRequire } from "node:module";
2
+ import { mkdir, readFile, writeFile } from "node:fs/promises";
3
+ import { join } from "node:path";
4
+ import { fileURLToPath, pathToFileURL } from "node:url";
5
+ import "@qilin-agent/http-proxy";
6
+ import { spawn } from "node:child_process";
7
+ import "@qilin-agent/kylin";
8
+ //#region ../../packages/subprocess/subprocess/src/index.ts
9
+ /**
10
+ * Credential-shaped environment names are NOT forwarded to children (the
11
+ * harness's own `DEEPSEEK_API_KEY`/secrets must not leak into a spawned
12
+ * process implicitly). One heuristic for every in-repo spawner; a
13
+ * deliberately supplied entry survives because explicit env layers merge
14
+ * after the scrub.
15
+ */
16
+ const SENSITIVE_ENV_PATTERN = /KEY|PASSWORD|SECRET|TOKEN/i;
17
+ //#endregion
18
+ //#region lib/types/tenant-runtime.js
19
+ /**
20
+ * One tenant's Web runtime: its own Harness home under the deployment home, the
21
+ * child process that serves it, and the launch-token URL a browser follows into
22
+ * it.
23
+ *
24
+ * A tenant is a deployment of its own. The child runs with `QILIN_HOME` set to
25
+ * this tenant's directory, so plugins, model settings, credentials, sessions,
26
+ * storages, attachments, skills, and presets all resolve inside it and nowhere
27
+ * else. The only shared file is the deployment's account roster, which the
28
+ * tenant's home layer points at while admitting exactly its own account.
29
+ * @module @qilin-agent/cli/tenant-runtime
30
+ */
31
+ /** Any startup line whose label is followed by the served URL. */
32
+ const READY_URL = /(?:^|\n)[A-Za-z][\w -]*: (https?:\/\/\S+)/u;
33
+ /** Resolution base for a loader named by a bare specifier. */
34
+ const require = createRequire(import.meta.url);
35
+ /**
36
+ * Workspace path map a source-mode child resolves against.
37
+ *
38
+ * A source launch's entry imports workspace packages by name. The child runs in
39
+ * its own tenant directory, where the loader would otherwise fall back to built
40
+ * `lib` output; this is the same map the repository's own subprocess runner
41
+ * gives a `.ts` entry (see `@qilin-agent/subprocess-local`).
42
+ */
43
+ const SOURCE_TSCONFIG_PATH = fileURLToPath(new URL("../../../tsconfig.base.json", import.meta.url));
44
+ /** Deadline for one tenant runtime to print its URL. */
45
+ const START_DEADLINE_MS = 12e4;
46
+ /** Node options whose value names a module the child must load. */
47
+ const LOADER_FLAGS = new Set([
48
+ "--import",
49
+ "--loader",
50
+ "--experimental-loader",
51
+ "-r",
52
+ "--require"
53
+ ]);
54
+ /**
55
+ * Resolve every bare module name in the hub's own Node options to an absolute
56
+ * path.
57
+ *
58
+ * The child starts in its own tenant directory, so a bare specifier such as the
59
+ * `tsx/esm` a source launch passes to `--import` resolves against the wrong
60
+ * tree and the child dies before it prints anything. Resolving here keeps the
61
+ * child on the hub's own loader regardless of where it runs.
62
+ * @param execArgv - the hub's `process.execArgv`.
63
+ * @returns the same options with loader values made absolute.
64
+ */
65
+ function resolveLoaderArguments(execArgv) {
66
+ const resolved = [];
67
+ for (const [index, argument] of execArgv.entries()) {
68
+ const previous = execArgv[index - 1];
69
+ if (previous !== void 0 && LOADER_FLAGS.has(previous) && !argument.startsWith(".") && !argument.startsWith("/")) try {
70
+ resolved.push(pathToFileURL(require.resolve(argument)).href);
71
+ continue;
72
+ } catch (error) {
73
+ if (!(error instanceof Error)) throw error;
74
+ }
75
+ resolved.push(argument);
76
+ }
77
+ return resolved;
78
+ }
79
+ /** Interface a tenant runtime binds when the deployment names none. */
80
+ const DEFAULT_TENANT_HOST = "127.0.0.1";
81
+ /**
82
+ * One tenant runtime's arguments, after the Node executable.
83
+ *
84
+ * The launcher stops reading its own flags at the first token it does not own,
85
+ * and `--host` belongs to the served application: everything from there on
86
+ * reaches that application. An overlay belongs to the launcher, so it travels
87
+ * before it, and the deployment's own application arguments follow `--no-open`
88
+ * so a runtime never opens a browser of its own.
89
+ * @param launch - the profile, interface, overlays, and application arguments.
90
+ * @returns argv for one child process.
91
+ */
92
+ function tenantChildArguments(launch) {
93
+ return [
94
+ "--profile",
95
+ launch.profile,
96
+ ...launch.patches.flatMap((path) => ["--patch", path]),
97
+ "--host",
98
+ launch.host,
99
+ "--port",
100
+ "0",
101
+ "--no-open",
102
+ ...launch.appArguments
103
+ ];
104
+ }
105
+ /** Deadline for one tenant runtime to exit after SIGTERM, before SIGKILL. */
106
+ const STOP_DEADLINE_MS = 15e3;
107
+ /**
108
+ * Ceiling the retry wait reaches. A tenant runtime that cannot start is broken
109
+ * in a way a browser reload does not fix, so the wait grows up to this bound
110
+ * instead of retrying on every visit.
111
+ */
112
+ const BACKOFF_CEILING_MS = 3e5;
113
+ /**
114
+ * Character set an account identity may use as a tenant directory name. The
115
+ * document each layer carries is built from it, so it never needs quoting.
116
+ */
117
+ const IDENTITY_PATTERN = /^[A-Za-z0-9._-]+$/u;
118
+ /** Prefix the deployment reserves for directories of its own under `tenants/`. */
119
+ const RESERVED_IDENTITY_PREFIX = ".";
120
+ /**
121
+ * Whether an account identity may name one tenant directory.
122
+ *
123
+ * A leading dot is reserved: the deployment keeps its own directories beside the
124
+ * tenants (`tenants/.usage` holds the token ledger, `tenants/.bootstrap` the
125
+ * first-run runtime), so an identity that read as one would take over the
126
+ * deployment's own bookkeeping instead of a home of its own.
127
+ *
128
+ * The account file is a shared document, so its ids reach path construction in
129
+ * more than one process; an id that is a path segment or names a directory would
130
+ * place a tenant's home somewhere other than under the deployment's own tenants.
131
+ * @param accountId - the identity stored in the roster.
132
+ * @returns true when the identity is one plain directory name.
133
+ */
134
+ function isTenantIdentity(accountId) {
135
+ return IDENTITY_PATTERN.test(accountId) && !accountId.startsWith(RESERVED_IDENTITY_PREFIX);
136
+ }
137
+ /** Environment variable naming the deployment home to a tenant runtime. */
138
+ const DEPLOYMENT_HOME_VARIABLE = "QILIN_DEPLOYMENT_HOME";
139
+ /** Environment variable naming the agent-config root (user skills and agent documents). */
140
+ const AGENTS_HOME_VARIABLE = "QILIN_AGENTS_HOME";
141
+ /**
142
+ * Environment variable naming the account one tenant runtime serves.
143
+ *
144
+ * The served document carries it back to the browser, and the client keeps its
145
+ * own local storage inside that account's space: one origin can serve several
146
+ * accounts behind this deployment, so a storage name that does not carry an
147
+ * account would be shared by all of them. Absent for the first-run runtime,
148
+ * which serves no account yet.
149
+ */
150
+ const ACCOUNT_VARIABLE = "QILIN_ACCOUNT_ID";
151
+ /** Environment variable naming the deployment's usage endpoint, when it has one. */
152
+ const USAGE_URL_VARIABLE = "QILIN_USAGE_URL";
153
+ /**
154
+ * Environment variable carrying the token that endpoint accepts.
155
+ *
156
+ * It identifies the runtime that presents it, so the hub books the report to the
157
+ * account that token was minted for rather than to whatever the body claims.
158
+ */
159
+ const USAGE_TOKEN_VARIABLE = "QILIN_USAGE_TOKEN";
160
+ /**
161
+ * The environment one tenant runtime starts with.
162
+ *
163
+ * The tenant's harness home is the isolation: every root the runtime resolves
164
+ * comes from `QILIN_HOME`. The agent-config root is the exception that has to be
165
+ * named explicitly, because it defaults to the shared `~/.agents` (or whatever
166
+ * the hub inherited), and user skills and agent documents belong to one account.
167
+ * The deployment home arrives by name so the roster layer can read the shared
168
+ * account file; nothing else of the deployment's is exposed.
169
+ *
170
+ * Credential-shaped names do not cross either: a key the hub was launched with
171
+ * authenticates the hub, and handing it to every account would bill one
172
+ * account's requests to whoever started the deployment. A tenant reaches a
173
+ * provider through its own credential record, or through an entry the
174
+ * deployment layers for that tenant on purpose. The rule is the harness's one
175
+ * credential heuristic; every other inherited name, including the deployment's
176
+ * own `QILIN_*` policy, still crosses.
177
+ * @param tenantDir - the tenant's own harness home.
178
+ * @param deploymentHome - the shared deployment home holding the account roster.
179
+ * The account this runtime serves is named too, so the document it hands the
180
+ * browser can say whose storage the page may use.
181
+ * @param base - the environment the hub itself runs with.
182
+ * @param accountId - the account this runtime serves, or undefined for the
183
+ * first-run runtime that serves no account yet.
184
+ * @param usage - endpoint the runtime reports its account's token spend to, and
185
+ * the token that endpoint accepts; absent when the deployment keeps no ledger.
186
+ * @returns the environment mapping for the child process.
187
+ */
188
+ function tenantChildEnvironment(tenantDir, deploymentHome, base = process.env, accountId, usage) {
189
+ const inherited = {};
190
+ for (const [name, value] of Object.entries(base)) {
191
+ if (value === void 0 || SENSITIVE_ENV_PATTERN.test(name)) continue;
192
+ inherited[name] = value;
193
+ }
194
+ return {
195
+ ...inherited,
196
+ QILIN_HOME: tenantDir,
197
+ ...accountId === void 0 ? {} : { [ACCOUNT_VARIABLE]: accountId },
198
+ [TENANT_VARIABLE]: "1",
199
+ [DEPLOYMENT_HOME_VARIABLE]: deploymentHome,
200
+ [AGENTS_HOME_VARIABLE]: join(tenantDir, "agents"),
201
+ ...usage === void 0 ? {} : {
202
+ [USAGE_URL_VARIABLE]: usage.url,
203
+ [USAGE_TOKEN_VARIABLE]: usage.token
204
+ }
205
+ };
206
+ }
207
+ /** Addresses that only the machine itself reaches. */
208
+ const LOOPBACK_HOSTS = [
209
+ "127.0.0.1",
210
+ "::1",
211
+ "localhost"
212
+ ];
213
+ /**
214
+ * Marks the process as one account's runtime rather than the entry that serves
215
+ * the public address. The supervisor sets it for every child it starts; a
216
+ * deployment that starts a single-account runtime directly sets it too.
217
+ */
218
+ const TENANT_VARIABLE = "QILIN_TENANT";
219
+ /**
220
+ * Layer of the runtime a deployment uses before it has any account.
221
+ *
222
+ * That runtime reads and writes the same roster, and admits whoever the roster
223
+ * holds: the product's own first-run form creates the first account, and the
224
+ * hub then serves it from a runtime of its own.
225
+ * @param secureCookies - whether the browser reaches this deployment over
226
+ * TLS, so its account surface marks session cookies `Secure`.
227
+ * @returns the layer document as YAML.
228
+ */
229
+ function bootstrapPatchYaml(registration = "open", secureCookies = false) {
230
+ return [
231
+ "# First-run layer for a deployment with no accounts yet.",
232
+ "# This runtime admits whoever the roster holds, so the product can create",
233
+ "# the first account and, when the deployment accepts new accounts, the ones",
234
+ "# after it; the hub then routes each of them to a runtime of its own.",
235
+ "- id: accounts",
236
+ " config:",
237
+ ` qilinHome: !!js process.env.${DEPLOYMENT_HOME_VARIABLE}`,
238
+ ` registration: ${registration}`,
239
+ ...secureCookies ? [" sessionCookieSecure: true"] : [],
240
+ ""
241
+ ].join("\n");
242
+ }
243
+ /** Directory name of the runtime a deployment without accounts uses. */
244
+ const BOOTSTRAP_DIRECTORY = ".bootstrap";
245
+ /**
246
+ * One tenant's harness home under the deployment home.
247
+ *
248
+ * The name is one path segment — an account identity, or the first-run
249
+ * directory — so a tenant home is always a sibling of every other one.
250
+ * @param home - deployment home.
251
+ * @param name - account identity or first-run directory name.
252
+ * @returns the tenant's harness home.
253
+ */
254
+ function tenantDirectory(home, name) {
255
+ return join(home, "tenants", name);
256
+ }
257
+ /**
258
+ * Build one tenant's harness-home layer: the account roster it reads and the
259
+ * single account it admits.
260
+ *
261
+ * The roster stays in the deployment home so one command manages every account,
262
+ * while the tenant's own home holds everything else. The deployment home
263
+ * arrives through the environment rather than as a literal, so a path with
264
+ * quotes or a trailing backslash cannot change the document's meaning.
265
+ * @param accountId - account identity this tenant serves.
266
+ * @param secureCookies - whether the browser reaches this deployment over
267
+ * TLS, so its account surface marks session cookies `Secure`.
268
+ * @returns the layer document as YAML.
269
+ * @throws Error when the identity is not usable as one tenant directory name.
270
+ */
271
+ function tenantPatchYaml(accountId, secureCookies = false) {
272
+ if (!isTenantIdentity(accountId)) throw new Error(`qilin tenants: ${accountId} is not usable as a tenant directory name`);
273
+ return [
274
+ `# Tenant harness-home layer for account ${accountId}.`,
275
+ "# This runtime admits exactly that account and reads the deployment roster;",
276
+ "# its plugins, model settings, credentials, sessions, and storages live in",
277
+ "# this tenant home.",
278
+ "- id: accounts",
279
+ " config:",
280
+ ` qilinHome: !!js process.env.${DEPLOYMENT_HOME_VARIABLE}`,
281
+ " admittedAccounts:",
282
+ ` - '${accountId}'`,
283
+ " registration: closed",
284
+ ...secureCookies ? [" sessionCookieSecure: true"] : [],
285
+ ""
286
+ ].join("\n");
287
+ }
288
+ var TenantRuntime = class {
289
+ options;
290
+ child;
291
+ exited;
292
+ launchUrl;
293
+ state = "stopped";
294
+ detail = "";
295
+ output = "";
296
+ starting;
297
+ consecutiveFailures = 0;
298
+ retryAt;
299
+ intentional = false;
300
+ /** Epoch milliseconds of the last browser handoff; idle reclamation measures it. */
301
+ lastUsedAt = 0;
302
+ /** How many times this tenant's runtime has started. */
303
+ starts = 0;
304
+ /**
305
+ * @param options - the shared home, the account this runtime serves, and how to start it.
306
+ */
307
+ constructor(options) {
308
+ this.options = options;
309
+ }
310
+ /**
311
+ * Where the hub sends this runtime's traffic.
312
+ *
313
+ * The runtime's own URL carries a launch token, which the hub never spends:
314
+ * every request keeps the path the browser asked for, so the application's
315
+ * account gate answers it. Only the origin is a target.
316
+ * @returns the runtime's origin, or undefined when it is not serving yet.
317
+ */
318
+ proxyTarget() {
319
+ if (this.launchUrl === void 0) return void 0;
320
+ return new URL(this.launchUrl).origin;
321
+ }
322
+ /** Absolute path of this tenant's harness-home layer. */
323
+ get patchPath() {
324
+ return join(this.tenantDir, "cordis.patch.yml");
325
+ }
326
+ /**
327
+ * This tenant's harness home: everything the runtime owns lives under it,
328
+ * and the deployment home holds only the roster and these directories.
329
+ */
330
+ get tenantDir() {
331
+ return tenantDirectory(this.options.home, this.options.directoryName ?? this.options.accountId);
332
+ }
333
+ /** What the hub's status read reports about this tenant. */
334
+ status() {
335
+ return {
336
+ accountId: this.options.accountId,
337
+ state: this.state,
338
+ pid: this.child?.pid,
339
+ origin: this.launchUrl === void 0 ? void 0 : new URL(this.launchUrl).origin,
340
+ starts: this.starts,
341
+ detail: this.detail,
342
+ consecutiveFailures: this.consecutiveFailures,
343
+ retryAt: this.retryAt
344
+ };
345
+ }
346
+ /**
347
+ * Return this tenant's launch URL, starting the runtime when it is not serving.
348
+ * @returns the launch-token URL of the running runtime.
349
+ * @throws Error when the runtime did not become ready; the state then reports `failed`.
350
+ */
351
+ async ensure() {
352
+ this.lastUsedAt = Date.now();
353
+ if (this.state === "running" && this.launchUrl !== void 0) return this.launchUrl;
354
+ const retryAt = this.retryAt;
355
+ if (this.consecutiveFailures > 0 && retryAt !== void 0 && Date.now() < retryAt) throw new Error(`qilin tenants: the runtime for ${this.options.accountId} failed ${String(this.consecutiveFailures)} time(s) and is not retried before ${new Date(retryAt).toISOString()}: ${this.detail}`);
356
+ this.starting ??= this.start();
357
+ try {
358
+ return await this.starting;
359
+ } finally {
360
+ this.starting = void 0;
361
+ }
362
+ }
363
+ /**
364
+ * Count one failed start or one crash of a runtime that had been serving, and
365
+ * schedule the earliest time the next attempt may run.
366
+ * @param reason - what to report as the current detail.
367
+ */
368
+ recordFailure(reason) {
369
+ this.detail = reason;
370
+ this.consecutiveFailures += 1;
371
+ const wait = Math.min(this.options.restartBackoffMilliseconds * 2 ** (this.consecutiveFailures - 1), BACKOFF_CEILING_MS);
372
+ this.retryAt = Date.now() + wait;
373
+ }
374
+ /** Ask this tenant's runtime to exit, holding it to the shutdown deadline. */
375
+ async stop() {
376
+ const child = this.child;
377
+ const exited = this.exited;
378
+ if (child === void 0 || exited === void 0) {
379
+ if (this.state !== "failed") this.state = "stopped";
380
+ return;
381
+ }
382
+ this.intentional = true;
383
+ child.kill("SIGTERM");
384
+ const killed = setTimeout(() => {
385
+ child.kill("SIGKILL");
386
+ }, STOP_DEADLINE_MS);
387
+ try {
388
+ await exited;
389
+ } finally {
390
+ clearTimeout(killed);
391
+ }
392
+ }
393
+ /** Start one child, wait for its URL, and keep its handles. */
394
+ async start() {
395
+ await mkdir(this.tenantDir, { recursive: true });
396
+ await this.writePatch();
397
+ this.state = "starting";
398
+ this.detail = "";
399
+ this.output = "";
400
+ this.launchUrl = void 0;
401
+ this.starts += 1;
402
+ const child = spawn(process.execPath, [
403
+ ...resolveLoaderArguments(this.options.execArgv),
404
+ this.options.binPath,
405
+ ...tenantChildArguments({
406
+ profile: this.options.profile,
407
+ host: this.options.host ?? DEFAULT_TENANT_HOST,
408
+ patches: this.options.patches,
409
+ appArguments: this.options.appArguments
410
+ })
411
+ ], {
412
+ cwd: this.tenantDir,
413
+ env: {
414
+ ...tenantChildEnvironment(this.tenantDir, this.options.home, process.env, this.options.bootstrap === true ? void 0 : this.options.accountId, this.options.bootstrap === true ? void 0 : this.options.usage),
415
+ ...this.options.binPath.endsWith(".ts") ? { TSX_TSCONFIG_PATH: SOURCE_TSCONFIG_PATH } : {}
416
+ },
417
+ stdio: [
418
+ "ignore",
419
+ "pipe",
420
+ "pipe"
421
+ ]
422
+ });
423
+ const ready = Promise.withResolvers();
424
+ let banner = "";
425
+ child.stdout.setEncoding("utf8").on("data", (chunk) => {
426
+ banner += chunk;
427
+ const lines = banner.split("\n");
428
+ banner = lines.pop() ?? "";
429
+ for (const line of lines) {
430
+ const found = READY_URL.exec(line)?.[1];
431
+ if (found !== void 0) ready.resolve(found);
432
+ }
433
+ });
434
+ let pending = "";
435
+ child.stderr.setEncoding("utf8").on("data", (chunk) => {
436
+ this.output = `${this.output}${chunk}`.slice(-64e3);
437
+ pending += chunk;
438
+ const lines = pending.split("\n");
439
+ pending = lines.pop() ?? "";
440
+ for (const line of lines) if (line.trim() !== "") this.options.log?.(line);
441
+ });
442
+ const exited = Promise.withResolvers();
443
+ child.once("error", (error) => {
444
+ this.output = `${this.output}\nspawn failed: ${String(error)}`.slice(-64e3);
445
+ });
446
+ child.once("close", (code) => {
447
+ if (this.child !== child) return;
448
+ const crashed = this.launchUrl !== void 0 && !this.intentional;
449
+ this.child = void 0;
450
+ this.launchUrl = void 0;
451
+ this.exited = void 0;
452
+ this.intentional = false;
453
+ this.state = crashed ? "failed" : "stopped";
454
+ if (crashed) this.recordFailure(`the runtime for ${this.options.accountId} exited with code ${String(code)} after it was ready`);
455
+ ready.reject(/* @__PURE__ */ new Error(`qilin tenants: the runtime for ${this.options.accountId} exited before its URL\n${this.output.slice(-2e3)}`));
456
+ exited.resolve();
457
+ });
458
+ this.child = child;
459
+ this.exited = exited.promise;
460
+ const deadline = setTimeout(() => {
461
+ ready.reject(/* @__PURE__ */ new Error(`qilin tenants: the runtime for ${this.options.accountId} printed no URL within ${String(START_DEADLINE_MS)}ms\n${this.output}`));
462
+ }, START_DEADLINE_MS);
463
+ try {
464
+ const url = await ready.promise;
465
+ this.launchUrl = url;
466
+ this.state = "running";
467
+ this.consecutiveFailures = 0;
468
+ this.retryAt = void 0;
469
+ this.detail = "";
470
+ return url;
471
+ } catch (error) {
472
+ this.state = "failed";
473
+ this.recordFailure(error instanceof Error ? error.message : String(error));
474
+ await this.stop();
475
+ throw error;
476
+ } finally {
477
+ clearTimeout(deadline);
478
+ }
479
+ }
480
+ /** Write this tenant's home layer when the file does not already hold it. */
481
+ async writePatch() {
482
+ const secureCookies = this.options.sessionCookieSecure === true;
483
+ const document = this.options.bootstrap === true ? bootstrapPatchYaml(this.options.registration ?? "open", secureCookies) : tenantPatchYaml(this.options.accountId, secureCookies);
484
+ if (await readFile(this.patchPath, "utf8").catch(() => void 0) === document) return;
485
+ await writeFile(this.patchPath, document);
486
+ }
487
+ };
488
+ //#endregion
489
+ export { isTenantIdentity as a, TenantRuntime as i, LOOPBACK_HOSTS as n, TENANT_VARIABLE as r, BOOTSTRAP_DIRECTORY as t };