@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.
@@ -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
- requiredKeyIds: requiredHostedKeyIds(body),
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
  }
@@ -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)({
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ouro.bot/cli",
3
- "version": "0.1.0-alpha.723",
3
+ "version": "0.1.0-alpha.724",
4
4
  "main": "dist/heart/daemon/ouro-entry.js",
5
5
  "bin": {
6
6
  "cli": "dist/heart/daemon/ouro-bot-entry.js",