@wilm-ai/wilma-cli 2.4.0 → 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.
package/dist/index.js CHANGED
@@ -6,14 +6,16 @@ import { spawn } from "node:child_process";
6
6
  import { homedir } from "node:os";
7
7
  import { dirname, resolve } from "node:path";
8
8
  import { fileURLToPath } from "node:url";
9
- import { APIError, AuthenticationError, MfaRequiredError, NetworkError, WilmaClient, listTenants, } from "@wilm-ai/wilma-client";
10
- import { clearConfig, getConfigPath, loadConfig, obfuscateSecret, revealSecret, saveConfig, savedStudent } from "./config.js";
11
- import { WilmaAccess, WilmaAiError, adoptSession, flushSessions, roleOf, useSessionStore, } from "./agent-data.js";
12
- import { createAccess, logoutAccounts, removeLogin } from "./access.js";
9
+ import { APIError, AuthenticationError, CredentialsUnavailableError, MfaRequiredError, NetworkError, WilmaClient, listTenants, } from "@wilm-ai/wilma-client";
10
+ import { clearConfig, editConfig, getConfigPath, loadConfig, revealSecret, savedStudent } from "./config.js";
11
+ import { WilmaAccess, WilmaAiError, flushSessions, roleOf, useSessionStore, } from "./agent-data.js";
12
+ import { createAccess, logoutAccounts, removeLogin, saveVerifiedLogin } from "./access.js";
13
13
  import { parseCliArgs } from "./cli-args.js";
14
14
  import { commandHelp, generalHelp } from "./cli-help.js";
15
15
  import { compactText, formatText, messageLine, newsLine } from "./cli-output.js";
16
- import { ENV_VARS, isMfaFailure, mfaCallbackFor, resolveAccounts, saveLogin, verifyLoginSession } from "./credentials.js";
16
+ import { ENV_VARS, accountFor, chooseSecretStore, isMfaFailure, mfaCallbackFor, resolveAccounts, saveTotpSecret, verifyLoginSession } from "./credentials.js";
17
+ import { deleteKnownEntries, moveLoginToFile, moveLoginToKeychain, removeStrayTempFiles, runBackgroundMoves, whereSecretsAre } from "./secret-moves.js";
18
+ import { abandonedKeychainCalls, effectiveSecretStore, keychainKind, keychainProblem, readBackLoginSecrets, SecretStoreError, secretErrorMessage } from "./secret-store.js";
17
19
  import { createUniqueDownloadFile } from "./downloads.js";
18
20
  import { HOME, homeText } from "./home.js";
19
21
  import { openBrowser, startLoginServer } from "./login-server.js";
@@ -131,11 +133,19 @@ async function runCommand(argv) {
131
133
  case "update":
132
134
  await handleUpdate();
133
135
  return;
134
- case "config":
136
+ case "config": {
137
+ if (action === "secrets") {
138
+ await handleSecrets(args[0], out);
139
+ return;
140
+ }
141
+ // The documented way out of a damaged config: read what can be read, delete the keychain entries it knows, then the files.
142
+ const notDeleted = await deleteKnownEntries(await readFile(getConfigPath(), "utf8").catch(() => null));
135
143
  await clearConfig();
136
144
  await clearSessions();
137
- out.data({ status: "cleared", configPath: getConfigPath() }, () => `Cleared saved logins and sessions (${getConfigPath()}).`);
145
+ out.data({ status: "cleared", configPath: getConfigPath(), ...(notDeleted.length ? { keychainEntriesNotDeleted: notDeleted.length } : {}) }, () => `Cleared saved logins and sessions (${getConfigPath()}).` +
146
+ (notDeleted.length ? `\n${notDeleted.length} keychain entr${notDeleted.length === 1 ? "y" : "ies"} named ai.wilm.wilma-cli couldn't be deleted; remove them in your keychain app.` : ""));
138
147
  return;
148
+ }
139
149
  default:
140
150
  try {
141
151
  await runDataCommand(parsed, out);
@@ -143,9 +153,78 @@ async function runCommand(argv) {
143
153
  finally {
144
154
  // Also after an error: the session is still good for the next command.
145
155
  await flushSessions();
156
+ await runBackgroundMoves();
146
157
  }
147
158
  }
148
159
  }
160
+ /** `wilma config secrets [file|auto|keychain]`: where saved passwords are kept, or a change of it (moving the logins). */
161
+ async function handleSecrets(setting, out) {
162
+ let moved;
163
+ let note;
164
+ if (setting) {
165
+ const problem = setting === "file" ? null : await keychainProblem();
166
+ if (setting === "keychain" && problem) {
167
+ throw new SecretStoreError("unavailable", `There's no keychain WilmAI can use on this computer (${problem}). Nothing was changed.`);
168
+ }
169
+ await editConfig((config) => {
170
+ config.secretStore = setting;
171
+ });
172
+ const config = await loadConfig();
173
+ moved = [];
174
+ if (setting === "file") {
175
+ for (const p of config.profiles)
176
+ if (p.secrets || config.keychainLogins?.[p.id])
177
+ moved.push(await moveLoginToFile(p.id));
178
+ }
179
+ else if (problem) {
180
+ note = `There's no keychain WilmAI can use on this computer (${problem}). Saved passwords stay in WilmAI's file.`;
181
+ }
182
+ else {
183
+ for (const p of config.profiles)
184
+ if (!p.secrets)
185
+ moved.push(await moveLoginToKeychain(p.id));
186
+ if (moved.some((m) => m.outcome === "moved")) {
187
+ await removeStrayTempFiles();
188
+ note = "Older copies of WilmAI's file (backups, synced dotfiles) may still hold the passwords; changing your Wilma password makes them useless.";
189
+ }
190
+ }
191
+ if (moved.some((m) => m.outcome === "failed"))
192
+ process.exitCode = 4;
193
+ }
194
+ const config = await loadConfig();
195
+ const problem = await keychainProblem();
196
+ const envOverride = process.env.WILMAI_SECRET_STORE?.trim() || null;
197
+ const logins = whereSecretsAre(config).map(({ id: _id, ...rest }) => rest);
198
+ const result = {
199
+ setting: effectiveSecretStore(config.secretStore, {}),
200
+ ...(envOverride ? { envOverride } : {}),
201
+ keychain: { kind: keychainKind(), usable: !problem, ...(problem ? { problem } : {}) },
202
+ logins,
203
+ ...(moved ? { moved: moved.map(({ id: _id, ...m }) => m) } : {}),
204
+ ...(note ? { note } : {}),
205
+ };
206
+ out.data(result, () => {
207
+ const place = (store) => (store === "keychain" ? `the ${keychainKind()}` : store === "file" ? "WilmAI's file" : "somewhere this version doesn't know");
208
+ const lines = [
209
+ `New logins are saved in: ${result.setting === "file" ? "WilmAI's file" : result.setting === "keychain" ? `the ${keychainKind()}` : `the ${keychainKind()} where it can be used, else WilmAI's file`} (setting: ${result.setting}${envOverride ? `; WILMAI_SECRET_STORE=${envOverride} for this process` : ""}).`,
210
+ `Keychain on this computer: ${problem ? `can't be used (${problem})` : `${keychainKind()}, can be used`}.`,
211
+ ];
212
+ if (logins.length) {
213
+ lines.push("Saved logins:");
214
+ for (const login of logins)
215
+ lines.push(` ${login.username} @ ${login.wilma}: ${place(login.store)}${login.hasTotp ? ", with an authenticator key" : ""}`);
216
+ }
217
+ for (const m of moved ?? []) {
218
+ if (m.outcome === "moved")
219
+ lines.push(`Moved ${m.username} @ ${m.wilma} to ${m.to === "keychain" ? `the ${keychainKind()}` : "WilmAI's file"}.${m.message ? ` ${m.message}` : ""}`);
220
+ else if (m.outcome !== "already")
221
+ lines.push(`Not moved: ${m.username} @ ${m.wilma}: ${m.message ?? m.outcome}`);
222
+ }
223
+ if (note)
224
+ lines.push(note);
225
+ return lines.join("\n");
226
+ });
227
+ }
149
228
  /** Data commands: every child by default, the same results as the MCP tools. */
150
229
  async function runDataCommand(parsed, out) {
151
230
  const { command, action, args, flags } = parsed;
@@ -227,13 +306,17 @@ async function handleFindSchool(query, out) {
227
306
  out.data({ wilmas, ...(hint ? { hint } : {}) }, () => (hint ? hint : wilmas.map((t) => `${t.url} ${t.name}`).join("\n")));
228
307
  }
229
308
  async function runInteractive(config) {
230
- const mfa = createInteractiveMfa(() => saveConfig(config));
309
+ const mfa = createInteractiveMfa();
310
+ let first = true;
231
311
  while (true) {
232
- const profile = await chooseProfile(config, mfa);
233
- if (!profile) {
312
+ // A fresh read on every pass: a login just added here, or a login another process moved, shows as it is now.
313
+ const chosen = await chooseProfile(first ? config : await loadConfig(), mfa);
314
+ first = false;
315
+ if (!chosen) {
234
316
  return;
235
317
  }
236
- const access = new WilmaAccess({ profile: { ...profile, studentNumber: null }, mfa: mfa.callback });
318
+ const { profile } = chosen;
319
+ const access = new WilmaAccess({ profile: { ...profile, studentNumber: null }, password: chosen.password, keys: chosen.keys, mfa: mfa.callback });
237
320
  const student = profile.studentNumber ?? undefined;
238
321
  const show = (command, result, action = null) => {
239
322
  console.clear();
@@ -385,29 +468,30 @@ async function chooseProfile(config, mfa) {
385
468
  }
386
469
  mfa.profile = stored;
387
470
  mfa.typedSecret = null;
388
- const secret = revealSecret(stored.passwordObfuscated);
389
- if (!secret) {
390
- throw new Error("Stored password could not be decoded");
391
- }
392
- const selectedStudent = await chooseStudentFromProfile(stored, {
393
- baseUrl: stored.tenantUrl,
394
- username: stored.username,
395
- password: secret,
396
- }, onMfa);
471
+ // A keychain login's password is read only if Wilma needs a new login.
472
+ const account = accountFor(stored);
473
+ const login = {
474
+ profile: account.profile,
475
+ password: account.secrets ? async () => (await account.secrets()).password : undefined,
476
+ keys: account.keys,
477
+ };
478
+ const selectedStudent = await chooseStudentFromProfile(stored, login, onMfa);
397
479
  if (!selectedStudent) {
398
480
  return null;
399
481
  }
400
- stored.lastUsedAt = new Date().toISOString();
401
- stored.lastStudentNumber = selectedStudent?.studentNumber ?? null;
402
- stored.lastStudentName = selectedStudent?.name ?? null;
403
- config.lastProfileId = stored.id;
404
- await saveConfig(config);
405
- return {
406
- baseUrl: stored.tenantUrl,
407
- username: stored.username,
408
- password: secret,
409
- studentNumber: selectedStudent?.studentNumber ?? undefined,
410
- };
482
+ // Onto a fresh read: another process may have changed the config (moved this login, saved another).
483
+ await editConfig((fresh) => {
484
+ const target = fresh.profiles.find((p) => p.id === stored.id);
485
+ if (!target)
486
+ return false;
487
+ target.lastUsedAt = new Date().toISOString();
488
+ target.lastStudentNumber = selectedStudent?.studentNumber ?? null;
489
+ target.lastStudentName = selectedStudent?.name ?? null;
490
+ if (stored.students)
491
+ target.students = stored.students;
492
+ fresh.lastProfileId = stored.id;
493
+ });
494
+ return { ...login, profile: { ...login.profile, studentNumber: selectedStudent?.studentNumber ?? undefined } };
411
495
  }
412
496
  }
413
497
  const tenant = await selectTenant();
@@ -422,32 +506,30 @@ async function chooseProfile(config, mfa) {
422
506
  // Codes for a new login can't come from another saved login's key.
423
507
  mfa.profile = undefined;
424
508
  mfa.typedSecret = null;
509
+ // Where the password goes is settled before Wilma is contacted.
510
+ const choice = await chooseSecretStore(await loadConfig(), tenant.url, username);
425
511
  const newProfile = { baseUrl: tenant.url, username, password: passwordValue };
426
512
  const verified = await WilmaClient.login(newProfile, onMfa);
427
513
  const students = await verified.students();
428
- // The commands that follow continue this session instead of logging in again.
429
- adoptSession(newProfile, verified);
430
514
  const student = await chooseStudent(students);
431
515
  if (!student)
432
516
  return null;
433
517
  // Same rules as `wilma login`: one entry per account, whatever the username's case.
434
- const stored = await saveLogin(config, {
435
- tenantUrl: tenant.url,
436
- tenantName: tenant.name,
437
- username,
438
- password: passwordValue,
439
- totpSecret: mfa.typedSecret,
440
- students,
518
+ // The commands that follow continue this session instead of logging in again.
519
+ const stored = await saveVerifiedLogin({ tenantUrl: tenant.url, tenantName: tenant.name, username, password: passwordValue, totpSecret: mfa.typedSecret, students }, verified, choice);
520
+ if (stored.note)
521
+ console.log(stored.note);
522
+ await editConfig((fresh) => {
523
+ const target = fresh.profiles.find((p) => p.id === stored.id);
524
+ if (!target)
525
+ return false;
526
+ target.lastStudentNumber = student.studentNumber;
527
+ target.lastStudentName = student.name;
441
528
  });
442
- stored.lastStudentNumber = student.studentNumber;
443
- stored.lastStudentName = student.name;
444
- await saveConfig(config);
445
529
  mfa.profile = stored;
446
530
  return {
447
- baseUrl: stored.tenantUrl,
448
- username,
449
- password: passwordValue,
450
- studentNumber: student.studentNumber,
531
+ profile: { baseUrl: stored.tenantUrl, username, password: passwordValue, studentNumber: student.studentNumber },
532
+ keys: accountFor(stored).keys,
451
533
  };
452
534
  }
453
535
  async function selectTenant() {
@@ -524,9 +606,9 @@ async function chooseStudent(students) {
524
606
  return null;
525
607
  return students.find((s) => s.studentNumber === selected) ?? null;
526
608
  }
527
- async function chooseStudentFromProfile(stored, baseProfile, onMfa) {
609
+ async function chooseStudentFromProfile(stored, login, onMfa) {
528
610
  let students = [];
529
- const fresh = await new WilmaAccess({ profile: baseProfile, mfa: onMfa }).students().catch((err) => {
611
+ const fresh = await new WilmaAccess({ profile: login.profile, password: login.password, keys: login.keys, mfa: onMfa }).students().catch((err) => {
530
612
  if (stored.students?.length)
531
613
  return [];
532
614
  throw err;
@@ -587,14 +669,17 @@ async function handleLogin(args, out) {
587
669
  }
588
670
  const report = (stored) => {
589
671
  const students = (stored.students ?? []).map((s) => s.name);
672
+ const secrets = stored.secrets === "keychain" ? "keychain" : "file";
590
673
  if (out.json) {
591
- out.data({ status: "ok", wilma: stored.tenantName ?? stored.tenantUrl, username: stored.username, students, configPath: getConfigPath() });
674
+ out.data({ status: "ok", wilma: stored.tenantName ?? stored.tenantUrl, username: stored.username, students, configPath: getConfigPath(), secrets, ...(stored.note ? { note: stored.note } : {}) });
592
675
  return;
593
676
  }
594
677
  console.log(`Logged in to ${stored.tenantName ?? stored.tenantUrl} as ${stored.username}.`);
595
678
  if (students.length)
596
679
  console.log(`Children: ${students.join(", ")}`);
597
- console.log(`Saved to ${getConfigPath()}`);
680
+ console.log(secrets === "keychain" ? `Saved to ${getConfigPath()}; the password is in the ${keychainKind()}.` : `Saved to ${getConfigPath()}`);
681
+ if (stored.note)
682
+ console.log(stored.note);
598
683
  };
599
684
  // Non-interactive: flags (with env fallback) — for agents and scripts.
600
685
  if (flags.tenant || flags.username || flags.passwordStdin) {
@@ -609,17 +694,11 @@ async function handleLogin(args, out) {
609
694
  }
610
695
  const tenant = await resolveTenant(tenantQuery);
611
696
  const totpSecret = flags.totpSecret ?? process.env[ENV_VARS.totpSecret] ?? null;
697
+ // Where the password goes is settled before Wilma is contacted (a login that can't be saved costs nothing).
698
+ const choice = await chooseSecretStore(await loadConfig(), tenant.url, username);
612
699
  const { client, students } = await verifyLoginSession({ tenantUrl: tenant.url, username, password, totpSecret });
613
700
  // The next commands continue this session: no second login (or code).
614
- adoptSession({ baseUrl: normalizeTenantUrl(tenant.url), username, password }, client);
615
- const stored = await saveLogin(await loadConfig(), {
616
- tenantUrl: tenant.url,
617
- tenantName: tenant.name,
618
- username,
619
- password,
620
- totpSecret,
621
- students,
622
- });
701
+ const stored = await saveVerifiedLogin({ tenantUrl: tenant.url, tenantName: tenant.name, username, password, totpSecret, students }, client, choice);
623
702
  report(stored);
624
703
  await flushSessions();
625
704
  return;
@@ -699,11 +778,12 @@ async function handleAccounts(action, args, out) {
699
778
  if (action === "remove") {
700
779
  const { removed, ...session } = await removeLogin(findSavedLogin(config.profiles, args.join(" ")).id);
701
780
  const result = { status: "removed", wilma: removed.tenantName ?? removed.tenantUrl, username: removed.username, ...session };
702
- out.data(result, () => session.session === "ended"
781
+ out.data(result, () => (session.session === "ended"
703
782
  ? `Removed ${removed.username} @ ${result.wilma} and ended its Wilma session.`
704
783
  : session.session === "not_ended"
705
784
  ? `Removed ${removed.username} @ ${result.wilma}. Its Wilma session couldn't be ended (${session.message}); Wilma ends it when it expires.`
706
- : `Removed ${removed.username} @ ${result.wilma}.`);
785
+ : `Removed ${removed.username} @ ${result.wilma}.`) +
786
+ (session.keychainEntryLeft ? `\nIts saved password couldn't be deleted from the keychain (${session.keychainEntryLeft}); \`wilma config clear\` tries again.` : ""));
707
787
  return;
708
788
  }
709
789
  const accounts = config.profiles.map((p, i) => {
@@ -844,7 +924,18 @@ async function selectOrCancel(opts, clearScreen = true) {
844
924
  process.stdin.removeListener("keypress", onKeypress);
845
925
  }
846
926
  }
847
- function createInteractiveMfa(saveProfile) {
927
+ /** A saved login's authenticator key: from the file, or (a keychain login) read from its entry now. */
928
+ async function savedTotpSecret(profile) {
929
+ if (!profile)
930
+ return null;
931
+ if (profile.secrets === "keychain" && profile.secretId) {
932
+ if (!profile.hasTotp)
933
+ return null;
934
+ return (await readBackLoginSecrets(profile.secretId))?.totpSecret ?? null;
935
+ }
936
+ return revealSecret(profile.totpSecretObfuscated);
937
+ }
938
+ function createInteractiveMfa() {
848
939
  let lastCode = null;
849
940
  let lastCodeTime = 0;
850
941
  let lastCodeFor;
@@ -859,9 +950,7 @@ function createInteractiveMfa(saveProfile) {
859
950
  const retry = formkey === lastFormkey;
860
951
  lastFormkey = formkey;
861
952
  // A saved or just-typed key generates codes (a retry waits for the next one).
862
- const secret = state.profile?.totpSecretObfuscated
863
- ? revealSecret(state.profile.totpSecretObfuscated)
864
- : state.typedSecret;
953
+ const secret = (await savedTotpSecret(state.profile)) ?? state.typedSecret;
865
954
  if (secret) {
866
955
  if (!generators.has(secret))
867
956
  generators.set(secret, mfaCallbackFor(secret));
@@ -890,8 +979,9 @@ function createInteractiveMfa(saveProfile) {
890
979
  throw new Error("MFA cancelled");
891
980
  const parsed = parseTotpSecret(secretInput);
892
981
  if (state.profile) {
893
- state.profile.totpSecretObfuscated = obfuscateSecret(secretInput);
894
- await saveProfile();
982
+ // Into the keychain entry for a keychain login, never the file.
983
+ await saveTotpSecret(state.profile.id, secretInput);
984
+ state.profile = (await loadConfig()).profiles.find((p) => p.id === state.profile.id) ?? state.profile;
895
985
  }
896
986
  else {
897
987
  // A new login: saved together with the login once it succeeds.
@@ -992,8 +1082,25 @@ function terminalSafe(value) {
992
1082
  function jsonSafe(value) {
993
1083
  return value.replace(/[\u007f-\u009f]/g, (c) => `\\u${c.charCodeAt(0).toString(16).padStart(4, "0")}`);
994
1084
  }
1085
+ /** A keychain failure, whether the CLI read the keychain itself or the client did while logging in again. */
1086
+ function secretFailure(err) {
1087
+ if (err instanceof SecretStoreError)
1088
+ return err;
1089
+ if (err instanceof CredentialsUnavailableError) {
1090
+ if (err.cause instanceof SecretStoreError)
1091
+ return err.cause;
1092
+ const reason = ["missing", "refused", "unavailable"].find((r) => r === err.reason) ?? "unavailable";
1093
+ return new SecretStoreError(reason, err.message || secretErrorMessage(reason));
1094
+ }
1095
+ return null;
1096
+ }
995
1097
  /** An error as agents and scripts get it: a stable code, a message, and the exit code. */
996
1098
  function describeError(err) {
1099
+ const secret = secretFailure(err);
1100
+ if (secret) {
1101
+ // Exit code 4: the password is saved, but this computer didn't hand it over (or it's gone: then log in again).
1102
+ return { code: `secret_${secret.reason}`, message: secret.message, exitCode: 4 };
1103
+ }
997
1104
  if (err instanceof WilmaAiError) {
998
1105
  const exitCode = err.code === "invalid_argument" || err.code === "unknown_command" ? 2 : err.code === "not_logged_in" ? 3 : 1;
999
1106
  return { code: err.code, message: err.message, details: err.details, exitCode };
@@ -1033,7 +1140,11 @@ function describeError(err) {
1033
1140
  return { code: "config_invalid", message, exitCode: 1 };
1034
1141
  return { code: "error", message, exitCode: 1 };
1035
1142
  }
1036
- main().catch((err) => {
1143
+ main().then(() => {
1144
+ // A keychain call that timed out can't be cancelled (Windows, Linux) and would keep the process alive: finish once the output is written.
1145
+ if (abandonedKeychainCalls())
1146
+ process.stdout.write("", () => process.exit(process.exitCode ?? 0));
1147
+ }).catch((err) => {
1037
1148
  if (isPromptCancel(err)) {
1038
1149
  process.exit(0);
1039
1150
  }
@@ -57,6 +57,8 @@ export interface LoginPageOptions {
57
57
  apiBase: string;
58
58
  /** Shown in the privacy note under the form. */
59
59
  configPath?: string;
60
+ /** The keychain new passwords go to (the privacy note names it), or undefined for the file. */
61
+ keychain?: string;
60
62
  openedBy?: OpenedBy;
61
63
  /** The logins already saved. */
62
64
  accounts?: SavedLogin[];
@@ -3,9 +3,10 @@ import { randomBytes } from "node:crypto";
3
3
  import { spawn } from "node:child_process";
4
4
  import { AuthenticationError } from "@wilm-ai/wilma-client";
5
5
  import { getConfigPath, loadConfig, saveConfig } from "./config.js";
6
- import { adoptSession, roleOf } from "./agent-data.js";
7
- import { removeLogin, setHiddenChildren } from "./access.js";
8
- import { saveLogin, TotpSecretInvalidError, TotpSecretRequiredError, verifyLoginSession } from "./credentials.js";
6
+ import { roleOf } from "./agent-data.js";
7
+ import { removeLogin, saveVerifiedLogin, setHiddenChildren } from "./access.js";
8
+ import { chooseSecretStore, TotpSecretInvalidError, TotpSecretRequiredError, verifyLoginSession } from "./credentials.js";
9
+ import { effectiveSecretStore, keychainKind, keychainProblem, SecretStoreError } from "./secret-store.js";
9
10
  import { normalizeTenantUrl, searchTenants } from "./tenant-search.js";
10
11
  const MAX_ATTEMPTS = 10;
11
12
  const MAX_BODY_BYTES = 16 * 1024;
@@ -25,6 +26,18 @@ function accountsSummary(profiles) {
25
26
  });
26
27
  }
27
28
  const savedLogins = async () => accountsSummary((await loadConfig()).profiles);
29
+ /** The keychain this page's logins would go to, or undefined when they go to the file. */
30
+ async function keychainForPage() {
31
+ try {
32
+ const setting = effectiveSecretStore((await loadConfig()).secretStore);
33
+ if (setting === "file")
34
+ return undefined;
35
+ return (await keychainProblem()) ? undefined : keychainKind();
36
+ }
37
+ catch {
38
+ return undefined;
39
+ }
40
+ }
28
41
  /** A plain app name from the name an MCP client reports, or undefined if unknown. */
29
42
  export function appName(client) {
30
43
  const raw = `${client?.title ?? ""} ${client?.name ?? ""}`.toLowerCase();
@@ -87,7 +100,7 @@ export async function startLoginServer(opts = {}) {
87
100
  });
88
101
  // The logins already saved, so adding another Wilma (or logging in again) starts from what's there.
89
102
  const accounts = await savedLogins().catch(() => []);
90
- res.end(renderLoginPage({ nonce, apiBase: `${base}/api`, configPath: getConfigPath(), openedBy: opts.openedBy, accounts }));
103
+ res.end(renderLoginPage({ nonce, apiBase: `${base}/api`, configPath: getConfigPath(), keychain: await keychainForPage(), openedBy: opts.openedBy, accounts }));
91
104
  return;
92
105
  }
93
106
  if (url.pathname === `${base}/api/tenants` && req.method === "GET") {
@@ -159,22 +172,15 @@ export async function startLoginServer(opts = {}) {
159
172
  return;
160
173
  }
161
174
  try {
175
+ // Where the password goes is settled before Wilma is contacted (a login that can't be saved costs no code).
176
+ const choice = await chooseSecretStore(before, wilmaUrl, username);
162
177
  const { client, students } = await verifyLoginSession({ tenantUrl, username, password, totpSecret });
163
- // Keep this verified session for the assistant's first questions (no second login or code).
164
- adoptSession({ baseUrl: wilmaUrl, username, password }, client);
165
- const config = await loadConfig();
166
- const stored = await saveLogin(config, {
167
- tenantUrl: wilmaUrl,
168
- tenantName: body.tenantName ?? null,
169
- username,
170
- password,
171
- totpSecret,
172
- students,
173
- });
178
+ // Saved first, then this verified session kept for the assistant's first questions (no second login or code).
179
+ const stored = await saveVerifiedLogin({ tenantUrl: wilmaUrl, tenantName: body.tenantName ?? null, username, password, totpSecret, students }, client, choice);
174
180
  if (replaced && replaced.id !== stored.id)
175
181
  await takePlace(stored.id, replaced.id);
176
182
  // Every Wilma login saved so far, so the page can show the whole family.
177
- sendJson(res, 200, { status: "ok", accounts: await savedLogins() });
183
+ sendJson(res, 200, { status: "ok", accounts: await savedLogins(), ...(stored.note ? { note: stored.note } : {}) });
178
184
  // What this page saved, for `wilma login` to report (a replaced login isn't).
179
185
  saved.splice(0, saved.length, ...saved.filter((p) => p.id !== stored.id && p.id !== replaced?.id), stored);
180
186
  attempts = 0;
@@ -183,7 +189,10 @@ export async function startLoginServer(opts = {}) {
183
189
  opts.onSaved?.(stored);
184
190
  }
185
191
  catch (err) {
186
- if (err instanceof TotpSecretRequiredError) {
192
+ if (err instanceof SecretStoreError) {
193
+ sendJson(res, 200, { status: "error", code: "secret_store", message: err.message });
194
+ }
195
+ else if (err instanceof TotpSecretRequiredError) {
187
196
  sendJson(res, 200, { status: "mfa_required" });
188
197
  }
189
198
  else if (err instanceof TotpSecretInvalidError) {
@@ -348,6 +357,7 @@ export function renderLoginPage(opts) {
348
357
  missing: "Choose the school's Wilma, and type the username and password.",
349
358
  expired: "This login page is no longer active. Start the login again from your assistant, or run wilma login.",
350
359
  privacy: "Your login is saved only on this computer, in {path}, readable only by your user account. To remove it, run wilma config clear or delete that file.",
360
+ privacyKeychain: "Your login is saved only on this computer: the password in the {keychain}, the rest in {path}. To remove it, run wilma config clear.",
351
361
  trustLocalTitle: "On your own computer",
352
362
  trustLocal: "This page isn't a website. It runs on this computer (that's what 127.0.0.1 in the address bar means), and WilmAI has no server.",
353
363
  trustWilmaTitle: "Only to your school's Wilma",
@@ -408,6 +418,7 @@ export function renderLoginPage(opts) {
408
418
  missing: "Valitse koulun Wilma ja kirjoita käyttäjätunnus ja salasana.",
409
419
  expired: "Tämä kirjautumissivu ei ole enää käytössä. Aloita kirjautuminen uudelleen avustajasi kautta tai komennolla wilma login.",
410
420
  privacy: "Kirjautumistietosi tallennetaan vain tälle tietokoneelle, tiedostoon {path}, jota vain sinun käyttäjätilisi voi lukea. Voit poistaa ne komennolla wilma config clear tai poistamalla tiedoston.",
421
+ privacyKeychain: "Kirjautumistietosi tallennetaan vain tälle tietokoneelle: salasana avainnippuun ({keychain}), muut tiedot tiedostoon {path}. Voit poistaa ne komennolla wilma config clear.",
411
422
  trustLocalTitle: "Omalla koneellasi",
412
423
  trustLocal: "Tämä sivu ei ole verkkosivusto. Se toimii tällä koneella (sitä osoiterivin 127.0.0.1 tarkoittaa), eikä WilmAI:lla ole palvelinta.",
413
424
  trustWilmaTitle: "Vain koulusi Wilmaan",
@@ -560,6 +571,7 @@ export function renderLoginPage(opts) {
560
571
  const STRINGS = ${jsonForScript(strings)};
561
572
  const API = ${jsonForScript(opts.apiBase)};
562
573
  const CONFIG_PATH = ${jsonForScript(opts.configPath ?? "")};
574
+ const KEYCHAIN = ${jsonForScript(opts.keychain ?? "")};
563
575
  const OPENED_BY = ${jsonForScript(opts.openedBy ?? null)};
564
576
  // Every saved login, as the page opened and after each change here.
565
577
  let accounts = ${jsonForScript(opts.accounts ?? [])};
@@ -610,7 +622,7 @@ export function renderLoginPage(opts) {
610
622
  text("who-label", t.who); text("who-guardian", t.parent); text("who-student", t.student);
611
623
  text("add", t.addAnother); text("add-help", t.addAnotherHint);
612
624
  text("finished-title", t.finishedTitle); text("finished-body", t.finishedBody);
613
- text("privacy", t.privacy.replace("{path}", CONFIG_PATH));
625
+ text("privacy", (KEYCHAIN ? t.privacyKeychain.replace("{keychain}", KEYCHAIN) : t.privacy).replace("{path}", CONFIG_PATH));
614
626
  text("learn-more", t.learnMore); text("source-code", t.sourceCode);
615
627
  text("disclaimer", t.disclaimer);
616
628
  for (const code of ["fi", "en"]) $("lang-" + code).setAttribute("aria-pressed", String(code === lang));
@@ -818,7 +830,7 @@ export function renderLoginPage(opts) {
818
830
  tenantUrl: d.tenant.url, tenantName: d.tenant.name, username: d.username.trim(), password: d.password,
819
831
  totpSecret: d.totp.trim() || undefined, replace: d.replace || undefined,
820
832
  });
821
- if (data.status === "ok") { accounts = data.accounts; drafts = drafts.filter((x) => x !== d); continue; }
833
+ if (data.status === "ok") { accounts = data.accounts; drafts = drafts.filter((x) => x !== d); if (data.note) note = data.note; continue; }
822
834
  first ??= d;
823
835
  if (data.status === "mfa_required") { d.mfa = true; continue; }
824
836
  if (data.code === "bad_totp") { d.mfa = true; d.error = lang === "fi" ? t.badTotp : data.message || t.badTotp; }
package/dist/mcp.js CHANGED
@@ -3,7 +3,9 @@ import { homedir } from "node:os";
3
3
  import { resolve } from "node:path";
4
4
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
5
5
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
6
- import { AuthenticationError } from "@wilm-ai/wilma-client";
6
+ import { AuthenticationError, CredentialsUnavailableError } from "@wilm-ai/wilma-client";
7
+ import { runBackgroundMoves } from "./secret-moves.js";
8
+ import { SecretStoreError } from "./secret-store.js";
7
9
  import { useSessionStore } from "./agent-data.js";
8
10
  import { fileSessionStore, nameSessionOwner } from "./session-store.js";
9
11
  import { createAccess, logoutAccounts } from "./access.js";
@@ -97,6 +99,9 @@ async function withAccess(run) {
97
99
  return await run(access);
98
100
  }
99
101
  catch (err) {
102
+ const secret = keychainFailure(err);
103
+ if (secret)
104
+ return textResult(secret, true);
100
105
  if (isMfaFailure(err)) {
101
106
  return textResult("Wilma rejected the two-step verification code; the saved authenticator setup key may be wrong. Call wilma_login to log in again.", true);
102
107
  }
@@ -106,6 +111,22 @@ async function withAccess(run) {
106
111
  return textResult(err instanceof Error ? err.message : String(err), true);
107
112
  }
108
113
  }
114
+ /**
115
+ * A keychain failure as the assistant should hear it. "missing": the password
116
+ * is gone, so a new login is the fix. "refused" and "unavailable": the
117
+ * password is saved but this computer didn't hand it over, so starting a
118
+ * login (and having the parent retype it) doesn't help.
119
+ */
120
+ function keychainFailure(err) {
121
+ const cause = err instanceof CredentialsUnavailableError && err.cause instanceof SecretStoreError ? err.cause : err;
122
+ const reason = cause instanceof SecretStoreError ? cause.reason : err instanceof CredentialsUnavailableError ? err.reason : null;
123
+ if (!reason)
124
+ return null;
125
+ const message = cause instanceof Error ? cause.message : String(cause);
126
+ if (reason === "missing")
127
+ return `${message}\nCall wilma_login so the user can log in again.`;
128
+ return `${message}\nDon't start a new Wilma login: the password is saved, and retyping it won't help. Tell the user what this says.`;
129
+ }
109
130
  function createWilmaMcpServer(version) {
110
131
  const server = new McpServer({ name: "wilma", title: "WilmAI", version }, { instructions: INSTRUCTIONS });
111
132
  registerWilmaTools(server, {
@@ -178,4 +199,6 @@ export async function runMcpServer(version) {
178
199
  const server = createWilmaMcpServer(version);
179
200
  mcpServer = server;
180
201
  await server.connect(new StdioServerTransport());
202
+ // With the keychain turned on: move file logins over (quietly, once at start; messages go to stderr).
203
+ void runBackgroundMoves();
181
204
  }
@@ -0,0 +1,44 @@
1
+ import { type CliConfig } from "./config.js";
2
+ export interface MoveResult {
3
+ id: string;
4
+ wilma: string;
5
+ username: string;
6
+ to: "keychain" | "file";
7
+ /** moved; already there; skipped (changed meanwhile, tried again later); failed (stays where it was). */
8
+ outcome: "moved" | "already" | "skipped" | "failed";
9
+ message?: string;
10
+ }
11
+ /** Move one file login to the keychain: reserve, write and check the entry, copy the session, flip, clean up. */
12
+ export declare function moveLoginToKeychain(id: string): Promise<MoveResult>;
13
+ /** Move a keychain login (or finish an F+ one) to the file, then delete its entry. */
14
+ export declare function moveLoginToFile(id: string): Promise<MoveResult>;
15
+ /**
16
+ * After a command, when the saved setting is `auto` or `keychain` (never the
17
+ * environment variable alone): move the file logins to the keychain, at most
18
+ * once a day for one that failed. `auto` stays quiet; `keychain` prints one
19
+ * line per failure. Never fails the command it follows.
20
+ */
21
+ export declare function runBackgroundMoves(warn?: (line: string) => void): Promise<void>;
22
+ /**
23
+ * Remove temp files a crashed save left next to the config (they can hold the
24
+ * scrambled password). Only ones older than a minute: a save in progress in
25
+ * another process may still be using its own.
26
+ */
27
+ export declare function removeStrayTempFiles(): Promise<number>;
28
+ /** Every login's place, for `wilma config secrets`. */
29
+ export declare function whereSecretsAre(config: CliConfig): {
30
+ id: string;
31
+ wilma: string;
32
+ username: string;
33
+ store: "file" | "keychain" | "other";
34
+ hasTotp: boolean;
35
+ keychainEntry?: boolean;
36
+ }[];
37
+ /**
38
+ * The keychain entries a config file names: `keychainLogins` values and
39
+ * `secretId`s. Read from the text itself, so a damaged file (`wilma config
40
+ * clear` is the way out of one) still gives the entries it names.
41
+ */
42
+ export declare function entriesNamedIn(text: string): string[];
43
+ /** Delete the keychain entries the config file names (for `wilma config clear`); the ones that couldn't be deleted. */
44
+ export declare function deleteKnownEntries(configText: string | null): Promise<string[]>;