@ouro.bot/cli 0.1.0-alpha.771 → 0.1.0-alpha.773

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 CHANGED
@@ -1,6 +1,23 @@
1
1
  {
2
2
  "_note": "This changelog is maintained as part of the PR/version-bump workflow. Agent-curated, not auto-generated. Agents read this file directly via read_file to understand what changed between versions.",
3
3
  "versions": [
4
+ {
5
+ "version": "0.1.0-alpha.773",
6
+ "changes": [
7
+ "Fail daemon startup closed until configured providers are live and their credentials are cached.",
8
+ "Surface provider startup failures immediately through a fresh redacted tombstone instead of waiting for a dead daemon or echoing raw logs.",
9
+ "Recover legacy provider dead letters from current-boot readiness once per generation without per-record alert noise.",
10
+ "Recover expired managed-runtime leases only after newer runtime liveness, with inspect-before-action turns and bounded batch wakeups."
11
+ ]
12
+ },
13
+ {
14
+ "version": "0.1.0-alpha.772",
15
+ "changes": [
16
+ "Make Sanctuary Unraid key rotation crash-resumable with exact transaction and rejection proofs.",
17
+ "Require durable zero-playback journal evidence before live callback injection.",
18
+ "Reject malformed Unraid rotation checkpoints before mutation with exact slot, attestation, revocation-order, and phase-state validation."
19
+ ]
20
+ },
4
21
  {
5
22
  "version": "0.1.0-alpha.771",
6
23
  "changes": [
@@ -2440,6 +2440,9 @@ Packaged Unit 16 acceptance execution:
2440
2440
  failure. It never reads or changes Unraid autostart configuration. Every command
2441
2441
  also receives a freshly generated, redacted typed container-inspect snapshot;
2442
2442
  raw container environment or credential values are never captured.
2443
+ Callback injection requires two stable zero observations from a durable callback playback journal under
2444
+ `state/approvals`; the journal is keyed only by the full callback-coordinate
2445
+ digest and stores no raw update, callback data, user, chat, message, or query ID.
2443
2446
  Evidence-snapshot keeps the bundle mount read-only and overlays only the exact
2444
2447
  canonical `state/acceptance` directory as writable. It first stops the exact
2445
2448
  healthy staging container under recovery responsibility, then opens the
@@ -2459,17 +2462,40 @@ Audit and safety verification:
2459
2462
  stop and restart mutations; only the separate write key may perform the one
2460
2463
  typed approved restart action.
2461
2464
  The packaged `unraid-key-rotate` command is the only canonical key-rotation
2462
- authority. It inventories the two occupied canonical names by exact immutable
2463
- ID, creates collision-safe `Butler RO Rotation <suffix>` and
2465
+ authority. Its host launcher stops the exact production container and assumes
2466
+ trap-backed recovery responsibility before key mutation. It inventories the
2467
+ two occupied canonical names by exact immutable ID, creates collision-safe
2468
+ `Butler RO Rotation <suffix>` and
2464
2469
  `Butler RW Rotation <suffix>` keys, stores and capability-probes both through
2465
- the Sanctuary machine vault, and requires an exact four-key inventory. It then
2470
+ the Sanctuary machine vault, and on the uninterrupted path requires an exact
2471
+ four-key inventory. Because
2472
+ Unraid 7.2.3 cannot create a role-free key through its CLI, the root broker
2473
+ creates each key as provisional `GUEST` with the intended scope plus only
2474
+ `API_KEY:UPDATE_ANY`, then immediately uses that in-memory credential to
2475
+ self-downgrade the same immutable ID to no roles and the exact intended scope.
2476
+ Disk and live-auth proofs must pass before the credential may reach the vault.
2477
+ The provisional credential never reaches argv, logs, evidence, the bundle, or
2478
+ the model. It then
2466
2479
  revokes both old IDs and proves each old credential receives a 401 or 403.
2467
2480
  Only after an exact inventory contains the temporary pair alone does it create,
2468
2481
  store, and probe the canonical `Butler RO` and `Butler RW` pair. It requires an
2469
- exact four-key temporary-plus-canonical inventory, revokes and rejection-probes
2482
+ exact four-key temporary-plus-canonical inventory on the uninterrupted path,
2483
+ revokes and rejection-probes
2470
2484
  both temporary IDs, and finally requires exactly the canonical pair. Every
2471
2485
  unexpected key, duplicate, malformed permission set, role, name, inventory
2472
- delta, or probe response fails closed.
2486
+ delta, or probe response fails closed. A resumed path may observe fewer keys
2487
+ only when the transaction checkpoint plus a durable rejection receipt proves
2488
+ that the missing immutable ID was already revoked; it never treats absence
2489
+ alone as success.
2490
+
2491
+ A failed invocation resumes from the durable redacted transaction checkpoint
2492
+ instead of reminting known keys. The checkpoint binds every created immutable
2493
+ ID to its name, scope, vault field, temporary/canonical class, and attestation
2494
+ state. Recovery reconciles exact live inventory and rejection proofs, skips
2495
+ already-attested vault writes after canonical fields advance, and never
2496
+ recreates a temporary key already known revoked. Root-owned recovery ownership
2497
+ survives broker response loss and is cleared only after the harness has
2498
+ checkpointed the immutable ID and the vault has read back the exact binding.
2473
2499
 
2474
2500
  Record only IDs, names, and permission sets in evidence; never raw key values.
2475
2501
  Raw credentials must not appear in argv, shell history, stdout, logs, or
@@ -2477,7 +2503,9 @@ Audit and safety verification:
2477
2503
  socket in process memory. The narrow revocation-retry recovery records under
2478
2504
  `/mnt/user/appdata/ouro-butler/acceptance/revoked-key-proof` are the sole file
2479
2505
  exception: they are root-owned mode 0600 beneath a root-owned mode 0700
2480
- directory, excluded from backup/evidence, streamed only on standard input to
2481
- the bounded rejection probe, retained across failed retries, and durably
2482
- deleted only after the exact authentication rejection is validated. Never
2483
- substitute names for IDs when revoking keys.
2506
+ directory, excluded from backup/evidence, and readable only by the root broker.
2507
+ Recovery state is retained across failed retries.
2508
+ Creation intent is fsynced before the CLI side effect so an exact provisional
2509
+ key can be adopted after process death. Raw recovery proof is durably deleted
2510
+ only after the exact authentication rejection and its redacted replayable
2511
+ receipt are fsynced. Never substitute names for IDs when revoking keys.
@@ -31,7 +31,7 @@
31
31
  "telegram-poller-quiescence": { "operation": "quiesce_telegram_poller", "authority": "fixed-managed-telegram-poller-stop-and-count", "modelReachable": false, "timeoutMs": 15000 },
32
32
  "telegram-vault-store": { "operation": "store_telegram_bootstrap", "authority": "fixed-runtime-vault-merge", "modelReachable": false, "timeoutMs": 15000 },
33
33
  "cursor-snapshot": { "operation": "snapshot", "authority": "fixed-telegram-state-read", "modelReachable": false, "timeoutMs": 15000 },
34
- "callback-live": { "operation": "inject_callbacks_concurrently|inject_callback_replay", "authority": "fixed-live-telegram-callback-probe", "modelReachable": false, "timeoutMs": 15000 },
34
+ "callback-live": { "operation": "callback_playback_preflight|inject_callbacks_concurrently|inject_callback_replay", "authority": "fixed-live-telegram-callback-probe", "modelReachable": false, "timeoutMs": 15000 },
35
35
  "key-inventory": { "operation": "inventory_keys", "authority": "fixed-unraid-key-directory-read", "modelReachable": false, "timeoutMs": 15000 },
36
36
  "key-create": { "operation": "create_key", "authority": "fixed-unraid-cli-create-and-vault-store", "modelReachable": false, "timeoutMs": 15000 },
37
37
  "key-store": { "operation": "store_key", "authority": "fixed-machine-vault-readback", "modelReachable": false, "timeoutMs": 15000 },
@@ -149,27 +149,113 @@ function runUnraid(args) {
149
149
  return object(JSON.parse(result.stdout), "Unraid API response")
150
150
  }
151
151
 
152
- function createUnraidKey(name, requested, dependencies = { runUnraid, inventoryRecords, persistRecovery }) {
152
+ function permissionObjects(values) {
153
+ const grouped = new Map()
154
+ for (const value of values) {
155
+ const [resource, action] = value.split(":")
156
+ const actions = grouped.get(resource) ?? []
157
+ actions.push(action)
158
+ grouped.set(resource, actions)
159
+ }
160
+ return [...grouped.entries()].sort(([left], [right]) => left.localeCompare(right))
161
+ .map(([resource, actions]) => ({ resource, actions: actions.sort() }))
162
+ }
163
+
164
+ async function createUnraidKey(name, requested, dependencies = {
165
+ runUnraid, inventoryRecords, persistRecovery, readRecovery: optionalRecoveryRecord,
166
+ persistIntent: persistCreateIntent, readIntent: optionalCreateIntent, removeIntent: removeCreateIntent,
167
+ fetchImpl: fetch,
168
+ }) {
153
169
  const before = dependencies.inventoryRecords()
154
170
  const beforeIds = new Set(before.map((record) => record.id))
171
+ const provisionalPermissions = [...requested, "API_KEY:UPDATE_ANY"].sort()
172
+ let recoveryId = null
173
+ let cliIdentity = null
155
174
  try {
156
- const created = object(dependencies.runUnraid(["apikey", "--name", name, "--create", "--permissions", requested.join(","), "--json"]), "created Unraid key")
157
- exactKeys(created, ["id", "key", "name"], "created Unraid key")
175
+ const occupied = before.filter((record) => record.name === name)
176
+ const existingIntent = dependencies.readIntent?.(name) ?? null
177
+ if (existingIntent === null) {
178
+ if (occupied.length !== 0) throw new Error("existing provisional key has no durable creation intent")
179
+ dependencies.persistIntent(name, provisionalPermissions)
180
+ } else if (existingIntent.name !== name || JSON.stringify(existingIntent.permissions) !== JSON.stringify(provisionalPermissions)) {
181
+ throw new Error("Unraid key creation intent is invalid")
182
+ }
183
+ let created
184
+ let provisional
185
+ let resumed = false
186
+ let resumedFinal = false
187
+ if (occupied.length === 1) {
188
+ provisional = occupied[0]
189
+ let recovery = dependencies.readRecovery?.(provisional.id) ?? null
190
+ const provisionalExact = JSON.stringify(flattened(provisional)) === JSON.stringify(provisionalPermissions)
191
+ && JSON.stringify(provisional.roles) === JSON.stringify(["GUEST"])
192
+ if (recovery === null && provisionalExact) {
193
+ dependencies.persistRecovery(provisional)
194
+ recovery = provisional
195
+ }
196
+ if (!recovery || recovery.id !== provisional.id || recovery.name !== provisional.name || recovery.key !== provisional.key
197
+ || JSON.stringify(flattened(recovery)) !== JSON.stringify(provisionalPermissions)
198
+ || JSON.stringify(recovery.roles) !== JSON.stringify(["GUEST"])
199
+ || !(provisionalExact
200
+ || (JSON.stringify(flattened(provisional)) === JSON.stringify(requested) && provisional.roles.length === 0))) {
201
+ throw new Error("existing provisional key is not exact owned recovery")
202
+ }
203
+ resumedFinal = provisional.roles.length === 0
204
+ created = { id: provisional.id, key: provisional.key, name: provisional.name }
205
+ resumed = true
206
+ } else if (occupied.length === 0) {
207
+ created = object(dependencies.runUnraid(["apikey", "--name", name, "--create", "--roles", "GUEST", "--permissions", provisionalPermissions.join(","), "--json"]), "created Unraid key")
208
+ exactKeys(created, ["id", "key", "name"], "created Unraid key")
209
+ cliIdentity = { id: text(created.id, "created key id", KEY_ID), key: text(created.key, "created key descriptor"), name: created.name }
210
+ } else {
211
+ throw new Error("created provisional key name is ambiguous")
212
+ }
158
213
  const id = text(created.id, "created key id", KEY_ID)
159
214
  const key = text(created.key, "created key descriptor")
160
- if (created.name !== name || beforeIds.has(id)) throw new Error("created key identity is invalid")
161
- const matches = dependencies.inventoryRecords().filter((record) => record.id === id)
215
+ if (created.name !== name || (!resumed && beforeIds.has(id))) throw new Error("created key identity is invalid")
216
+ const matches = resumed ? [provisional] : dependencies.inventoryRecords().filter((record) => record.id === id)
162
217
  if (matches.length !== 1 || matches[0].name !== name || matches[0].key !== key
163
- || JSON.stringify(flattened(matches[0])) !== JSON.stringify(requested) || matches[0].roles.length !== 0) {
164
- throw new Error("created key record does not match the exact CLI result")
218
+ || (!resumedFinal && (JSON.stringify(flattened(matches[0])) !== JSON.stringify(provisionalPermissions)
219
+ || JSON.stringify(matches[0].roles) !== JSON.stringify(["GUEST"])))) {
220
+ throw new Error("created provisional key record does not match the exact CLI result")
165
221
  }
166
- return matches[0]
167
- } catch (error) {
168
- const recoveredIds = new Set()
169
- for (const record of dependencies.inventoryRecords().filter((candidate) => !beforeIds.has(candidate.id))) {
170
- if (!recoveredIds.has(record.id)) dependencies.persistRecovery(record)
171
- recoveredIds.add(record.id)
222
+ if (!resumed) dependencies.persistRecovery(matches[0])
223
+ recoveryId = id
224
+ const headers = { "content-type": "application/json", "x-api-key": key }
225
+ if (!resumedFinal) try {
226
+ const updateResponse = await dependencies.fetchImpl(GRAPHQL_ENDPOINT, {
227
+ method: "POST", headers,
228
+ body: JSON.stringify({
229
+ query: "mutation AcceptanceApiKeyDowngrade($input: UpdateApiKeyInput!) { apiKey { update(input: $input) { id name roles permissions { resource actions } } } }",
230
+ variables: { input: { id, roles: [], permissions: permissionObjects(requested) } },
231
+ }),
232
+ signal: AbortSignal.timeout(10_000),
233
+ })
234
+ const updateEnvelope = object(await updateResponse.json(), "Unraid key downgrade response")
235
+ if (!updateResponse.ok || updateEnvelope.errors || !updateEnvelope.data) throw new Error("Unraid key downgrade was rejected")
236
+ } catch {
237
+ // A lost response is accepted only when disk and live-auth attestation below prove convergence.
172
238
  }
239
+ const finalized = dependencies.inventoryRecords().filter((record) => record.id === id)
240
+ if (finalized.length !== 1 || finalized[0].name !== name || finalized[0].key !== key
241
+ || JSON.stringify(flattened(finalized[0])) !== JSON.stringify(requested) || finalized[0].roles.length !== 0) {
242
+ throw new Error("created key did not converge to its exact final scope")
243
+ }
244
+ const probeResponse = await dependencies.fetchImpl(GRAPHQL_ENDPOINT, {
245
+ method: "POST", headers,
246
+ body: JSON.stringify({ query: "query AcceptanceAuthProbe { info { os { hostname } } }", variables: {} }),
247
+ signal: AbortSignal.timeout(10_000),
248
+ })
249
+ const probeEnvelope = object(await probeResponse.json(), "Unraid key readiness response")
250
+ if (!probeResponse.ok || probeEnvelope.errors || !probeEnvelope.data) throw new Error("created key readiness probe failed")
251
+ return finalized[0]
252
+ } catch (error) {
253
+ const candidates = dependencies.inventoryRecords().filter((record) => record.name === name
254
+ && !beforeIds.has(record.id)
255
+ && JSON.stringify(flattened(record)) === JSON.stringify(provisionalPermissions)
256
+ && JSON.stringify(record.roles) === JSON.stringify(["GUEST"])
257
+ && (cliIdentity === null || (record.id === cliIdentity.id && record.key === cliIdentity.key && record.name === cliIdentity.name)))
258
+ if (candidates.length === 1 && candidates[0].id !== recoveryId) dependencies.persistRecovery(candidates[0])
173
259
  throw error
174
260
  }
175
261
  }
@@ -570,27 +656,149 @@ function containerRestartSnapshot(expectedImage) {
570
656
  }
571
657
 
572
658
  function recoveryPath(id) { return `${RECOVERY_ROOT}/${id}.json` }
659
+ function createIntentPath(name) { return `${RECOVERY_ROOT}/create-${createHash("sha256").update(name).digest("hex")}.json` }
660
+ function rejectionReceiptPath(id) { return `${RECOVERY_ROOT}/${id}.rejected.json` }
573
661
 
574
- function persistRecovery(record) {
662
+ function persistPrivateJson(target, value) {
575
663
  mkdirSync(RECOVERY_ROOT, { recursive: true, mode: 0o700 })
576
664
  chmodSync(RECOVERY_ROOT, 0o700)
577
665
  chownSync(RECOVERY_ROOT, 0, 0)
578
- const target = recoveryPath(record.id)
579
666
  const temporary = `${target}.tmp-${process.pid}`
580
667
  const fd = openSync(temporary, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL, 0o600)
581
- try { writeFileSync(fd, `${JSON.stringify(record)}\n`); fsyncSync(fd) } finally { closeSync(fd) }
582
- chownSync(temporary, 0, 0)
668
+ try { chownSync(temporary, 0, 0); writeFileSync(fd, `${JSON.stringify(value)}\n`); fsyncSync(fd) } finally { closeSync(fd) }
583
669
  renameSync(temporary, target)
584
670
  const directory = openSync(RECOVERY_ROOT, constants.O_RDONLY)
585
671
  try { fsyncSync(directory) } finally { closeSync(directory) }
586
672
  }
587
673
 
674
+ function optionalPrivateJson(target) {
675
+ try { return object(readPrivateJson(target), "private acceptance record") }
676
+ catch (error) { if (error?.code === "ENOENT") return null; throw error }
677
+ }
678
+
679
+ function persistCreateIntent(name, requested) {
680
+ mkdirSync(RECOVERY_ROOT, { recursive: true, mode: 0o700 })
681
+ chmodSync(RECOVERY_ROOT, 0o700)
682
+ chownSync(RECOVERY_ROOT, 0, 0)
683
+ const target = createIntentPath(name)
684
+ const fd = openSync(target, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL, 0o600)
685
+ try {
686
+ chownSync(target, 0, 0)
687
+ writeFileSync(fd, `${JSON.stringify({ schemaVersion: 1, name, permissions: requested })}\n`)
688
+ fsyncSync(fd)
689
+ } finally { closeSync(fd) }
690
+ const directory = openSync(RECOVERY_ROOT, constants.O_RDONLY)
691
+ try { fsyncSync(directory) } finally { closeSync(directory) }
692
+ }
693
+
694
+ function optionalCreateIntent(name) {
695
+ const value = optionalPrivateJson(createIntentPath(name))
696
+ if (value === null) return null
697
+ exactKeys(value, ["schemaVersion", "name", "permissions"], "Unraid key creation intent")
698
+ if (value.schemaVersion !== 1 || value.name !== name) throw new Error("Unraid key creation intent is invalid")
699
+ return { name, permissions: permissions(value.permissions) }
700
+ }
701
+
702
+ function removePrivateFile(target) {
703
+ try { unlinkSync(target) } catch (error) { if (error?.code !== "ENOENT") throw error }
704
+ const directory = openSync(RECOVERY_ROOT, constants.O_RDONLY)
705
+ try { fsyncSync(directory) } finally { closeSync(directory) }
706
+ }
707
+
708
+ function removeCreateIntent(name) { removePrivateFile(createIntentPath(name)) }
709
+
710
+ function optionalRejectionReceipt(id) {
711
+ const value = optionalPrivateJson(rejectionReceiptPath(id))
712
+ if (value === null) return null
713
+ exactKeys(value, ["schemaVersion", "id", "status", "proofDigest"], "revoked key rejection receipt")
714
+ if (value.schemaVersion !== 1 || value.id !== id || (value.status !== 401 && value.status !== 403)
715
+ || value.proofDigest !== createHash("sha256").update(`revoked-key-rejected\0${id}\0${value.status}`).digest("hex")) {
716
+ throw new Error("revoked key rejection receipt is invalid")
717
+ }
718
+ return { valid: false, status: value.status, id }
719
+ }
720
+
721
+ function persistRejectionReceipt(id, status) {
722
+ persistPrivateJson(rejectionReceiptPath(id), {
723
+ schemaVersion: 1, id, status,
724
+ proofDigest: createHash("sha256").update(`revoked-key-rejected\0${id}\0${status}`).digest("hex"),
725
+ })
726
+ }
727
+
728
+ async function probeRevokedUnraidKey(id, dependencies = {
729
+ readReceipt: optionalRejectionReceipt,
730
+ readRecovery: optionalRecoveryRecord,
731
+ persistReceipt: persistRejectionReceipt,
732
+ removeRecovery,
733
+ fetchImpl: fetch,
734
+ }) {
735
+ text(id, "key id", KEY_ID)
736
+ const receipt = dependencies.readReceipt(id)
737
+ if (receipt !== null) {
738
+ const staleRecovery = dependencies.readRecovery(id)
739
+ if (staleRecovery !== null) {
740
+ if (staleRecovery.id !== id) throw new Error("recovery key id is invalid")
741
+ dependencies.removeRecovery(id)
742
+ }
743
+ return receipt
744
+ }
745
+ const record = dependencies.readRecovery(id)
746
+ if (record === null || record.id !== id) throw new Error("recovery key id is invalid")
747
+ const response = await dependencies.fetchImpl(GRAPHQL_ENDPOINT, {
748
+ method: "POST", headers: { "content-type": "application/json", "x-api-key": record.key },
749
+ body: JSON.stringify({ query: "query AcceptanceAuthProbe { info { os { hostname } } }", variables: {} }),
750
+ signal: AbortSignal.timeout(10_000),
751
+ })
752
+ if (response.status !== 401 && response.status !== 403) throw new Error("revoked key still authenticates")
753
+ dependencies.persistReceipt(id, response.status)
754
+ dependencies.removeRecovery(id)
755
+ return { valid: false, status: response.status, id }
756
+ }
757
+
758
+ function optionalRecoveryRecord(id) {
759
+ try { return normalizeRecord(readPrivateJson(recoveryPath(id))) }
760
+ catch (error) { if (error?.code === "ENOENT") return null; throw error }
761
+ }
762
+
763
+ function persistRecovery(record) {
764
+ persistPrivateJson(recoveryPath(record.id), record)
765
+ }
766
+
588
767
  function removeRecovery(id) {
589
768
  unlinkSync(recoveryPath(id))
590
769
  const directory = openSync(RECOVERY_ROOT, constants.O_RDONLY)
591
770
  try { fsyncSync(directory) } finally { closeSync(directory) }
592
771
  }
593
772
 
773
+ function acknowledgeStoredUnraidKey(id, dependencies = {
774
+ readRecovery: optionalRecoveryRecord,
775
+ inventoryRecords,
776
+ removeRecovery,
777
+ removeIntent: removeCreateIntent,
778
+ }) {
779
+ text(id, "key id", KEY_ID)
780
+ const matches = dependencies.inventoryRecords().filter((record) => record.id === id)
781
+ if (matches.length !== 1 || !KEY_NAME.test(matches[0].name)) throw new Error("Unraid key final storage acknowledgement is invalid")
782
+ const expected = matches[0].name.startsWith("Butler RO") ? RO_PERMISSIONS : [...RO_PERMISSIONS, "DOCKER:UPDATE_ANY"].sort()
783
+ if (matches[0].roles.length !== 0 || JSON.stringify(flattened(matches[0])) !== JSON.stringify(expected)) {
784
+ throw new Error("Unraid key final storage acknowledgement is invalid")
785
+ }
786
+ const recovery = dependencies.readRecovery(id)
787
+ if (recovery === null) {
788
+ dependencies.removeIntent?.(matches[0].name)
789
+ return { acknowledged: true, id }
790
+ }
791
+ const provisional = [...expected, "API_KEY:UPDATE_ANY"].sort()
792
+ if (recovery.id !== id || recovery.name !== matches[0].name || recovery.key !== matches[0].key
793
+ || JSON.stringify(recovery.roles) !== JSON.stringify(["GUEST"])
794
+ || JSON.stringify(flattened(recovery)) !== JSON.stringify(provisional)) {
795
+ throw new Error("Unraid key recovery ownership is invalid")
796
+ }
797
+ dependencies.removeRecovery(id)
798
+ dependencies.removeIntent?.(matches[0].name)
799
+ return { acknowledged: true, id }
800
+ }
801
+
594
802
  function permissions(value) {
595
803
  if (!Array.isArray(value) || value.length === 0) throw new Error("permissions are invalid")
596
804
  const result = value.map((item) => text(item, "permission", PERMISSION)).sort()
@@ -1556,7 +1764,12 @@ async function dispatch(request, dependencies = {
1556
1764
  if (payload.targetServerId !== TARGET_SERVER) throw new Error("target server is invalid")
1557
1765
  const name = text(payload.name, "key name", KEY_NAME)
1558
1766
  const requested = permissions(payload.permissions)
1559
- return createUnraidKey(name, requested)
1767
+ return await createUnraidKey(name, requested)
1768
+ }
1769
+ if (operation === "acknowledge_key_storage") {
1770
+ exactKeys(payload, ["operation", "targetServerId", "keyId"], operation)
1771
+ if (payload.targetServerId !== TARGET_SERVER) throw new Error("target server is invalid")
1772
+ return acknowledgeStoredUnraidKey(text(payload.keyId, "key id", KEY_ID))
1560
1773
  }
1561
1774
  if (operation === "revoke_key") {
1562
1775
  exactKeys(payload, ["operation", "targetServerId", "keyId"], operation)
@@ -1574,17 +1787,7 @@ async function dispatch(request, dependencies = {
1574
1787
  if (operation === "probe_revoked_key") {
1575
1788
  exactKeys(payload, ["operation", "targetServerId", "keyId"], operation)
1576
1789
  if (payload.targetServerId !== TARGET_SERVER) throw new Error("target server is invalid")
1577
- const id = text(payload.keyId, "key id", KEY_ID)
1578
- const record = normalizeRecord(readPrivateJson(recoveryPath(id)))
1579
- if (record.id !== id) throw new Error("recovery key id is invalid")
1580
- const response = await fetch(GRAPHQL_ENDPOINT, {
1581
- method: "POST", headers: { "content-type": "application/json", "x-api-key": record.key },
1582
- body: JSON.stringify({ query: "query AcceptanceAuthProbe { info { os { hostname } } }", variables: {} }),
1583
- signal: AbortSignal.timeout(10_000),
1584
- })
1585
- if (response.status !== 401 && response.status !== 403) throw new Error("revoked key still authenticates")
1586
- removeRecovery(id)
1587
- return { valid: false, status: response.status, id }
1790
+ return await probeRevokedUnraidKey(text(payload.keyId, "key id", KEY_ID))
1588
1791
  }
1589
1792
  if (operation === "request_reboot") {
1590
1793
  exactKeys(payload, ["operation", "targetId", "idempotencyKey", "preflightDigest", "processBindingDigest"], operation)
@@ -1721,7 +1924,11 @@ function writeClosedInventory(file) {
1721
1924
 
1722
1925
  function createBrokerServer(dispatchRequest = dispatch) {
1723
1926
  const dispatchDrain = createDispatchDrain()
1927
+ const connections = new Set()
1724
1928
  const server = createServer({ allowHalfOpen: true }, (connection) => {
1929
+ connections.add(connection)
1930
+ connection.setTimeout(30_000, () => connection.destroy())
1931
+ connection.once("close", () => connections.delete(connection))
1725
1932
  let input = ""
1726
1933
  connection.setEncoding("utf8")
1727
1934
  connection.on("data", (chunk) => {
@@ -1737,7 +1944,8 @@ function createBrokerServer(dispatchRequest = dispatch) {
1737
1944
  } catch { connection.end(`${JSON.stringify({ ok: false, error: "host operation failed" })}\n`) }
1738
1945
  })
1739
1946
  })
1740
- return { server, dispatchDrain }
1947
+ const destroyConnections = () => { for (const connection of connections) connection.destroy() }
1948
+ return { server, dispatchDrain, destroyConnections }
1741
1949
  }
1742
1950
 
1743
1951
  async function main() {
@@ -1754,7 +1962,7 @@ async function main() {
1754
1962
  const snapshotFd = openSync(initialSnapshot, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL, 0o600)
1755
1963
  try { writeFileSync(snapshotFd, `${JSON.stringify(snapshot)}\n`); fsyncSync(snapshotFd) } finally { closeSync(snapshotFd) }
1756
1964
  chownSync(initialSnapshot, 0, 0)
1757
- const { server, dispatchDrain } = createBrokerServer()
1965
+ const { server, dispatchDrain, destroyConnections } = createBrokerServer()
1758
1966
  server.listen(socket, () => {
1759
1967
  chownSync(socket, 0, 10001)
1760
1968
  chmodSync(socket, 0o660)
@@ -1764,6 +1972,7 @@ async function main() {
1764
1972
  if (shuttingDown) return
1765
1973
  shuttingDown = true
1766
1974
  server.close()
1975
+ destroyConnections()
1767
1976
  await dispatchDrain.stopAndDrain()
1768
1977
  await interactiveRestartDriver.stopAndDrain()
1769
1978
  let failed = false
@@ -1781,6 +1990,7 @@ if (process.argv[1]?.endsWith("sanctuary-unit16-host-broker.mjs")) {
1781
1990
  }
1782
1991
 
1783
1992
  export {
1993
+ acknowledgeStoredUnraidKey,
1784
1994
  assertStableContainerProcess,
1785
1995
  attestHealthProbeProcessAbsent,
1786
1996
  armRestartAfterResponseClosed,
@@ -1805,6 +2015,7 @@ export {
1805
2015
  observeRebootPreflight,
1806
2016
  parseVaultStatus,
1807
2017
  optionalStoppedContainerExact,
2018
+ probeRevokedUnraidKey,
1808
2019
  queryGraphqlAutostart,
1809
2020
  readBoundedProcStatus,
1810
2021
  driveDuplicateCallbacks,
@@ -50,9 +50,22 @@ assert_health_probe_cleanup() {
50
50
  ' "$ACCEPTANCE_STATE_ROOT" "$BUNDLE_ROOT/state/health/sanctuary-health.json.lease"
51
51
  }
52
52
 
53
+ terminate_broker() {
54
+ test -n "$BROKER_PID" || return 0
55
+ kill "$BROKER_PID" 2>/dev/null || true
56
+ BROKER_WAIT=0
57
+ while kill -0 "$BROKER_PID" 2>/dev/null && test "$BROKER_WAIT" -lt 10; do
58
+ sleep 1
59
+ BROKER_WAIT=$(( BROKER_WAIT + 1 ))
60
+ done
61
+ if kill -0 "$BROKER_PID" 2>/dev/null; then kill -KILL "$BROKER_PID" 2>/dev/null || true; fi
62
+ wait "$BROKER_PID" 2>/dev/null || true
63
+ BROKER_PID=
64
+ }
65
+
53
66
  cleanup_unit16() {
54
67
  CLEANUP_STATUS=$?
55
- if test -n "$BROKER_PID"; then kill "$BROKER_PID" 2>/dev/null || true; wait "$BROKER_PID" 2>/dev/null || true; fi
68
+ terminate_broker
56
69
  if test "$ACCEPTANCE_CANONICAL_PINNED" = yes && ! assert_health_probe_cleanup; then CLEANUP_STATUS=1; fi
57
70
  if test "$PRODUCTION_STOPPED" = yes && test "$HOST_REBOOT_COMMIT_STATE" = not_sent; then
58
71
  if ! restore_production_container; then CLEANUP_STATUS=1; fi
@@ -73,7 +86,10 @@ cleanup_unit16() {
73
86
  if test "$ACCEPTANCE_ALIAS_MOUNTED" = no && test -n "$PRIVATE_ROOT" && test -d "$PRIVATE_ROOT"; then rm -rf -- "$PRIVATE_ROOT"; fi
74
87
  return "$CLEANUP_STATUS"
75
88
  }
76
- trap cleanup_unit16 EXIT HUP INT TERM
89
+ trap cleanup_unit16 EXIT
90
+ trap 'exit 129' HUP
91
+ trap 'exit 130' INT
92
+ trap 'exit 143' TERM
77
93
 
78
94
  case "$MODE" in
79
95
  materialize)
@@ -347,6 +363,7 @@ cmp -s "$EXPECTED_CONFIG" "$CONFIG_PATH" || exit 1
347
363
  case "$COMMAND" in telegram-bootstrap|callback-inject) test -r /proc/self/fd/3 || exit 2 ;; esac
348
364
  prepare_live_facts
349
365
  if test "$COMMAND" = telegram-bootstrap; then quiesce_production_telegram_poller; fi
366
+ if test "$COMMAND" = unraid-key-rotate; then stop_exact_production_container; fi
350
367
 
351
368
  case "$COMMAND" in
352
369
  telegram-bootstrap) TIME_LIMIT=900; NETWORK=host; INPUT=yes; BUNDLE_MODE=readonly; BROKER=no ;;
@@ -1,5 +1,5 @@
1
1
  {
2
- "runtimeVersion": "0.1.0-alpha.771",
2
+ "runtimeVersion": "0.1.0-alpha.773",
3
3
  "bundleSchemaVersion": 3,
4
4
  "lastUpdated": "2026-08-30T00:00:00.000Z"
5
5
  }
@@ -1,7 +1,7 @@
1
1
  <?xml version="1.0"?>
2
2
  <Container version="2">
3
3
  <Name>Mendelow Cloud Butler</Name>
4
- <Repository>ghcr.io/ourostack/ouroboros-butler:0.1.0-alpha.771</Repository>
4
+ <Repository>ghcr.io/ourostack/ouroboros-butler:0.1.0-alpha.773</Repository>
5
5
  <Registry>https://github.com/ourostack/ouroboros/pkgs/container/ouroboros-butler</Registry>
6
6
  <Network>host</Network>
7
7
  <Shell>sh</Shell>
@@ -77,6 +77,8 @@ const provider_models_1 = require("../provider-models");
77
77
  const cli_parse_1 = require("./cli-parse");
78
78
  const provider_discovery_1 = require("./provider-discovery");
79
79
  const provider_credentials_1 = require("../provider-credentials");
80
+ const startup_tui_1 = require("./startup-tui");
81
+ const daemon_tombstone_1 = require("./daemon-tombstone");
80
82
  // ── Default implementations ──
81
83
  async function defaultStartDaemonProcess(socketPath) {
82
84
  const launchdStarted = await startDaemonProcessViaLaunchd(socketPath);
@@ -724,6 +726,7 @@ function createDefaultOuroCliDeps(socketPath = socket_client_1.DEFAULT_DAEMON_SO
724
726
  readHealthState: daemon_health_1.readHealth,
725
727
  readHealthUpdatedAt: defaultReadHealthUpdatedAt,
726
728
  readRecentDaemonLogLines: defaultReadRecentDaemonLogLines,
729
+ readDaemonStartupTombstone: daemon_tombstone_1.readDaemonTombstone,
727
730
  sleep: defaultSleep,
728
731
  spawnBackgroundCli: defaultSpawnBackgroundCli,
729
732
  now: () => Date.now(),
@@ -732,6 +735,7 @@ function createDefaultOuroCliDeps(socketPath = socket_client_1.DEFAULT_DAEMON_SO
732
735
  startupStabilityWindowMs: 1_500,
733
736
  startupTimeoutMs: 60_000,
734
737
  startupRetryLimit: 1,
738
+ isProcessAlive: startup_tui_1.defaultIsProcessAlive,
735
739
  listDiscoveredAgents: defaultListDiscoveredAgents,
736
740
  runHatchFlow: hatch_flow_1.runHatchFlow,
737
741
  promptInput: defaultPromptInput,
@@ -113,6 +113,7 @@ const readiness_repair_1 = require("./readiness-repair");
113
113
  const human_readiness_1 = require("./human-readiness");
114
114
  const human_command_screens_1 = require("./human-command-screens");
115
115
  const startup_tui_1 = require("./startup-tui");
116
+ const daemon_bootstrap_startup_1 = require("./daemon-bootstrap-startup");
116
117
  const stale_bundle_prune_1 = require("./stale-bundle-prune");
117
118
  const up_progress_1 = require("./up-progress");
118
119
  const provider_ping_progress_1 = require("./provider-ping-progress");
@@ -1192,6 +1193,7 @@ async function ensureDaemonRunning(deps, options = {}) {
1192
1193
  writeRaw: deps.writeRaw ?? ((text) => process.stdout.write(text)),
1193
1194
  /* v8 ignore next -- thin wrapper: real stdout TTY detection injected by default deps @preserve */
1194
1195
  isTTY: deps.isTTY ?? process.stdout.isTTY === true,
1196
+ isProcessAlive: deps.isProcessAlive,
1195
1197
  /* v8 ignore next -- thin wrapper: real Date.now fallback injected by default deps @preserve */
1196
1198
  now: deps.now ?? (() => Date.now()),
1197
1199
  /* v8 ignore next -- thin wrapper: real setTimeout fallback injected by default deps @preserve */
@@ -1240,6 +1242,7 @@ async function ensureDaemonRunning(deps, options = {}) {
1240
1242
  writeRaw: deps.writeRaw ?? ((text) => process.stdout.write(text)),
1241
1243
  /* v8 ignore next -- thin wrapper: real stdout TTY detection injected by default deps @preserve */
1242
1244
  isTTY: deps.isTTY ?? process.stdout.isTTY === true,
1245
+ isProcessAlive: deps.isProcessAlive,
1243
1246
  /* v8 ignore next -- thin wrapper: real Date.now fallback injected by default deps @preserve */
1244
1247
  now: deps.now ?? (() => Date.now()),
1245
1248
  /* v8 ignore next -- thin wrapper: real setTimeout fallback injected by default deps @preserve */
@@ -1295,7 +1298,9 @@ function formatDaemonStartupFailureMessage(pid, failure, deps) {
1295
1298
  const lines = [
1296
1299
  `background service started (pid ${pid ?? "unknown"}) but did not finish booting: ${failure.reason}`,
1297
1300
  ];
1298
- const recentLogLines = deps.readRecentDaemonLogLines?.(DEFAULT_DAEMON_STARTUP_LOG_LINES) ?? [];
1301
+ const recentLogLines = failure.includeRecentLogs === false
1302
+ ? []
1303
+ : deps.readRecentDaemonLogLines?.(DEFAULT_DAEMON_STARTUP_LOG_LINES) ?? [];
1299
1304
  if (recentLogLines.length > 0) {
1300
1305
  lines.push("recent daemon logs:");
1301
1306
  lines.push(...recentLogLines.map((line) => ` ${line}`));
@@ -1303,6 +1308,19 @@ function formatDaemonStartupFailureMessage(pid, failure, deps) {
1303
1308
  lines.push("Run `ouro logs` to watch live startup logs or `ouro doctor` for a deeper diagnosis.");
1304
1309
  return lines.join("\n");
1305
1310
  }
1311
+ function readFreshDaemonStartupFailure(deps, options) {
1312
+ const tombstone = deps.readDaemonStartupTombstone?.() ?? null;
1313
+ if (!tombstone || (tombstone.reason !== daemon_bootstrap_startup_1.PUBLIC_DAEMON_STARTUP_FAILURE_REASON && tombstone.reason !== "startupFailure"))
1314
+ return null;
1315
+ const timestampMs = Date.parse(tombstone.timestamp);
1316
+ if (!Number.isFinite(timestampMs) || timestampMs < options.bootStartedAtMs)
1317
+ return null;
1318
+ if (options.pid !== null && tombstone.pid !== options.pid)
1319
+ return null;
1320
+ return tombstone.reason === daemon_bootstrap_startup_1.PUBLIC_DAEMON_STARTUP_FAILURE_REASON
1321
+ ? tombstone.message
1322
+ : "background service failed during startup; run `ouro doctor` for diagnosis";
1323
+ }
1306
1324
  async function waitForDaemonStartup(deps, options) {
1307
1325
  const now = deps.now ?? Date.now;
1308
1326
  const sleep = deps.sleep ?? defaultSleep;
@@ -1316,6 +1334,16 @@ async function waitForDaemonStartup(deps, options) {
1316
1334
  if (!useHealthMonitor) {
1317
1335
  while (now() < deadline) {
1318
1336
  await sleep(pollIntervalMs);
1337
+ const startupFailure = readFreshDaemonStartupFailure(deps, options);
1338
+ if (startupFailure)
1339
+ return { reason: startupFailure, retryable: false, includeRecentLogs: false };
1340
+ if (options.pid !== null && deps.isProcessAlive && !deps.isProcessAlive(options.pid)) {
1341
+ return {
1342
+ reason: `${options.serviceLabel} exited before answering`,
1343
+ retryable: false,
1344
+ includeRecentLogs: false,
1345
+ };
1346
+ }
1319
1347
  const latestEvent = options.readLatestDaemonEvent?.() ?? null;
1320
1348
  deps.reportDaemonStartupPhase?.(formatDaemonStartupProgressLine(options.serviceLabel, "waiting", latestEvent));
1321
1349
  if (await deps.checkSocketAlive(deps.socketPath))
@@ -1328,8 +1356,18 @@ async function waitForDaemonStartup(deps, options) {
1328
1356
  }
1329
1357
  while (now() < deadline) {
1330
1358
  await sleep(pollIntervalMs);
1359
+ const startupFailure = readFreshDaemonStartupFailure(deps, options);
1360
+ if (startupFailure)
1361
+ return { reason: startupFailure, retryable: false, includeRecentLogs: false };
1331
1362
  const aliveNow = await deps.checkSocketAlive(deps.socketPath);
1332
1363
  if (!aliveNow) {
1364
+ if (options.pid !== null && deps.isProcessAlive && !deps.isProcessAlive(options.pid)) {
1365
+ return {
1366
+ reason: `${options.serviceLabel} exited before answering`,
1367
+ retryable: false,
1368
+ includeRecentLogs: false,
1369
+ };
1370
+ }
1333
1371
  if (sawSocket) {
1334
1372
  return {
1335
1373
  reason: `${options.serviceLabel} answered once and then disappeared during startup`,