@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/README.md +19 -2
- package/dist/access.d.ts +23 -2
- package/dist/access.js +66 -12
- package/dist/agent-data.d.ts +16 -7
- package/dist/agent-data.js +66 -40
- package/dist/cli-args.js +11 -3
- package/dist/cli-help.js +6 -1
- package/dist/config.d.ts +25 -2
- package/dist/config.js +22 -0
- package/dist/credentials.d.ts +49 -2
- package/dist/credentials.js +199 -64
- package/dist/index.js +180 -69
- package/dist/login-server.d.ts +2 -0
- package/dist/login-server.js +31 -19
- package/dist/mcp.js +24 -1
- package/dist/secret-moves.d.ts +44 -0
- package/dist/secret-moves.js +297 -0
- package/dist/secret-store.d.ts +45 -0
- package/dist/secret-store.js +487 -0
- package/dist/session-keys.d.ts +19 -0
- package/dist/session-keys.js +26 -0
- package/dist/session-store.d.ts +16 -0
- package/dist/session-store.js +69 -0
- package/package.json +8 -3
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,
|
|
11
|
-
import { WilmaAccess, WilmaAiError,
|
|
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,
|
|
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(
|
|
309
|
+
const mfa = createInteractiveMfa();
|
|
310
|
+
let first = true;
|
|
231
311
|
while (true) {
|
|
232
|
-
|
|
233
|
-
|
|
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
|
|
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
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
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
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
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
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
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
|
-
|
|
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,
|
|
609
|
+
async function chooseStudentFromProfile(stored, login, onMfa) {
|
|
528
610
|
let students = [];
|
|
529
|
-
const fresh = await new WilmaAccess({ profile:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
894
|
-
await
|
|
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().
|
|
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
|
}
|
package/dist/login-server.d.ts
CHANGED
|
@@ -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[];
|
package/dist/login-server.js
CHANGED
|
@@ -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 {
|
|
7
|
-
import { removeLogin, setHiddenChildren } from "./access.js";
|
|
8
|
-
import {
|
|
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
|
-
//
|
|
164
|
-
|
|
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
|
|
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[]>;
|