@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.
@@ -0,0 +1,297 @@
1
+ /*
2
+ * Moving saved logins between the config file and the keychain (section 6
3
+ * and machine 1 in docs/secret-store.md). Every step can be interrupted:
4
+ *
5
+ * F --reserve--> F+ --entry, session copy, flip--> K
6
+ *
7
+ * F+ (a file login with an entry reserved in `keychainLogins`) is read from
8
+ * the file, so a crash anywhere before the flip leaves a working login, and
9
+ * the next move reuses its entry. A crash after the flip leaves a v1 session
10
+ * entry tagged with the login's id, which any later run deletes.
11
+ */
12
+ import { readdir, rm, stat } from "node:fs/promises";
13
+ import { basename, dirname, join } from "node:path";
14
+ import { dropPooledSession } from "./agent-data.js";
15
+ import { editConfig, getConfigPath, loadConfig, obfuscateSecret, revealSecret } from "./config.js";
16
+ import { deleteLoginSecrets, keychainProblem, newCredentialsStamp, newSecretId, readBackLoginSecrets, SecretStoreError, secretErrorMessage, writeLoginSecrets, } from "./secret-store.js";
17
+ import { fileLoginKeys, keychainLoginKeys, sessionKeyV1, sessionKeyV2 } from "./session-keys.js";
18
+ import { copySession, fileSessionStore, removeTaggedSessions } from "./session-store.js";
19
+ /** Background moves wait this long after a failure. */
20
+ const RETRY_AFTER_MS = 24 * 60 * 60 * 1000;
21
+ const describe = (p) => ({ id: p.id, wilma: p.tenantName ?? p.tenantUrl, username: p.username });
22
+ const errorText = (err) => (err instanceof Error ? err.message : String(err));
23
+ /** Move one file login to the keychain: reserve, write and check the entry, copy the session, flip, clean up. */
24
+ export async function moveLoginToKeychain(id) {
25
+ // 1. Reserve the entry in the config before it exists (F -> F+).
26
+ let snapshot;
27
+ let secretId = "";
28
+ await editConfig((config) => {
29
+ snapshot = config.profiles.find((p) => p.id === id);
30
+ if (!snapshot || snapshot.secrets)
31
+ return false;
32
+ secretId = config.keychainLogins?.[id] ?? newSecretId();
33
+ if (config.keychainLogins?.[id] === secretId)
34
+ return false;
35
+ config.keychainLogins = { ...config.keychainLogins, [id]: secretId };
36
+ });
37
+ if (!snapshot)
38
+ return { id, wilma: id, username: "", to: "keychain", outcome: "failed", message: "That login isn't saved any more." };
39
+ const base = { ...describe(snapshot), to: "keychain" };
40
+ if (snapshot.secrets === "keychain")
41
+ return { ...base, outcome: "already" };
42
+ if (snapshot.secrets)
43
+ return { ...base, outcome: "failed", message: secretErrorMessage("unavailable", base.wilma) };
44
+ const password = revealSecret(snapshot.passwordObfuscated);
45
+ if (!password)
46
+ return { ...base, outcome: "failed", message: `The saved password for ${base.wilma} can't be read. Log in again (\`wilma login\`).` };
47
+ const totpSecret = revealSecret(snapshot.totpSecretObfuscated);
48
+ const secrets = { password, totpSecret };
49
+ try {
50
+ // 2. Write the entry and read it back.
51
+ await writeLoginSecrets(secretId, secrets);
52
+ const back = await readBackLoginSecrets(secretId);
53
+ if (back?.password !== password || (back?.totpSecret ?? null) !== (totpSecret ?? null)) {
54
+ throw new SecretStoreError("refused", "The keychain didn't keep the password as saved.");
55
+ }
56
+ }
57
+ catch (err) {
58
+ await markMoveFailed(id);
59
+ return { ...base, outcome: "failed", message: errorText(err) };
60
+ }
61
+ // 3. The session goes with it (no new Wilma login, no new code), both entries tagged. With the labels
62
+ // overlapping v2 is usually there already; an older program may have saved a newer one under v1 only.
63
+ const v1 = sessionKeyV1(snapshot.tenantUrl, snapshot.username, password);
64
+ const v2 = sessionKeyV2(snapshot.tenantUrl, snapshot.username);
65
+ await copySession(v1, v2, id).catch(() => false);
66
+ // 4. Flip, unless the login changed meanwhile (then the next run tries again).
67
+ let flipped = false;
68
+ await editConfig((config) => {
69
+ const profile = config.profiles.find((p) => p.id === id);
70
+ if (!profile ||
71
+ profile.secrets ||
72
+ profile.passwordObfuscated !== snapshot.passwordObfuscated ||
73
+ (profile.totpSecretObfuscated ?? null) !== (snapshot.totpSecretObfuscated ?? null)) {
74
+ return false;
75
+ }
76
+ profile.secrets = "keychain";
77
+ profile.secretId = secretId;
78
+ profile.hasTotp = Boolean(totpSecret);
79
+ // A new stamp: a running MCP server mustn't reuse a client made for this login's earlier place.
80
+ profile.credentialsStamp = newCredentialsStamp();
81
+ delete profile.passwordObfuscated;
82
+ delete profile.totpSecretObfuscated;
83
+ delete profile.moveFailedAt;
84
+ config.keychainLogins = { ...config.keychainLogins, [id]: secretId };
85
+ flipped = true;
86
+ });
87
+ if (!flipped)
88
+ return { ...base, outcome: "skipped", message: "The login changed while it was being moved; it's tried again next time." };
89
+ // 5. The old session entry (keyed by the password) goes; so does this process's client for it.
90
+ await fileSessionStore.remove(v1).catch(() => { });
91
+ dropPooledSession(fileLoginKeys(snapshot.tenantUrl, snapshot.username, password));
92
+ return { ...base, outcome: "moved" };
93
+ }
94
+ /** Move a keychain login (or finish an F+ one) to the file, then delete its entry. */
95
+ export async function moveLoginToFile(id) {
96
+ const config = await loadConfig();
97
+ const profile = config.profiles.find((p) => p.id === id);
98
+ if (!profile)
99
+ return { id, wilma: id, username: "", to: "file", outcome: "failed", message: "That login isn't saved any more." };
100
+ const base = { ...describe(profile), to: "file" };
101
+ const entry = profile.secretId ?? config.keychainLogins?.[id];
102
+ if (profile.secrets === "keychain" && profile.secretId) {
103
+ let secrets;
104
+ try {
105
+ secrets = await readBackLoginSecrets(profile.secretId);
106
+ }
107
+ catch (err) {
108
+ return { ...base, outcome: "failed", message: errorText(err) };
109
+ }
110
+ if (!secrets)
111
+ return { ...base, outcome: "failed", message: secretErrorMessage("missing", base.wilma) };
112
+ const stamp = profile.credentialsStamp;
113
+ let flipped = false;
114
+ await editConfig((fresh) => {
115
+ const p = fresh.profiles.find((q) => q.id === id);
116
+ if (!p || p.secrets !== "keychain" || p.secretId !== profile.secretId || p.credentialsStamp !== stamp)
117
+ return false;
118
+ p.passwordObfuscated = obfuscateSecret(secrets.password);
119
+ p.totpSecretObfuscated = secrets.totpSecret ? obfuscateSecret(secrets.totpSecret) : null;
120
+ p.hasTotp = Boolean(secrets.totpSecret);
121
+ p.credentialsStamp = newCredentialsStamp();
122
+ delete p.secrets;
123
+ delete p.secretId;
124
+ flipped = true;
125
+ });
126
+ if (!flipped)
127
+ return { ...base, outcome: "skipped", message: "The login changed while it was being moved; try again." };
128
+ // A file login uses both labels: the session stays under v2 and is copied to v1 for older programs.
129
+ const v2 = sessionKeyV2(profile.tenantUrl, profile.username);
130
+ const v1 = sessionKeyV1(profile.tenantUrl, profile.username, secrets.password);
131
+ await copySession(v2, v1, id).catch(() => false);
132
+ dropPooledSession(keychainLoginKeys(profile.tenantUrl, profile.username, stamp));
133
+ }
134
+ else if (profile.secrets) {
135
+ return { ...base, outcome: "failed", message: secretErrorMessage("unavailable", base.wilma) };
136
+ }
137
+ else if (!entry) {
138
+ return { ...base, outcome: "already" };
139
+ }
140
+ // The entry last: a crash before this leaves an F+ login, which this finishes when run again.
141
+ if (entry) {
142
+ try {
143
+ await deleteLoginSecrets(entry);
144
+ }
145
+ catch (err) {
146
+ return { ...base, outcome: "moved", message: `Moved, but its keychain entry couldn't be deleted (${errorText(err)}).` };
147
+ }
148
+ await editConfig((fresh) => {
149
+ if (!fresh.keychainLogins?.[id])
150
+ return false;
151
+ const { [id]: _gone, ...rest } = fresh.keychainLogins;
152
+ fresh.keychainLogins = rest;
153
+ });
154
+ }
155
+ return { ...base, outcome: "moved" };
156
+ }
157
+ async function markMoveFailed(id) {
158
+ await editConfig((config) => {
159
+ const profile = config.profiles.find((p) => p.id === id);
160
+ if (!profile)
161
+ return false;
162
+ profile.moveFailedAt = new Date().toISOString();
163
+ }).catch(() => { });
164
+ }
165
+ /** Logins a move to the keychain should take: file logins, and F+ ones an old version rewrote. */
166
+ const inFile = (p) => !p.secrets;
167
+ /** Delete the session entries a move interrupted after its flip left behind (tagged, under the old key). */
168
+ async function cleanLeftoverSessions(config) {
169
+ for (const profile of config.profiles) {
170
+ if (profile.secrets !== "keychain")
171
+ continue;
172
+ await removeTaggedSessions(profile.id, sessionKeyV2(profile.tenantUrl, profile.username)).catch(() => 0);
173
+ }
174
+ }
175
+ /**
176
+ * After a command, when the saved setting is `auto` or `keychain` (never the
177
+ * environment variable alone): move the file logins to the keychain, at most
178
+ * once a day for one that failed. `auto` stays quiet; `keychain` prints one
179
+ * line per failure. Never fails the command it follows.
180
+ */
181
+ export async function runBackgroundMoves(warn = (line) => console.error(line)) {
182
+ try {
183
+ const config = await loadConfig();
184
+ await cleanLeftoverSessions(config);
185
+ const setting = config.secretStore;
186
+ if (setting !== "auto" && setting !== "keychain")
187
+ return;
188
+ const now = Date.now();
189
+ const due = config.profiles.filter((p) => inFile(p) && !(p.moveFailedAt && now - Date.parse(p.moveFailedAt) < RETRY_AFTER_MS));
190
+ if (!due.length)
191
+ return;
192
+ const problem = await keychainProblem();
193
+ if (problem) {
194
+ for (const profile of due)
195
+ await markMoveFailed(profile.id);
196
+ if (setting === "keychain")
197
+ warn(`WilmAI couldn't move saved passwords to the keychain: ${problem}. They stay in WilmAI's file for now.`);
198
+ return;
199
+ }
200
+ let moved = false;
201
+ for (const profile of due) {
202
+ const result = await moveLoginToKeychain(profile.id);
203
+ if (result.outcome === "moved")
204
+ moved = true;
205
+ if (result.outcome === "failed" && setting === "keychain")
206
+ warn(`WilmAI couldn't move the password for ${result.wilma} to the keychain: ${result.message}`);
207
+ }
208
+ if (moved)
209
+ await removeStrayTempFiles();
210
+ }
211
+ catch {
212
+ // A background move never fails the command it follows.
213
+ }
214
+ }
215
+ /**
216
+ * Remove temp files a crashed save left next to the config (they can hold the
217
+ * scrambled password). Only ones older than a minute: a save in progress in
218
+ * another process may still be using its own.
219
+ */
220
+ export async function removeStrayTempFiles() {
221
+ const dir = dirname(getConfigPath());
222
+ const prefixes = [`${basename(getConfigPath())}.`, "wilmai-sessions.json."];
223
+ let removed = 0;
224
+ let names = [];
225
+ try {
226
+ names = await readdir(dir);
227
+ }
228
+ catch {
229
+ return 0;
230
+ }
231
+ for (const name of names) {
232
+ if (!name.endsWith(".tmp") || !prefixes.some((prefix) => name.startsWith(prefix)))
233
+ continue;
234
+ const path = join(dir, name);
235
+ try {
236
+ if (Date.now() - (await stat(path)).mtimeMs < 60_000)
237
+ continue;
238
+ await rm(path, { force: true });
239
+ removed += 1;
240
+ }
241
+ catch {
242
+ // gone already
243
+ }
244
+ }
245
+ return removed;
246
+ }
247
+ /** Every login's place, for `wilma config secrets`. */
248
+ export function whereSecretsAre(config) {
249
+ return config.profiles.map((p) => ({
250
+ ...describe(p),
251
+ store: p.secrets === "keychain" ? "keychain" : p.secrets ? "other" : "file",
252
+ hasTotp: p.secrets ? Boolean(p.hasTotp) : Boolean(p.totpSecretObfuscated),
253
+ ...(!p.secrets && config.keychainLogins?.[p.id] ? { keychainEntry: true } : {}),
254
+ }));
255
+ }
256
+ /**
257
+ * The keychain entries a config file names: `keychainLogins` values and
258
+ * `secretId`s. Read from the text itself, so a damaged file (`wilma config
259
+ * clear` is the way out of one) still gives the entries it names.
260
+ */
261
+ export function entriesNamedIn(text) {
262
+ const entries = new Set();
263
+ try {
264
+ const config = JSON.parse(text);
265
+ for (const value of Object.values(config.keychainLogins ?? {}))
266
+ if (typeof value === "string")
267
+ entries.add(value);
268
+ for (const p of Array.isArray(config.profiles) ? config.profiles : [])
269
+ if (typeof p?.secretId === "string")
270
+ entries.add(p.secretId);
271
+ }
272
+ catch {
273
+ // Damaged: fall back to the entry ids written in it (32 hex characters as a value).
274
+ }
275
+ const block = /"keychainLogins"\s*:\s*\{([^}]*)/.exec(text)?.[1] ?? "";
276
+ for (const m of block.matchAll(/:\s*"([0-9a-f]{32})"/g))
277
+ entries.add(m[1]);
278
+ for (const m of text.matchAll(/"secretId"\s*:\s*"([0-9a-f]{32})"/g))
279
+ entries.add(m[1]);
280
+ return [...entries].filter((e) => /^[0-9a-f]{32}$/.test(e));
281
+ }
282
+ /** Delete the keychain entries the config file names (for `wilma config clear`); the ones that couldn't be deleted. */
283
+ export async function deleteKnownEntries(configText) {
284
+ if (!configText)
285
+ return [];
286
+ const entries = entriesNamedIn(configText);
287
+ const failed = [];
288
+ for (const entry of entries) {
289
+ try {
290
+ await deleteLoginSecrets(entry);
291
+ }
292
+ catch {
293
+ failed.push(entry);
294
+ }
295
+ }
296
+ return failed;
297
+ }
@@ -0,0 +1,45 @@
1
+ export declare const KEYCHAIN_SERVICE = "ai.wilm.wilma-cli";
2
+ export declare const SECRET_STORE_SETTINGS: readonly ["file", "auto", "keychain"];
3
+ export type SecretStoreSetting = (typeof SECRET_STORE_SETTINGS)[number];
4
+ export type SecretErrorReason = "missing" | "refused" | "unavailable";
5
+ export interface LoginSecrets {
6
+ password: string;
7
+ totpSecret?: string | null;
8
+ }
9
+ /** A keychain entry couldn't be read, written or deleted; `reason` says what the user can do. */
10
+ export declare class SecretStoreError extends Error {
11
+ readonly reason: SecretErrorReason;
12
+ constructor(reason: SecretErrorReason, message: string, cause?: unknown);
13
+ }
14
+ /** The message a parent sees for each kind of failure (`wilma` names the Wilma when known). */
15
+ export declare function secretErrorMessage(reason: SecretErrorReason, wilma?: string | null): string;
16
+ /** The setting in effect: WILMAI_SECRET_STORE for this process, else the saved one, else the file. */
17
+ export declare function effectiveSecretStore(saved: string | null | undefined, env?: NodeJS.ProcessEnv): SecretStoreSetting;
18
+ export declare const newSecretId: () => string;
19
+ export declare const newCredentialsStamp: () => string;
20
+ /** null when the keychain can be used here (checked once per process); otherwise why not. */
21
+ export declare function keychainProblem(): Promise<string | null>;
22
+ /** Which keychain this process would use, for `wilma config secrets`. */
23
+ export declare function keychainKind(): string;
24
+ export interface SecretRef {
25
+ secretId: string;
26
+ credentialsStamp?: string | null;
27
+ /** The Wilma's name, for messages. */
28
+ wilma?: string | null;
29
+ }
30
+ /** A login's password and authenticator key from the keychain: read once per process, only when needed. */
31
+ export declare function readLoginSecrets(ref: SecretRef): Promise<LoginSecrets>;
32
+ /** Save a login's secrets in its keychain entry (replacing what was there). */
33
+ export declare function writeLoginSecrets(secretId: string, secrets: LoginSecrets): Promise<void>;
34
+ /**
35
+ * Read an entry straight from the keychain (no per-process memory), e.g. to
36
+ * check what was just written. A Windows read right after a write on another
37
+ * thread can briefly miss, so a miss is tried once more.
38
+ */
39
+ export declare function readBackLoginSecrets(secretId: string): Promise<LoginSecrets | null>;
40
+ /** Delete a login's keychain entry; true when there was one. */
41
+ export declare function deleteLoginSecrets(secretId: string): Promise<boolean>;
42
+ /** Whether a keychain call timed out and may still be running (the process should then exit when done). */
43
+ export declare function abandonedKeychainCalls(): boolean;
44
+ /** For tests in one process: forget what was read. */
45
+ export declare function forgetSecretReads(): void;