@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,252 @@
1
+ import { a as isTenantIdentity } from "./tenant-runtime-ChD53FKq.js";
2
+ import { resolveQilinHome } from "@qilin-agent/home-paths";
3
+ import { mkdir, rm, stat } from "node:fs/promises";
4
+ import { dirname, join, resolve } from "node:path";
5
+ import { createInterface } from "node:readline/promises";
6
+ import { Writable } from "node:stream";
7
+ import { AccountStore, MIN_PASSWORD_LENGTH, accountsFilePath, isValidEmail, isValidUsername, normalizeEmail, normalizeUsername } from "@qilin-agent/accounts-local";
8
+ //#region lib/types/tenants-roster.js
9
+ /**
10
+ * `qilin tenants` roster operations: the account file every tenant runtime on
11
+ * this Harness home shares, and the tenant directory each account owns.
12
+ *
13
+ * A tenant runtime admits exactly the account it serves, so a new account
14
+ * cannot be created through a tenant's own Web surface (its registration is
15
+ * closed). These commands are how a deployment adds, re-keys, and retires the
16
+ * accounts its tenant runtimes serve.
17
+ * @module @qilin-agent/cli/tenants-roster
18
+ */
19
+ /** Absolute tenant directory one account owns. */
20
+ function tenantDirectory(home, accountId) {
21
+ return join(home, "tenants", accountId);
22
+ }
23
+ /** Whether a path exists. */
24
+ async function exists(path) {
25
+ return await stat(path).then(() => true, () => false);
26
+ }
27
+ /** Open the shared home's roster. */
28
+ async function roster(home) {
29
+ return await AccountStore.open(accountsFilePath(home));
30
+ }
31
+ /**
32
+ * Every account of one shared home with the tenant directory it owns.
33
+ * @param home - absolute shared Harness home.
34
+ * @returns one entry per account, oldest first.
35
+ */
36
+ async function rosterEntries(home) {
37
+ const store = await roster(home);
38
+ return await Promise.all(store.all().map(async (account) => ({
39
+ id: account.id,
40
+ username: account.username,
41
+ email: account.email,
42
+ createdAt: account.createdAt,
43
+ tenantDirectory: tenantDirectory(home, account.id),
44
+ tenantDataPresent: await exists(tenantDirectory(home, account.id))
45
+ })));
46
+ }
47
+ /**
48
+ * Create one account in the shared roster.
49
+ * @param home - absolute shared Harness home.
50
+ * @param request - the login name, address, password, and administrator seat.
51
+ * @returns the created account.
52
+ * @throws Error when the name or address is refused or already taken, or when
53
+ * the deployment already has an administrator.
54
+ */
55
+ async function addAccount(home, request) {
56
+ const username = normalizeUsername(request.username);
57
+ if (!isValidUsername(username)) throw new Error(`qilin tenants: '${request.username}' is not an acceptable username: 3-32 characters of a-z, 0-9, '_', '.', or '-', starting with a letter or digit`);
58
+ if (request.password.length < MIN_PASSWORD_LENGTH) throw new Error(`qilin tenants: a password needs at least ${String(MIN_PASSWORD_LENGTH)} characters`);
59
+ const store = await roster(home);
60
+ if (store.byUsername(username) !== void 0) throw new Error(`qilin tenants: the account ${username} already exists`);
61
+ let email = null;
62
+ if (request.email !== void 0 && request.email.trim() !== "") {
63
+ email = normalizeEmail(request.email);
64
+ if (!isValidEmail(email)) throw new Error(`qilin tenants: '${request.email}' is not an acceptable email address`);
65
+ if (store.byEmail(email) !== void 0) throw new Error(`qilin tenants: the address ${email} already belongs to an account`);
66
+ }
67
+ return await store.add({
68
+ username,
69
+ email
70
+ }, request.password, { admin: request.admin === true });
71
+ }
72
+ /**
73
+ * Replace one account's password, retiring the sessions the previous one issued.
74
+ * @param home - absolute shared Harness home.
75
+ * @param username - the account's login name, normalized before lookup.
76
+ * @param password - the replacement password.
77
+ * @returns the stored successor.
78
+ * @throws Error when no account has that name or the password is too short.
79
+ */
80
+ async function setAccountPassword(home, username, password) {
81
+ if (password.length < MIN_PASSWORD_LENGTH) throw new Error(`qilin tenants: a password needs at least ${String(MIN_PASSWORD_LENGTH)} characters`);
82
+ const store = await roster(home);
83
+ const account = store.byUsername(normalizeUsername(username));
84
+ if (account === void 0) throw new Error(`qilin tenants: no account is named ${normalizeUsername(username)}`);
85
+ return await store.setPassword(account.id, password);
86
+ }
87
+ /**
88
+ * Remove one account from the shared roster.
89
+ *
90
+ * A running tenant runtime keeps serving the account until it is reclaimed or
91
+ * restarted; a runtime started afterwards fails loud, because the overlay
92
+ * admits an account the roster no longer holds.
93
+ * @param home - absolute shared Harness home.
94
+ * @param username - the account's login name, normalized before lookup.
95
+ * @param purgeData - also remove that account's tenant directory.
96
+ * @returns the removed account and, when asked, the removed directory.
97
+ * @throws Error when no account has that name, or when the tenant directory is not where it belongs.
98
+ */
99
+ async function removeAccount(home, username, purgeData) {
100
+ const store = await roster(home);
101
+ const account = store.byUsername(normalizeUsername(username));
102
+ if (account === void 0) throw new Error(`qilin tenants: no account is named ${normalizeUsername(username)}`);
103
+ const directory = tenantDirectory(home, account.id);
104
+ const resolved = resolve(directory);
105
+ if (purgeData && (!isTenantIdentity(account.id) || dirname(resolved) !== resolve(join(home, "tenants")))) throw new Error(`qilin tenants: refusing to remove ${resolved}, which is not this account's tenant directory`);
106
+ const removed = await store.remove(account.id);
107
+ if (!purgeData || !await exists(directory)) return {
108
+ account: removed,
109
+ purgedDirectory: void 0
110
+ };
111
+ await rm(directory, {
112
+ recursive: true,
113
+ force: true
114
+ });
115
+ return {
116
+ account: removed,
117
+ purgedDirectory: directory
118
+ };
119
+ }
120
+ /**
121
+ * Read one password from standard input without echoing it.
122
+ * @param prompt - the text shown on standard error before reading.
123
+ * @returns the entered line, without its terminator.
124
+ * @throws Error when standard input is not a terminal, so an unattended run cannot wait for input.
125
+ */
126
+ async function promptPassword(prompt) {
127
+ if (!process.stdin.isTTY) throw new Error("qilin tenants: standard input is not a terminal; pipe the password in and pass --password-stdin");
128
+ const silent = new Writable({ write(_chunk, _encoding, done) {
129
+ done();
130
+ } });
131
+ const prompt_ = createInterface({
132
+ input: process.stdin,
133
+ output: silent,
134
+ terminal: true
135
+ });
136
+ try {
137
+ process.stderr.write(prompt);
138
+ const entered = await prompt_.question("");
139
+ process.stderr.write("\n");
140
+ return entered;
141
+ } finally {
142
+ prompt_.close();
143
+ }
144
+ }
145
+ /**
146
+ * Read the first line of standard input.
147
+ * @returns the line, without its terminator.
148
+ */
149
+ async function readStandardInput() {
150
+ const chunks = [];
151
+ for await (const chunk of process.stdin) chunks.push(Buffer.from(chunk));
152
+ return (Buffer.concat(chunks).toString("utf8").split("\n")[0] ?? "").replace(/\r$/u, "");
153
+ }
154
+ /**
155
+ * Read the password one mutation applies.
156
+ * @param source - whether to prompt or read standard input.
157
+ * @param label - the account being changed, named in the confirmation prompt.
158
+ * @returns the password.
159
+ * @throws Error when an interactive entry is not confirmed, or when standard input is a terminal without a prompt.
160
+ */
161
+ async function readPassword(source, label) {
162
+ if (source.kind === "stdin") return await readStandardInput();
163
+ const entered = await promptPassword(`Password for ${label}: `);
164
+ if (entered !== await promptPassword("Repeat password: ")) throw new Error("qilin tenants: the two passwords differ");
165
+ return entered;
166
+ }
167
+ /** One roster row as the table prints it. */
168
+ function formatRow(entry) {
169
+ const created = new Date(entry.createdAt).toISOString().slice(0, 10);
170
+ const data = entry.tenantDataPresent ? "yes" : "no";
171
+ return `${entry.username.padEnd(20)} ${entry.id} ${created} ${data.padEnd(3)} ${entry.email ?? "-"}`;
172
+ }
173
+ /**
174
+ * Print the roster of one shared home.
175
+ * @param home - absolute shared Harness home.
176
+ * @param write - where each line goes.
177
+ * @returns the printed entries.
178
+ */
179
+ async function printRoster(home, write) {
180
+ const entries = await rosterEntries(home);
181
+ if (entries.length === 0) {
182
+ write(`qilin tenants: ${home} has no accounts yet; create one with: qilin tenants add <username>`);
183
+ return entries;
184
+ }
185
+ write("USERNAME ID CREATED DATA EMAIL");
186
+ for (const entry of entries) write(formatRow(entry));
187
+ write(`qilin tenants: ${String(entries.length)} account(s) in ${accountsFilePath(home)}`);
188
+ return entries;
189
+ }
190
+ /**
191
+ * Create the tenant directory one account owns, so its first runtime starts in
192
+ * a directory that exists even before a browser visits it.
193
+ * @param home - absolute shared Harness home.
194
+ * @param accountId - the account whose directory to create.
195
+ * @returns the absolute directory.
196
+ */
197
+ async function ensureTenantDirectory(home, accountId) {
198
+ const directory = tenantDirectory(home, accountId);
199
+ await mkdir(directory, { recursive: true });
200
+ return directory;
201
+ }
202
+ /**
203
+ * Run one roster subcommand over this Harness home and print what it did.
204
+ *
205
+ * Failures are reported as a message and a non-zero exit code rather than a
206
+ * stack trace: every refusal here is something the operator can correct.
207
+ * @param request - the roster subcommand and its arguments.
208
+ * @returns the process exit code: 0 when the request was carried out, 1 when it was refused.
209
+ */
210
+ async function runTenantsRoster(request) {
211
+ const home = resolveQilinHome();
212
+ try {
213
+ switch (request.command) {
214
+ case "accounts":
215
+ await printRoster(home, (line) => {
216
+ process.stdout.write(`${line}\n`);
217
+ });
218
+ return 0;
219
+ case "add": {
220
+ const password = await readPassword(request.password, request.username);
221
+ const account = await addAccount(home, {
222
+ username: request.username,
223
+ email: request.email,
224
+ password,
225
+ admin: request.admin
226
+ });
227
+ const directory = await ensureTenantDirectory(home, account.id);
228
+ process.stdout.write(`qilin tenants: created ${account.username} (${account.id}); its tenant directory is ${directory}\n`);
229
+ if (account.admin === true) process.stdout.write(`qilin tenants: ${account.username} is this deployment's administrator and reads every account's usage ledger\n`);
230
+ return 0;
231
+ }
232
+ case "passwd": {
233
+ const password = await readPassword(request.password, request.username);
234
+ const account = await setAccountPassword(home, request.username, password);
235
+ process.stdout.write(`qilin tenants: replaced the password of ${account.username}; sessions it issued no longer verify\n`);
236
+ return 0;
237
+ }
238
+ case "remove": {
239
+ const removed = await removeAccount(home, request.username, request.purgeData);
240
+ const kept = removed.purgedDirectory === void 0 ? `its Sessions stay in ${tenantDirectory(home, removed.account.id)}` : `its tenant directory ${removed.purgedDirectory} is gone`;
241
+ process.stdout.write(`qilin tenants: retired ${removed.account.username} (${removed.account.id}); ${kept}\n`);
242
+ return 0;
243
+ }
244
+ default: throw new Error(`qilin tenants: unhandled roster subcommand ${JSON.stringify(request)}`);
245
+ }
246
+ } catch (error) {
247
+ process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
248
+ return 1;
249
+ }
250
+ }
251
+ //#endregion
252
+ export { runTenantsRoster };
@@ -14,6 +14,7 @@
14
14
  * plugin dependencies by forwarding to pnpm.
15
15
  * @module @qilin-agent/cli/args
16
16
  */
17
+ import type { RosterRequest } from './tenants-roster.ts';
17
18
  /** Boot a named profile and hand it the invocation's inner arguments. */
18
19
  interface ProfileInvocation {
19
20
  mode: 'profile';
@@ -42,8 +43,16 @@ interface PluginInvocation {
42
43
  /** Raw pnpm arguments, verbatim. */
43
44
  args: string[];
44
45
  }
46
+ /** What one `qilin tenants` invocation asks for. */
47
+ type TenantsRequest = RosterRequest;
48
+ /** Serve one isolated Web runtime per account, or manage this home's roster. */
49
+ interface TenantsInvocation {
50
+ mode: 'tenants';
51
+ /** The validated subcommand and the options it takes. */
52
+ request: TenantsRequest;
53
+ }
45
54
  /** The resolved `qilin` invocation. Help, version, and errors exit inside {@link parseQilinArgs}. */
46
- export type QilinInvocation = ProfileInvocation | DumpConfigInvocation | PluginInvocation;
55
+ export type QilinInvocation = ProfileInvocation | DumpConfigInvocation | PluginInvocation | TenantsInvocation;
47
56
  /**
48
57
  * Resolve argv into one invocation, or print and exit for help, version, or an
49
58
  * error.
@@ -0,0 +1,19 @@
1
+ /**
2
+ * Host fallback for the Web Scheduler API global.
3
+ * @module @qilin-agent/cli/scheduler-yield
4
+ */
5
+ /**
6
+ * Install `globalThis.scheduler.yield` unless the host already defines a
7
+ * `scheduler`.
8
+ *
9
+ * The fallback resolves on the next `setImmediate` — the API's "yield to the
10
+ * event loop once, then continue" semantic — and a host-provided `scheduler`
11
+ * is left untouched. Call it before importing anything that may read the
12
+ * global; `bin.ts` calls it at the top of the entry, ahead of every mode
13
+ * module. Rationale: [the host-shim Agent
14
+ * Note](../../../.agents/notes/implemented/architecture/2026-10-07-web-scheduler-yield-host-shim.md).
15
+ *
16
+ * @returns whether this call installed the fallback.
17
+ */
18
+ export declare function installSchedulerYield(): boolean;
19
+ //# sourceMappingURL=scheduler-yield.d.ts.map
@@ -0,0 +1,101 @@
1
+ /**
2
+ * The hub's transport hop into one tenant runtime: an HTTP request forwarded
3
+ * with that runtime's own authority, and an upgraded socket piped the same way.
4
+ *
5
+ * The hop changes transport facts only. It rewrites `Host` to the tenant's
6
+ * authority — the tenant's session cookie is named after the authority it was
7
+ * issued for, so one browser can hold one cookie per tenant on one origin —
8
+ * and rewrites `Origin` to that same authority after checking the browser's
9
+ * own Origin names the hub, which keeps the tenant's same-origin fence as
10
+ * strong as it is without a proxy. Everything else, including `Range`,
11
+ * `Set-Cookie`, `Location`, and partial-content answers, crosses unchanged.
12
+ *
13
+ * The hop decides no access: the tenant runtime's own account gate answers
14
+ * every request, so a forged routing cookie reaches a runtime that does not
15
+ * recognize the caller.
16
+ * @module @qilin-agent/cli/tenant-proxy
17
+ */
18
+ import { type IncomingHttpHeaders, type IncomingMessage, type ServerResponse } from 'node:http';
19
+ import { Readable, type Duplex } from 'node:stream';
20
+ /** One request the hub forwards. */
21
+ export interface ProxyRequestOptions {
22
+ /** Origin the runtime serves, such as `http://127.0.0.1:41234`. */
23
+ readonly origin: string;
24
+ /** Request method, verbatim. */
25
+ readonly method: string;
26
+ /** Path and query to request upstream, exactly the path the browser asked for. */
27
+ readonly path: string;
28
+ /** Browser headers, verbatim. */
29
+ readonly headers: IncomingHttpHeaders;
30
+ /** Browser authority that reached the hub, checked against the Origin fence. */
31
+ readonly hubAuthority: string;
32
+ /** Incoming body, when the request carries one. */
33
+ readonly body?: Readable | undefined;
34
+ }
35
+ /** One upstream answer, still streaming. */
36
+ export interface ProxyAnswer {
37
+ /** Status code the runtime answered. */
38
+ readonly status: number;
39
+ /** Answer headers, hop-by-hop entries removed. */
40
+ readonly headers: IncomingHttpHeaders;
41
+ /** Answer body. */
42
+ readonly stream: Readable;
43
+ }
44
+ /**
45
+ * Whether a browser-labeled request may be forwarded at all.
46
+ *
47
+ * Over plain HTTP a read carries neither Origin nor Fetch-Metadata, so an
48
+ * absent Origin is accepted and the tenant's own Host fence still binds the
49
+ * request. A present Origin must name the hub exactly, and an explicit
50
+ * cross-site marker is refused: the hub is the outermost hop, so this is the
51
+ * only place those markers can be read before the tenant's authority replaces
52
+ * them.
53
+ * @param headers - browser headers, verbatim.
54
+ * @param hubAuthority - authority the browser used to reach the hub.
55
+ * @returns true when the request may be forwarded.
56
+ */
57
+ export declare function hubAcceptsOrigin(headers: IncomingHttpHeaders, hubAuthority: string): boolean;
58
+ /**
59
+ * Headers to forward upstream: the browser's own, minus connection-scoped
60
+ * entries, with the authority-bearing ones rewritten to the tenant.
61
+ * @param headers - browser headers, verbatim.
62
+ * @param tenantAuthority - the runtime's own `host:port`.
63
+ * @param deploymentAuthority - authority the browser used to reach the hub, and
64
+ * therefore the one the runtime names its sessions for; empty when the request
65
+ * reached a runtime directly, which then keeps its own authority as the name.
66
+ * Required rather than optional because every hop of this deployment knows it,
67
+ * and an upgrade that forgot it authenticates against a cookie no browser holds.
68
+ * @returns the forwarded header set.
69
+ */
70
+ export declare function forwardedHeaders(headers: IncomingHttpHeaders, tenantAuthority: string, deploymentAuthority: string): IncomingHttpHeaders;
71
+ /**
72
+ * Send one request to a tenant runtime and hand back its streaming answer.
73
+ *
74
+ * The hub's own origin fence runs here, in the operation that would forward the
75
+ * request, so no caller can forward a browser request whose Origin names
76
+ * another site.
77
+ * @param options - the runtime, the path, headers, and body to forward.
78
+ * @returns the runtime's status, headers, and body, or the refusal.
79
+ * @throws Error when the runtime is unreachable or refuses the connection.
80
+ */
81
+ export declare function proxyRequest(options: ProxyRequestOptions): Promise<ProxyAnswer>;
82
+ /**
83
+ * Write one upstream answer to the browser, streaming the body.
84
+ * @param answer - the runtime's status, headers, and body.
85
+ * @param response - the browser's response.
86
+ */
87
+ export declare function relayAnswer(answer: ProxyAnswer, response: ServerResponse): void;
88
+ /**
89
+ * Pipe one upgraded browser socket to the same path on a tenant runtime, with
90
+ * the tenant's authority in place of the hub's.
91
+ *
92
+ * The upgrade is piped, never terminated: the hub holds no copy of the frames,
93
+ * so the tenant's own mux and its trust fence stay in charge of the stream.
94
+ * @param request - the browser's upgrade request.
95
+ * @param socket - the browser's socket.
96
+ * @param head - bytes already read from the browser.
97
+ * @param target - origin of the runtime the socket belongs to.
98
+ * @param headers - already-forwarded headers, with the tenant's authority set.
99
+ */
100
+ export declare function relayUpgrade(request: IncomingMessage, socket: Duplex, head: Buffer, target: string, headers: IncomingHttpHeaders): void;
101
+ //# sourceMappingURL=tenant-proxy.d.ts.map