@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.
- package/README.md +20 -3
- package/dist/access.d.ts +40 -1
- package/dist/access.js +105 -6
- package/dist/agent-data.d.ts +200 -21
- package/dist/agent-data.js +261 -56
- package/dist/cli-args.js +12 -3
- package/dist/cli-help.js +29 -13
- package/dist/cli-output.d.ts +4 -1
- package/dist/cli-output.js +19 -5
- package/dist/config.d.ts +36 -6
- package/dist/config.js +26 -0
- package/dist/credentials.d.ts +49 -2
- package/dist/credentials.js +199 -64
- package/dist/index.js +214 -108
- package/dist/login-server.d.ts +28 -6
- package/dist/login-server.js +468 -239
- package/dist/mcp-tools.d.ts +2 -2
- package/dist/mcp-tools.js +46 -15
- package/dist/mcp.js +26 -15
- 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/mcp-tools.d.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
2
2
|
import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js";
|
|
3
|
-
import type
|
|
4
|
-
export declare const INSTRUCTIONS = "Read-only access to Finland's Wilma school system for a parent/guardian, or for a student with their own login.\n- Start with wilma_summary: today's and tomorrow's lessons, upcoming exams, recent homework, news and messages for every child in one call.\n- Tools cover all children by default (a student's own login has just that student); pass \"student\" (name or student number) to narrow to one child.\n- Wilma content is usually in Finnish. Answer in the user's language and translate as needed. Write dates the way that language does: Finnish \"ke 7.10.\" (with the final period), English \"Wed 7 Oct\".\n- Message and news lists return ids; use wilma_read_message / wilma_read_news for full text. Bulletins can link attachments; fetch them with wilma_get_news_attachment.\n- Teachers' feedback (\"forgot books\", \"did well\") and absences are lesson notes: wilma_lesson_notes for recent days, wilma_lesson_notes_summary for counts. Course and report-card grades are in wilma_gradebook.\n- If a tool says the user isn't logged in, relay the login link or instructions to the user exactly; never ask the user to type their Wilma password into the chat.\n- Wilma allows one session per account, so each WilmAI login logs the user out of Wilma in their browser. If they want to use Wilma in the browser, call wilma_logout and then call Wilma tools only when they ask again.\n- If a result ends with an \"Update note\", answer the question first, then pass the note on in one sentence.";
|
|
3
|
+
import { type FetchedAttachment, type WilmaAccess } from "./agent-data.js";
|
|
4
|
+
export declare const INSTRUCTIONS = "Read-only access to Finland's Wilma school system for a parent/guardian, or for a student with their own login.\n- Start with wilma_summary: today's and tomorrow's lessons, upcoming exams, recent homework, news and messages for every child in one call.\n- Tools cover all children by default (a student's own login has just that student); pass \"student\" (name or student number) to narrow to one child.\n- Wilma content is usually in Finnish. Answer in the user's language and translate as needed. Write dates the way that language does: Finnish \"ke 7.10.\" (with the final period), English \"Wed 7 Oct\".\n- Message and news lists return ids; use wilma_read_message / wilma_read_news for full text. Bulletins can link attachments; fetch them with wilma_get_news_attachment.\n- Teachers' feedback (\"forgot books\", \"did well\") and absences are lesson notes: wilma_lesson_notes for recent days, wilma_lesson_notes_summary for counts. Course and report-card grades are in wilma_gradebook. The class, class teacher and teachers are in wilma_contacts.\n- If a tool says the user isn't logged in, relay the login link or instructions to the user exactly; never ask the user to type their Wilma password into the chat.\n- Wilma allows one session per account, so each WilmAI login logs the user out of Wilma in their browser. If they want to use Wilma in the browser, call wilma_logout and then call Wilma tools only when they ask again.\n- If a result ends with an \"Update note\", answer the question first, then pass the note on in one sentence.";
|
|
5
5
|
export declare const READ_ONLY: {
|
|
6
6
|
readonly readOnlyHint: true;
|
|
7
7
|
readonly destructiveHint: false;
|
package/dist/mcp-tools.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
|
+
import { SCHEDULE_MAX_DAYS } from "./agent-data.js";
|
|
2
3
|
import { normalizeResourceId } from "./downloads.js";
|
|
3
4
|
import { toAgentJson } from "./output-json.js";
|
|
4
5
|
/*
|
|
@@ -10,7 +11,7 @@ export const INSTRUCTIONS = `Read-only access to Finland's Wilma school system f
|
|
|
10
11
|
- Tools cover all children by default (a student's own login has just that student); pass "student" (name or student number) to narrow to one child.
|
|
11
12
|
- Wilma content is usually in Finnish. Answer in the user's language and translate as needed. Write dates the way that language does: Finnish "ke 7.10." (with the final period), English "Wed 7 Oct".
|
|
12
13
|
- Message and news lists return ids; use wilma_read_message / wilma_read_news for full text. Bulletins can link attachments; fetch them with wilma_get_news_attachment.
|
|
13
|
-
- Teachers' feedback ("forgot books", "did well") and absences are lesson notes: wilma_lesson_notes for recent days, wilma_lesson_notes_summary for counts. Course and report-card grades are in wilma_gradebook.
|
|
14
|
+
- Teachers' feedback ("forgot books", "did well") and absences are lesson notes: wilma_lesson_notes for recent days, wilma_lesson_notes_summary for counts. Course and report-card grades are in wilma_gradebook. The class, class teacher and teachers are in wilma_contacts.
|
|
14
15
|
- If a tool says the user isn't logged in, relay the login link or instructions to the user exactly; never ask the user to type their Wilma password into the chat.
|
|
15
16
|
- Wilma allows one session per account, so each WilmAI login logs the user out of Wilma in their browser. If they want to use Wilma in the browser, call wilma_logout and then call Wilma tools only when they ask again.
|
|
16
17
|
- If a result ends with an "Update note", answer the question first, then pass the note on in one sentence.`;
|
|
@@ -74,7 +75,7 @@ function attachmentResult(fetched, savedPath) {
|
|
|
74
75
|
export function registerWilmaTools(server, ctx) {
|
|
75
76
|
server.registerTool("wilma_summary", {
|
|
76
77
|
title: "School summary",
|
|
77
|
-
description: "Daily briefing per child: today's and the next school day's lessons, upcoming exams (with start times when given), recent homework, lesson notes since the previous school day (teachers' feedback and absences), recent bulletins, and recent and unread messages (ids, unread, replyCount). What Wilma didn't give is listed in unavailable (\"news\": this Wilma doesn't show this account its bulletins). The best starting point for any question about school.",
|
|
78
|
+
description: "Daily briefing per child: today's and the next school day's lessons, upcoming exams (with start times when given), recent homework, lesson notes since the previous school day (teachers' feedback and absences), recent bulletins, and recent and unread messages (ids, unread, replyCount; event invitations with invitationStatus, as in wilma_list_messages). What Wilma didn't give is listed in unavailable (\"news\": this Wilma doesn't show this account its bulletins). The best starting point for any question about school.",
|
|
78
79
|
inputSchema: {
|
|
79
80
|
student: studentArg,
|
|
80
81
|
days: z.number().int().min(1).max(60).optional().describe("How many days back to include news and messages (default 7)."),
|
|
@@ -87,7 +88,7 @@ export function registerWilmaTools(server, ctx) {
|
|
|
87
88
|
}, async ({ student, days, since }) => ctx.withAccess(async (a) => json(await a.summary({ student, days, since }))));
|
|
88
89
|
server.registerTool("wilma_schedule", {
|
|
89
90
|
title: "Lesson schedule",
|
|
90
|
-
description: "
|
|
91
|
+
description: "List each child's lessons for one day or a school week: today, the next school day, this or next week (Monday to Friday), a date, or the next given weekday; or any other week or period with from/to (e.g. a week further ahead, or several weeks to see the usual school days). Lessons come in time order with the date, start and end time, subject, teacher and room (when Wilma gives one); a day without school has no lessons. Exams and homework are in wilma_upcoming_exams and wilma_homework.",
|
|
91
92
|
inputSchema: {
|
|
92
93
|
student: studentArg,
|
|
93
94
|
when: z.enum(["today", "tomorrow", "week", "next-week"]).optional().describe("Default: week. 'tomorrow' means the next school day."),
|
|
@@ -96,37 +97,61 @@ export function registerWilmaTools(server, ctx) {
|
|
|
96
97
|
.string()
|
|
97
98
|
.optional()
|
|
98
99
|
.describe("Next occurrence of a weekday: mon..sun (Finnish ma..su also work). Overrides 'when'."),
|
|
100
|
+
from: z.string().optional().describe("Start of a period, YYYY-MM-DD. Overrides 'when'."),
|
|
101
|
+
to: z
|
|
102
|
+
.string()
|
|
103
|
+
.optional()
|
|
104
|
+
.describe(`End of the period, YYYY-MM-DD (default: the Friday of from's week). At most ${SCHEDULE_MAX_DAYS} days.`),
|
|
99
105
|
},
|
|
100
106
|
annotations: { title: "Lesson schedule", ...READ_ONLY },
|
|
101
|
-
}, async ({ student, when, date, weekday }) => ctx.withAccess(async (a) => json(await a.schedule({ student, when, date, weekday }))));
|
|
107
|
+
}, async ({ student, when, date, weekday, from, to }) => ctx.withAccess(async (a) => json(await a.schedule({ student, when, date, weekday, from, to }))));
|
|
102
108
|
server.registerTool("wilma_homework", {
|
|
103
109
|
title: "Homework",
|
|
104
|
-
description: "
|
|
105
|
-
inputSchema: {
|
|
110
|
+
description: "List the homework teachers have entered for each child, newest first: the date, subject, teacher and what to do. It reads the homework Wilma's front page lists (Wilma decides how far back that goes). wilma_summary already includes the last few days' homework.",
|
|
111
|
+
inputSchema: {
|
|
112
|
+
student: studentArg,
|
|
113
|
+
limit: z.number().int().min(1).max(100).optional().describe("How many entries per child, newest first (default 10)."),
|
|
114
|
+
},
|
|
106
115
|
annotations: { title: "Homework", ...READ_ONLY },
|
|
107
116
|
}, async ({ student, limit }) => ctx.withAccess(async (a) => json(await a.homework({ student, limit }))));
|
|
108
117
|
server.registerTool("wilma_upcoming_exams", {
|
|
109
118
|
title: "Upcoming exams",
|
|
110
|
-
description: "
|
|
111
|
-
inputSchema: {
|
|
119
|
+
description: "List each child's exams from today on that have no grade yet, soonest first: the date, start time (when the school gives one), exam name, subject, topic (what to study) and teacher. Graded exams are in wilma_grades.",
|
|
120
|
+
inputSchema: {
|
|
121
|
+
student: studentArg,
|
|
122
|
+
limit: z.number().int().min(1).max(100).optional().describe("How many exams per child, soonest first (default 20)."),
|
|
123
|
+
},
|
|
112
124
|
annotations: { title: "Upcoming exams", ...READ_ONLY },
|
|
113
125
|
}, async ({ student, limit }) => ctx.withAccess(async (a) => json(await a.upcomingExams({ student, limit }))));
|
|
114
126
|
server.registerTool("wilma_grades", {
|
|
115
127
|
title: "Exam grades",
|
|
116
|
-
description: "
|
|
117
|
-
inputSchema: {
|
|
128
|
+
description: "List each child's graded exams, newest first: the date, exam name, subject, grade as Wilma shows it (e.g. 8+, 9½, S), the teacher's notes and when a guardian marked it seen. It reads the exams Wilma's front page lists (Wilma decides how far back that goes); with from, Wilma's list of past exams for that period instead, as far back as the school keeps them, including exams still waiting for a grade (grade: null). For course, term and report-card grades use wilma_gradebook.",
|
|
129
|
+
inputSchema: {
|
|
130
|
+
student: studentArg,
|
|
131
|
+
limit: z
|
|
132
|
+
.number()
|
|
133
|
+
.int()
|
|
134
|
+
.min(1)
|
|
135
|
+
.max(100)
|
|
136
|
+
.optional()
|
|
137
|
+
.describe("How many grades per child, newest first (default 20; with from, every exam of the period)."),
|
|
138
|
+
from: z.string().optional().describe("YYYY-MM-DD: past exams from this day on, e.g. 1 August for this school year."),
|
|
139
|
+
to: z.string().optional().describe("YYYY-MM-DD, with from. Default: today."),
|
|
140
|
+
},
|
|
118
141
|
annotations: { title: "Exam grades", ...READ_ONLY },
|
|
119
|
-
}, async ({ student, limit }) => ctx.withAccess(async (a) => json(await a.grades({ student, limit }))));
|
|
142
|
+
}, async ({ student, limit, from, to }) => ctx.withAccess(async (a) => json(await a.grades({ student, limit, from, to }))));
|
|
120
143
|
server.registerTool("wilma_lesson_notes", {
|
|
121
144
|
title: "Lesson notes and absences",
|
|
122
|
-
description: "Lesson notes (merkinnät) teachers logged: absences, lateness, and feedback such as praise or missing books or homework, with the teacher's own words when given. One day (default today)
|
|
145
|
+
description: "Lesson notes (merkinnät) teachers logged: absences, lateness, and feedback such as praise or missing books or homework, with the teacher's own words when given. One day (default today), the last N days, or a period with from/to, which can reach ahead (a teacher may note a make-up exam on a coming lesson).",
|
|
123
146
|
inputSchema: {
|
|
124
147
|
student: studentArg,
|
|
125
148
|
date: z.string().optional().describe("YYYY-MM-DD, default today."),
|
|
126
149
|
days: z.number().int().min(1).max(365).optional().describe("The last N days up to today, instead of one date."),
|
|
150
|
+
from: z.string().optional().describe("Start of a period, YYYY-MM-DD, instead of a date or days."),
|
|
151
|
+
to: z.string().optional().describe("End of the period, YYYY-MM-DD (default today; may be after today)."),
|
|
127
152
|
},
|
|
128
153
|
annotations: { title: "Lesson notes and absences", ...READ_ONLY },
|
|
129
|
-
}, async ({ student, date, days }) => ctx.withAccess(async (a) => json(await a.lessonNotes({ student, date, days }))));
|
|
154
|
+
}, async ({ student, date, days, from, to }) => ctx.withAccess(async (a) => json(await a.lessonNotes({ student, date, days, from, to }))));
|
|
130
155
|
server.registerTool("wilma_lesson_notes_summary", {
|
|
131
156
|
title: "Absences and feedback summary",
|
|
132
157
|
description: "Lesson notes counted by kind (e.g. absences for health reasons, unexplained absences, lateness, praise, missing study materials): this school year by default, or from a date.",
|
|
@@ -139,10 +164,16 @@ export function registerWilmaTools(server, ctx) {
|
|
|
139
164
|
}, async ({ student, from, to }) => ctx.withAccess(async (a) => json(await a.lessonNotesSummary({ student, from, to }))));
|
|
140
165
|
server.registerTool("wilma_gradebook", {
|
|
141
166
|
title: "Gradebook",
|
|
142
|
-
description: "
|
|
167
|
+
description: "Get each child's gradebook (Suoritukset): completed courses and their grades, grouped by subject (subject > syllabus > course), with course codes, credits and completion dates, plus term and school-year (report card) grades. Use it for course and report-card grades; single exam grades are in wilma_grades.",
|
|
143
168
|
inputSchema: { student: studentArg },
|
|
144
169
|
annotations: { title: "Gradebook", ...READ_ONLY },
|
|
145
170
|
}, async ({ student }) => ctx.withAccess(async (a) => json(await a.gradebook({ student }))));
|
|
171
|
+
server.registerTool("wilma_contacts", {
|
|
172
|
+
title: "Class and teachers",
|
|
173
|
+
description: "Each child's class (e.g. 9A), class teacher and other supervisors with their role as Wilma names it (e.g. Luokanvalvoja), and the child's own teachers as Wilma's message recipients list them, with the subjects each teaches the child in this and next week's timetable (empty when none). Other school staff aren't included. Lessons with times and rooms are in wilma_schedule.",
|
|
174
|
+
inputSchema: { student: studentArg },
|
|
175
|
+
annotations: { title: "Class and teachers", ...READ_ONLY },
|
|
176
|
+
}, async ({ student }) => ctx.withAccess(async (a) => json(await a.contacts({ student }))));
|
|
146
177
|
server.registerTool("wilma_list_printouts", {
|
|
147
178
|
title: "List printouts",
|
|
148
179
|
description: "PDF documents the school offers (Tulosteet), such as report cards or absence reports. Fetch one with wilma_get_printout.",
|
|
@@ -172,7 +203,7 @@ export function registerWilmaTools(server, ctx) {
|
|
|
172
203
|
}));
|
|
173
204
|
server.registerTool("wilma_list_messages", {
|
|
174
205
|
title: "List messages",
|
|
175
|
-
description: "Wilma messages, newest first (subject, sender, date, id). Use wilma_read_message for the full text.",
|
|
206
|
+
description: "Wilma messages, newest first (subject, sender, date, id, unread, replyCount). The appointments folder holds event invitations (parents' evenings, learning discussions) with the family's answer as Wilma lists it: invitationStatus (\"Applied\": signed up, the time in invitationInfo; \"ExpiredReg\": sign-up closed; null: no answer shown). Use wilma_read_message for the full text, and for an invitation's time slots.",
|
|
176
207
|
inputSchema: {
|
|
177
208
|
student: studentArg,
|
|
178
209
|
folder: z.enum(["inbox", "archive", "outbox", "drafts", "appointments"]).optional().describe("Default: inbox."),
|
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, {
|
|
@@ -121,7 +142,7 @@ function createWilmaMcpServer(version) {
|
|
|
121
142
|
});
|
|
122
143
|
server.registerTool("wilma_account", {
|
|
123
144
|
title: "Wilma account status",
|
|
124
|
-
description: "Which Wilma logins are connected and
|
|
145
|
+
description: "Which Wilma logins are connected: each one's Wilma, username, role (\"guardian\", or \"student\" for a student's own login) and the children it lists. Families with children on different Wilmas connect one login per Wilma (wilma_login adds another). When two logins on one Wilma list the same child (a parent's and the child's own), tools read that child through the guardian's login, and the other login's entry for the child says readVia. A child unticked on the login page (a profile Wilma lists twice) has hidden: true and isn't read.",
|
|
125
146
|
inputSchema: {},
|
|
126
147
|
annotations: { title: "Wilma account status", ...READ_ONLY },
|
|
127
148
|
}, async () => {
|
|
@@ -129,19 +150,7 @@ function createWilmaMcpServer(version) {
|
|
|
129
150
|
const accounts = await resolveAccounts(config);
|
|
130
151
|
if (!accounts.length)
|
|
131
152
|
return json({ connected: false, hint: "Call wilma_login to connect." });
|
|
132
|
-
return withAccess(async (a) => {
|
|
133
|
-
const students = await a.students();
|
|
134
|
-
return json({
|
|
135
|
-
connected: true,
|
|
136
|
-
source: accounts[0].source,
|
|
137
|
-
wilmas: accounts.map((account, index) => ({
|
|
138
|
-
wilma: account.stored?.tenantName ?? account.profile.baseUrl,
|
|
139
|
-
username: account.profile.username,
|
|
140
|
-
children: students.filter((s) => s.account === index && s.name).map((s) => ({ name: s.name, studentNumber: s.studentNumber })),
|
|
141
|
-
})),
|
|
142
|
-
...(a.problems.length ? { problems: a.problems } : {}),
|
|
143
|
-
});
|
|
144
|
-
});
|
|
153
|
+
return withAccess(async (a) => json({ connected: true, source: accounts[0].source, wilmas: await a.logins() }));
|
|
145
154
|
});
|
|
146
155
|
server.registerTool("wilma_find_school", {
|
|
147
156
|
title: "Find a school's Wilma",
|
|
@@ -190,4 +199,6 @@ export async function runMcpServer(version) {
|
|
|
190
199
|
const server = createWilmaMcpServer(version);
|
|
191
200
|
mcpServer = server;
|
|
192
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();
|
|
193
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[]>;
|
|
@@ -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;
|