@ouro.bot/cli 0.1.0-alpha.723 → 0.1.0-alpha.724
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.json +283 -275
- package/dist/heart/daemon/cli-exec.js +60 -1
- package/dist/heart/daemon/doctor.js +61 -0
- package/dist/senses/mail.js +129 -0
- package/package.json +1 -1
|
@@ -3707,6 +3707,57 @@ function hostedMissingMailKeys(body, keys) {
|
|
|
3707
3707
|
rotateSourceGrant: missingSourceGrant,
|
|
3708
3708
|
};
|
|
3709
3709
|
}
|
|
3710
|
+
/**
|
|
3711
|
+
* Key ids the public registry declares for this agent, or `[]` when the
|
|
3712
|
+
* registry cannot be consulted.
|
|
3713
|
+
*
|
|
3714
|
+
* `requiredHostedKeyIds` derives the asserted set from the ensure response
|
|
3715
|
+
* alone, so anything the response omits is never asserted — and the server
|
|
3716
|
+
* omits `sourceGrant` unless a source alias is in play. That is how the
|
|
3717
|
+
* 2026-07-24 gap passed: the registry declared a source-grant key, the vault
|
|
3718
|
+
* never received its private half, and setup reported success. The registry is
|
|
3719
|
+
* the authority on what mail is currently being encrypted to, so it is
|
|
3720
|
+
* consulted too.
|
|
3721
|
+
*
|
|
3722
|
+
* Never throws. An unreachable registry degrades to the response-only
|
|
3723
|
+
* assertion, because refusing a setup that previously succeeded — on a machine
|
|
3724
|
+
* whose Azure credentials happen to be unavailable — would be a worse failure
|
|
3725
|
+
* than the one this guards against.
|
|
3726
|
+
*
|
|
3727
|
+
* The agent match is case-insensitive: the harness sends `agentId: agent`
|
|
3728
|
+
* verbatim in the ensure payload while registry records can carry the
|
|
3729
|
+
* lowercased form.
|
|
3730
|
+
*/
|
|
3731
|
+
async function hostedRegistryDeclaredKeyIds(input) {
|
|
3732
|
+
try {
|
|
3733
|
+
const publicRegistry = isPlainRecord(input.body.publicRegistry) ? input.body.publicRegistry : {};
|
|
3734
|
+
const managedIdentityClientId = stringField(input.existingMailroom ?? {}, "azureManagedIdentityClientId");
|
|
3735
|
+
const registry = await input.readRegistry({
|
|
3736
|
+
mailboxAddress: "",
|
|
3737
|
+
privateKeys: {},
|
|
3738
|
+
registryAzureAccountUrl: stringField(publicRegistry, "azureAccountUrl"),
|
|
3739
|
+
registryContainer: stringField(publicRegistry, "container"),
|
|
3740
|
+
registryBlob: stringField(publicRegistry, "blob"),
|
|
3741
|
+
...(managedIdentityClientId ? { azureManagedIdentityClientId: managedIdentityClientId } : {}),
|
|
3742
|
+
});
|
|
3743
|
+
const wanted = input.agent.toLowerCase();
|
|
3744
|
+
const isThisAgent = (agentId) => agentId.toLowerCase() === wanted;
|
|
3745
|
+
return [
|
|
3746
|
+
...registry.mailboxes.filter((mailbox) => isThisAgent(mailbox.agentId)).map((mailbox) => mailbox.keyId),
|
|
3747
|
+
...registry.sourceGrants.filter((grant) => isThisAgent(grant.agentId) && grant.enabled).map((grant) => grant.keyId),
|
|
3748
|
+
].filter((keyId) => keyId.trim().length > 0);
|
|
3749
|
+
}
|
|
3750
|
+
catch (error) {
|
|
3751
|
+
(0, runtime_1.emitNervesEvent)({
|
|
3752
|
+
level: "warn",
|
|
3753
|
+
component: "cli",
|
|
3754
|
+
event: "cli.hosted_mail_registry_assertion_skipped",
|
|
3755
|
+
message: "hosted mail key assertion could not consult the public registry",
|
|
3756
|
+
meta: { agent: input.agent, error: error instanceof Error ? error.message : String(error) },
|
|
3757
|
+
});
|
|
3758
|
+
return [];
|
|
3759
|
+
}
|
|
3760
|
+
}
|
|
3710
3761
|
function assertHostedPrivateKeys(input) {
|
|
3711
3762
|
for (const keyId of input.requiredKeyIds) {
|
|
3712
3763
|
if (input.keys[keyId])
|
|
@@ -3811,10 +3862,18 @@ async function ensureAgentMailroom(agent, input, deps, progressLabel) {
|
|
|
3811
3862
|
generatedPrivateKeys = responsePrivateKeys(body.generatedPrivateKeys);
|
|
3812
3863
|
Object.assign(privateKeys, generatedPrivateKeys);
|
|
3813
3864
|
}
|
|
3865
|
+
const registryKeyIds = await hostedRegistryDeclaredKeyIds({
|
|
3866
|
+
agent,
|
|
3867
|
+
body,
|
|
3868
|
+
existingMailroom,
|
|
3869
|
+
readRegistry: deps.readMailroomRegistry ?? reader_1.readMailroomRegistry,
|
|
3870
|
+
});
|
|
3814
3871
|
assertHostedPrivateKeys({
|
|
3815
3872
|
agent,
|
|
3816
3873
|
keys: privateKeys,
|
|
3817
|
-
|
|
3874
|
+
// Union, not replacement: the response still names keys it just minted,
|
|
3875
|
+
// which a registry read taken before the write would not yet show.
|
|
3876
|
+
requiredKeyIds: [...new Set([...requiredHostedKeyIds(body), ...registryKeyIds])],
|
|
3818
3877
|
});
|
|
3819
3878
|
const publicRegistry = requiredResponseRecord(body.publicRegistry, "publicRegistry");
|
|
3820
3879
|
const blobStore = requiredResponseRecord(body.blobStore, "blobStore");
|
|
@@ -99,6 +99,10 @@ function textField(record, key) {
|
|
|
99
99
|
const value = record?.[key];
|
|
100
100
|
return typeof value === "string" ? value.trim() : "";
|
|
101
101
|
}
|
|
102
|
+
function stringArrayField(record, key) {
|
|
103
|
+
const value = record?.[key];
|
|
104
|
+
return Array.isArray(value) ? value.filter((entry) => typeof entry === "string") : [];
|
|
105
|
+
}
|
|
102
106
|
function numberField(record, key, fallback) {
|
|
103
107
|
const value = record?.[key];
|
|
104
108
|
return typeof value === "number" && Number.isFinite(value) ? value : fallback;
|
|
@@ -271,6 +275,59 @@ function checkAgents(deps) {
|
|
|
271
275
|
}
|
|
272
276
|
return { name: "Agents", checks };
|
|
273
277
|
}
|
|
278
|
+
/**
|
|
279
|
+
* Mail key coverage, as recorded by the mail sense.
|
|
280
|
+
*
|
|
281
|
+
* Doctor reads the sense's own per-cycle verdict rather than re-deriving it:
|
|
282
|
+
* a health check must need neither network access nor Azure credentials, and
|
|
283
|
+
* the registry that declares the key ids lives in a Blob container in hosted
|
|
284
|
+
* mode. The mail sense already fetches it every cycle, so its record is both
|
|
285
|
+
* the cheapest and the only honest source here.
|
|
286
|
+
*
|
|
287
|
+
* Absent is a `fail` because mail encrypted to a key with no private half is
|
|
288
|
+
* permanently unreadable — unlike an API key, ciphertext cannot be reissued.
|
|
289
|
+
* Anything the sense could not verify is a `warn`, never a `fail`: a locked or
|
|
290
|
+
* unavailable vault must not read as key loss.
|
|
291
|
+
*/
|
|
292
|
+
function mailKeyCoverageCheck(deps, agentDir) {
|
|
293
|
+
const id = "mail.key_coverage";
|
|
294
|
+
const label = `${agentDir} mail key coverage`;
|
|
295
|
+
const statePath = `${deps.bundlesRoot}/${agentDir}/state/senses/mail/runtime.json`;
|
|
296
|
+
const unverified = (detail) => ({ id, label, status: "warn", detail });
|
|
297
|
+
if (!deps.existsSync(statePath)) {
|
|
298
|
+
return unverified(`no mail sense runtime state at ${statePath} — coverage is unverified until the mail sense runs a scan`);
|
|
299
|
+
}
|
|
300
|
+
let coverage;
|
|
301
|
+
try {
|
|
302
|
+
coverage = asRecord(JSON.parse(deps.readFileSync(statePath)).keyCoverage) ?? {};
|
|
303
|
+
}
|
|
304
|
+
catch {
|
|
305
|
+
return unverified(`mail sense runtime state at ${statePath} could not be read`);
|
|
306
|
+
}
|
|
307
|
+
const declaredKeyIds = stringArrayField(coverage, "declaredKeyIds");
|
|
308
|
+
const absentKeyIds = stringArrayField(coverage, "absentKeyIds");
|
|
309
|
+
const checkedAt = textField(coverage, "checkedAt");
|
|
310
|
+
const provenance = checkedAt ? ` (recorded ${checkedAt})` : "";
|
|
311
|
+
if (coverage.status === "covered") {
|
|
312
|
+
return {
|
|
313
|
+
id,
|
|
314
|
+
label,
|
|
315
|
+
status: "pass",
|
|
316
|
+
detail: declaredKeyIds.length === 1
|
|
317
|
+
? `the 1 registry-declared mail key has a private half in the vault${provenance}`
|
|
318
|
+
: `all ${declaredKeyIds.length} registry-declared mail keys have a private half in the vault${provenance}`,
|
|
319
|
+
};
|
|
320
|
+
}
|
|
321
|
+
if (coverage.status === "absent") {
|
|
322
|
+
return {
|
|
323
|
+
id,
|
|
324
|
+
label,
|
|
325
|
+
status: "fail",
|
|
326
|
+
detail: `the mailroom registry declares mail key${absentKeyIds.length === 1 ? "" : "s"} with no private half in this agent's vault — mail encrypted to ${absentKeyIds.join(", ")} cannot be decrypted and cannot be recovered${provenance}; run \`ouro connect mail --agent ${agentDir.replace(/\.ouro$/, "")}\` and, if Mail Control no longer holds the one-time key, rerun it with --rotate-missing-mail-keys to mint a replacement for future mail`,
|
|
327
|
+
};
|
|
328
|
+
}
|
|
329
|
+
return unverified(`mail sense could not verify key coverage${provenance}: ${textField(coverage, "reason") || "no reason recorded"}`);
|
|
330
|
+
}
|
|
274
331
|
async function checkSenses(deps) {
|
|
275
332
|
const checks = [];
|
|
276
333
|
const agents = discoverAgents(deps);
|
|
@@ -422,6 +479,10 @@ async function checkSenses(deps) {
|
|
|
422
479
|
mailAutonomyDetail(mailroom),
|
|
423
480
|
].join("; "),
|
|
424
481
|
});
|
|
482
|
+
// Config being well-formed says nothing about whether the vault can
|
|
483
|
+
// still decrypt what the registry points mail at — the 2026-07-24 gap
|
|
484
|
+
// sat behind a passing mail config check for 23 hours.
|
|
485
|
+
checks.push(mailKeyCoverageCheck(deps, agentDir));
|
|
425
486
|
}
|
|
426
487
|
}
|
|
427
488
|
}
|
package/dist/senses/mail.js
CHANGED
|
@@ -50,6 +50,125 @@ const smtp_ingress_1 = require("../mailroom/smtp-ingress");
|
|
|
50
50
|
function readRegistry(registryPath) {
|
|
51
51
|
return JSON.parse(fs.readFileSync(registryPath, "utf-8"));
|
|
52
52
|
}
|
|
53
|
+
function isRecord(value) {
|
|
54
|
+
return !!value && typeof value === "object" && !Array.isArray(value);
|
|
55
|
+
}
|
|
56
|
+
function unverifiableCoverage(checkedAt, reason) {
|
|
57
|
+
return { status: "could-not-verify", declaredKeyIds: [], absentKeyIds: [], reason, checkedAt };
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* Key ids the registry says mail can currently be encrypted to for this agent.
|
|
61
|
+
*
|
|
62
|
+
* Filtered to this agent because the hosted registry is domain-wide: counting
|
|
63
|
+
* every agent's records would report another agent's keys as absent on every
|
|
64
|
+
* cycle, and a check that always fails is a check operators learn to ignore.
|
|
65
|
+
*
|
|
66
|
+
* The match is case-insensitive. The harness sends `agentId: agent` verbatim to
|
|
67
|
+
* hosted Mail Control while this repo's own hosted ensure-response fixture
|
|
68
|
+
* returns the lowercased form for that same request, so a registry record's
|
|
69
|
+
* agent id can differ from the sense's agent name by case alone. Case-sensitive
|
|
70
|
+
* matching would then declare nothing and report `could-not-verify` forever —
|
|
71
|
+
* the same silence this probe exists to end. Widening the match cannot produce
|
|
72
|
+
* a false `absent` unless two agents differ only by case, which the store and
|
|
73
|
+
* address resolvers could not tell apart either.
|
|
74
|
+
*
|
|
75
|
+
* Disabled source grants are excluded because `resolveMailAddress`
|
|
76
|
+
* (`mailroom/core.ts`) refuses their alias outright, so no new mail can be
|
|
77
|
+
* encrypted to their key and a missing private half is not a live gap.
|
|
78
|
+
*/
|
|
79
|
+
function registryDeclaredKeyIds(registry, agentName) {
|
|
80
|
+
const wanted = agentName.toLowerCase();
|
|
81
|
+
const isThisAgent = (agentId) => agentId.toLowerCase() === wanted;
|
|
82
|
+
const declared = [
|
|
83
|
+
...registry.mailboxes.filter((mailbox) => isThisAgent(mailbox.agentId)).map((mailbox) => mailbox.keyId),
|
|
84
|
+
...registry.sourceGrants.filter((grant) => isThisAgent(grant.agentId) && grant.enabled).map((grant) => grant.keyId),
|
|
85
|
+
];
|
|
86
|
+
return [...new Set(declared.filter((keyId) => keyId.trim().length > 0))];
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* Mail key ids the vault can actually decrypt with, read fresh rather than
|
|
90
|
+
* taken from the reader resolution captured at sense startup — a sense that has
|
|
91
|
+
* been up for days would otherwise measure a key set the vault has since
|
|
92
|
+
* changed, and report a rotation as a loss.
|
|
93
|
+
*
|
|
94
|
+
* Throws rather than returning empty when the read is untrustworthy, so the
|
|
95
|
+
* caller classifies it `could-not-verify` instead of `absent`. An empty-but-
|
|
96
|
+
* present `privateKeys` record is a real observation and is not a throw.
|
|
97
|
+
*/
|
|
98
|
+
function vaultMailKeyIds(agentName) {
|
|
99
|
+
const runtime = (0, runtime_credentials_1.readRuntimeCredentialConfig)(agentName);
|
|
100
|
+
if (!runtime.ok)
|
|
101
|
+
throw new Error(`vault ${runtime.itemPath} read is ${runtime.reason}`);
|
|
102
|
+
const mailroom = runtime.config.mailroom;
|
|
103
|
+
const privateKeys = isRecord(mailroom) ? mailroom.privateKeys : undefined;
|
|
104
|
+
if (!isRecord(privateKeys))
|
|
105
|
+
throw new Error(`vault ${runtime.itemPath} has no mailroom.privateKeys record`);
|
|
106
|
+
return Object.entries(privateKeys)
|
|
107
|
+
.filter((entry) => typeof entry[1] === "string" && entry[1].trim().length > 0)
|
|
108
|
+
.map((entry) => entry[0]);
|
|
109
|
+
}
|
|
110
|
+
/**
|
|
111
|
+
* Never throws: every failure degrades to `could-not-verify`. `scan()` is
|
|
112
|
+
* awaited during sense startup, so a throw here would stop the mail sense from
|
|
113
|
+
* starting — an integrity probe must not be able to take mail down.
|
|
114
|
+
*/
|
|
115
|
+
async function computeMailKeyCoverage(input) {
|
|
116
|
+
try {
|
|
117
|
+
const registry = await input.readRegistry(input.config);
|
|
118
|
+
const declaredKeyIds = registryDeclaredKeyIds(registry, input.agentName);
|
|
119
|
+
if (declaredKeyIds.length === 0) {
|
|
120
|
+
// Nothing to check against is not the same as nothing wrong: reporting
|
|
121
|
+
// `covered` for an empty declaration would turn an unprovisioned or
|
|
122
|
+
// wrong registry into a green light.
|
|
123
|
+
return unverifiableCoverage(input.checkedAt, `mailroom registry declares no mail keys for ${input.agentName}`);
|
|
124
|
+
}
|
|
125
|
+
const present = new Set(vaultMailKeyIds(input.agentName));
|
|
126
|
+
const absentKeyIds = declaredKeyIds.filter((keyId) => !present.has(keyId));
|
|
127
|
+
return {
|
|
128
|
+
status: absentKeyIds.length === 0 ? "covered" : "absent",
|
|
129
|
+
declaredKeyIds,
|
|
130
|
+
absentKeyIds,
|
|
131
|
+
reason: null,
|
|
132
|
+
checkedAt: input.checkedAt,
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
catch (error) {
|
|
136
|
+
return unverifiableCoverage(input.checkedAt, error instanceof Error ? error.message : String(error));
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
/**
|
|
140
|
+
* Coverage has to push, not wait to be asked. `ouro doctor` is on-demand — it
|
|
141
|
+
* has no scheduled invocation — so a doctor line alone leaves a gap invisible
|
|
142
|
+
* until someone happens to run it, which is how `mail_slugger-hey_6e7b4bfa34c0b826`
|
|
143
|
+
* stayed unnoticed for 23 hours while mail encrypted to it piled up unreadable.
|
|
144
|
+
*/
|
|
145
|
+
function emitMailKeyCoverageEvents(agentName, coverage) {
|
|
146
|
+
(0, runtime_1.emitNervesEvent)({
|
|
147
|
+
component: "senses",
|
|
148
|
+
event: "senses.mail_key_coverage_recorded",
|
|
149
|
+
message: "mail key coverage recorded",
|
|
150
|
+
meta: {
|
|
151
|
+
agentName,
|
|
152
|
+
status: coverage.status,
|
|
153
|
+
declaredCount: coverage.declaredKeyIds.length,
|
|
154
|
+
absentCount: coverage.absentKeyIds.length,
|
|
155
|
+
reason: coverage.reason,
|
|
156
|
+
},
|
|
157
|
+
});
|
|
158
|
+
if (coverage.status !== "absent")
|
|
159
|
+
return;
|
|
160
|
+
(0, runtime_1.emitNervesEvent)({
|
|
161
|
+
level: "error",
|
|
162
|
+
component: "senses",
|
|
163
|
+
event: "senses.mail_key_coverage_absent",
|
|
164
|
+
message: `mailroom registry declares mail keys with no private half in ${agentName}'s vault: ${coverage.absentKeyIds.join(", ")}`,
|
|
165
|
+
meta: {
|
|
166
|
+
agentName,
|
|
167
|
+
absentKeyIds: coverage.absentKeyIds,
|
|
168
|
+
declaredKeyIds: coverage.declaredKeyIds,
|
|
169
|
+
},
|
|
170
|
+
});
|
|
171
|
+
}
|
|
53
172
|
function validPort(value) {
|
|
54
173
|
return typeof value === "number" && Number.isInteger(value) && value >= 0 && value <= 65535 ? value : 0;
|
|
55
174
|
}
|
|
@@ -280,6 +399,7 @@ async function startMailSenseApp(options) {
|
|
|
280
399
|
const importDiscoveryPath = importDiscoveryStatePath(options.agentName);
|
|
281
400
|
let lastScanAt = null;
|
|
282
401
|
let lastQueuedCount = 0;
|
|
402
|
+
let lastKeyCoverage = unverifiableCoverage(new Date(now()).toISOString(), "mail key coverage not probed yet");
|
|
283
403
|
const scan = async () => {
|
|
284
404
|
const scanStartedAt = new Date(now()).toISOString();
|
|
285
405
|
let queuedCount = 0;
|
|
@@ -322,6 +442,13 @@ async function startMailSenseApp(options) {
|
|
|
322
442
|
meta: { agentName: options.agentName, error: error instanceof Error ? error.message : String(error) },
|
|
323
443
|
});
|
|
324
444
|
}
|
|
445
|
+
lastKeyCoverage = await computeMailKeyCoverage({
|
|
446
|
+
agentName: options.agentName,
|
|
447
|
+
config: resolved.config,
|
|
448
|
+
readRegistry: options.readRegistry ?? reader_1.readMailroomRegistry,
|
|
449
|
+
checkedAt: scanStartedAt,
|
|
450
|
+
});
|
|
451
|
+
emitMailKeyCoverageEvents(options.agentName, lastKeyCoverage);
|
|
325
452
|
lastScanAt = scanStartedAt;
|
|
326
453
|
lastQueuedCount = queuedCount;
|
|
327
454
|
writeRuntimeState(runtimePath, {
|
|
@@ -336,6 +463,7 @@ async function startMailSenseApp(options) {
|
|
|
336
463
|
storeLabel: resolved.storeLabel,
|
|
337
464
|
lastScanAt,
|
|
338
465
|
lastQueuedCount,
|
|
466
|
+
keyCoverage: lastKeyCoverage,
|
|
339
467
|
updatedAt: new Date(now()).toISOString(),
|
|
340
468
|
});
|
|
341
469
|
};
|
|
@@ -382,6 +510,7 @@ async function startMailSenseApp(options) {
|
|
|
382
510
|
storeLabel: resolved.storeLabel,
|
|
383
511
|
lastScanAt,
|
|
384
512
|
lastQueuedCount,
|
|
513
|
+
keyCoverage: lastKeyCoverage,
|
|
385
514
|
updatedAt: new Date(now()).toISOString(),
|
|
386
515
|
});
|
|
387
516
|
(0, runtime_1.emitNervesEvent)({
|