@wilm-ai/wilma-cli 2.3.2 → 2.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,487 @@
1
+ /*
2
+ * Where saved Wilma passwords (and authenticator keys) are kept: in the config
3
+ * file, scrambled, as always (the default), or in the operating system's
4
+ * keychain when the user or a program turns that on (`wilma config secrets`,
5
+ * WILMAI_SECRET_STORE). See docs/secret-store.md.
6
+ *
7
+ * One keychain entry per login: service "ai.wilm.wilma-cli", account the
8
+ * login's random `secretId`, value JSON {password, totpSecret}.
9
+ *
10
+ * - macOS: through /usr/bin/security, in a child process with a hard
11
+ * timeout. Items trust /usr/bin/security only (-T), so any node binary (a
12
+ * Homebrew upgrade, Claude Desktop's own Node) and WilmAI Home read them
13
+ * without macOS asking for the Mac password.
14
+ * - Windows and Linux: @napi-rs/keyring, an optional dependency loaded only
15
+ * when needed (Credential Manager; the Secret Service, never the kernel
16
+ * keyring, which is empty after a reboot).
17
+ */
18
+ import { spawn } from "node:child_process";
19
+ import { randomBytes } from "node:crypto";
20
+ import { access, mkdir, readFile, rename, writeFile } from "node:fs/promises";
21
+ import { dirname } from "node:path";
22
+ export const KEYCHAIN_SERVICE = "ai.wilm.wilma-cli";
23
+ export const SECRET_STORE_SETTINGS = ["file", "auto", "keychain"];
24
+ /** How long one keychain call may take (a prompt waiting for a person counts as refused). WILMAI_KEYCHAIN_TIMEOUT_MS is a test hook. */
25
+ const CALL_TIMEOUT_MS = Number(process.env.WILMAI_KEYCHAIN_TIMEOUT_MS) || 15_000;
26
+ /** The macOS child process is killed after this. */
27
+ const SECURITY_TIMEOUT_MS = 10_000;
28
+ /** After a refusal, no new read for this long (so several children don't each show a prompt). */
29
+ const REFUSED_PAUSE_MS = 60_000;
30
+ /** A keychain entry couldn't be read, written or deleted; `reason` says what the user can do. */
31
+ export class SecretStoreError extends Error {
32
+ reason;
33
+ constructor(reason, message, cause) {
34
+ super(message, cause === undefined ? undefined : { cause });
35
+ this.name = "SecretStoreError";
36
+ this.reason = reason;
37
+ }
38
+ }
39
+ /** The message a parent sees for each kind of failure (`wilma` names the Wilma when known). */
40
+ export function secretErrorMessage(reason, wilma) {
41
+ switch (reason) {
42
+ case "missing":
43
+ return `The saved password for ${wilma ?? "this Wilma login"} isn't in the keychain any more. Log in again (\`wilma login\`).`;
44
+ case "refused":
45
+ return "The keychain on this computer didn't hand over WilmAI's saved Wilma password. If the computer is locked or asking for a password, unlock it, then try again.";
46
+ case "unavailable":
47
+ return "This login's password is saved in the keychain, which this copy of WilmAI can't open here. Update WilmAI, or log in again to save it where it can.";
48
+ }
49
+ }
50
+ function storeError(reason, detail, cause, wilma) {
51
+ const message = secretErrorMessage(reason, wilma) + (detail ? ` (${detail})` : "");
52
+ return new SecretStoreError(reason, message, cause);
53
+ }
54
+ /** The setting in effect: WILMAI_SECRET_STORE for this process, else the saved one, else the file. */
55
+ export function effectiveSecretStore(saved, env = process.env) {
56
+ const fromEnv = env.WILMAI_SECRET_STORE?.trim();
57
+ if (fromEnv) {
58
+ if (SECRET_STORE_SETTINGS.includes(fromEnv))
59
+ return fromEnv;
60
+ throw new Error(`WILMAI_SECRET_STORE must be file, auto or keychain (it is "${fromEnv}").`);
61
+ }
62
+ return SECRET_STORE_SETTINGS.includes(saved ?? "") ? saved : "file";
63
+ }
64
+ export const newSecretId = () => randomBytes(16).toString("hex");
65
+ export const newCredentialsStamp = () => randomBytes(8).toString("hex");
66
+ const service = () => process.env.WILMAI_KEYCHAIN_SERVICE || KEYCHAIN_SERVICE; // a test hook
67
+ /** A test hook: a keychain file to use on macOS instead of the default (login) keychain. */
68
+ const keychainFile = () => (process.env.WILMAI_KEYCHAIN_PATH ? [process.env.WILMAI_KEYCHAIN_PATH] : []);
69
+ function runSecurity(args, input = "") {
70
+ return new Promise((resolve) => {
71
+ let settled = false;
72
+ const done = (run) => {
73
+ if (settled)
74
+ return;
75
+ settled = true;
76
+ clearTimeout(timer);
77
+ resolve(run);
78
+ };
79
+ let stdout = "";
80
+ let stderr = "";
81
+ let timedOut = false;
82
+ const child = spawn("/usr/bin/security", args, { stdio: ["pipe", "pipe", "pipe"] });
83
+ const timer = setTimeout(() => {
84
+ timedOut = true;
85
+ child.kill("SIGKILL");
86
+ }, SECURITY_TIMEOUT_MS);
87
+ child.stdout.setEncoding("utf8").on("data", (d) => (stdout += d));
88
+ child.stderr.setEncoding("utf8").on("data", (d) => (stderr += d));
89
+ child.on("error", (spawnError) => done({ code: null, stdout, stderr, spawnError, timedOut }));
90
+ child.on("close", (code) => done({ code, stdout, stderr, timedOut }));
91
+ child.stdin.on("error", () => { });
92
+ child.stdin.end(input);
93
+ });
94
+ }
95
+ /** A failed `security` run as a store error: not runnable = unavailable, anything else = refused. */
96
+ function securityFailure(run, what) {
97
+ if (run.spawnError)
98
+ return storeError("unavailable", `${what}: ${run.spawnError.message}`, run.spawnError);
99
+ if (run.timedOut)
100
+ return storeError("refused", `${what}: no answer from the keychain in time`);
101
+ const detail = run.stderr.trim().split("\n").pop() || `exit ${run.code}`;
102
+ return storeError("refused", `${what}: ${detail}`);
103
+ }
104
+ const macosBackend = {
105
+ kind: "macos",
106
+ async check() {
107
+ if (process.platform !== "darwin" && !process.env.WILMAI_KEYCHAIN_BACKEND)
108
+ return "not macOS";
109
+ try {
110
+ await access("/usr/bin/security");
111
+ return null;
112
+ }
113
+ catch {
114
+ return "/usr/bin/security isn't there";
115
+ }
116
+ },
117
+ async get(account) {
118
+ const run = await runSecurity(["find-generic-password", "-s", service(), "-a", account, "-w", ...keychainFile()]);
119
+ if (run.code === 0)
120
+ return run.stdout.replace(/\n$/, "");
121
+ if (run.code === 44)
122
+ return null; // errSecItemNotFound
123
+ throw securityFailure(run, "reading");
124
+ },
125
+ async set(account, value) {
126
+ // Never change an item in place (-U): macOS asks for authorization to change an item's content, a dialog
127
+ // nobody answers in a background run, and the keychain refuses everything else until it's answered.
128
+ // Deleting and adding again asks nothing. (Parent Recap's ADR 0009 does the same.)
129
+ const removed = await runSecurity(["delete-generic-password", "-s", service(), "-a", account, ...keychainFile()]);
130
+ if (removed.code !== 0 && removed.code !== 44)
131
+ throw securityFailure(removed, "saving");
132
+ // The command goes in on stdin (never on a command line), the value as hex (nothing to quote).
133
+ const hex = Buffer.from(value, "utf8").toString("hex");
134
+ const target = keychainFile().map((path) => ` "${path}"`).join("");
135
+ const command = `add-generic-password -s ${service()} -a ${account} -l "WilmAI saved login" -T /usr/bin/security -X ${hex}${target}\n`;
136
+ const run = await runSecurity(["-i"], command);
137
+ if (run.code !== 0 || /returned -\d+/.test(run.stderr))
138
+ throw securityFailure(run, "saving");
139
+ },
140
+ async delete(account) {
141
+ const run = await runSecurity(["delete-generic-password", "-s", service(), "-a", account, ...keychainFile()]);
142
+ if (run.code === 0)
143
+ return true;
144
+ if (run.code === 44)
145
+ return false;
146
+ throw securityFailure(run, "deleting");
147
+ },
148
+ };
149
+ let keyringModule = null;
150
+ function loadKeyring() {
151
+ // A name in a variable: bundlers leave the optional dependency out (it's loaded from node_modules when there).
152
+ const name = "@napi-rs/keyring";
153
+ keyringModule ??= import(name).then((mod) => (mod.AsyncEntry ? mod : (mod.default ?? null)), () => null);
154
+ return keyringModule;
155
+ }
156
+ async function keyringEntry(account) {
157
+ const mod = await loadKeyring();
158
+ if (!mod)
159
+ throw storeError("unavailable", "this copy of WilmAI was installed without keychain support");
160
+ try {
161
+ // Linux: the Secret Service only. The library would otherwise fall back to the kernel keyring, which forgets everything at a reboot.
162
+ return new mod.AsyncEntry(service(), account, process.platform === "linux" ? { linux: { store: "secret-service" } } : undefined);
163
+ }
164
+ catch (err) {
165
+ throw storeError("unavailable", err instanceof Error ? err.message : String(err), err);
166
+ }
167
+ }
168
+ /** Windows sessions without credentials (an SSH key login) and a missing store are "can't be used", the rest refused. */
169
+ function keyringFailure(err, what) {
170
+ if (err instanceof SecretStoreError)
171
+ return err;
172
+ const message = err instanceof Error ? err.message : String(err);
173
+ // @napi-rs/keyring 2.1 says "Couldn't access platform storage: ..." when there is no store to use (no Secret
174
+ // Service, a Windows session without credentials); "Platform failure: ..." covers refusals and the rest.
175
+ const unusable = /couldn't access platform|platform secure storage|NoStorageAccess|no.such.logon.session|secret service|org\.freedesktop|dbus/i.test(message);
176
+ return storeError(unusable ? "unavailable" : "refused", `${what}: ${message}`, err);
177
+ }
178
+ const keyringBackend = {
179
+ kind: "keyring",
180
+ async check() {
181
+ if (!(await loadKeyring()))
182
+ return "installed without keychain support";
183
+ if (process.platform === "linux" && !process.env.DBUS_SESSION_BUS_ADDRESS)
184
+ return "no Secret Service here (no desktop session)";
185
+ try {
186
+ const entry = await keyringEntry(`probe-${process.pid}`);
187
+ await withTimeout(entry.getPassword(), CALL_TIMEOUT_MS, "checking");
188
+ return null;
189
+ }
190
+ catch (err) {
191
+ return keyringFailure(err, "checking").message;
192
+ }
193
+ },
194
+ async get(account) {
195
+ const entry = await keyringEntry(account);
196
+ try {
197
+ return (await entry.getPassword()) ?? null;
198
+ }
199
+ catch (err) {
200
+ throw keyringFailure(err, "reading");
201
+ }
202
+ },
203
+ async set(account, value) {
204
+ const entry = await keyringEntry(account);
205
+ try {
206
+ await entry.setPassword(value);
207
+ }
208
+ catch (err) {
209
+ throw keyringFailure(err, "saving");
210
+ }
211
+ },
212
+ async delete(account) {
213
+ const entry = await keyringEntry(account);
214
+ try {
215
+ return await entry.deletePassword();
216
+ }
217
+ catch (err) {
218
+ const failure = keyringFailure(err, "deleting");
219
+ if (/no (matching )?entry|not found/i.test(failure.message))
220
+ return false;
221
+ throw failure;
222
+ }
223
+ },
224
+ };
225
+ /**
226
+ * For tests: a keychain in a JSON file shared by processes,
227
+ * WILMAI_KEYCHAIN_BACKEND=fake:/path. `{"entries": {...}}`, plus `"check"`
228
+ * (why it can't be used), `"fail": {"get"|"set"|"delete": reason}` and
229
+ * `"hangMs"` to simulate a prompt nobody answers.
230
+ */
231
+ function fakeBackend(path) {
232
+ const load = async () => {
233
+ try {
234
+ return JSON.parse(await readFile(path, "utf8"));
235
+ }
236
+ catch {
237
+ return {};
238
+ }
239
+ };
240
+ const save = async (fake) => {
241
+ await mkdir(dirname(path), { recursive: true });
242
+ const tmp = `${path}.${process.pid}.${randomBytes(4).toString("hex")}.tmp`;
243
+ await writeFile(tmp, JSON.stringify(fake, null, 2));
244
+ await rename(tmp, path);
245
+ };
246
+ const maybeFail = async (fake, op) => {
247
+ if (fake.hangMs)
248
+ await new Promise((r) => setTimeout(r, fake.hangMs));
249
+ const reason = fake.fail?.[op];
250
+ if (reason)
251
+ throw storeError(reason, `fake ${op}`);
252
+ };
253
+ return {
254
+ kind: "fake",
255
+ async check() {
256
+ return (await load()).check ?? null;
257
+ },
258
+ async get(account) {
259
+ const fake = await load();
260
+ await maybeFail(fake, "get");
261
+ return fake.entries?.[account] ?? null;
262
+ },
263
+ async set(account, value) {
264
+ const fake = await load();
265
+ await maybeFail(fake, "set");
266
+ await save({ ...fake, entries: { ...fake.entries, [account]: value } });
267
+ },
268
+ async delete(account) {
269
+ const fake = await load();
270
+ await maybeFail(fake, "delete");
271
+ if (!fake.entries || !(account in fake.entries))
272
+ return false;
273
+ delete fake.entries[account];
274
+ await save(fake);
275
+ return true;
276
+ },
277
+ };
278
+ }
279
+ function backend() {
280
+ const forced = process.env.WILMAI_KEYCHAIN_BACKEND; // a test hook
281
+ if (forced?.startsWith("fake:"))
282
+ return fakeBackend(forced.slice(5));
283
+ if (forced === "keyring")
284
+ return keyringBackend;
285
+ if (forced === "macos")
286
+ return macosBackend;
287
+ return process.platform === "darwin" ? macosBackend : keyringBackend;
288
+ }
289
+ /**
290
+ * Keychain calls that timed out but are still running. A native call can't be
291
+ * cancelled (Windows, Linux) and keeps the process alive, so a CLI command
292
+ * exits by itself once its output is written (abandonedKeychainCalls).
293
+ */
294
+ let abandonedCalls = 0;
295
+ function withTimeout(promise, ms, what) {
296
+ return new Promise((resolve, reject) => {
297
+ let timedOut = false;
298
+ const timer = setTimeout(() => {
299
+ timedOut = true;
300
+ abandonedCalls += 1;
301
+ reject(storeError("refused", `${what}: no answer from the keychain in time`));
302
+ }, ms);
303
+ promise.then((value) => {
304
+ clearTimeout(timer);
305
+ if (timedOut)
306
+ abandonedCalls -= 1;
307
+ else
308
+ resolve(value);
309
+ }, (err) => {
310
+ clearTimeout(timer);
311
+ if (timedOut)
312
+ abandonedCalls -= 1;
313
+ else
314
+ reject(err);
315
+ });
316
+ });
317
+ }
318
+ // ---------------------------------------------------------------------------
319
+ // Using it
320
+ let checked = null;
321
+ /** null when the keychain can be used here (checked once per process); otherwise why not. */
322
+ export function keychainProblem() {
323
+ checked ??= backend()
324
+ .check()
325
+ .catch((err) => (err instanceof Error ? err.message : String(err)));
326
+ return checked;
327
+ }
328
+ /** Which keychain this process would use, for `wilma config secrets`. */
329
+ export function keychainKind() {
330
+ const kind = backend().kind;
331
+ if (kind === "macos")
332
+ return "macOS Keychain";
333
+ if (kind === "fake")
334
+ return "test keychain";
335
+ return process.platform === "win32" ? "Windows Credential Manager" : process.platform === "darwin" ? "macOS Keychain" : "Secret Service";
336
+ }
337
+ /** JSON with everything past ASCII escaped, so every keychain hands it back unchanged. */
338
+ function encode(secrets) {
339
+ return JSON.stringify({ password: secrets.password, totpSecret: secrets.totpSecret ?? null }).replace(/[\u007f-￿]/g, (c) => `\\u${c.charCodeAt(0).toString(16).padStart(4, "0")}`);
340
+ }
341
+ function decode(raw, wilma) {
342
+ try {
343
+ const parsed = JSON.parse(raw);
344
+ if (typeof parsed.password === "string" && parsed.password) {
345
+ return { password: parsed.password, totpSecret: typeof parsed.totpSecret === "string" && parsed.totpSecret ? parsed.totpSecret : null };
346
+ }
347
+ }
348
+ catch {
349
+ // fall through
350
+ }
351
+ return storeErrorThrow("missing", "the keychain entry is damaged", wilma);
352
+ }
353
+ function storeErrorThrow(reason, detail, wilma) {
354
+ throw storeError(reason, detail, undefined, wilma);
355
+ }
356
+ const reads = new Map();
357
+ /** A login's password and authenticator key from the keychain: read once per process, only when needed. */
358
+ export function readLoginSecrets(ref) {
359
+ const key = `${ref.secretId}|${ref.credentialsStamp ?? ""}`;
360
+ let state = reads.get(key);
361
+ if (!state) {
362
+ state = {};
363
+ reads.set(key, state);
364
+ }
365
+ if (state.value)
366
+ return Promise.resolve(state.value);
367
+ if (state.pending)
368
+ return state.pending;
369
+ if (state.abandoned)
370
+ return Promise.reject(storeError("refused", "still waiting for the keychain to answer an earlier request"));
371
+ if (state.error && (state.error.reason !== "refused" || Date.now() < (state.retryAt ?? 0)))
372
+ return Promise.reject(state.error);
373
+ const s = state;
374
+ s.error = undefined;
375
+ const settle = (outcome) => {
376
+ if ("value" in outcome)
377
+ s.value = outcome.value;
378
+ else {
379
+ s.error = outcome.error;
380
+ s.retryAt = Date.now() + REFUSED_PAUSE_MS;
381
+ }
382
+ };
383
+ const call = (async () => {
384
+ let raw = await backend().get(ref.secretId);
385
+ if (raw === null) {
386
+ // Saving deletes the entry and adds it again (macOS): a read in between finds nothing for a moment
387
+ // (two short `security` runs). Well inside the call's own timeout.
388
+ await new Promise((r) => setTimeout(r, Math.min(150, CALL_TIMEOUT_MS / 4)));
389
+ raw = await backend().get(ref.secretId);
390
+ }
391
+ if (raw === null)
392
+ storeErrorThrow("missing", "no keychain entry", ref.wilma);
393
+ return decode(raw, ref.wilma);
394
+ })();
395
+ s.pending = new Promise((resolve, reject) => {
396
+ let answered = false;
397
+ const timer = setTimeout(() => {
398
+ if (answered)
399
+ return;
400
+ answered = true;
401
+ // The call can't be cancelled (Windows, Linux): wait for it, start no new one, keep a late value.
402
+ s.pending = undefined;
403
+ s.abandoned = true;
404
+ abandonedCalls += 1;
405
+ reject(storeError("refused", "no answer from the keychain in time"));
406
+ }, CALL_TIMEOUT_MS);
407
+ const late = () => {
408
+ s.abandoned = false;
409
+ abandonedCalls -= 1;
410
+ };
411
+ call.then((value) => {
412
+ settle({ value });
413
+ if (answered)
414
+ late();
415
+ else {
416
+ answered = true;
417
+ clearTimeout(timer);
418
+ s.pending = undefined;
419
+ resolve(value);
420
+ }
421
+ }, (err) => {
422
+ const error = keyringFailure(err, "reading");
423
+ settle({ error });
424
+ if (answered)
425
+ late();
426
+ else {
427
+ answered = true;
428
+ clearTimeout(timer);
429
+ s.pending = undefined;
430
+ reject(error);
431
+ }
432
+ });
433
+ });
434
+ return s.pending;
435
+ }
436
+ /** Save a login's secrets in its keychain entry (replacing what was there). */
437
+ export async function writeLoginSecrets(secretId, secrets) {
438
+ try {
439
+ await withTimeout(backend().set(secretId, encode(secrets)), CALL_TIMEOUT_MS, "saving");
440
+ }
441
+ catch (err) {
442
+ throw keyringFailure(err, "saving");
443
+ }
444
+ forgetReads(secretId);
445
+ }
446
+ /** Forget what was read from an entry, except reads still waiting (they must stay counted). */
447
+ function forgetReads(secretId) {
448
+ for (const [key, state] of [...reads])
449
+ if (key.startsWith(`${secretId}|`) && !state.pending && !state.abandoned)
450
+ reads.delete(key);
451
+ }
452
+ /**
453
+ * Read an entry straight from the keychain (no per-process memory), e.g. to
454
+ * check what was just written. A Windows read right after a write on another
455
+ * thread can briefly miss, so a miss is tried once more.
456
+ */
457
+ export async function readBackLoginSecrets(secretId) {
458
+ for (let attempt = 0;; attempt++) {
459
+ const raw = await withTimeout(backend().get(secretId), CALL_TIMEOUT_MS, "reading").catch((err) => {
460
+ throw keyringFailure(err, "reading");
461
+ });
462
+ if (raw !== null)
463
+ return decode(raw);
464
+ if (attempt >= 1)
465
+ return null;
466
+ await new Promise((r) => setTimeout(r, 200));
467
+ }
468
+ }
469
+ /** Delete a login's keychain entry; true when there was one. */
470
+ export async function deleteLoginSecrets(secretId) {
471
+ forgetReads(secretId);
472
+ try {
473
+ return await withTimeout(backend().delete(secretId), CALL_TIMEOUT_MS, "deleting");
474
+ }
475
+ catch (err) {
476
+ throw keyringFailure(err, "deleting");
477
+ }
478
+ }
479
+ /** Whether a keychain call timed out and may still be running (the process should then exit when done). */
480
+ export function abandonedKeychainCalls() {
481
+ return abandonedCalls > 0;
482
+ }
483
+ /** For tests in one process: forget what was read. */
484
+ export function forgetSecretReads() {
485
+ reads.clear();
486
+ checked = null;
487
+ }
@@ -0,0 +1,19 @@
1
+ export declare function sessionKeyV1(baseUrl: string, username: string, password: string): string;
2
+ export declare function sessionKeyV2(baseUrl: string, username: string): string;
3
+ /**
4
+ * Where a login's session lives: the session-file keys it is saved under (all
5
+ * of them) and looked up under (the newest entry wins), and the key of its
6
+ * client in a process's pool.
7
+ *
8
+ * The labels overlap while v1 is retired (docs/secret-store.md, the plan):
9
+ * file and environment logins use v2 and v1, so programs that only know v1
10
+ * (CLI 2.4.0 and older, an old Claude Desktop extension, Home 0.3.1 and older)
11
+ * keep sharing their session; keychain logins use v2 only.
12
+ */
13
+ export interface SessionKeys {
14
+ sessionKeys: string[];
15
+ /** In-memory only: v1 for a file login (a new password makes a new client), v2 plus the credentials stamp for a keychain login. */
16
+ poolKey: string;
17
+ }
18
+ export declare function fileLoginKeys(baseUrl: string, username: string, password: string): SessionKeys;
19
+ export declare function keychainLoginKeys(baseUrl: string, username: string, credentialsStamp: string | null | undefined): SessionKeys;
@@ -0,0 +1,26 @@
1
+ import { createHash } from "node:crypto";
2
+ /*
3
+ * The key a login's session is saved under in wilmai-sessions.json, shared by
4
+ * the CLI, the MCP server and WilmAI Home (which computes the same keys).
5
+ *
6
+ * - v1, file and environment logins (as always): SHA-256 of the address,
7
+ * username and password, so a changed password starts a fresh session.
8
+ * - v2, every login over time: SHA-256 of "wilmai-session-v2", the address
9
+ * and the username. No password: the session is found without reading a
10
+ * keychain, and the file holds nothing derived from the password.
11
+ */
12
+ const address = (baseUrl) => baseUrl.replace(/\/$/, "");
13
+ export function sessionKeyV1(baseUrl, username, password) {
14
+ return createHash("sha256").update(`${address(baseUrl)}\n${username.toLowerCase()}\n${password}`).digest("hex");
15
+ }
16
+ export function sessionKeyV2(baseUrl, username) {
17
+ return createHash("sha256").update(`wilmai-session-v2\n${address(baseUrl)}\n${username.toLowerCase()}`).digest("hex");
18
+ }
19
+ export function fileLoginKeys(baseUrl, username, password) {
20
+ const v1 = sessionKeyV1(baseUrl, username, password);
21
+ return { sessionKeys: [sessionKeyV2(baseUrl, username), v1], poolKey: v1 };
22
+ }
23
+ export function keychainLoginKeys(baseUrl, username, credentialsStamp) {
24
+ const v2 = sessionKeyV2(baseUrl, username);
25
+ return { sessionKeys: [v2], poolKey: `${v2}|${credentialsStamp ?? ""}` };
26
+ }
@@ -3,10 +3,26 @@ export interface SessionStore {
3
3
  save(key: string, state: string): Promise<void>;
4
4
  /** Forget one session (after logging out). */
5
5
  remove(key: string): Promise<void>;
6
+ /** The newest session saved under any of these keys (one login's labels). */
7
+ loadNewest?(keys: string[]): Promise<string | null>;
8
+ /** Save one session under all of these keys, in one write. */
9
+ saveAll?(keys: string[], state: string): Promise<void>;
10
+ /** Forget a session under all of these keys, in one write. */
11
+ removeAll?(keys: string[]): Promise<void>;
6
12
  }
7
13
  /** Name this program in the sessions it saves (the MCP server says "mcp"). */
8
14
  export declare function nameSessionOwner(name: string): void;
9
15
  export declare const fileSessionStore: SessionStore;
16
+ /**
17
+ * Make sure a login's newest session is under `to` too (moving a login
18
+ * between the file and the keychain), keeping when and by whom it was saved,
19
+ * and tag both entries with the login's id. With the labels overlapping, `to`
20
+ * usually has it already; an older program may have saved a newer one under
21
+ * `from` only. Nothing to copy (none, or expired): false.
22
+ */
23
+ export declare function copySession(from: string, to: string, login: string): Promise<boolean>;
24
+ /** Delete the entries tagged with a login's id except `keep` (what a move interrupted before its last step left). */
25
+ export declare function removeTaggedSessions(login: string, keep: string): Promise<number>;
10
26
  /** When one login's session was saved, and by which program (null when there's none to try). */
11
27
  export declare function sessionInfo(key: string): Promise<{
12
28
  savedAt: number;
@@ -61,6 +61,42 @@ export const fileSessionStore = {
61
61
  delete all[key];
62
62
  await writeAll(all);
63
63
  },
64
+ async loadNewest(keys) {
65
+ if (process.env.WILMAI_NO_SESSION_CACHE === "1")
66
+ return null;
67
+ const all = await readAll();
68
+ let newest = null;
69
+ for (const key of keys) {
70
+ const entry = all[key];
71
+ if (!entry || typeof entry.state !== "string" || Date.now() - entry.savedAt > SESSION_TTL_MS)
72
+ continue;
73
+ if (!newest || entry.savedAt > newest.savedAt)
74
+ newest = entry;
75
+ }
76
+ return newest?.state ?? null;
77
+ },
78
+ async saveAll(keys, state) {
79
+ if (process.env.WILMAI_NO_SESSION_CACHE === "1")
80
+ return;
81
+ const all = await readAll();
82
+ const now = Date.now();
83
+ for (const [k, entry] of Object.entries(all)) {
84
+ if (now - entry.savedAt > SESSION_TTL_MS)
85
+ delete all[k];
86
+ }
87
+ for (const key of keys)
88
+ all[key] = { state, savedAt: now, by: savedBy };
89
+ await writeAll(all);
90
+ },
91
+ async removeAll(keys) {
92
+ const all = await readAll();
93
+ const present = keys.filter((key) => key in all);
94
+ if (!present.length)
95
+ return;
96
+ for (const key of present)
97
+ delete all[key];
98
+ await writeAll(all);
99
+ },
64
100
  };
65
101
  async function writeAll(all) {
66
102
  const path = sessionsPath();
@@ -70,6 +106,39 @@ async function writeAll(all) {
70
106
  await writeFile(tmp, JSON.stringify(all), { encoding: "utf-8", mode: 0o600 });
71
107
  await rename(tmp, path);
72
108
  }
109
+ /**
110
+ * Make sure a login's newest session is under `to` too (moving a login
111
+ * between the file and the keychain), keeping when and by whom it was saved,
112
+ * and tag both entries with the login's id. With the labels overlapping, `to`
113
+ * usually has it already; an older program may have saved a newer one under
114
+ * `from` only. Nothing to copy (none, or expired): false.
115
+ */
116
+ export async function copySession(from, to, login) {
117
+ if (process.env.WILMAI_NO_SESSION_CACHE === "1")
118
+ return false;
119
+ const all = await readAll();
120
+ const valid = (entry) => entry && typeof entry.state === "string" && Date.now() - entry.savedAt <= SESSION_TTL_MS;
121
+ const source = all[from];
122
+ const target = all[to];
123
+ if (!valid(source))
124
+ return false;
125
+ const newest = valid(target) && target.savedAt >= source.savedAt ? target : source;
126
+ all[from] = { ...source, login };
127
+ all[to] = { ...newest, login };
128
+ await writeAll(all);
129
+ return true;
130
+ }
131
+ /** Delete the entries tagged with a login's id except `keep` (what a move interrupted before its last step left). */
132
+ export async function removeTaggedSessions(login, keep) {
133
+ const all = await readAll();
134
+ const gone = Object.keys(all).filter((key) => key !== keep && all[key]?.login === login);
135
+ if (!gone.length)
136
+ return 0;
137
+ for (const key of gone)
138
+ delete all[key];
139
+ await writeAll(all);
140
+ return gone.length;
141
+ }
73
142
  /** When one login's session was saved, and by which program (null when there's none to try). */
74
143
  export async function sessionInfo(key) {
75
144
  if (process.env.WILMAI_NO_SESSION_CACHE === "1")
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wilm-ai/wilma-cli",
3
- "version": "2.3.2",
3
+ "version": "2.5.0",
4
4
  "description": "Read your children's Wilma (Finnish school system) from the terminal or an AI assistant: schedule, homework, exams, messages and news. Students can use their own login too.",
5
5
  "mcpName": "ai.wilm/wilma",
6
6
  "license": "MIT",
@@ -22,16 +22,18 @@
22
22
  "scripts": {
23
23
  "build": "tsc -p tsconfig.json",
24
24
  "lint": "tsc -p tsconfig.json --noEmit",
25
- "test": "pnpm --filter @wilm-ai/wilma-client build && pnpm run build && node test/news-resources.mjs && node test/mcp-login.mjs && node test/mfa.mjs && node test/logout.mjs && node test/shared-session.mjs && node test/audit.mjs && node test/update-notice.mjs && node test/registry.mjs",
25
+ "test": "pnpm --filter @wilm-ai/wilma-client build && pnpm run build && node test/news-resources.mjs && node test/mcp-login.mjs && node test/mfa.mjs && node test/logout.mjs && node test/shared-session.mjs && node test/secret-store.mjs && node test/audit.mjs && node test/family.mjs && node test/update-notice.mjs && node test/registry.mjs",
26
26
  "start": "node dist/index.js",
27
27
  "prepack": "pnpm --filter @wilm-ai/wilma-client build && pnpm --filter @wilm-ai/wilma-cli build",
28
28
  "test:live": "tsc -p tsconfig.json && node test/live.spec.mjs",
29
+ "test:keychain": "pnpm run build && node test/keychain-real.mjs --run",
30
+ "test:keychain:temp": "pnpm run build && node test/keychain-real.mjs --temp",
29
31
  "build:mcpb": "pnpm run build && node scripts/build-mcpb.mjs"
30
32
  },
31
33
  "dependencies": {
32
34
  "@inquirer/prompts": "^5.3.8",
33
35
  "@modelcontextprotocol/sdk": "^1.32.0",
34
- "@wilm-ai/wilma-client": "^1.8.0",
36
+ "@wilm-ai/wilma-client": "^1.10.0",
35
37
  "zod": "^4.6.6"
36
38
  },
37
39
  "devDependencies": {
@@ -46,5 +48,8 @@
46
48
  ],
47
49
  "publishConfig": {
48
50
  "access": "public"
51
+ },
52
+ "optionalDependencies": {
53
+ "@napi-rs/keyring": "2.1.2"
49
54
  }
50
55
  }