@ouro.bot/cli 0.1.0-alpha.770 → 0.1.0-alpha.772

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,20 @@
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.772",
6
+ "changes": [
7
+ "Make Sanctuary Unraid key rotation crash-resumable with exact transaction and rejection proofs.",
8
+ "Require durable zero-playback journal evidence before live callback injection.",
9
+ "Reject malformed Unraid rotation checkpoints before mutation with exact slot, attestation, revocation-order, and phase-state validation."
10
+ ]
11
+ },
12
+ {
13
+ "version": "0.1.0-alpha.771",
14
+ "changes": [
15
+ "Accept and durably attest the exact Unraid 7.2.3 API-key create response before key rotation proceeds."
16
+ ]
17
+ },
4
18
  {
5
19
  "version": "0.1.0-alpha.770",
6
20
  "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,6 +149,117 @@ function runUnraid(args) {
149
149
  return object(JSON.parse(result.stdout), "Unraid API response")
150
150
  }
151
151
 
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
+ }) {
169
+ const before = dependencies.inventoryRecords()
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
174
+ try {
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
+ }
213
+ const id = text(created.id, "created key id", KEY_ID)
214
+ const key = text(created.key, "created key descriptor")
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)
217
+ if (matches.length !== 1 || matches[0].name !== name || matches[0].key !== key
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")
221
+ }
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.
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])
259
+ throw error
260
+ }
261
+ }
262
+
152
263
  function autostartFileExact() {
153
264
  const fd = openSync(AUTOSTART_FILE, constants.O_RDONLY | constants.O_NOFOLLOW)
154
265
  let content
@@ -545,27 +656,149 @@ function containerRestartSnapshot(expectedImage) {
545
656
  }
546
657
 
547
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` }
548
661
 
549
- function persistRecovery(record) {
662
+ function persistPrivateJson(target, value) {
550
663
  mkdirSync(RECOVERY_ROOT, { recursive: true, mode: 0o700 })
551
664
  chmodSync(RECOVERY_ROOT, 0o700)
552
665
  chownSync(RECOVERY_ROOT, 0, 0)
553
- const target = recoveryPath(record.id)
554
666
  const temporary = `${target}.tmp-${process.pid}`
555
667
  const fd = openSync(temporary, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL, 0o600)
556
- try { writeFileSync(fd, `${JSON.stringify(record)}\n`); fsyncSync(fd) } finally { closeSync(fd) }
557
- chownSync(temporary, 0, 0)
668
+ try { chownSync(temporary, 0, 0); writeFileSync(fd, `${JSON.stringify(value)}\n`); fsyncSync(fd) } finally { closeSync(fd) }
558
669
  renameSync(temporary, target)
559
670
  const directory = openSync(RECOVERY_ROOT, constants.O_RDONLY)
560
671
  try { fsyncSync(directory) } finally { closeSync(directory) }
561
672
  }
562
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
+
563
767
  function removeRecovery(id) {
564
768
  unlinkSync(recoveryPath(id))
565
769
  const directory = openSync(RECOVERY_ROOT, constants.O_RDONLY)
566
770
  try { fsyncSync(directory) } finally { closeSync(directory) }
567
771
  }
568
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
+
569
802
  function permissions(value) {
570
803
  if (!Array.isArray(value) || value.length === 0) throw new Error("permissions are invalid")
571
804
  const result = value.map((item) => text(item, "permission", PERMISSION)).sort()
@@ -1531,18 +1764,12 @@ async function dispatch(request, dependencies = {
1531
1764
  if (payload.targetServerId !== TARGET_SERVER) throw new Error("target server is invalid")
1532
1765
  const name = text(payload.name, "key name", KEY_NAME)
1533
1766
  const requested = permissions(payload.permissions)
1534
- const created = runUnraid(["apikey", "--name", name, "--create", "--permissions", requested.join(","), "--json"])
1535
- const id = text(created.id, "created key id", KEY_ID)
1536
- const key = text(created.key, "created key descriptor")
1537
- const actualPermissions = permissions(created.permissions)
1538
- if (created.name !== name || JSON.stringify(actualPermissions) !== JSON.stringify(requested)
1539
- || !Array.isArray(created.roles) || created.roles.length !== 0) throw new Error("created key identity is invalid")
1540
- const matches = inventoryRecords().filter((record) => record.id === id)
1541
- if (matches.length !== 1 || matches[0].name !== name || matches[0].key !== key
1542
- || JSON.stringify(flattened(matches[0])) !== JSON.stringify(requested) || matches[0].roles.length !== 0) {
1543
- throw new Error("created key record does not match the exact CLI result")
1544
- }
1545
- return matches[0]
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))
1546
1773
  }
1547
1774
  if (operation === "revoke_key") {
1548
1775
  exactKeys(payload, ["operation", "targetServerId", "keyId"], operation)
@@ -1560,17 +1787,7 @@ async function dispatch(request, dependencies = {
1560
1787
  if (operation === "probe_revoked_key") {
1561
1788
  exactKeys(payload, ["operation", "targetServerId", "keyId"], operation)
1562
1789
  if (payload.targetServerId !== TARGET_SERVER) throw new Error("target server is invalid")
1563
- const id = text(payload.keyId, "key id", KEY_ID)
1564
- const record = normalizeRecord(readPrivateJson(recoveryPath(id)))
1565
- if (record.id !== id) throw new Error("recovery key id is invalid")
1566
- const response = await fetch(GRAPHQL_ENDPOINT, {
1567
- method: "POST", headers: { "content-type": "application/json", "x-api-key": record.key },
1568
- body: JSON.stringify({ query: "query AcceptanceAuthProbe { info { os { hostname } } }", variables: {} }),
1569
- signal: AbortSignal.timeout(10_000),
1570
- })
1571
- if (response.status !== 401 && response.status !== 403) throw new Error("revoked key still authenticates")
1572
- removeRecovery(id)
1573
- return { valid: false, status: response.status, id }
1790
+ return await probeRevokedUnraidKey(text(payload.keyId, "key id", KEY_ID))
1574
1791
  }
1575
1792
  if (operation === "request_reboot") {
1576
1793
  exactKeys(payload, ["operation", "targetId", "idempotencyKey", "preflightDigest", "processBindingDigest"], operation)
@@ -1707,7 +1924,11 @@ function writeClosedInventory(file) {
1707
1924
 
1708
1925
  function createBrokerServer(dispatchRequest = dispatch) {
1709
1926
  const dispatchDrain = createDispatchDrain()
1927
+ const connections = new Set()
1710
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))
1711
1932
  let input = ""
1712
1933
  connection.setEncoding("utf8")
1713
1934
  connection.on("data", (chunk) => {
@@ -1723,7 +1944,8 @@ function createBrokerServer(dispatchRequest = dispatch) {
1723
1944
  } catch { connection.end(`${JSON.stringify({ ok: false, error: "host operation failed" })}\n`) }
1724
1945
  })
1725
1946
  })
1726
- return { server, dispatchDrain }
1947
+ const destroyConnections = () => { for (const connection of connections) connection.destroy() }
1948
+ return { server, dispatchDrain, destroyConnections }
1727
1949
  }
1728
1950
 
1729
1951
  async function main() {
@@ -1740,7 +1962,7 @@ async function main() {
1740
1962
  const snapshotFd = openSync(initialSnapshot, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL, 0o600)
1741
1963
  try { writeFileSync(snapshotFd, `${JSON.stringify(snapshot)}\n`); fsyncSync(snapshotFd) } finally { closeSync(snapshotFd) }
1742
1964
  chownSync(initialSnapshot, 0, 0)
1743
- const { server, dispatchDrain } = createBrokerServer()
1965
+ const { server, dispatchDrain, destroyConnections } = createBrokerServer()
1744
1966
  server.listen(socket, () => {
1745
1967
  chownSync(socket, 0, 10001)
1746
1968
  chmodSync(socket, 0o660)
@@ -1750,6 +1972,7 @@ async function main() {
1750
1972
  if (shuttingDown) return
1751
1973
  shuttingDown = true
1752
1974
  server.close()
1975
+ destroyConnections()
1753
1976
  await dispatchDrain.stopAndDrain()
1754
1977
  await interactiveRestartDriver.stopAndDrain()
1755
1978
  let failed = false
@@ -1767,6 +1990,7 @@ if (process.argv[1]?.endsWith("sanctuary-unit16-host-broker.mjs")) {
1767
1990
  }
1768
1991
 
1769
1992
  export {
1993
+ acknowledgeStoredUnraidKey,
1770
1994
  assertStableContainerProcess,
1771
1995
  attestHealthProbeProcessAbsent,
1772
1996
  armRestartAfterResponseClosed,
@@ -1775,6 +1999,7 @@ export {
1775
1999
  createBrokerServer,
1776
2000
  createHealthProbeOperationCoordinator,
1777
2001
  createOwnerMutationCoordinator,
2002
+ createUnraidKey,
1778
2003
  createInteractiveRestartDriver,
1779
2004
  dispatch,
1780
2005
  denialTargetSnapshot,
@@ -1790,6 +2015,7 @@ export {
1790
2015
  observeRebootPreflight,
1791
2016
  parseVaultStatus,
1792
2017
  optionalStoppedContainerExact,
2018
+ probeRevokedUnraidKey,
1793
2019
  queryGraphqlAutostart,
1794
2020
  readBoundedProcStatus,
1795
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.770",
2
+ "runtimeVersion": "0.1.0-alpha.772",
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.770</Repository>
4
+ <Repository>ghcr.io/ourostack/ouroboros-butler:0.1.0-alpha.772</Repository>
5
5
  <Registry>https://github.com/ourostack/ouroboros/pkgs/container/ouroboros-butler</Registry>
6
6
  <Network>host</Network>
7
7
  <Shell>sh</Shell>
@@ -32,6 +32,9 @@ var __importStar = (this && this.__importStar) || (function () {
32
32
  return result;
33
33
  };
34
34
  })();
35
+ var __importDefault = (this && this.__importDefault) || function (mod) {
36
+ return (mod && mod.__esModule) ? mod : { "default": mod };
37
+ };
35
38
  Object.defineProperty(exports, "__esModule", { value: true });
36
39
  exports.proveAttemptedRecoveryWithoutRetry = void 0;
37
40
  exports.evaluateSanctuaryProviderReadinessContract = evaluateSanctuaryProviderReadinessContract;
@@ -45,6 +48,8 @@ exports.canonicalDockerIdFromUnraidPrefixedId = canonicalDockerIdFromUnraidPrefi
45
48
  exports.runSanctuaryProductionBoundaryProbe = runSanctuaryProductionBoundaryProbe;
46
49
  exports.createSanctuaryReadOnlyDenialScenarioDriver = createSanctuaryReadOnlyDenialScenarioDriver;
47
50
  exports.readDefaultSanctuaryScenarioFacts = readDefaultSanctuaryScenarioFacts;
51
+ exports.callbackPlaybackSnapshot = callbackPlaybackSnapshot;
52
+ exports.recordCallbackPlayback = recordCallbackPlayback;
48
53
  exports.executeSanctuaryAcceptanceAdapter = executeSanctuaryAcceptanceAdapter;
49
54
  exports.executeSanctuaryAcceptanceVaultProbe = executeSanctuaryAcceptanceVaultProbe;
50
55
  exports.executeSanctuaryAcceptanceRevokedProbe = executeSanctuaryAcceptanceRevokedProbe;
@@ -55,6 +60,7 @@ const node_crypto_1 = require("node:crypto");
55
60
  const node_fs_1 = require("node:fs");
56
61
  const node_net_1 = require("node:net");
57
62
  const path = __importStar(require("node:path"));
63
+ const better_sqlite3_1 = __importDefault(require("better-sqlite3"));
58
64
  const friends_1 = require("@ouro.bot/friends");
59
65
  const runtime_1 = require("../../nerves/runtime");
60
66
  const telegram_approval_runtime_1 = require("../../senses/telegram-approval-runtime");
@@ -99,6 +105,7 @@ const NETWORK_TIMEOUT_MS = 10_000;
99
105
  const KEY_DIRECTORY = "/boot/config/plugins/dynamix.my.servers/keys";
100
106
  const SELECTED_KEY_RECORD = "/run/ouro-acceptance/unraid-key.json";
101
107
  const TELEGRAM_OFFSET = "/home/ouro/AgentBundles/sanctuary.ouro/state/senses/telegram/offset.json";
108
+ const CALLBACK_PLAYBACK_JOURNAL = "state/approvals/sanctuary-callback-playback.sqlite";
102
109
  const TELEGRAM_IDENTITY_KEY = "/home/ouro/AgentBundles/sanctuary.ouro/state/senses/telegram/identity.key";
103
110
  const TELEGRAM_AUDIT = `/home/ouro/AgentBundles/sanctuary.ouro/${telegram_audit_ledger_1.TELEGRAM_ACCEPTANCE_AUDIT_RELATIVE_PATH}`;
104
111
  const TELEGRAM_AUDIT_HEAD = `/home/ouro/AgentBundles/sanctuary.ouro/${telegram_audit_ledger_1.TELEGRAM_ACCEPTANCE_AUDIT_HEAD_RELATIVE_PATH}`;
@@ -244,6 +251,7 @@ function createSanctuaryAcceptanceAdapterDependencies(secretFd = 3, options = {}
244
251
  refreshMachine: runtime_credentials_1.refreshMachineRuntimeCredentialConfig,
245
252
  mergeMachine: runtime_credentials_1.mergeMachineRuntimeCredentialConfig,
246
253
  callbackProbe: executeSanctuaryAcceptanceCallbackProbe,
254
+ callbackPlaybackSnapshot: (coordinateDigest) => callbackPlaybackSnapshot((0, identity_1.getAgentRoot)(TARGET_ID), coordinateDigest),
247
255
  interactiveRuntime: executeSanctuaryInteractiveRuntimeOperation,
248
256
  hostRequest: options.hostRequest ?? ((payload) => defaultHostRequest(payload, hostBrokerSocket, adapterTimeoutMs)),
249
257
  telegramCredentials: () => (0, telegram_1.loadTelegramSenseCredentials)(TARGET_ID),
@@ -1912,6 +1920,94 @@ function callbackUpdate(value) {
1912
1920
  object(update.callback_query, "callback update callback_query");
1913
1921
  return update;
1914
1922
  }
1923
+ function withCallbackPlaybackJournal(agentRoot, operation) {
1924
+ const databasePath = path.join(agentRoot, CALLBACK_PLAYBACK_JOURNAL);
1925
+ (0, node_fs_1.mkdirSync)(path.dirname(databasePath), { recursive: true, mode: 0o700 });
1926
+ const database = new better_sqlite3_1.default(databasePath);
1927
+ try {
1928
+ (0, node_fs_1.chmodSync)(databasePath, 0o600);
1929
+ database.pragma("journal_mode = DELETE");
1930
+ database.pragma("synchronous = FULL");
1931
+ database.exec(`
1932
+ CREATE TABLE IF NOT EXISTS callback_playback (
1933
+ coordinate_digest TEXT PRIMARY KEY NOT NULL CHECK(length(coordinate_digest) = 64),
1934
+ playback_count INTEGER NOT NULL CHECK(playback_count > 0)
1935
+ )
1936
+ `);
1937
+ return operation(database);
1938
+ }
1939
+ finally {
1940
+ database.close();
1941
+ }
1942
+ }
1943
+ function callbackPlaybackSnapshot(agentRoot, coordinateDigest) {
1944
+ if (!SHA256.test(coordinateDigest))
1945
+ throw new Error("callback coordinate digest is invalid");
1946
+ return withCallbackPlaybackJournal(agentRoot, (database) => {
1947
+ const rows = database.prepare("SELECT coordinate_digest, playback_count FROM callback_playback ORDER BY coordinate_digest")
1948
+ .all();
1949
+ if (rows.some((row) => !SHA256.test(row.coordinate_digest) || !Number.isSafeInteger(row.playback_count) || row.playback_count <= 0)) {
1950
+ throw new Error("callback playback journal is invalid");
1951
+ }
1952
+ const playbackCount = rows.find((row) => row.coordinate_digest === coordinateDigest)?.playback_count ?? 0;
1953
+ return { playbackCount, coordinateDigest, journalDigest: sha256(JSON.stringify(rows)) };
1954
+ });
1955
+ }
1956
+ function recordCallbackPlayback(agentRoot, coordinateDigest) {
1957
+ if (!SHA256.test(coordinateDigest))
1958
+ throw new Error("callback coordinate digest is invalid");
1959
+ withCallbackPlaybackJournal(agentRoot, (database) => {
1960
+ database.prepare(`
1961
+ INSERT INTO callback_playback (coordinate_digest, playback_count) VALUES (?, 1)
1962
+ ON CONFLICT(coordinate_digest) DO UPDATE SET playback_count = playback_count + 1
1963
+ `).run(coordinateDigest);
1964
+ });
1965
+ }
1966
+ function callbackCoordinate(update) {
1967
+ const updateId = update.update_id;
1968
+ if (!Number.isSafeInteger(updateId) || Number(updateId) < 0)
1969
+ throw new Error("callback update_id is invalid");
1970
+ const callback = object(update.callback_query, "callback update callback_query");
1971
+ const from = object(callback.from, "callback update sender");
1972
+ const message = object(callback.message, "callback update message");
1973
+ const chat = object(message.chat, "callback update chat");
1974
+ const callbackId = (value, label) => {
1975
+ if (!Number.isSafeInteger(value) || Number(value) <= 0)
1976
+ throw new Error(`${label} is invalid`);
1977
+ return String(value);
1978
+ };
1979
+ const coordinate = {
1980
+ updateId: Number(updateId),
1981
+ queryId: text(callback.id, "callback query id"),
1982
+ callbackData: text(callback.data, "callback data"),
1983
+ userId: callbackId(from.id, "callback user id"),
1984
+ chatId: callbackId(chat.id, "callback chat id"),
1985
+ messageId: callbackId(message.message_id, "callback message id"),
1986
+ };
1987
+ return {
1988
+ updateId: coordinate.updateId,
1989
+ digest: sha256(`ouroboros.sanctuary.callback-coordinate.v1\0${JSON.stringify(coordinate)}`),
1990
+ };
1991
+ }
1992
+ function callbackPlaybackPreflight(payload, deps) {
1993
+ exactKeys(payload, ["operation", "update"], "callback playback preflight request");
1994
+ const coordinate = callbackCoordinate(callbackUpdate(payload.update));
1995
+ const offset = object(JSON.parse(fixedFile(deps, TELEGRAM_OFFSET)), "Telegram offset");
1996
+ exactKeys(offset, ["nextUpdateId"], "Telegram offset");
1997
+ if (!Number.isSafeInteger(offset.nextUpdateId) || Number(offset.nextUpdateId) < 0)
1998
+ throw new Error("Telegram offset is invalid");
1999
+ const snapshot = object(dependency(deps.callbackPlaybackSnapshot, "callback playback journal")(coordinate.digest), "callback playback journal snapshot");
2000
+ exactKeys(snapshot, ["coordinateDigest", "journalDigest", "playbackCount"], "callback playback journal snapshot");
2001
+ if (snapshot.coordinateDigest !== coordinate.digest || typeof snapshot.journalDigest !== "string" || !SHA256.test(snapshot.journalDigest)
2002
+ || !Number.isSafeInteger(snapshot.playbackCount) || Number(snapshot.playbackCount) < 0) {
2003
+ throw new Error("callback playback journal snapshot is invalid");
2004
+ }
2005
+ return {
2006
+ playbackCount: Math.max(Number(snapshot.playbackCount), Number(offset.nextUpdateId) > coordinate.updateId ? 1 : 0),
2007
+ coordinateDigest: coordinate.digest,
2008
+ journalDigest: snapshot.journalDigest,
2009
+ };
2010
+ }
1915
2011
  async function concurrentCallbackProbe(payload, deps) {
1916
2012
  const update = callbackUpdate(payload.update);
1917
2013
  if (!Number.isSafeInteger(payload.concurrency) || payload.concurrency < 2 || payload.concurrency > 16) {
@@ -2026,6 +2122,12 @@ async function storeKey(payload, deps) {
2026
2122
  if (text(handles[id], "vault-backed Unraid credential") !== text(stored[field], "vault-backed Unraid credential")) {
2027
2123
  throw new Error("Unraid key handle does not bind to the active vault field");
2028
2124
  }
2125
+ const acknowledged = object(await dependency(deps.hostRequest, "Sanctuary host broker")({
2126
+ operation: "acknowledge_key_storage", targetServerId: TARGET_SERVER_ID, keyId: id,
2127
+ }), "Unraid key storage acknowledgement");
2128
+ if (acknowledged.acknowledged !== true || acknowledged.id !== id) {
2129
+ throw new Error("Unraid key storage acknowledgement failed");
2130
+ }
2029
2131
  return { stored: true, keyId: id };
2030
2132
  }
2031
2133
  async function probeKey(payload, deps) {
@@ -2232,6 +2334,9 @@ async function executeSanctuaryAcceptanceAdapter(rawPayload, deps = createSanctu
2232
2334
  case "snapshot":
2233
2335
  result = cursorSnapshot(payload, deps);
2234
2336
  break;
2337
+ case "callback_playback_preflight":
2338
+ result = callbackPlaybackPreflight(payload, deps);
2339
+ break;
2235
2340
  case "inject_callbacks_concurrently":
2236
2341
  result = await concurrentCallbackProbe(payload, deps);
2237
2342
  break;
@@ -2418,7 +2523,7 @@ async function executeSanctuaryInteractiveRuntimeOperation(rawPayload, supplied)
2418
2523
  };
2419
2524
  return (0, sanctuary_interactive_control_1.executeSanctuaryInteractiveEngine)(rawPayload, deps);
2420
2525
  }
2421
- async function executeSanctuaryAcceptanceCallbackProbe(rawUpdate, _replay, deps = {
2526
+ async function executeSanctuaryAcceptanceCallbackProbe(rawUpdate, replay, deps = {
2422
2527
  refresh: runtime_credentials_1.refreshRuntimeCredentialConfig,
2423
2528
  credentials: telegram_1.loadTelegramSenseCredentials,
2424
2529
  identityKey: telegram_1.readOrCreateTelegramIdentityKey,
@@ -2426,6 +2531,7 @@ async function executeSanctuaryAcceptanceCallbackProbe(rawUpdate, _replay, deps
2426
2531
  createRuntime: telegram_approval_runtime_1.createTelegramApprovalRuntime,
2427
2532
  toolContext: sanctuary_runtime_1.createSanctuaryToolContext,
2428
2533
  effects: createAcceptanceProbeEffects,
2534
+ recordCallbackPlayback: (coordinateDigest) => recordCallbackPlayback((0, identity_1.getAgentRoot)(TARGET_ID), coordinateDigest),
2429
2535
  }) {
2430
2536
  const update = callbackUpdate(rawUpdate);
2431
2537
  const refreshed = await deps.refresh(TARGET_ID);
@@ -2453,7 +2559,11 @@ async function executeSanctuaryAcceptanceCallbackProbe(rawUpdate, _replay, deps
2453
2559
  effects: effectBoundary?.port ?? unavailableEffects,
2454
2560
  });
2455
2561
  try {
2562
+ deps.recordCallbackPlayback(callbackCoordinate(update).digest);
2456
2563
  const result = await runtime.transport.handleUpdate(update);
2564
+ if (replay && (result.handled !== true || result.accepted !== false || result.reason !== "stale_callback")) {
2565
+ throw new Error("Telegram callback replay did not settle as stale");
2566
+ }
2457
2567
  return {
2458
2568
  settled: result.handled,
2459
2569
  claimed: result.reason === "accepted" || result.reason === "decision_refused",
@@ -1065,6 +1065,32 @@ async function callbackInject(config, deps) {
1065
1065
  const base = { schemaVersion: 1, operation: "callback-inject", phase: "preflight", updateDigest: digest(update), concurrency, replay: true };
1066
1066
  initializeCheckpoint(root, evidencePath, base);
1067
1067
  try {
1068
+ const preflight = async () => {
1069
+ const result = object(await deps.runAdapter(executable, { operation: "callback_playback_preflight", update }), "callback playback preflight");
1070
+ if (JSON.stringify(Object.keys(result).sort()) !== JSON.stringify(["coordinateDigest", "journalDigest", "playbackCount"])) {
1071
+ throw new Error("callback playback preflight shape is invalid");
1072
+ }
1073
+ if (!Number.isSafeInteger(result.playbackCount) || Number(result.playbackCount) < 0) {
1074
+ throw new Error("callback playback preflight count is invalid");
1075
+ }
1076
+ if (typeof result.coordinateDigest !== "string" || !/^[0-9a-f]{64}$/u.test(result.coordinateDigest)) {
1077
+ throw new Error("callback playback preflight coordinate digest is invalid");
1078
+ }
1079
+ if (typeof result.journalDigest !== "string" || !/^[0-9a-f]{64}$/u.test(result.journalDigest)) {
1080
+ throw new Error("callback playback preflight journal digest is invalid");
1081
+ }
1082
+ return { playbackCount: Number(result.playbackCount), coordinateDigest: result.coordinateDigest, journalDigest: result.journalDigest };
1083
+ };
1084
+ const firstPreflight = await preflight();
1085
+ if (firstPreflight.playbackCount !== 0)
1086
+ throw new Error("callback injection requires zero prior playback");
1087
+ const secondPreflight = await preflight();
1088
+ if (secondPreflight.playbackCount !== 0)
1089
+ throw new Error("callback injection requires zero prior playback");
1090
+ if (secondPreflight.coordinateDigest !== firstPreflight.coordinateDigest)
1091
+ throw new Error("callback playback coordinate changed between preflights");
1092
+ if (secondPreflight.journalDigest !== firstPreflight.journalDigest)
1093
+ throw new Error("callback playback journal changed between preflights");
1068
1094
  const batch = object(await deps.runAdapter(executable, { operation: "inject_callbacks_concurrently", update, concurrency }), "callback batch result");
1069
1095
  if (!Array.isArray(batch.results) || batch.results.length !== concurrency)
1070
1096
  throw new Error("callback batch result count mismatch");
@@ -1094,7 +1120,7 @@ async function callbackInject(config, deps) {
1094
1120
  }
1095
1121
  if (replayResult.claimed || replayResult.mutated)
1096
1122
  throw new Error("callback replay was claimed or mutated state");
1097
- replaceCheckpoint(root, evidencePath, { ...base, phase: "complete", claims, mutations, replayClaimed: false, replayMutated: false, completedAt: deps.now() });
1123
+ replaceCheckpoint(root, evidencePath, { ...base, phase: "complete", coordinateDigest: firstPreflight.coordinateDigest, zeroPlayback: true, claims, mutations, replayClaimed: false, replayMutated: false, completedAt: deps.now() });
1098
1124
  }
1099
1125
  catch (error) {
1100
1126
  failedCheckpoint(root, evidencePath, base, error);
@@ -1135,99 +1161,267 @@ function sameInventory(left, right) {
1135
1161
  }
1136
1162
  async function rotateOccupiedCanonicalUnraidKeys(input) {
1137
1163
  const { root, evidencePath, targetServerId, adapters, desired, oldKeys, initial, deps } = input;
1164
+ const prior = pathEntryExists(evidencePath) ? readCheckpoint(root, evidencePath) : null;
1165
+ if (prior && (prior.schemaVersion !== 1 || prior.operation !== "unraid-key-rotate" || prior.targetServerId !== targetServerId)) {
1166
+ throw new Error("Unraid rotation checkpoint identity mismatch");
1167
+ }
1168
+ const suffix = prior ? text(prior.transactionSuffix, "Unraid rotation transaction suffix") : deps.randomBytes(8).subarray(0, 8).toString("hex");
1169
+ if (!/^[0-9a-f]{16}$/u.test(suffix))
1170
+ throw new Error("Unraid rotation transaction suffix is invalid");
1171
+ const createdKeyIds = prior ? stringArray(prior.createdKeyIds, "createdKeyIds") : [];
1172
+ const revokedKeyIds = prior ? stringArray(prior.revokedKeyIds, "revokedKeyIds") : [];
1173
+ const transactionKeys = prior ? (() => {
1174
+ if (!Array.isArray(prior.transactionKeys))
1175
+ throw new Error("Unraid rotation transaction keys are unavailable");
1176
+ return prior.transactionKeys.map((raw) => {
1177
+ const value = object(raw, "Unraid rotation transaction key");
1178
+ return {
1179
+ id: text(value.id, "transaction key id"), name: text(value.name, "transaction key name"),
1180
+ vaultField: text(value.vaultField, "transaction key vault field"),
1181
+ permissions: stringArray(value.permissions, "transaction key permissions").sort(),
1182
+ temporary: boolean(value.temporary, "transaction key temporary"),
1183
+ attested: boolean(value.attested, "transaction key attested"),
1184
+ };
1185
+ });
1186
+ })() : [];
1187
+ if (new Set(createdKeyIds).size !== createdKeyIds.length || new Set(revokedKeyIds).size !== revokedKeyIds.length) {
1188
+ throw new Error("Unraid rotation checkpoint IDs are ambiguous");
1189
+ }
1190
+ if (new Set(transactionKeys.map((entry) => entry.id)).size !== transactionKeys.length
1191
+ || new Set(transactionKeys.map((entry) => entry.name)).size !== transactionKeys.length
1192
+ || !sameStrings(createdKeyIds, transactionKeys.map((entry) => entry.id))) {
1193
+ throw new Error("Unraid rotation transaction key metadata is ambiguous");
1194
+ }
1195
+ const slots = desired.flatMap((key) => [
1196
+ { ...key, name: `${key.name} Rotation ${suffix}`, temporary: true },
1197
+ { ...key, temporary: false },
1198
+ ]);
1199
+ if (transactionKeys.some((entry) => {
1200
+ const slot = slots.find((candidate) => candidate.name === entry.name);
1201
+ return !slot || entry.temporary !== slot.temporary || entry.vaultField !== slot.vaultField
1202
+ || !sameStrings(entry.permissions, slot.permissions);
1203
+ }))
1204
+ throw new Error("Unraid rotation checkpoint transaction slots are invalid");
1205
+ const revocableKeyIds = new Set([
1206
+ ...oldKeys.map((entry) => entry.id),
1207
+ ...transactionKeys.filter((entry) => entry.temporary).map((entry) => entry.id),
1208
+ ]);
1209
+ if (revokedKeyIds.some((id) => !revocableKeyIds.has(id))) {
1210
+ throw new Error("Unraid rotation checkpoint contains an unowned revoked key ID");
1211
+ }
1212
+ const temporary = transactionKeys.filter((entry) => entry.temporary);
1213
+ const canonical = transactionKeys.filter((entry) => !entry.temporary);
1214
+ const allOldRevoked = oldKeys.every((entry) => revokedKeyIds.includes(entry.id));
1215
+ const allTemporaryAttested = temporary.length === desired.length && temporary.every((entry) => entry.attested);
1216
+ const allCanonicalAttested = canonical.length === desired.length && canonical.every((entry) => entry.attested);
1217
+ const revokedTemporary = temporary.filter((entry) => revokedKeyIds.includes(entry.id));
1218
+ if (revokedTemporary.some((entry) => !entry.attested)
1219
+ || (oldKeys.some((entry) => revokedKeyIds.includes(entry.id)) && !allTemporaryAttested)
1220
+ || (revokedTemporary.length > 0 && !allCanonicalAttested)
1221
+ || (canonical.length > 0 && !allOldRevoked)) {
1222
+ throw new Error("Unraid rotation checkpoint revocation order is invalid");
1223
+ }
1224
+ const phase = prior ? text(prior.phase, "Unraid rotation phase") : "preflight";
1225
+ const knownPhases = new Set(["preflight", "temporary_key_created", "temporary_key_attested", "key_revoke_attempted", "key_revoked", "canonical_key_created", "canonical_key_attested", "complete"]);
1226
+ const resumePhase = phase === "failed" ? text(prior?.resumePhase, "Unraid rotation resume phase") : phase;
1227
+ if (!knownPhases.has(resumePhase) || (phase !== "failed" && prior?.resumePhase !== undefined)) {
1228
+ throw new Error("Unraid rotation checkpoint phase is invalid");
1229
+ }
1230
+ const stateMatchesPhase = resumePhase === "preflight" ? transactionKeys.length === 0 && revokedKeyIds.length === 0
1231
+ : resumePhase === "temporary_key_created" ? temporary.length > 0 && canonical.length === 0 && revokedKeyIds.length === 0 && temporary.filter((entry) => !entry.attested).length === 1
1232
+ : resumePhase === "temporary_key_attested" ? temporary.length > 0 && canonical.length === 0 && revokedKeyIds.length === 0 && temporary.every((entry) => entry.attested)
1233
+ : resumePhase === "key_revoke_attempted" ? allTemporaryAttested && (canonical.length === 0 || allCanonicalAttested)
1234
+ : resumePhase === "key_revoked" ? revokedKeyIds.length > 0 && allTemporaryAttested && (canonical.length === 0 || allCanonicalAttested)
1235
+ : resumePhase === "canonical_key_created" ? allTemporaryAttested && canonical.length > 0 && sameStrings(revokedKeyIds, oldKeys.map((entry) => entry.id)) && canonical.filter((entry) => !entry.attested).length === 1
1236
+ : resumePhase === "canonical_key_attested" ? allTemporaryAttested && canonical.length > 0 && sameStrings(revokedKeyIds, oldKeys.map((entry) => entry.id)) && canonical.every((entry) => entry.attested)
1237
+ : transactionKeys.length === slots.length && allTemporaryAttested && allCanonicalAttested
1238
+ && sameStrings(revokedKeyIds, [...oldKeys.map((entry) => entry.id), ...temporary.map((entry) => entry.id)]);
1239
+ if (!stateMatchesPhase)
1240
+ throw new Error("Unraid rotation checkpoint state does not match its phase");
1138
1241
  const base = {
1139
- schemaVersion: 1,
1140
- operation: "unraid-key-rotate",
1141
- phase: "preflight",
1142
- targetServerId,
1143
- initialInventoryDigest: digest(initial),
1144
- createdKeyIds: [],
1145
- revokedKeyIds: [],
1242
+ schemaVersion: 1, operation: "unraid-key-rotate", targetServerId,
1243
+ initialInventoryDigest: prior ? text(prior.initialInventoryDigest, "initialInventoryDigest") : digest(initial),
1244
+ transactionSuffix: suffix,
1146
1245
  };
1147
- initializeCheckpoint(root, evidencePath, base);
1246
+ if (!prior)
1247
+ initializeCheckpoint(root, evidencePath, { ...base, phase: "preflight", createdKeyIds, revokedKeyIds, transactionKeys });
1248
+ if (phase === "complete") {
1249
+ const final = inventory(await deps.runAdapter(adapters.inventory, { operation: "inventory_keys", targetServerId }));
1250
+ const canonical = transactionKeys.filter((entry) => !entry.temporary)
1251
+ .map(({ id, name, permissions }) => ({ id, name, permissions, roles: [] }));
1252
+ if (canonical.length !== desired.length || !sameInventory(final, canonical)) {
1253
+ throw new Error("completed Unraid rotation inventory drifted");
1254
+ }
1255
+ return;
1256
+ }
1148
1257
  const created = [];
1149
- const revokedKeyIds = [];
1150
- const suffix = deps.randomBytes(8).toString("hex");
1258
+ let activePhase = resumePhase;
1259
+ const checkpoint = (phase) => {
1260
+ activePhase = phase;
1261
+ replaceCheckpoint(root, evidencePath, { ...base, phase, createdKeyIds, revokedKeyIds, transactionKeys });
1262
+ };
1151
1263
  const createAndAttest = async (key, name, temporary) => {
1152
- const response = object(await deps.runAdapter(adapters.create, {
1153
- operation: "create_key", targetServerId, name, permissions: key.permissions,
1154
- }), "created Unraid key");
1155
- const id = text(response.id, "created key id");
1156
- const handle = text(response.key, "created key handle");
1157
- if (response.name !== name || !sameStrings(stringArray(response.permissions, "created permissions"), key.permissions)
1158
- || !Array.isArray(response.roles) || response.roles.length !== 0
1159
- || initial.some((entry) => entry.id === id) || created.some((entry) => entry.id === id)) {
1160
- throw new Error("created Unraid key scope or identity mismatch");
1264
+ const live = inventory(await deps.runAdapter(adapters.inventory, { operation: "inventory_keys", targetServerId }));
1265
+ const matches = live.filter((entry) => entry.name === name);
1266
+ const transaction = transactionKeys.find((entry) => entry.name === name);
1267
+ if (transaction && revokedKeyIds.includes(transaction.id)) {
1268
+ if (matches.length !== 0)
1269
+ throw new Error("revoked Unraid transaction key inventory mismatch");
1270
+ return;
1271
+ }
1272
+ if (transaction && temporary && matches.length === 0)
1273
+ return;
1274
+ let id;
1275
+ let handle;
1276
+ if (matches.length === 1) {
1277
+ const existing = matches[0];
1278
+ if (!sameStrings(existing.permissions, key.permissions) || existing.roles.length !== 0
1279
+ || oldKeys.some((old) => old.id === existing.id)
1280
+ || (transaction && transaction.id !== existing.id)) {
1281
+ throw new Error("resumable Unraid key scope or identity mismatch");
1282
+ }
1283
+ id = existing.id;
1284
+ handle = `unraid-key:${id}:${key.vaultField}`;
1285
+ if (!transaction) {
1286
+ const recovered = object(await deps.runAdapter(adapters.create, {
1287
+ operation: "create_key", targetServerId, name, permissions: key.permissions,
1288
+ }), "recovered Unraid key");
1289
+ if (recovered.id !== id || recovered.name !== name
1290
+ || !sameStrings(stringArray(recovered.permissions, "recovered permissions"), key.permissions)
1291
+ || !Array.isArray(recovered.roles) || recovered.roles.length !== 0) {
1292
+ throw new Error("recovered Unraid key scope or identity mismatch");
1293
+ }
1294
+ handle = text(recovered.key, "recovered key handle");
1295
+ }
1296
+ }
1297
+ else {
1298
+ const response = object(await deps.runAdapter(adapters.create, {
1299
+ operation: "create_key", targetServerId, name, permissions: key.permissions,
1300
+ }), "created Unraid key");
1301
+ id = text(response.id, "created key id");
1302
+ handle = text(response.key, "created key handle");
1303
+ if (response.name !== name || !sameStrings(stringArray(response.permissions, "created permissions"), key.permissions)
1304
+ || !Array.isArray(response.roles) || response.roles.length !== 0
1305
+ || oldKeys.some((old) => old.id === id) || created.some((entry) => entry.id === id)) {
1306
+ throw new Error("created Unraid key scope or identity mismatch");
1307
+ }
1161
1308
  }
1162
1309
  created.push({ id, name, permissions: key.permissions, roles: [], handle, desired: key, temporary });
1163
- replaceCheckpoint(root, evidencePath, { ...base, phase: temporary ? "temporary_key_created" : "canonical_key_created", createdKeyIds: created.map((entry) => entry.id), revokedKeyIds });
1164
- const stored = object(await deps.runAdapter(adapters.store, {
1165
- operation: "store_key", targetServerId, vaultField: key.vaultField, keyId: id, key: handle,
1166
- }), "key store result");
1167
- if (stored.stored !== true || stored.keyId !== id)
1168
- throw new Error("vault did not attest exact key storage");
1310
+ if (!createdKeyIds.includes(id))
1311
+ createdKeyIds.push(id);
1312
+ const metadata = transaction ?? { id, name, vaultField: key.vaultField, permissions: key.permissions, temporary, attested: false };
1313
+ if (!transaction)
1314
+ transactionKeys.push(metadata);
1315
+ checkpoint(temporary ? "temporary_key_created" : "canonical_key_created");
1316
+ if (!metadata.attested) {
1317
+ const stored = object(await deps.runAdapter(adapters.store, {
1318
+ operation: "store_key", targetServerId, vaultField: key.vaultField, keyId: id, key: handle,
1319
+ }), "key store result");
1320
+ if (stored.stored !== true || stored.keyId !== id)
1321
+ throw new Error("vault did not attest exact key storage");
1322
+ }
1169
1323
  const probe = object(await deps.runAdapter(adapters.probe, {
1170
1324
  operation: "probe_new_key", targetServerId, id, key: handle,
1171
1325
  }), "new key probe");
1172
1326
  if (probe.valid !== true)
1173
1327
  throw new Error("new Unraid key readiness failed");
1328
+ metadata.attested = true;
1329
+ checkpoint(temporary ? "temporary_key_attested" : "canonical_key_attested");
1174
1330
  };
1175
1331
  const revokeAndReject = async (id, handle) => {
1176
- const revoked = object(await deps.runAdapter(adapters.revoke, { operation: "revoke_key", targetServerId, id }), "key revoke result");
1177
- if (revoked.revoked !== true || revoked.id !== id)
1178
- throw new Error("Unraid revoke did not attest exact key id");
1332
+ if (revokedKeyIds.includes(id))
1333
+ return;
1334
+ const live = inventory(await deps.runAdapter(adapters.inventory, { operation: "inventory_keys", targetServerId }));
1335
+ if (live.some((entry) => entry.id === id)) {
1336
+ const revoked = object(await deps.runAdapter(adapters.revoke, { operation: "revoke_key", targetServerId, id }), "key revoke result");
1337
+ if (revoked.revoked !== true || revoked.id !== id)
1338
+ throw new Error("Unraid revoke did not attest exact key id");
1339
+ checkpoint("key_revoke_attempted");
1340
+ }
1179
1341
  const probe = object(await deps.runAdapter(adapters.probe, { operation: "probe_revoked_key", targetServerId, id, key: handle }), "revoked key probe");
1180
1342
  if (probe.valid !== false || (probe.status !== 401 && probe.status !== 403))
1181
1343
  throw new Error("revoked Unraid key still authenticates");
1182
1344
  revokedKeyIds.push(id);
1183
- replaceCheckpoint(root, evidencePath, { ...base, phase: "key_revoked", createdKeyIds: created.map((entry) => entry.id), revokedKeyIds });
1345
+ checkpoint("key_revoked");
1184
1346
  };
1185
1347
  try {
1186
1348
  for (const key of desired)
1187
1349
  await createAndAttest(key, `${key.name} Rotation ${suffix}`, true);
1188
1350
  const withTemporary = inventory(await deps.runAdapter(adapters.inventory, { operation: "inventory_keys", targetServerId }));
1189
- if (!sameInventory(withTemporary, [...initial, ...created.map(({ handle: _handle, desired: _desired, temporary: _temporary, ...entry }) => entry)])) {
1351
+ const liveOld = withTemporary.filter((entry) => oldKeys.some((old) => old.id === entry.id));
1352
+ const inventoryAmbiguous = withTemporary.some((entry) => {
1353
+ if (oldKeys.some((old) => old.id === entry.id)) {
1354
+ const expected = desired.find((candidate) => candidate.name === entry.name);
1355
+ return !expected || !sameStrings(expected.permissions, entry.permissions) || entry.roles.length !== 0;
1356
+ }
1357
+ const metadata = transactionKeys.find((candidate) => candidate.id === entry.id);
1358
+ return !metadata || metadata.name !== entry.name || !sameStrings(metadata.permissions, entry.permissions) || entry.roles.length !== 0;
1359
+ });
1360
+ const missingBridge = liveOld.length > 0 && transactionKeys.filter((entry) => entry.temporary && !revokedKeyIds.includes(entry.id))
1361
+ .some((entry) => !withTemporary.some((candidate) => candidate.id === entry.id));
1362
+ if (inventoryAmbiguous || missingBridge) {
1190
1363
  throw new Error("temporary Unraid key inventory changed ambiguously");
1191
1364
  }
1192
1365
  for (const old of oldKeys) {
1193
- const secret = object(await deps.runAdapter(old.secretAdapter, { operation: "read_old_key", targetServerId, id: old.id }), "old key handle result");
1194
- await revokeAndReject(old.id, text(secret.key, "old key handle"));
1366
+ const live = inventory(await deps.runAdapter(adapters.inventory, { operation: "inventory_keys", targetServerId }));
1367
+ if (live.some((entry) => entry.id === old.id)) {
1368
+ const secret = object(await deps.runAdapter(old.secretAdapter, { operation: "read_old_key", targetServerId, id: old.id }), "old key handle result");
1369
+ await revokeAndReject(old.id, text(secret.key, "old key handle"));
1370
+ }
1371
+ else {
1372
+ await revokeAndReject(old.id, `unraid-key:${old.id}:legacy`);
1373
+ }
1374
+ }
1375
+ let afterOldRevokes = inventory(await deps.runAdapter(adapters.inventory, { operation: "inventory_keys", targetServerId }));
1376
+ const existingCanonical = transactionKeys.filter((entry) => !entry.temporary);
1377
+ if (existingCanonical.length === desired.length) {
1378
+ for (const key of transactionKeys.filter((entry) => entry.temporary && !revokedKeyIds.includes(entry.id))) {
1379
+ if (!afterOldRevokes.some((entry) => entry.id === key.id)) {
1380
+ await revokeAndReject(key.id, `unraid-key:${key.id}:${key.vaultField}`);
1381
+ }
1382
+ }
1383
+ afterOldRevokes = inventory(await deps.runAdapter(adapters.inventory, { operation: "inventory_keys", targetServerId }));
1195
1384
  }
1196
- const temporary = [...created];
1197
- const afterOldRevokes = inventory(await deps.runAdapter(adapters.inventory, { operation: "inventory_keys", targetServerId }));
1198
- const expectedTemporary = temporary.map(({ handle: _handle, desired: _desired, temporary: _temporary, ...entry }) => entry);
1199
- if (!sameInventory(afterOldRevokes, expectedTemporary))
1385
+ const exactPhase = transactionKeys.filter((entry) => !revokedKeyIds.includes(entry.id))
1386
+ .map(({ id, name, permissions }) => ({ id, name, permissions, roles: [] }));
1387
+ if (existingCanonical.length > desired.length || !sameInventory(afterOldRevokes, exactPhase)) {
1200
1388
  throw new Error("old-key revoke inventory is not the exact temporary pair");
1389
+ }
1201
1390
  for (const key of desired)
1202
1391
  await createAndAttest(key, key.name, false);
1203
1392
  const withCanonical = inventory(await deps.runAdapter(adapters.inventory, { operation: "inventory_keys", targetServerId }));
1204
- const expectedWithCanonical = created.map(({ handle: _handle, desired: _desired, temporary: _temporary, ...entry }) => entry);
1205
- if (!sameInventory(withCanonical, expectedWithCanonical))
1393
+ if (withCanonical.some((entry) => {
1394
+ const metadata = transactionKeys.find((candidate) => candidate.id === entry.id);
1395
+ return !metadata || metadata.name !== entry.name || !sameStrings(metadata.permissions, entry.permissions) || entry.roles.length !== 0;
1396
+ }) || transactionKeys.filter((entry) => !entry.temporary).some((entry) => !withCanonical.some((candidate) => candidate.id === entry.id))) {
1206
1397
  throw new Error("canonical-key mint inventory changed ambiguously");
1207
- for (const key of temporary)
1208
- await revokeAndReject(key.id, key.handle);
1398
+ }
1399
+ for (const key of transactionKeys.filter((entry) => entry.temporary)) {
1400
+ await revokeAndReject(key.id, `unraid-key:${key.id}:${key.vaultField}`);
1401
+ }
1209
1402
  const final = inventory(await deps.runAdapter(adapters.inventory, { operation: "inventory_keys", targetServerId }));
1210
- const canonical = created.filter((entry) => !entry.temporary).map(({ handle: _handle, desired: _desired, temporary: _temporary, ...entry }) => entry);
1403
+ const canonical = transactionKeys.filter((entry) => !entry.temporary)
1404
+ .map(({ id, name, permissions }) => ({ id, name, permissions, roles: [] }));
1211
1405
  if (!sameInventory(final, canonical))
1212
1406
  throw new Error("final canonical Unraid key inventory mismatch");
1213
1407
  replaceCheckpoint(root, evidencePath, {
1214
1408
  ...base,
1215
1409
  phase: "complete",
1216
- createdKeyIds: created.map((entry) => entry.id),
1410
+ createdKeyIds,
1217
1411
  revokedKeyIds,
1412
+ transactionKeys,
1218
1413
  finalInventoryDigest: digest(final),
1219
1414
  completedAt: deps.now(),
1220
1415
  });
1221
1416
  }
1222
1417
  catch (error) {
1223
- failedCheckpoint(root, evidencePath, { ...base, createdKeyIds: created.map((entry) => entry.id), revokedKeyIds }, error);
1418
+ replaceCheckpoint(root, evidencePath, { ...base, phase: "failed", resumePhase: activePhase, createdKeyIds, revokedKeyIds, transactionKeys, errorCategory: safeErrorCategory(error) });
1224
1419
  throw error;
1225
1420
  }
1226
1421
  }
1227
1422
  async function unraidKeyRotate(config, deps) {
1228
1423
  const root = privateAllowedRoot(config);
1229
1424
  const evidencePath = confinedPath(root, config.evidencePath, "evidencePath");
1230
- refuseExistingCheckpoint(root, evidencePath);
1231
1425
  const targetServerId = text(config.targetServerId, "targetServerId");
1232
1426
  const inventoryAdapter = adapter(config.inventoryAdapter, "inventoryAdapter");
1233
1427
  const createAdapter = adapter(config.createAdapter, "createAdapter");
@@ -1257,12 +1451,13 @@ async function unraidKeyRotate(config, deps) {
1257
1451
  throw new Error("old key IDs must be unique");
1258
1452
  const initial = inventory(await deps.runAdapter(inventoryAdapter, { operation: "inventory_keys", targetServerId }));
1259
1453
  const occupiedCanonicalContract = targetServerId === "sanctuary-unraid"
1260
- && desired.length === 2 && initial.length === 2 && oldKeys.length === 2
1454
+ && desired.length === 2 && oldKeys.length === 2
1261
1455
  && JSON.stringify(desired.map((key) => key.name).sort()) === JSON.stringify(["Butler RO", "Butler RW"])
1262
- && desired.every((key) => {
1456
+ && (pathEntryExists(evidencePath) || (initial.length === 2 && desired.every((key) => {
1263
1457
  const existing = initial.find((entry) => entry.name === key.name);
1264
- return existing !== undefined && oldKeys.some((old) => old.id === existing.id);
1265
- })
1458
+ return existing !== undefined && oldKeys.some((old) => old.id === existing.id)
1459
+ && existing.roles.length === 0 && sameStrings(existing.permissions, key.permissions);
1460
+ })))
1266
1461
  && [inventoryAdapter, createAdapter, storeAdapter, revokeAdapter, probeAdapter, ...oldKeys.map((old) => old.secretAdapter)]
1267
1462
  .every((value) => value === SANCTUARY_ACCEPTANCE_ADAPTER);
1268
1463
  if (occupiedCanonicalContract) {
@@ -1273,6 +1468,7 @@ async function unraidKeyRotate(config, deps) {
1273
1468
  });
1274
1469
  return;
1275
1470
  }
1471
+ refuseExistingCheckpoint(root, evidencePath);
1276
1472
  for (const key of desired)
1277
1473
  if (initial.some((entry) => entry.name === key.name))
1278
1474
  throw new Error(`Unraid key ${key.name} already exists`);
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@ouro.bot/cli",
3
- "version": "0.1.0-alpha.770",
3
+ "version": "0.1.0-alpha.772",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@ouro.bot/cli",
9
- "version": "0.1.0-alpha.770",
9
+ "version": "0.1.0-alpha.772",
10
10
  "dependencies": {
11
11
  "@anthropic-ai/sdk": "^0.78.0",
12
12
  "@azure/identity": "^4.13.0",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ouro.bot/cli",
3
- "version": "0.1.0-alpha.770",
3
+ "version": "0.1.0-alpha.772",
4
4
  "engines": {
5
5
  "node": ">=22"
6
6
  },