create-evda 0.1.9 → 0.1.10
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/CHANGELOG.md +72 -0
- package/README.md +12 -5
- package/bin/evda.mjs +122 -5
- package/create-evda.mjs +11 -3
- package/lib/adopt.mjs +488 -0
- package/lib/agents.mjs +1 -1
- package/lib/attested-host.mjs +269 -0
- package/lib/clients.mjs +11 -0
- package/lib/connect.mjs +1 -1
- package/lib/connecting-apps.mjs +56 -0
- package/lib/connector-tools.mjs +85 -0
- package/lib/corrections.mjs +34 -0
- package/lib/credentials-migrate.mjs +120 -0
- package/lib/day-file.mjs +121 -12
- package/lib/day-writer.mjs +8 -1
- package/lib/doctor.mjs +32 -3
- package/lib/every-chat.mjs +7 -11
- package/lib/gather-apply.mjs +133 -24
- package/lib/gather.mjs +116 -16
- package/lib/html-notes.mjs +124 -0
- package/lib/keep-hook.mjs +61 -8
- package/lib/keychain.mjs +38 -1
- package/lib/manifest.mjs +3 -1
- package/lib/mcp-http.mjs +19 -1
- package/lib/mcp.mjs +358 -56
- package/lib/measurement-log.mjs +3 -0
- package/lib/migrate-people.mjs +54 -20
- package/lib/person-folders.mjs +89 -0
- package/lib/plugin-hook.mjs +109 -0
- package/lib/plugin.mjs +208 -0
- package/lib/presets.mjs +26 -5
- package/lib/publish.mjs +55 -10
- package/lib/record.mjs +54 -0
- package/lib/routines.mjs +13 -2
- package/lib/takeout.mjs +169 -0
- package/lib/tdx-attestation.mjs +412 -0
- package/lib/tdx-trust.json +10 -0
- package/lib/tools.mjs +669 -59
- package/lib/touches.mjs +427 -0
- package/lib/update-check.mjs +30 -0
- package/lib/yaml.mjs +8 -4
- package/package.json +2 -1
- package/templates/AGENTS.md +56 -6
- package/templates/evda-digest.md +2 -0
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
// The gate a client goes through before it sends a hosted EVÐA anything: a record, a circle key, a
|
|
2
|
+
// token. (HQ-2083, Spec 08 section 8: "If it doesn't match, your phone won't send anything.")
|
|
3
|
+
//
|
|
4
|
+
// lib/tdx-attestation.mjs answers "does this quote prove this VM?". This file is what makes that answer
|
|
5
|
+
// decide whether bytes leave the device. A client gets a payload to a hosted EVÐA only through
|
|
6
|
+
// `gate.open(...)` and then `session.send(...)`, and every path that is not a verified quote under a
|
|
7
|
+
// pinned measurement ends in a refusal with nothing sent.
|
|
8
|
+
//
|
|
9
|
+
// The protocol, version 1. Over TLS to the host, on one connection:
|
|
10
|
+
// client -> host POST /.well-known/evda-attestation {"v":1,"nonce":"<base64, 32 bytes>"}
|
|
11
|
+
// host -> client {"v":1,"platform":"tdx","quote":"<base64>","collateral":{tcbInfo,
|
|
12
|
+
// tcbInfoIssuerChain,qeIdentity,qeIdentityIssuerChain}}
|
|
13
|
+
// The VM puts reportDataFor(nonce, its TLS public key) in the quote's REPORTDATA. The client takes the
|
|
14
|
+
// TLS key from the connection it is holding, never from the response body, so a host cannot name a key
|
|
15
|
+
// other than the one it is actually serving with. Delivery then goes over a connection pinned to that
|
|
16
|
+
// key (SHA-256 of its SPKI DER); a connection presenting any other key is refused before anything is
|
|
17
|
+
// written to it.
|
|
18
|
+
//
|
|
19
|
+
// The pinned measurement is the client's own configuration, never something a host supplies. With no
|
|
20
|
+
// pinned measurement the gate refuses: an unconfigured client is not a client that trusts everyone.
|
|
21
|
+
//
|
|
22
|
+
// Not here yet: tying the pinned measurement to EVÐA's Sigstore log (lib/measurement-log.mjs checks a
|
|
23
|
+
// logged digest; the statement that carries MRTD and RTMRs is a later slice), SEV-SNP, and the Swift
|
|
24
|
+
// port of this gate in iris-mobile, which follows this file and its test vectors.
|
|
25
|
+
|
|
26
|
+
import { createHash, createPublicKey, KeyObject, timingSafeEqual, X509Certificate } from "node:crypto";
|
|
27
|
+
import { request as httpsRequest } from "node:https";
|
|
28
|
+
import { createNonceBook, MEASUREMENT_NAMES, TDX_TRUST, verifyTdxAttestation, parseTdxQuote } from "./tdx-attestation.mjs";
|
|
29
|
+
|
|
30
|
+
/** Where a hosted EVÐA answers the attestation challenge. */
|
|
31
|
+
export const ATTESTATION_PATH = "/.well-known/evda-attestation";
|
|
32
|
+
|
|
33
|
+
/** The protocol version this client speaks. A response naming another version is refused. */
|
|
34
|
+
export const ATTESTATION_PROTOCOL_VERSION = 1;
|
|
35
|
+
|
|
36
|
+
/** Why the gate refused, as a stable code. `message` carries the same thing for a person. */
|
|
37
|
+
export const REFUSAL = Object.freeze({
|
|
38
|
+
NO_POLICY: "no-policy",
|
|
39
|
+
UNREACHABLE: "unreachable",
|
|
40
|
+
NO_QUOTE: "no-quote",
|
|
41
|
+
UNREADABLE_QUOTE: "unreadable-quote",
|
|
42
|
+
WRONG_MEASUREMENT: "wrong-measurement",
|
|
43
|
+
NOT_FRESH: "not-fresh",
|
|
44
|
+
NOT_BOUND: "not-bound",
|
|
45
|
+
NOT_PROVEN: "not-proven",
|
|
46
|
+
WRONG_CONNECTION: "wrong-connection",
|
|
47
|
+
NOT_DELIVERED: "not-delivered",
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
// What a person reads. Each says that nothing was sent and why, in words that need no TDX.
|
|
51
|
+
const SAID = {
|
|
52
|
+
[REFUSAL.NO_POLICY]: "this app has no published EVÐA code to compare the server against, so it cannot tell whether the server is safe",
|
|
53
|
+
[REFUSAL.UNREACHABLE]: "the server could not be reached to ask it for proof",
|
|
54
|
+
[REFUSAL.NO_QUOTE]: "the server did not send any proof of what code it is running",
|
|
55
|
+
[REFUSAL.UNREADABLE_QUOTE]: "the server's proof could not be read",
|
|
56
|
+
[REFUSAL.WRONG_MEASUREMENT]: "the server is not running the code EVÐA publishes",
|
|
57
|
+
[REFUSAL.NOT_FRESH]: "the server's proof answered a request this phone did not just make, so it could be an old proof played back",
|
|
58
|
+
[REFUSAL.NOT_BOUND]: "the server's proof was not made for this request on this connection, so it could be an old proof played back or someone in the middle",
|
|
59
|
+
[REFUSAL.NOT_PROVEN]: "the server could not prove it is a locked, up to date part of the chip",
|
|
60
|
+
[REFUSAL.WRONG_CONNECTION]: "the connection is not the one the server proved, so someone else could be in the middle",
|
|
61
|
+
[REFUSAL.NOT_DELIVERED]: "the server was proven, and sending to it then failed",
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
const sha256Hex = (b) => createHash("sha256").update(b).digest("hex");
|
|
65
|
+
|
|
66
|
+
/** A refusal: nothing was sent. `reason` is the detail for a log, `message` the sentence for a person. */
|
|
67
|
+
function refuse(code, reason) {
|
|
68
|
+
return { ok: false, sent: false, code, reason, message: `Nothing was sent: ${SAID[code]}.` };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const spkiDer = (key) => {
|
|
72
|
+
if (Buffer.isBuffer(key) || key instanceof Uint8Array) return createPublicKey({ key: Buffer.from(key), format: "der", type: "spki" }).export({ type: "spki", format: "der" });
|
|
73
|
+
if (key instanceof KeyObject && key.type === "public") return key.export({ type: "spki", format: "der" });
|
|
74
|
+
return createPublicKey(key).export({ type: "spki", format: "der" });
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
/** The pin for a TLS public key: SHA-256 of its SPKI DER, lowercase hex. The same value the verifier returns. */
|
|
78
|
+
export function tlsKeyPin(tlsPublicKey) {
|
|
79
|
+
return sha256Hex(spkiDer(tlsPublicKey));
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Whether `tlsPublicKey` is the key `pin` names. False for anything unreadable. */
|
|
83
|
+
export function pinMatches(pin, tlsPublicKey) {
|
|
84
|
+
try {
|
|
85
|
+
const have = Buffer.from(tlsKeyPin(tlsPublicKey), "hex");
|
|
86
|
+
const want = Buffer.from(String(pin ?? ""), "hex");
|
|
87
|
+
return want.length === 32 && timingSafeEqual(have, want);
|
|
88
|
+
} catch {
|
|
89
|
+
return false;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Why `policy` cannot gate anything, in words, or null when it can.
|
|
95
|
+
* policy: { platform: "tdx", measurements: { mrtd, rtmr0, rtmr1, rtmr2, rtmr3 }, minTcb: { tcbEvaluationDataNumber, statuses? } }
|
|
96
|
+
*/
|
|
97
|
+
export function hostPolicyProblem(policy) {
|
|
98
|
+
if (!policy) return "no pinned measurement is configured";
|
|
99
|
+
if (policy.platform !== "tdx") return `platform ${JSON.stringify(policy.platform ?? null)} is not one this client verifies (only tdx)`;
|
|
100
|
+
const m = policy.measurements;
|
|
101
|
+
const missing = MEASUREMENT_NAMES.filter((n) => !/^[0-9a-f]{96}$/.test(m?.[n] ?? ""));
|
|
102
|
+
if (missing.length) return `the pinned measurement does not name ${missing.join(", ")} as 96 lowercase hex characters each`;
|
|
103
|
+
if (!Number.isSafeInteger(policy.minTcb?.tcbEvaluationDataNumber)) return "the policy names no minimum TCB evaluation number";
|
|
104
|
+
return null;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** The JSON body a client sends to ATTESTATION_PATH for `nonce` (32 bytes). */
|
|
108
|
+
export function attestationRequest(nonce) {
|
|
109
|
+
const n = Buffer.from(nonce);
|
|
110
|
+
if (n.length !== 32) throw new Error("the nonce must be 32 bytes");
|
|
111
|
+
return { v: ATTESTATION_PROTOCOL_VERSION, nonce: n.toString("base64") };
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** Reads a host's answer. Returns { quote, collateral } or { error } in words; never throws. */
|
|
115
|
+
export function parseAttestationResponse(body) {
|
|
116
|
+
let r = body;
|
|
117
|
+
if (typeof r === "string" || Buffer.isBuffer(r) || r instanceof Uint8Array) {
|
|
118
|
+
try { r = JSON.parse(Buffer.from(r).toString("utf8")); } catch { return { error: "the response is not JSON" }; }
|
|
119
|
+
}
|
|
120
|
+
if (!r || typeof r !== "object") return { error: "the response is empty" };
|
|
121
|
+
if (r.v !== ATTESTATION_PROTOCOL_VERSION) return { error: `the response speaks protocol ${JSON.stringify(r.v ?? null)}, not ${ATTESTATION_PROTOCOL_VERSION}` };
|
|
122
|
+
if (r.platform !== "tdx") return { error: `the response is for platform ${JSON.stringify(r.platform ?? null)}, not tdx` };
|
|
123
|
+
if (typeof r.quote !== "string" || r.quote.length === 0) return { error: "the response carries no quote", missing: true };
|
|
124
|
+
if (!/^[A-Za-z0-9+/]+={0,2}$/.test(r.quote)) return { error: "the quote is not base64" };
|
|
125
|
+
return { quote: Buffer.from(r.quote, "base64"), collateral: r.collateral ?? null };
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// The verifier's reasons, sorted into what a person needs to know. The tests hold one case per code,
|
|
129
|
+
// so a reworded reason in tdx-attestation.mjs that stops matching fails there, not silently here.
|
|
130
|
+
function classify(reason) {
|
|
131
|
+
if (/^(MRTD|RTMR\d) is /.test(reason)) return REFUSAL.WRONG_MEASUREMENT;
|
|
132
|
+
if (/^REPORTDATA /.test(reason)) return REFUSAL.NOT_BOUND;
|
|
133
|
+
if (/nonce/.test(reason)) return REFUSAL.NOT_FRESH;
|
|
134
|
+
if (/could not be read|truncated|too short|shorter than|not understood/.test(reason)) return REFUSAL.UNREADABLE_QUOTE;
|
|
135
|
+
return REFUSAL.NOT_PROVEN;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* The gate. `policy` is the client's pinned measurement (see hostPolicyProblem); `clock` returns the
|
|
140
|
+
* time certificates, collateral and the nonce are judged at.
|
|
141
|
+
*
|
|
142
|
+
* gate.open({ challenge }) asks the host to prove itself. `challenge(requestBody)` is the transport: it
|
|
143
|
+
* POSTs the body to ATTESTATION_PATH and resolves { body, tlsPublicKey }, the host's answer and the TLS
|
|
144
|
+
* public key of the connection that answer came over. It resolves to an attested session, or a refusal.
|
|
145
|
+
*
|
|
146
|
+
* session.send(payload, { deliver }) hands `payload` to `deliver(payload, { pin })` once. `deliver` must
|
|
147
|
+
* send only over a connection whose TLS key matches `pin` (httpsHostTransport does this for Node) and
|
|
148
|
+
* resolve { tlsPublicKey, result } naming the key it actually used; a key other than the attested one is
|
|
149
|
+
* reported, and a transport that refuses the wrong key throws an error with code EVDA_PIN_MISMATCH. `payload` may be a function, called only after the host is proven, so key
|
|
150
|
+
* material can be produced only for a host that passed.
|
|
151
|
+
*/
|
|
152
|
+
export function createAttestedHostGate({ policy, nonces = createNonceBook(), clock = () => Date.now(), trust = TDX_TRUST } = {}) {
|
|
153
|
+
return {
|
|
154
|
+
async open({ challenge } = {}) {
|
|
155
|
+
const problem = hostPolicyProblem(policy);
|
|
156
|
+
if (problem) return refuse(REFUSAL.NO_POLICY, problem);
|
|
157
|
+
if (typeof challenge !== "function") return refuse(REFUSAL.UNREACHABLE, "no transport was given to reach the host");
|
|
158
|
+
|
|
159
|
+
const nonce = nonces.issue(clock());
|
|
160
|
+
let answer;
|
|
161
|
+
try {
|
|
162
|
+
answer = await challenge(attestationRequest(nonce));
|
|
163
|
+
} catch (err) {
|
|
164
|
+
return refuse(REFUSAL.UNREACHABLE, `the attestation request failed: ${err?.message ?? err}`);
|
|
165
|
+
}
|
|
166
|
+
const parsed = parseAttestationResponse(answer?.body);
|
|
167
|
+
if (parsed.error) return refuse(parsed.missing || answer?.body == null ? REFUSAL.NO_QUOTE : REFUSAL.UNREADABLE_QUOTE, parsed.error);
|
|
168
|
+
try {
|
|
169
|
+
parseTdxQuote(parsed.quote);
|
|
170
|
+
} catch (err) {
|
|
171
|
+
return refuse(REFUSAL.UNREADABLE_QUOTE, `the quote could not be read: ${err.message}`);
|
|
172
|
+
}
|
|
173
|
+
if (!answer.tlsPublicKey) return refuse(REFUSAL.WRONG_CONNECTION, "the transport did not report the TLS key of the connection the quote came over");
|
|
174
|
+
|
|
175
|
+
const v = verifyTdxAttestation({
|
|
176
|
+
quote: parsed.quote, nonce, nonces, tlsPublicKey: answer.tlsPublicKey,
|
|
177
|
+
expected: policy.measurements, minTcb: policy.minTcb, collateral: parsed.collateral, now: clock(), trust,
|
|
178
|
+
});
|
|
179
|
+
if (!v.ok) return refuse(classify(v.reason), v.reason);
|
|
180
|
+
return session({ pin: v.tlsPublicKeySha256, measurements: v.measurements, tcb: v.tcb });
|
|
181
|
+
},
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function session({ pin, measurements, tcb }) {
|
|
186
|
+
return {
|
|
187
|
+
ok: true,
|
|
188
|
+
pin,
|
|
189
|
+
measurements,
|
|
190
|
+
tcb,
|
|
191
|
+
async send(payload, { deliver } = {}) {
|
|
192
|
+
if (typeof deliver !== "function") return refuse(REFUSAL.NOT_DELIVERED, "no delivery transport was given");
|
|
193
|
+
let bytes;
|
|
194
|
+
try {
|
|
195
|
+
bytes = typeof payload === "function" ? await payload({ pin, measurements }) : payload;
|
|
196
|
+
} catch (err) {
|
|
197
|
+
return refuse(REFUSAL.NOT_DELIVERED, `the payload could not be prepared: ${err?.message ?? err}`);
|
|
198
|
+
}
|
|
199
|
+
let out;
|
|
200
|
+
try {
|
|
201
|
+
out = await deliver(bytes, { pin });
|
|
202
|
+
} catch (err) {
|
|
203
|
+
if (err?.code === "EVDA_PIN_MISMATCH") return refuse(REFUSAL.WRONG_CONNECTION, err.message);
|
|
204
|
+
return refuse(REFUSAL.NOT_DELIVERED, `delivery failed: ${err?.message ?? err}`);
|
|
205
|
+
}
|
|
206
|
+
// A transport that pins as it must never reaches this; one that does not has already sent, and says so.
|
|
207
|
+
if (!pinMatches(pin, out?.tlsPublicKey)) return { ok: false, sent: true, code: REFUSAL.WRONG_CONNECTION, reason: "the delivery went over a TLS key other than the attested one", message: `Sent over the wrong connection: ${SAID[REFUSAL.WRONG_CONNECTION]}. Treat what was sent as exposed.` };
|
|
208
|
+
return { ok: true, sent: true, pin, result: out.result };
|
|
209
|
+
},
|
|
210
|
+
};
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// ─── the Node transport (the Mac client; the phone's is Swift) ──────────────────────────────────
|
|
214
|
+
|
|
215
|
+
const keyOfCert = (cert) => (cert?.raw ? new X509Certificate(cert.raw).publicKey : null);
|
|
216
|
+
|
|
217
|
+
function postJson(url, body, { pin } = {}) {
|
|
218
|
+
return new Promise((resolve, reject) => {
|
|
219
|
+
const u = new URL(url);
|
|
220
|
+
if (u.protocol !== "https:") return reject(new Error(`${u.origin} is not https`));
|
|
221
|
+
const data = Buffer.from(JSON.stringify(body));
|
|
222
|
+
const req = httpsRequest(u, { method: "POST", rejectUnauthorized: false, agent: false, headers: { "content-type": "application/json", "content-length": data.length } }, (res) => {
|
|
223
|
+
const chunks = [];
|
|
224
|
+
res.on("data", (c) => chunks.push(c));
|
|
225
|
+
res.on("end", () => resolve({ status: res.statusCode, body: Buffer.concat(chunks), tlsPublicKey }));
|
|
226
|
+
res.on("error", reject);
|
|
227
|
+
});
|
|
228
|
+
let tlsPublicKey = null;
|
|
229
|
+
req.on("error", reject);
|
|
230
|
+
// Nothing is written until the handshake is done and the key is read, so a pinned request to the
|
|
231
|
+
// wrong key leaves with no header and no body.
|
|
232
|
+
req.on("socket", (socket) => {
|
|
233
|
+
socket.once("secureConnect", () => {
|
|
234
|
+
try { tlsPublicKey = keyOfCert(socket.getPeerCertificate(true)); } catch { tlsPublicKey = null; }
|
|
235
|
+
if (pin !== undefined && !pinMatches(pin, tlsPublicKey)) {
|
|
236
|
+
const err = Object.assign(new Error("the server's TLS key is not the attested one"), { code: "EVDA_PIN_MISMATCH" });
|
|
237
|
+
req.destroy(err);
|
|
238
|
+
return;
|
|
239
|
+
}
|
|
240
|
+
req.end(data);
|
|
241
|
+
});
|
|
242
|
+
});
|
|
243
|
+
});
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* The Node transport for one hosted EVÐA at `origin` (https). challenge() for gate.open, and
|
|
248
|
+
* deliver(path) for session.send, which posts the payload (JSON-serializable) over a connection pinned
|
|
249
|
+
* to the attested key: a server presenting any other key is dropped after the handshake, before any
|
|
250
|
+
* header or body is written. The VM's certificate is self-signed inside the VM, so the CA check is
|
|
251
|
+
* replaced by the attestation and the pin, not by nothing.
|
|
252
|
+
*/
|
|
253
|
+
export function httpsHostTransport(origin) {
|
|
254
|
+
return {
|
|
255
|
+
async challenge(requestBody) {
|
|
256
|
+
const r = await postJson(new URL(ATTESTATION_PATH, origin), requestBody);
|
|
257
|
+
if (r.status !== 200) throw new Error(`the host answered ${r.status}`);
|
|
258
|
+
return { body: r.body, tlsPublicKey: r.tlsPublicKey };
|
|
259
|
+
},
|
|
260
|
+
deliver(path) {
|
|
261
|
+
return async (payload, { pin }) => {
|
|
262
|
+
if (!pin) throw new Error("no pin was given, so delivery cannot be bound to the attested key");
|
|
263
|
+
const r = await postJson(new URL(path, origin), payload, { pin });
|
|
264
|
+
if (r.status < 200 || r.status >= 300) throw new Error(`the host answered ${r.status}`);
|
|
265
|
+
return { tlsPublicKey: r.tlsPublicKey, result: r.body };
|
|
266
|
+
};
|
|
267
|
+
},
|
|
268
|
+
};
|
|
269
|
+
}
|
package/lib/clients.mjs
CHANGED
|
@@ -40,6 +40,17 @@ export function clientsFor(command, args, { desktopConfig = null, filing = null
|
|
|
40
40
|
text: JSON.stringify({ mcpServers: { [SERVER_NAME]: { command, args } } }, null, 2),
|
|
41
41
|
file: desktopConfig ?? "claude_desktop_config.json",
|
|
42
42
|
},
|
|
43
|
+
{
|
|
44
|
+
// Cowork starts no server from claude_desktop_config.json and reads no folder's hooks; a plugin
|
|
45
|
+
// is the one way in (HQ-2802, lib/plugin.mjs). The same command as the server, with plugin for mcp.
|
|
46
|
+
key: "cowork",
|
|
47
|
+
label: "Claude Cowork",
|
|
48
|
+
steps: [
|
|
49
|
+
"Cowork does not use the desktop config above. Run this once to build a plugin for this record, then in the Claude app open Customize, then Plugins, then Add, then Upload plugin, and choose the zip it names.",
|
|
50
|
+
],
|
|
51
|
+
text: [command, ...args.map((a, i) => (i === args.length - 2 && a === "mcp" ? "plugin" : a))].map(shellQuote).join(" "),
|
|
52
|
+
file: null,
|
|
53
|
+
},
|
|
43
54
|
{
|
|
44
55
|
key: "project",
|
|
45
56
|
label: "One folder only (.mcp.json)",
|
package/lib/connect.mjs
CHANGED
|
@@ -82,7 +82,7 @@ export function guardStatus(recordDir) {
|
|
|
82
82
|
|
|
83
83
|
// A `node` on PATH that is this same binary, when there is one: Homebrew's /opt/homebrew/bin/node
|
|
84
84
|
// survives an upgrade, and the versioned Cellar path process.execPath reports does not.
|
|
85
|
-
function stableNode(execPath, env) {
|
|
85
|
+
export function stableNode(execPath = process.execPath, env = process.env) {
|
|
86
86
|
let target;
|
|
87
87
|
try { target = realpathSync(execPath); } catch { return execPath; }
|
|
88
88
|
for (const d of String(env.PATH ?? "").split(delimiter)) {
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
// Which app is connecting, from initialize's clientInfo.name (HQ-2218). One table names each app once,
|
|
2
|
+
// and both uses read it: clientProfile in lib/mcp.mjs (which sections, how large a part) by the exact
|
|
3
|
+
// names in `names`, and chatAppOf in lib/every-chat.mjs (which every-chat setup words) by `like`.
|
|
4
|
+
// Before this the two were separate tables (HQ-2207, HQ-2205) that agreed only by care.
|
|
5
|
+
// No node: imports, because lib/every-chat.mjs reads this file and lib/clients.mjs, rendered in the
|
|
6
|
+
// EVDA app's webview, reads that one.
|
|
7
|
+
//
|
|
8
|
+
// names: the exact clientInfo.name values, compared trimmed and case-insensitively. claude-code and
|
|
9
|
+
// codex-mcp-client measured 2026-10-05 (a probe server logging clientInfo, driven by `claude -p` and
|
|
10
|
+
// `codex exec`). claude.ai sends Anthropic/ClaudeAI, measured 2026-10-05 in the remote server's log
|
|
11
|
+
// from a fresh web chat; claude-ai stays as the published form. openai-mcp is the name ChatGPT
|
|
12
|
+
// publishes and is unmeasured until Hestia's evda-mcp log holds one (it held none on 2026-10-15).
|
|
13
|
+
//
|
|
14
|
+
// like: a pattern for names nobody has measured yet, read top to bottom, first match wins. Every
|
|
15
|
+
// coding agent comes before the chat apps, because its every-session file is AGENTS.md or
|
|
16
|
+
// CLAUDE.md, which create-evda already writes into the record, so it gets no every-chat words
|
|
17
|
+
// (chat: null). A name no row matches is "other", asked with GENERIC_WHERE, so a new app still gets
|
|
18
|
+
// the line.
|
|
19
|
+
//
|
|
20
|
+
// surface, max, budget: what clientProfile serves an app it knows by name. max null is the smallest
|
|
21
|
+
// documented cap, LAYER_PART_MAX in lib/mcp.mjs; budget null is CHAT_LAYER_BUDGET there.
|
|
22
|
+
// Limits: claude.ai and the desktop app take a tool result of about 150,000 characters
|
|
23
|
+
// (claude.com/docs/connectors/building, "Design within the size and timeout limits"); Claude Code
|
|
24
|
+
// 25,000 tokens (same page, MAX_MCP_OUTPUT_TOKENS); OpenAI publishes no figure for ChatGPT and
|
|
25
|
+
// truncates an over-budget result at a line, so ChatGPT and every unknown app get the smallest
|
|
26
|
+
// cap. Codex documents none either and gets the same.
|
|
27
|
+
// claude.ai's budget is its own (HQ-2327): his chat layer measured 2026-10-07 at about 90,000
|
|
28
|
+
// characters whole (head 5,000, core 28,717, owner sections 56,041), and at the shared 70,000 his
|
|
29
|
+
// "Proactive Context Loading" (20,238, the Deep Work Queue, Weekends & Trips and friction rules)
|
|
30
|
+
// reached claude.ai as one listed heading, fetched 0 times in 21 layer loads. 100,000 serves it
|
|
31
|
+
// whole and leaves 40,000 of the 140,000 cap for what the first call adds after the layer
|
|
32
|
+
// (openChat: replies, recent days, people).
|
|
33
|
+
export const CLAUDE_AI_LAYER_BUDGET = 100_000;
|
|
34
|
+
|
|
35
|
+
export const CONNECTING_APPS = [
|
|
36
|
+
{ app: "Claude Code", names: ["claude-code"], like: /claude[-_ ]?code/i, chat: null, surface: "terminal", max: null, budget: null },
|
|
37
|
+
{ app: "Codex", names: ["codex-mcp-client"], like: /codex/i, chat: null, surface: "terminal", max: null, budget: null },
|
|
38
|
+
// Coding agents nobody has measured. No exact name, so clientProfile treats them as unknown apps.
|
|
39
|
+
{ app: "another coding agent", names: [], like: /cursor|copilot|vscode|windsurf|zed|opencode|cline|aider|gemini[-_ ]?cli|goose|mcp-inspector|inspector/i, chat: null, surface: null, max: null, budget: null },
|
|
40
|
+
{ app: "claude.ai", names: ["claude-ai", "anthropic/claudeai"], like: /claude|anthropic/i, chat: "claude", surface: "chat", max: 140_000, budget: CLAUDE_AI_LAYER_BUDGET },
|
|
41
|
+
{ app: "ChatGPT", names: ["openai-mcp"], like: /openai|chatgpt/i, chat: "chatgpt", surface: "chat", max: null, budget: null },
|
|
42
|
+
];
|
|
43
|
+
|
|
44
|
+
const clean = (name) => (typeof name === "string" ? name.trim() : "");
|
|
45
|
+
|
|
46
|
+
// The row whose exact names include this one, or null.
|
|
47
|
+
export function appNamed(name) {
|
|
48
|
+
const n = clean(name).toLowerCase();
|
|
49
|
+
return n ? CONNECTING_APPS.find((a) => a.names.includes(n)) ?? null : null;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// The first row whose pattern matches this name, or null.
|
|
53
|
+
export function appLike(name) {
|
|
54
|
+
const n = String(name ?? "").trim();
|
|
55
|
+
return CONNECTING_APPS.find((a) => a.like.test(n)) ?? null;
|
|
56
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
// New tools in a chat app (HQ-2205, the last step). A chat app keeps a connector's tool list and the
|
|
2
|
+
// permission set on each tool, and the permissions a person set before do not cover a tool that came
|
|
3
|
+
// later: a call to it can be refused, or held for approval, without the model being told why. Adding
|
|
4
|
+
// or renaming a tool on this server is exactly that. So the server remembers, per record and per
|
|
5
|
+
// chat app, every tool name it has offered that app, and the first time it offers a name the app has
|
|
6
|
+
// never been offered, the next tool result says so once, in plain words, with where the permissions
|
|
7
|
+
// are set. Then the name joins the remembered list and is never raised again in that app.
|
|
8
|
+
//
|
|
9
|
+
// Only names count. A permission belongs to a tool's name, so a changed description needs nothing,
|
|
10
|
+
// and a tool that left the list (evda_every_chat once the owner answered it) needs nothing either.
|
|
11
|
+
// One that comes back is not new: the app was offered it before.
|
|
12
|
+
//
|
|
13
|
+
// A first connection is told nothing: the person is setting permissions for the first time, and the
|
|
14
|
+
// setup already says where (ALWAYS_ALLOW_LINE in lib/mcp.mjs). It only remembers what was offered.
|
|
15
|
+
//
|
|
16
|
+
// Where it is kept: `<record>/.evda/connector-tools.json`, the record's own state, which sync never
|
|
17
|
+
// sends to another holder (sync-rules EXCLUDE_ROOT_NAMES) and nothing reads as the owner's words. It
|
|
18
|
+
// is bookkeeping about what was offered an app, kept with the record rather than the computer because
|
|
19
|
+
// an app's permissions belong to the owner's account in that app, whichever computer serves it. Lost,
|
|
20
|
+
// it costs one notice: the next connection is taken for a first one and remembers the list again.
|
|
21
|
+
//
|
|
22
|
+
// { "claude": { "tools": ["evda_read", ...], "date": "2026-10-18" } }
|
|
23
|
+
|
|
24
|
+
import { mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
|
25
|
+
import { join } from "node:path";
|
|
26
|
+
import { CHAT_APPS } from "./every-chat.mjs";
|
|
27
|
+
|
|
28
|
+
export const CONNECTOR_TOOLS_FILE = "connector-tools.json";
|
|
29
|
+
|
|
30
|
+
// Where each app sets a connector's tool permissions. Claude's is the one this package already gives
|
|
31
|
+
// in setup (ALWAYS_ALLOW_LINE); an app whose page has not been read gets the general words, because
|
|
32
|
+
// naming a menu that does not exist would send them hunting for it.
|
|
33
|
+
export const PERMISSIONS_WHERE = {
|
|
34
|
+
claude: "claude.ai Settings, Connectors, EVÐA, tool permissions",
|
|
35
|
+
};
|
|
36
|
+
export const GENERIC_PERMISSIONS_WHERE = "the EVÐA connector's settings in their app, where each tool's permission is set";
|
|
37
|
+
|
|
38
|
+
export const CONNECTOR_TOOLS = join(".evda", CONNECTOR_TOOLS_FILE);
|
|
39
|
+
export function connectorToolsPath(evda) {
|
|
40
|
+
return typeof evda?.dir === "string" && evda.dir ? join(evda.dir, CONNECTOR_TOOLS) : null;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function readSeen(path) {
|
|
44
|
+
try {
|
|
45
|
+
const all = JSON.parse(readFileSync(path, "utf8"));
|
|
46
|
+
return all && typeof all === "object" && !Array.isArray(all) ? all : {};
|
|
47
|
+
} catch { return {}; }
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function writeSeen(path, all) {
|
|
51
|
+
mkdirSync(join(path, ".."), { recursive: true });
|
|
52
|
+
const part = `${path}.${process.pid}.tmp`;
|
|
53
|
+
writeFileSync(part, `${JSON.stringify(all, null, 2)}\n`);
|
|
54
|
+
renameSync(part, path);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Compare what this app is offered now against every name it was offered before, and remember the
|
|
58
|
+
// union. Returns the names it has never been offered, or [] on a first connection, an unchanged
|
|
59
|
+
// list, or no record to keep the list in.
|
|
60
|
+
export function newToolsFor(evda, app, offered, { now = new Date() } = {}) {
|
|
61
|
+
if (!app) return [];
|
|
62
|
+
const path = connectorToolsPath(evda);
|
|
63
|
+
if (!path) return [];
|
|
64
|
+
const all = readSeen(path);
|
|
65
|
+
const before = Array.isArray(all[app]?.tools) ? all[app].tools.filter((n) => typeof n === "string") : null;
|
|
66
|
+
const names = [...new Set(offered)].sort();
|
|
67
|
+
const fresh = before ? names.filter((n) => !before.includes(n)) : [];
|
|
68
|
+
if (before && !fresh.length) return [];
|
|
69
|
+
all[app] = { tools: [...new Set([...(before ?? []), ...names])].sort(), date: now.toISOString().slice(0, 10) };
|
|
70
|
+
writeSeen(path, all);
|
|
71
|
+
return fresh;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// What the model reads, after the tool's own result, the once an app is offered new tools. `titles`
|
|
75
|
+
// maps a tool name to the title the app shows the person beside its permission.
|
|
76
|
+
export function newToolsNotice(app, names, titles = {}) {
|
|
77
|
+
const label = CHAT_APPS[app]?.label ?? "this app";
|
|
78
|
+
const where = PERMISSIONS_WHERE[app] ?? GENERIC_PERMISSIONS_WHERE;
|
|
79
|
+
const list = names.map((n) => (titles[n] ? `${titles[n]} (${n})` : n)).join(", ");
|
|
80
|
+
return [
|
|
81
|
+
`## New EVÐA tools in ${label}`,
|
|
82
|
+
"",
|
|
83
|
+
`EVÐA has tools ${label} was not offered before: ${list}. A chat app keeps the tool permissions the owner set when they connected, and those do not cover a tool added later, so a call to one of these can be refused or held without saying why. Once, after answering what they asked, tell them in a sentence or two: EVÐA added tools, and to use them they should open ${where} and set the new ones to allow. If a call to one of these tools fails in this chat, tell them this is the likely reason rather than trying it again. This is said once in ${label} and not again.`,
|
|
84
|
+
].join("\n");
|
|
85
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
// Where a correction lands (HQ-2325). By default it is one dated line in the operating layer's
|
|
2
|
+
// "## What I have been taught". A record that keeps that list in its own file names it in evda.json:
|
|
3
|
+
//
|
|
4
|
+
// "corrections": "<path to a Markdown file, relative to the record>"
|
|
5
|
+
//
|
|
6
|
+
// and evda_record_correction appends a dated section there instead. The owner's vault keeps its list
|
|
7
|
+
// in a Schema Friction Log that CLAUDE.md names as its "What I have been taught", so without this
|
|
8
|
+
// every correction from a chat app was refused and lost. Doctor reads the same setting through this
|
|
9
|
+
// one function, so the tool and the check cannot disagree on what a valid setting is.
|
|
10
|
+
|
|
11
|
+
import { existsSync, statSync } from "node:fs";
|
|
12
|
+
import { isAbsolute, relative, resolve, sep } from "node:path";
|
|
13
|
+
import { isSealed } from "./circles.mjs";
|
|
14
|
+
|
|
15
|
+
export const CORRECTIONS_KEY = "corrections";
|
|
16
|
+
|
|
17
|
+
// null when evda.json does not set it. Otherwise { rel, abs, problem }: problem is null when the
|
|
18
|
+
// setting names an existing, unsealed Markdown file inside the record, and a plain sentence when not.
|
|
19
|
+
export function correctionsTarget(evda) {
|
|
20
|
+
const value = evda.manifest?.[CORRECTIONS_KEY];
|
|
21
|
+
if (value === undefined) return null;
|
|
22
|
+
const bad = (problem, rel = String(value)) => ({ rel, abs: null, problem });
|
|
23
|
+
if (typeof value !== "string" || value.trim() === "") return bad(`"${CORRECTIONS_KEY}" must be the path of a Markdown file, relative to the record.`);
|
|
24
|
+
const want = value.trim();
|
|
25
|
+
if (isAbsolute(want)) return bad(`"${CORRECTIONS_KEY}" is ${want}, an absolute path; it names a file relative to the record.`, want);
|
|
26
|
+
const root = resolve(evda.dir);
|
|
27
|
+
const abs = resolve(root, want);
|
|
28
|
+
if (!abs.startsWith(root + sep)) return bad(`"${CORRECTIONS_KEY}" is ${want}, which is outside the record.`, want);
|
|
29
|
+
const rel = relative(root, abs).split("\\").join("/");
|
|
30
|
+
if (!/\.md$/i.test(abs)) return { rel, abs, problem: `"${CORRECTIONS_KEY}" names ${rel}, which is not a Markdown file.` };
|
|
31
|
+
if (!existsSync(abs) || !statSync(abs).isFile()) return { rel, abs, problem: `"${CORRECTIONS_KEY}" names ${rel}, and there is no file there, so a correction has nowhere to land.` };
|
|
32
|
+
if (isSealed(abs)) return { rel, abs, problem: `"${CORRECTIONS_KEY}" names ${rel}, which is sealed, so no agent writes a correction into it.` };
|
|
33
|
+
return { rel, abs, problem: null };
|
|
34
|
+
}
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
// One-time move of Credentials/ files written before HQ-1916 into the Keychain (HQ-2622).
|
|
2
|
+
//
|
|
3
|
+
// HQ-1916 made `evda gather apply` put each credential value into the login Keychain and leave a
|
|
4
|
+
// pointer in the file. Files already sitting in a record's Credentials/ folder, sealed before that
|
|
5
|
+
// shipped (or kept plain because the Keychain write failed then), still hold their values. This
|
|
6
|
+
// walks Credentials/ once and gives each of those files the same treatment: every value goes into
|
|
7
|
+
// the Keychain, and its line keeps its label with the value replaced by the pointer HQ-1916 writes:
|
|
8
|
+
// Password: in your Keychain (EVÐA, wifi.md, password)
|
|
9
|
+
//
|
|
10
|
+
// Safe to run again: a line that already holds a pointer is not a value (extractSecrets skips it),
|
|
11
|
+
// so a migrated file has nothing left to move and is not rewritten.
|
|
12
|
+
// All-or-nothing per file: a value is never removed from a file until every value in that file is
|
|
13
|
+
// in the Keychain, and if any write fails, the items this run added for that file are removed again
|
|
14
|
+
// and the file is left byte for byte as it was. Nothing is lost.
|
|
15
|
+
// A Keychain item already at an account with a different value (another file of the same name, or
|
|
16
|
+
// one the owner made by hand) is never overwritten: the value is filed under the next free label,
|
|
17
|
+
// "password 2", and the pointer names that label. One already holding the same value is reused.
|
|
18
|
+
//
|
|
19
|
+
// The Keychain is reached only through lib/keychain.mjs, so EVDA_KEYCHAIN_FALSIFY and a throwaway
|
|
20
|
+
// keychain path work here exactly as they do for gather apply.
|
|
21
|
+
|
|
22
|
+
import { existsSync, readdirSync, readFileSync, renameSync, statSync, writeFileSync, chmodSync, unlinkSync } from "node:fs";
|
|
23
|
+
import { join } from "node:path";
|
|
24
|
+
import { CREDENTIALS } from "./gather.mjs";
|
|
25
|
+
import { SERVICE, accountFor, addSecret, extractSecrets, keychainAvailable, pointerFor, readSecret, removeSecret } from "./keychain.mjs";
|
|
26
|
+
|
|
27
|
+
// The label a value is filed under: its own, unless the Keychain already holds a different value
|
|
28
|
+
// there or this file already used it, then "<label> 2", "<label> 3", ... until one is free or
|
|
29
|
+
// already holds this exact value. Returns { label, exists }.
|
|
30
|
+
function freeLabel(fileName, label, value, used, keychainPath) {
|
|
31
|
+
for (let n = 1; ; n++) {
|
|
32
|
+
const candidate = n === 1 ? label : `${label} ${n}`;
|
|
33
|
+
if (used.has(candidate)) continue;
|
|
34
|
+
let held;
|
|
35
|
+
try { held = readSecret({ account: accountFor(fileName, candidate), keychainPath }); } catch { return { label: candidate, exists: false }; }
|
|
36
|
+
if (held === value) return { label: candidate, exists: true };
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// Move one file's values into the Keychain and return its new text, or say why it stays as it is.
|
|
41
|
+
// Returns { status: "moved", text, added, labels } | { status: "unchanged" } | { status: "kept", why }.
|
|
42
|
+
export function migrateText(text, fileName, { keychainPath = null, dry = false } = {}) {
|
|
43
|
+
const secrets = extractSecrets(text);
|
|
44
|
+
if (!secrets.length) return { status: "unchanged" };
|
|
45
|
+
if (dry) return { status: "moved", text: null, added: [], labels: secrets.map((s) => s.label), dry: true };
|
|
46
|
+
const used = new Set();
|
|
47
|
+
const added = [];
|
|
48
|
+
const labels = [];
|
|
49
|
+
try {
|
|
50
|
+
for (const s of secrets) {
|
|
51
|
+
const { label, exists } = freeLabel(fileName, s.label, s.value, used, keychainPath);
|
|
52
|
+
used.add(label);
|
|
53
|
+
labels.push(label);
|
|
54
|
+
if (exists) continue;
|
|
55
|
+
const account = accountFor(fileName, label);
|
|
56
|
+
addSecret({ account, value: s.value, keychainPath });
|
|
57
|
+
added.push(account);
|
|
58
|
+
}
|
|
59
|
+
} catch (e) {
|
|
60
|
+
for (const account of added) removeSecret({ account, keychainPath });
|
|
61
|
+
return { status: "kept", why: `the Keychain write failed (${String(e.message ?? e).split("\n")[0]})` };
|
|
62
|
+
}
|
|
63
|
+
const lines = text.split("\n");
|
|
64
|
+
secrets.forEach((s, i) => { lines[s.index] = s.whole ? pointerFor(fileName, labels[i]) : `${s.prefix}${pointerFor(fileName, labels[i])}`; });
|
|
65
|
+
return { status: "moved", text: lines.join("\n"), added, labels };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Every file directly in <root>/Credentials/, migrated. Never throws for one file's failure.
|
|
69
|
+
// Returns { folder, files: [{ name, status, why?, count? }], moved, unchanged, kept, skipped }.
|
|
70
|
+
export function migrateCredentials(root, { keychainPath = null, dry = false } = {}) {
|
|
71
|
+
const folder = join(root, CREDENTIALS);
|
|
72
|
+
const r = { folder, exists: existsSync(folder), available: true, dry, files: [], moved: 0, unchanged: 0, kept: 0, skipped: 0 };
|
|
73
|
+
if (!r.exists) return r;
|
|
74
|
+
const names = readdirSync(folder).filter((n) => !n.startsWith(".")).sort();
|
|
75
|
+
r.available = keychainAvailable();
|
|
76
|
+
for (const name of names) {
|
|
77
|
+
const abs = join(folder, name);
|
|
78
|
+
const st = statSync(abs);
|
|
79
|
+
if (!st.isFile() || !/\.(md|txt)$/i.test(name)) { r.skipped += 1; r.files.push({ name, status: "skipped", why: st.isFile() ? "not a text note" : "a folder; only files directly in Credentials/ are read" }); continue; }
|
|
80
|
+
const text = readFileSync(abs, "utf8");
|
|
81
|
+
if (!extractSecrets(text).length) { r.unchanged += 1; r.files.push({ name, status: "unchanged" }); continue; }
|
|
82
|
+
if (!r.available) { r.kept += 1; r.files.push({ name, status: "kept", why: "not on macOS, or the Keychain is unavailable" }); continue; }
|
|
83
|
+
const m = migrateText(text, name, { keychainPath, dry });
|
|
84
|
+
if (m.status === "kept") { r.kept += 1; r.files.push({ name, status: "kept", why: m.why }); continue; }
|
|
85
|
+
if (!dry) {
|
|
86
|
+
// Written beside the original and renamed over it, so the file is never half written.
|
|
87
|
+
const tmp = join(folder, `.${name}.hq2622-${process.pid}.tmp`);
|
|
88
|
+
try {
|
|
89
|
+
writeFileSync(tmp, m.text, { mode: st.mode & 0o7777 });
|
|
90
|
+
chmodSync(tmp, st.mode & 0o7777);
|
|
91
|
+
renameSync(tmp, abs);
|
|
92
|
+
} catch (e) {
|
|
93
|
+
try { unlinkSync(tmp); } catch { /* never made */ }
|
|
94
|
+
for (const account of m.added) removeSecret({ account, keychainPath });
|
|
95
|
+
r.kept += 1;
|
|
96
|
+
r.files.push({ name, status: "kept", why: `the file could not be rewritten (${String(e.message ?? e).split("\n")[0]})` });
|
|
97
|
+
continue;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
r.moved += 1;
|
|
101
|
+
r.files.push({ name, status: "moved", count: m.labels.length });
|
|
102
|
+
}
|
|
103
|
+
return r;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// What `evda secret migrate` prints, ending in one CHECKED or SKIPPED line.
|
|
107
|
+
export function migrateLines(r) {
|
|
108
|
+
if (!r.exists) return [`SKIPPED Credentials: ${r.folder} does not exist, so there is nothing to move`];
|
|
109
|
+
const out = [];
|
|
110
|
+
for (const f of r.files) {
|
|
111
|
+
if (f.status === "moved") out.push(` ${r.dry ? "would move" : "moved"} ${f.name}: ${f.count} value(s) ${r.dry ? "would go" : "went"} to the Keychain (${SERVICE}); the file holds a pointer, not the value`);
|
|
112
|
+
else if (f.status === "kept") out.push(` kept ${f.name}: the value stays in the sealed file (${f.why})`);
|
|
113
|
+
else if (f.status === "skipped") out.push(` skipped ${f.name}: ${f.why}`);
|
|
114
|
+
else out.push(` done ${f.name}: no value left in the file`);
|
|
115
|
+
}
|
|
116
|
+
const n = r.files.length - r.skipped;
|
|
117
|
+
if (!n) out.push(`SKIPPED Credentials: ${r.folder} holds no credential file`);
|
|
118
|
+
else out.push(`CHECKED ${n} credential file(s) ${r.moved} ${r.dry ? "would move" : "moved"} to the Keychain, ${r.unchanged} already holding no value, ${r.kept} kept in the sealed file`);
|
|
119
|
+
return out;
|
|
120
|
+
}
|