@consiliency/agent-board-cli 1.24.0 → 1.25.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,549 @@
1
+ // SETUP SL-2 — text-preserving edits of harness config files (TOML and JSON/JSONC).
2
+ //
3
+ // The readers are doctor's (`parseRestrictedToml`, `parseJsonc`); nothing here has a grammar of its
4
+ // own for what a file MEANS. An edit is text surgery on the one entry setup owns — every comment,
5
+ // blank line and unrelated key stays byte-for-byte — and it is accepted only when doctor's reader,
6
+ // run over the result, sees exactly the requested entry and, with that entry removed, exactly the
7
+ // document it saw before. Anything the surgery cannot place unambiguously is refused with an authored
8
+ // reason rather than appended: a blind second `[mcp_servers.x]` is a duplicate key the reader refuses.
9
+ //
10
+ // Nothing here touches the filesystem; `setup_writers.ts` reads and writes the files.
11
+ import { FORBIDDEN_CONFIG_KEYS, UnparseableConfigError, own, parseJsonc, parseRestrictedToml, } from "./doctor_scopes.js";
12
+ /** A refusal: the reason is an authored code, never file content. */
13
+ export class ConfigEditRefused extends Error {
14
+ reason;
15
+ constructor(reason) {
16
+ super(reason);
17
+ this.reason = reason;
18
+ this.name = "ConfigEditRefused";
19
+ }
20
+ }
21
+ // ---------------------------------------------------------------------------------------------
22
+ // Comparison
23
+ // ---------------------------------------------------------------------------------------------
24
+ /** A canonical form: object keys sorted, prototype-free objects compared like plain ones. */
25
+ function canonical(value) {
26
+ if (Array.isArray(value))
27
+ return value.map(canonical);
28
+ if (value !== null && typeof value === "object") {
29
+ const out = {};
30
+ for (const key of Object.keys(value).sort())
31
+ out[key] = canonical(value[key]);
32
+ return out;
33
+ }
34
+ return value;
35
+ }
36
+ export function deepEqual(a, b) {
37
+ return JSON.stringify(canonical(a)) === JSON.stringify(canonical(b));
38
+ }
39
+ /** `doc` with `path` removed (a copy); the parent tables are kept even when left empty. */
40
+ function without(doc, path) {
41
+ const copy = canonical(doc);
42
+ let node = copy;
43
+ for (const part of path.slice(0, -1)) {
44
+ node = own(node, part);
45
+ if (node === undefined || node === null || typeof node !== "object")
46
+ return copy;
47
+ }
48
+ delete node[path[path.length - 1]];
49
+ return copy;
50
+ }
51
+ function lookup(doc, path) {
52
+ let node = doc;
53
+ for (const part of path)
54
+ node = own(node, part);
55
+ return node;
56
+ }
57
+ /**
58
+ * The accepted-edit check: the entry is exactly `entry`, and nothing else moved. A parent table the
59
+ * edit had to create (absent before) is removed from the comparison once empty.
60
+ */
61
+ function verified(before, after, path, entry) {
62
+ if (!deepEqual(lookup(after, path), entry))
63
+ return false;
64
+ let rest = without(after, path);
65
+ for (let depth = path.length - 1; depth > 0; depth -= 1) {
66
+ const parent = path.slice(0, depth);
67
+ const created = lookup(before, parent) === undefined;
68
+ const node = lookup(rest, parent);
69
+ if (created && node !== null && typeof node === "object" && Object.keys(node).length === 0)
70
+ rest = without(rest, parent);
71
+ }
72
+ return deepEqual(without(before, path), rest);
73
+ }
74
+ // ---------------------------------------------------------------------------------------------
75
+ // TOML
76
+ // ---------------------------------------------------------------------------------------------
77
+ const BARE_KEY = /^[A-Za-z0-9_-]+$/;
78
+ const tomlKey = (key) => (BARE_KEY.test(key) ? key : JSON.stringify(key));
79
+ // A JSON string literal is a valid TOML basic string for everything JSON.stringify emits.
80
+ const tomlString = (value) => JSON.stringify(value);
81
+ function tomlValue(value) {
82
+ if (typeof value === "string")
83
+ return tomlString(value);
84
+ if (typeof value === "boolean")
85
+ return value ? "true" : "false";
86
+ if (typeof value === "number") {
87
+ // The reader returns a number for integers and finite floats only.
88
+ if (!Number.isFinite(value))
89
+ throw new ConfigEditRefused("config_layout_unrecognised");
90
+ return String(value);
91
+ }
92
+ if (Array.isArray(value))
93
+ return `[${value.map(tomlValue).join(", ")}]`;
94
+ const table = value;
95
+ const pairs = Object.keys(table).map((key) => `${tomlKey(key)} = ${tomlValue(table[key])}`);
96
+ return pairs.length === 0 ? "{}" : `{ ${pairs.join(", ")} }`;
97
+ }
98
+ /** The block setup writes for `path` (a table header plus one line per key, in the entry's order). */
99
+ export function renderTomlTable(path, entry) {
100
+ const lines = [`[${path.map(tomlKey).join(".")}]`];
101
+ for (const key of Object.keys(entry))
102
+ lines.push(`${tomlKey(key)} = ${tomlValue(entry[key])}`);
103
+ return `${lines.join("\n")}\n`;
104
+ }
105
+ /** The single-key chain of a parsed fragment (`a.b.c = 0` → [a, b, c]); arrays step into their last element. */
106
+ function chain(parsed) {
107
+ const path = [];
108
+ let node = parsed;
109
+ for (;;) {
110
+ if (Array.isArray(node))
111
+ node = node[node.length - 1];
112
+ const keys = node && typeof node === "object" ? Object.keys(node) : [];
113
+ if (keys.length !== 1)
114
+ return path;
115
+ path.push(keys[0]);
116
+ node = node[keys[0]];
117
+ }
118
+ }
119
+ /**
120
+ * Where every table header and every key/value statement starts, found STRUCTURALLY: strings
121
+ * (basic, literal, and both multi-line forms), comments, and the brackets of arrays and inline
122
+ * tables are skipped, so a line inside a multi-line string or a multi-line array is never taken
123
+ * for a header or a key. Each path is read by doctor's own reader, so quoting and dotted forms
124
+ * match what the reader means. Returns null when the scan cannot place something (the caller
125
+ * refuses rather than guesses).
126
+ */
127
+ function tomlStructure(text) {
128
+ const headers = [];
129
+ const keys = [];
130
+ let table = [];
131
+ let pos = 0;
132
+ const lineOf = (at) => text.slice(0, at).split("\n").length - 1;
133
+ /** Skip one value (or the rest of a statement) to the end of its line, honouring strings and brackets. */
134
+ const skipValue = () => {
135
+ let depth = 0;
136
+ while (pos < text.length) {
137
+ const char = text[pos];
138
+ if (text.startsWith("\"\"\"", pos) || text.startsWith("\'\'\'", pos)) {
139
+ // A multi-line string. A basic one (three double quotes) honours escapes, so a backslash
140
+ // followed by three quotes does not close it; a literal one (three single quotes) has no
141
+ // escapes. Up to two extra quotes before the closer belong to the content.
142
+ const quote = text.slice(pos, pos + 3);
143
+ let end = pos + 3;
144
+ for (;;) {
145
+ if (end >= text.length)
146
+ return false;
147
+ if (quote[0] === "\"" && text[end] === "\\") {
148
+ end += 2;
149
+ continue;
150
+ }
151
+ if (text.startsWith(quote, end))
152
+ break;
153
+ end += 1;
154
+ }
155
+ while (text[end + 3] === quote[0])
156
+ end += 1;
157
+ pos = end + 3;
158
+ }
159
+ else if (char === "\"") {
160
+ pos += 1;
161
+ while (pos < text.length && text[pos] !== "\"" && text[pos] !== "\n")
162
+ pos += text[pos] === "\\" ? 2 : 1;
163
+ if (text[pos] !== "\"")
164
+ return false;
165
+ pos += 1;
166
+ }
167
+ else if (char === "'") {
168
+ const end = text.indexOf("'", pos + 1);
169
+ if (end < 0 || text.slice(pos, end).includes("\n"))
170
+ return false;
171
+ pos = end + 1;
172
+ }
173
+ else if (char === "#") {
174
+ while (pos < text.length && text[pos] !== "\n")
175
+ pos += 1;
176
+ }
177
+ else if (char === "[" || char === "{") {
178
+ depth += 1;
179
+ pos += 1;
180
+ }
181
+ else if (char === "]" || char === "}") {
182
+ depth -= 1;
183
+ pos += 1;
184
+ }
185
+ else if (char === "\n" && depth === 0) {
186
+ pos += 1;
187
+ return true;
188
+ }
189
+ else
190
+ pos += 1;
191
+ }
192
+ return depth === 0;
193
+ };
194
+ while (pos < text.length) {
195
+ while (pos < text.length && (text[pos] === " " || text[pos] === "\t" || text[pos] === "\r"))
196
+ pos += 1;
197
+ if (pos >= text.length)
198
+ break;
199
+ const char = text[pos];
200
+ if (char === "\n") {
201
+ pos += 1;
202
+ continue;
203
+ }
204
+ if (char === "#") {
205
+ while (pos < text.length && text[pos] !== "\n")
206
+ pos += 1;
207
+ continue;
208
+ }
209
+ const index = lineOf(pos);
210
+ if (char === "[") {
211
+ const start = pos;
212
+ if (!skipValue())
213
+ return null;
214
+ const header = text.slice(start, pos).trim();
215
+ let parsed;
216
+ try {
217
+ parsed = parseRestrictedToml(header);
218
+ }
219
+ catch {
220
+ return null;
221
+ }
222
+ const path = chain(parsed);
223
+ headers.push({ index, path, array: header.startsWith("[[") });
224
+ table = path;
225
+ continue;
226
+ }
227
+ // A key/value statement: the key runs to the first `=` outside quotes.
228
+ let at = pos;
229
+ while (at < text.length && text[at] !== "=" && text[at] !== "\n") {
230
+ if (text[at] === "\"" || text[at] === "'") {
231
+ const close = text.indexOf(text[at], at + 1);
232
+ if (close < 0)
233
+ return null;
234
+ at = close + 1;
235
+ }
236
+ else
237
+ at += 1;
238
+ }
239
+ if (text[at] !== "=")
240
+ return null;
241
+ let key;
242
+ try {
243
+ key = chain(parseRestrictedToml(`${text.slice(pos, at).trim()} = 0`));
244
+ }
245
+ catch {
246
+ return null;
247
+ }
248
+ keys.push({ index, table, key });
249
+ pos = at + 1;
250
+ if (!skipValue())
251
+ return null;
252
+ }
253
+ return { headers, keys };
254
+ }
255
+ /**
256
+ * A table on `path` (or on any prefix of it) that is not an ordinary `[table]`: an inline table or
257
+ * a dotted key defines it (TOML forbids extending either with a `[header]` later), or an
258
+ * array-of-tables header makes it an array. Appending `[mcp_servers.<name>]` beside one of those
259
+ * produces TOML a spec parser refuses, even when a permissive reader accepts it.
260
+ */
261
+ function unownedDefinition(structure, path) {
262
+ const prefixes = path.map((_, index) => path.slice(0, index + 1));
263
+ for (const header of structure.headers) {
264
+ if (!header.array)
265
+ continue;
266
+ if (prefixes.some((prefix) => startsWith(prefix, header.path)) || startsWith(header.path, path))
267
+ return "config_array_table";
268
+ }
269
+ for (const line of structure.keys) {
270
+ const full = [...line.table, ...line.key];
271
+ if (prefixes.some((prefix) => line.table.length < prefix.length && startsWith(full, prefix)))
272
+ return "config_inline_table";
273
+ }
274
+ return null;
275
+ }
276
+ const startsWith = (path, prefix) => path.length >= prefix.length && prefix.every((part, index) => path[index] === part);
277
+ function parseToml(text) {
278
+ try {
279
+ return parseRestrictedToml(text);
280
+ }
281
+ catch (error) {
282
+ if (error instanceof UnparseableConfigError)
283
+ throw new ConfigEditRefused("config_unparseable");
284
+ throw error;
285
+ }
286
+ }
287
+ /**
288
+ * Put `entry` at the table `path` (e.g. `["mcp_servers", "agent-board"]`) of a TOML document.
289
+ * Absent → appended as its own block; equal → the text unchanged; different → the table's own
290
+ * block(s) (its header, its sub-tables' headers, and their keys) replaced in place by the rendered
291
+ * block. Keys defined for it any other way (a dotted key under a parent table, an inline table)
292
+ * are a layout this edit does not own: refused, never appended beside.
293
+ */
294
+ export function upsertTomlTable(text, path, entry) {
295
+ if (path.some((part) => FORBIDDEN_CONFIG_KEYS.has(part)))
296
+ throw new ConfigEditRefused("config_layout_unrecognised");
297
+ const before = parseToml(text);
298
+ const existing = lookup(before, path);
299
+ if (existing !== undefined && deepEqual(existing, entry))
300
+ return { prior: "exact", text };
301
+ const structure = tomlStructure(text);
302
+ if (structure === null)
303
+ throw new ConfigEditRefused("config_layout_unrecognised");
304
+ const unowned = unownedDefinition(structure, path);
305
+ if (unowned !== null)
306
+ throw new ConfigEditRefused(unowned);
307
+ const block = renderTomlTable(path, entry);
308
+ let next;
309
+ if (existing === undefined) {
310
+ const base = text === "" || text.endsWith("\n") ? text : `${text}\n`;
311
+ next = base.trim() === "" ? block : `${base}${base.endsWith("\n\n") ? "" : "\n"}${block}`;
312
+ }
313
+ else {
314
+ const lines = text.split("\n");
315
+ const headers = structure.headers;
316
+ const owned = headers.filter((header) => !header.array && startsWith(header.path, path));
317
+ if (owned.length === 0 || !owned.some((header) => header.path.length === path.length)) {
318
+ throw new ConfigEditRefused("config_layout_unrecognised");
319
+ }
320
+ // Each owned header's block runs to the next header of any kind; trailing blank and comment
321
+ // lines are left to whatever follows.
322
+ const ranges = owned.map((header) => {
323
+ const following = headers.find((other) => other.index > header.index);
324
+ let end = following ? following.index : lines.length;
325
+ while (end > header.index + 1 && /^\s*(#.*)?$/.test(lines[end - 1]))
326
+ end -= 1;
327
+ return { start: header.index, end };
328
+ });
329
+ const out = [];
330
+ let cursor = 0;
331
+ ranges.forEach((range, index) => {
332
+ out.push(...lines.slice(cursor, range.start));
333
+ if (index === 0)
334
+ out.push(...block.replace(/\n$/, "").split("\n"));
335
+ cursor = range.end;
336
+ });
337
+ out.push(...lines.slice(cursor));
338
+ next = out.join("\n");
339
+ }
340
+ let after;
341
+ try {
342
+ after = parseRestrictedToml(next);
343
+ }
344
+ catch {
345
+ throw new ConfigEditRefused("config_layout_unrecognised");
346
+ }
347
+ if (!verified(before, after, path, entry))
348
+ throw new ConfigEditRefused("config_layout_unrecognised");
349
+ return { prior: existing === undefined ? "absent" : "differs", text: next };
350
+ }
351
+ /** A JSONC scanner that records where each object member sits; comments and strings are skipped. */
352
+ function scanJsonc(text) {
353
+ let pos = 0;
354
+ const fail = () => {
355
+ throw new ConfigEditRefused("config_unparseable");
356
+ };
357
+ const skip = () => {
358
+ for (;;) {
359
+ while (pos < text.length && /\s/.test(text[pos]))
360
+ pos += 1;
361
+ if (text.startsWith("//", pos)) {
362
+ while (pos < text.length && text[pos] !== "\n")
363
+ pos += 1;
364
+ }
365
+ else if (text.startsWith("/*", pos)) {
366
+ const end = text.indexOf("*/", pos + 2);
367
+ if (end < 0)
368
+ fail();
369
+ pos = end + 2;
370
+ }
371
+ else
372
+ return;
373
+ }
374
+ };
375
+ const string = () => {
376
+ const start = pos;
377
+ pos += 1;
378
+ while (pos < text.length && text[pos] !== "\"")
379
+ pos += text[pos] === "\\" ? 2 : 1;
380
+ if (text[pos] !== "\"")
381
+ fail();
382
+ pos += 1;
383
+ return { start, end: pos };
384
+ };
385
+ const value = () => {
386
+ skip();
387
+ const start = pos;
388
+ const char = text[pos];
389
+ if (char === "{") {
390
+ pos += 1;
391
+ const members = [];
392
+ for (;;) {
393
+ skip();
394
+ if (text[pos] === "}") {
395
+ pos += 1;
396
+ return { kind: "object", span: { start, end: pos }, members };
397
+ }
398
+ if (text[pos] !== "\"")
399
+ fail();
400
+ const keySpan = string();
401
+ let key;
402
+ try {
403
+ key = JSON.parse(text.slice(keySpan.start, keySpan.end));
404
+ }
405
+ catch {
406
+ return fail();
407
+ }
408
+ skip();
409
+ if (text[pos] !== ":")
410
+ fail();
411
+ pos += 1;
412
+ const member = { key, keySpan, value: value(), comma: null };
413
+ members.push(member);
414
+ skip();
415
+ if (text[pos] === ",") {
416
+ member.comma = pos;
417
+ pos += 1;
418
+ }
419
+ else if (text[pos] !== "}")
420
+ fail();
421
+ }
422
+ }
423
+ if (char === "[") {
424
+ pos += 1;
425
+ for (;;) {
426
+ skip();
427
+ if (text[pos] === "]") {
428
+ pos += 1;
429
+ return { kind: "other", span: { start, end: pos } };
430
+ }
431
+ value();
432
+ skip();
433
+ if (text[pos] === ",")
434
+ pos += 1;
435
+ else if (text[pos] !== "]")
436
+ fail();
437
+ }
438
+ }
439
+ if (char === "\"") {
440
+ string();
441
+ return { kind: "other", span: { start, end: pos } };
442
+ }
443
+ const match = /^(?:true|false|null|-?\d+(?:\.\d+)?(?:[eE][+-]?\d+)?)/.exec(text.slice(pos));
444
+ if (!match)
445
+ return fail();
446
+ pos += match[0].length;
447
+ return { kind: "other", span: { start, end: pos } };
448
+ };
449
+ const root = value();
450
+ skip();
451
+ if (pos !== text.length)
452
+ fail();
453
+ return root;
454
+ }
455
+ /** The indentation of the line `index` sits on. */
456
+ function indentAt(text, index) {
457
+ const lineStart = text.lastIndexOf("\n", index - 1) + 1;
458
+ return /^[ \t]*/.exec(text.slice(lineStart))[0];
459
+ }
460
+ /** `value` as JSON, its nested lines indented under `indent`, stepped by `unit`. */
461
+ function renderJson(value, indent, unit) {
462
+ return JSON.stringify(value, null, unit).split("\n").join(`\n${indent}`);
463
+ }
464
+ function unitOf(text) {
465
+ const match = /\n([ \t]+)\S/.exec(text);
466
+ return match ? match[1] : " ";
467
+ }
468
+ /**
469
+ * Put `entry` at `path` of a JSON/JSONC object document. Empty text is an empty object. Missing
470
+ * intermediate objects are created; an existing member is replaced in its span; a new member is
471
+ * inserted before its object's closing brace. `strict` also requires the input to be plain JSON,
472
+ * for a harness whose reader is strict (an edit never makes a file unreadable to its harness).
473
+ */
474
+ export function upsertJsonMember(text, path, entry, options = {}) {
475
+ if (path.length === 0 || path.some((part) => FORBIDDEN_CONFIG_KEYS.has(part)))
476
+ throw new ConfigEditRefused("config_layout_unrecognised");
477
+ const source = text.trim() === "" ? "{}\n" : text;
478
+ let before;
479
+ try {
480
+ if (options.strict)
481
+ JSON.parse(source);
482
+ before = parseJsonc(source);
483
+ }
484
+ catch (error) {
485
+ if (error instanceof UnparseableConfigError || error instanceof SyntaxError)
486
+ throw new ConfigEditRefused("config_unparseable");
487
+ throw error;
488
+ }
489
+ if (!before || typeof before !== "object" || Array.isArray(before))
490
+ throw new ConfigEditRefused("config_not_an_object");
491
+ const existing = lookup(before, path);
492
+ if (existing !== undefined && deepEqual(existing, entry))
493
+ return { prior: "exact", text };
494
+ const unit = unitOf(source);
495
+ let node = scanJsonc(source);
496
+ let next = null;
497
+ for (let depth = 0; depth < path.length; depth += 1) {
498
+ if (node.kind !== "object")
499
+ throw new ConfigEditRefused("config_layout_unrecognised");
500
+ const matches = node.members.filter((member) => member.key === path[depth]);
501
+ if (matches.length > 1)
502
+ throw new ConfigEditRefused("config_layout_unrecognised");
503
+ const member = matches[0];
504
+ if (member && depth === path.length - 1) {
505
+ const indent = indentAt(source, member.keySpan.start);
506
+ next = source.slice(0, member.value.span.start) + renderJson(entry, indent, unit) + source.slice(member.value.span.end);
507
+ break;
508
+ }
509
+ if (member) {
510
+ node = member.value;
511
+ continue;
512
+ }
513
+ // Insert the rest of the path as one new member of this object.
514
+ let value = entry;
515
+ for (let rest = path.length - 1; rest > depth; rest -= 1)
516
+ value = { [path[rest]]: value };
517
+ const close = node.span.end - 1;
518
+ const last = node.members[node.members.length - 1];
519
+ const objectIndent = indentAt(source, node.span.start);
520
+ const memberIndent = last ? indentAt(source, last.keySpan.start) : objectIndent + unit;
521
+ const rendered = `${JSON.stringify(path[depth])}: ${renderJson(value, memberIndent, unit)}`;
522
+ if (last) {
523
+ // After the last member's comma when it has one (a JSONC trailing comma is kept, and ours
524
+ // follows the same style), else after its value with a comma added.
525
+ next = last.comma !== null
526
+ ? `${source.slice(0, last.comma + 1)}\n${memberIndent}${rendered},${source.slice(last.comma + 1)}`
527
+ : `${source.slice(0, last.value.span.end)},\n${memberIndent}${rendered}${source.slice(last.value.span.end)}`;
528
+ }
529
+ else {
530
+ const inner = source.slice(node.span.start + 1, close);
531
+ next = `${source.slice(0, node.span.start + 1)}${inner.trimEnd() === "" ? "" : inner.trimEnd()}\n${memberIndent}${rendered}\n${objectIndent}${source.slice(close)}`;
532
+ }
533
+ break;
534
+ }
535
+ if (next === null)
536
+ throw new ConfigEditRefused("config_layout_unrecognised");
537
+ let after;
538
+ try {
539
+ if (options.strict)
540
+ JSON.parse(next);
541
+ after = parseJsonc(next);
542
+ }
543
+ catch {
544
+ throw new ConfigEditRefused("config_layout_unrecognised");
545
+ }
546
+ if (!verified(before, after, path, entry))
547
+ throw new ConfigEditRefused("config_layout_unrecognised");
548
+ return { prior: existing === undefined ? "absent" : "differs", text: next };
549
+ }
package/dist/doctor.d.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  import { type IdentityHarness, type OperationSuccess, type OsKeychainExec, type SessionRecordEntry, type TrustOptions } from "@consiliency/agent-board-client";
2
2
  import { type ClaudePluginSummary, type RegistrationFs, type RegQuery, type RegistrationVerdict } from "./doctor_scopes.js";
3
3
  export type DoctorStatus = "pass" | "fail" | "undetermined";
4
- export declare const DOCTOR_CHECKS: readonly ["identity_file", "keystore_key", "whoami", "board_membership", "registration", "runtime"];
4
+ export declare const DOCTOR_CHECKS: readonly ["identity_file", "keystore_key", "key_storage", "whoami", "board_membership", "registration", "runtime"];
5
5
  export type DoctorCheckName = (typeof DOCTOR_CHECKS)[number];
6
6
  export interface DoctorCheck {
7
7
  status: DoctorStatus;
@@ -91,6 +91,12 @@ export interface DoctorOptions {
91
91
  lookupUserName?: () => string;
92
92
  /** Registry probe (Windows/WSL policy keys). Default: `reg query … /v Settings`, 5 s. */
93
93
  regQuery?: RegQuery;
94
+ /** key_storage: the directory holding the packaged `status.sh`. Default `resolveKeyslotDir()`. */
95
+ keyslotDir?: string;
96
+ /** key_storage: the host identity the keyslot records bind to. Default `/etc/machine-id`. */
97
+ machineIdFile?: string;
98
+ /** key_storage: doctor's own uid. Default `process.getuid()`. */
99
+ uid?: number;
94
100
  }
95
101
  /**
96
102
  * An error's `code`, only when this repository wrote it: a member of the derived