@ouro.bot/cli 0.1.0-alpha.815 → 0.1.0-alpha.817
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/assets/sanctuary-host-launcher.sh +16 -0
- package/changelog.json +18 -0
- package/deploy/unraid/Dockerfile +6 -0
- package/deploy/unraid/README.txt +305 -365
- package/deploy/unraid/docker-man-template-transaction.mjs +192 -7
- package/deploy/unraid/sanctuary-acceptance-adapter.sh +1 -1
- package/deploy/unraid/sanctuary-acceptance-contract.json +7 -7
- package/deploy/unraid/sanctuary-authority-installation.json +36 -0
- package/deploy/unraid/sanctuary-authority-service.sh +30 -0
- package/deploy/unraid/sanctuary-unit16-host-broker.mjs +62 -9
- package/deploy/unraid/sanctuary-unit16-run.sh +15 -15
- package/deploy/unraid/sanctuary.ouro/bundle-meta.json +1 -1
- package/deploy/unraid/sanctuary.ouro/tool-profiles.json +2 -2
- package/deploy/unraid/sanctuary.xml +2 -1
- package/dist/heart/approval-store.js +11 -1
- package/dist/heart/core.js +2 -1
- package/dist/heart/daemon/container-spec-auditor-main.js +9 -8
- package/dist/heart/daemon/container-spec-auditor.js +17 -24
- package/dist/heart/daemon/sanctuary-acceptance-adapter.js +95 -91
- package/dist/heart/daemon/sanctuary-acceptance-harness.js +95 -169
- package/dist/heart/daemon/sanctuary-acceptance-scenarios.js +4 -5
- package/dist/heart/daemon/sanctuary-authority-codec.js +86 -0
- package/dist/heart/daemon/sanctuary-authority-epoch.js +256 -0
- package/dist/heart/daemon/sanctuary-authority-installation.js +140 -0
- package/dist/heart/daemon/sanctuary-authority-ledger.js +241 -0
- package/dist/heart/daemon/sanctuary-authority-root-lifecycle.js +780 -0
- package/dist/heart/daemon/sanctuary-authority-vault-migration.js +79 -0
- package/dist/heart/daemon/sanctuary-host-authority.js +1008 -0
- package/dist/heart/daemon/sanctuary-host-detached-supervisor.js +494 -0
- package/dist/heart/daemon/sanctuary-host-executor.js +670 -0
- package/dist/heart/daemon/sanctuary-host-linux-kernel.js +334 -0
- package/dist/heart/daemon/sanctuary-host-supervisor-entry.js +207 -0
- package/dist/heart/daemon/sanctuary-host-supervisor.js +95 -0
- package/dist/heart/daemon/sanctuary-telegram-authority-entry.js +445 -0
- package/dist/heart/daemon/sanctuary-telegram-authority-gateway.js +585 -0
- package/dist/heart/daemon/sanctuary-telegram-authority-service.js +615 -0
- package/dist/heart/daemon/sense-manager.js +20 -10
- package/dist/heart/external-events/router.js +31 -15
- package/dist/heart/steward-policy.js +374 -58
- package/dist/heart/tool-approval.js +8 -1
- package/dist/repertoire/relationship-authorization.js +128 -0
- package/dist/repertoire/tools-base.js +9 -13
- package/dist/repertoire/tools-sanctuary-host.js +202 -0
- package/dist/repertoire/tools-steward-policy.js +34 -10
- package/dist/repertoire/tools-unraid.js +79 -32
- package/dist/repertoire/tools.js +37 -25
- package/dist/repertoire/unraid-restart.js +179 -52
- package/dist/senses/private-runtime.js +27 -12
- package/dist/senses/root-host-approval-port.js +345 -0
- package/dist/senses/root-host-approval-runtime.js +393 -0
- package/dist/senses/sanctuary-authority-resident.js +102 -0
- package/dist/senses/sanctuary-health-runner.js +0 -1
- package/dist/senses/sanctuary-media-catalog-contract.js +4 -1
- package/dist/senses/sanctuary-runtime.js +2 -0
- package/dist/senses/telegram-admission.js +7 -0
- package/dist/senses/telegram-approval-runtime.js +130 -12
- package/dist/senses/telegram-attachments.js +5 -1
- package/dist/senses/telegram-authority-transport.js +231 -0
- package/dist/senses/telegram-client.js +31 -8
- package/dist/senses/telegram-entry.js +1 -1
- package/dist/senses/telegram.js +174 -28
- package/npm-shrinkwrap.json +2 -2
- package/package.json +1 -1
|
@@ -19,6 +19,8 @@ const CONTAINER_ID = /^[0-9a-f]{64}$/u
|
|
|
19
19
|
const VERSION_TAG = /^ghcr\.io\/ourostack\/ouroboros-butler:[0-9]+\.[0-9]+\.[0-9]+(?:-[0-9A-Za-z.-]+)?$/u
|
|
20
20
|
const JELLYFIN_STATES = new Set(["created", "running", "paused", "restarting", "removing", "exited", "dead"])
|
|
21
21
|
const JELLYFIN_FORMAT = '{"name":{{json .Name}},"containerId":{{json .Id}},"imageId":{{json .Image}},"state":{{json .State.Status}},"restartCount":{{json .RestartCount}}}'
|
|
22
|
+
const AUTHORITY_INSTALL_STEPS = ["freeze-resident", "stage-authority", "verify-token-rotation", "transfer-cursor", "remove-resident-token", "start-gateway", "configure-resident", "start-resident", "verify-install"]
|
|
23
|
+
const AUTHORITY_ROLLBACK_STEPS = ["freeze-resident", "retire-registrations", "reconcile-executions", "stop-gateway", "end-epoch", "restore-token-cursor", "restore-resident", "verify-rollback"]
|
|
22
24
|
|
|
23
25
|
function object(value, label) {
|
|
24
26
|
if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error(`${label} must be an object`)
|
|
@@ -171,12 +173,12 @@ function syncDirectory(path) {
|
|
|
171
173
|
try { fsyncSync(fd) } finally { closeSync(fd) }
|
|
172
174
|
}
|
|
173
175
|
|
|
174
|
-
function atomicWrite(path, temporaryPath, bytes, metadata
|
|
176
|
+
function atomicWrite(path, temporaryPath, bytes, metadata) {
|
|
175
177
|
const fd = openSync(temporaryPath, constants.O_CREAT | constants.O_EXCL | constants.O_WRONLY | constants.O_NOFOLLOW, metadata.mode)
|
|
176
178
|
try {
|
|
177
179
|
writeFileSync(fd, bytes)
|
|
178
180
|
fchmodSync(fd, metadata.mode)
|
|
179
|
-
fchownSync(fd, metadata.uid
|
|
181
|
+
fchownSync(fd, metadata.uid, metadata.gid)
|
|
180
182
|
fsyncSync(fd)
|
|
181
183
|
} finally {
|
|
182
184
|
closeSync(fd)
|
|
@@ -197,7 +199,8 @@ function decodeCanonicalBase64(value) {
|
|
|
197
199
|
|
|
198
200
|
function validateJournalRecord(raw, state) {
|
|
199
201
|
const record = object(raw, "template transaction journal")
|
|
200
|
-
exactKeys(record, ["schemaVersion", "state", "target", "priorTemplate", "priorTemplateDigest", "targetTemplateDigest", "canonicalVersionTag", "reviewedManifestDigest", "rollbackImageId", "targetImageId", "jellyfin", "digest"], "template transaction journal")
|
|
202
|
+
exactKeys(record, ["schemaVersion", "state", "target", "priorTemplate", "priorTemplateDigest", "targetTemplateDigest", "canonicalVersionTag", "reviewedManifestDigest", "rollbackImageId", "targetImageId", "jellyfin", "digest", ...(Object.hasOwn(record, "authority") ? ["authority"] : [])], "template transaction journal")
|
|
203
|
+
if (Object.hasOwn(record, "authority")) validateAuthorityHandoff(record.authority, record)
|
|
201
204
|
if (record.schemaVersion !== 1 || !["rollback", "committing"].includes(record.state)) throw new Error("template transaction journal state is invalid")
|
|
202
205
|
const target = object(record.target, "template transaction journal target")
|
|
203
206
|
exactKeys(target, ["path", "name", "templateUrl", "icon"], "template transaction journal target")
|
|
@@ -230,7 +233,7 @@ function readJournal(state) {
|
|
|
230
233
|
|
|
231
234
|
function writeJournal(record, state) {
|
|
232
235
|
const complete = { ...record, digest: recordDigest(record) }
|
|
233
|
-
atomicWrite(state.journalPath, state.journalTemporary, Buffer.from(`${JSON.stringify(complete)}\n`), { uid: state.expectedUid, gid: state.expectedGid, mode: 0o600 }
|
|
236
|
+
atomicWrite(state.journalPath, state.journalTemporary, Buffer.from(`${JSON.stringify(complete)}\n`), { uid: state.expectedUid, gid: state.expectedGid, mode: 0o600 })
|
|
234
237
|
return complete
|
|
235
238
|
}
|
|
236
239
|
|
|
@@ -344,7 +347,7 @@ export function prepareDockerManTemplateTransaction(input, options = {}) {
|
|
|
344
347
|
}
|
|
345
348
|
const record = writeJournal(unsigned, state)
|
|
346
349
|
options.checkpoint?.("after-template-journal")
|
|
347
|
-
atomicWrite(state.targetPath, state.targetTemporary, sourceBytes, { uid: state.expectedUid, gid: state.expectedGid, mode: 0o600 }
|
|
350
|
+
atomicWrite(state.targetPath, state.targetTemporary, sourceBytes, { uid: state.expectedUid, gid: state.expectedGid, mode: 0o600 })
|
|
348
351
|
options.checkpoint?.("after-template-replacement")
|
|
349
352
|
return record
|
|
350
353
|
}
|
|
@@ -354,6 +357,7 @@ export function markDockerManTemplateTransactionCommitting(options = {}) {
|
|
|
354
357
|
validateParents(state)
|
|
355
358
|
const current = readJournal(state)
|
|
356
359
|
if (!current) throw new Error("template transaction journal is absent")
|
|
360
|
+
requireActiveAuthority(current, state)
|
|
357
361
|
if (currentTargetDigest(state) !== current.targetTemplateDigest) throw new Error("installed DockerMan template does not match the transaction")
|
|
358
362
|
assertJellyfinUnchanged(current.jellyfin)
|
|
359
363
|
if (current.state === "committing") {
|
|
@@ -371,11 +375,12 @@ export function rollbackDockerManTemplateTransaction(options = {}) {
|
|
|
371
375
|
validateParents(state)
|
|
372
376
|
const current = readJournal(state)
|
|
373
377
|
if (!current) return false
|
|
378
|
+
if (current.authority && current.authority.state !== "retired") throw new Error("authority epoch must be retired before template rollback")
|
|
374
379
|
const installedDigest = currentTargetDigest(state)
|
|
375
380
|
assertJellyfinUnchanged(current.jellyfin)
|
|
376
381
|
if (current.priorTemplate.present) {
|
|
377
382
|
if (installedDigest !== current.targetTemplateDigest && installedDigest !== current.priorTemplateDigest) throw new Error("installed DockerMan template cannot be safely restored")
|
|
378
|
-
if (installedDigest !== current.priorTemplateDigest) atomicWrite(state.targetPath, state.targetTemporary, current.priorBytes, current.priorTemplate.metadata
|
|
383
|
+
if (installedDigest !== current.priorTemplateDigest) atomicWrite(state.targetPath, state.targetTemporary, current.priorBytes, current.priorTemplate.metadata)
|
|
379
384
|
} else if (installedDigest !== null) {
|
|
380
385
|
if (installedDigest !== current.targetTemplateDigest) throw new Error("installed DockerMan template cannot be safely removed")
|
|
381
386
|
deleteDurably(state.targetPath)
|
|
@@ -410,6 +415,7 @@ export function commitDockerManTemplateTransaction(proof, options = {}) {
|
|
|
410
415
|
validateParents(state)
|
|
411
416
|
const current = readJournal(state)
|
|
412
417
|
if (!current) return false
|
|
418
|
+
requireActiveAuthority(current, state)
|
|
413
419
|
if (current.state !== "committing" || currentTargetDigest(state) !== current.targetTemplateDigest) throw new Error("template transaction is not ready to commit")
|
|
414
420
|
validateFinalProof(proof, current, state)
|
|
415
421
|
assertJellyfinUnchanged(current.jellyfin)
|
|
@@ -417,6 +423,146 @@ export function commitDockerManTemplateTransaction(proof, options = {}) {
|
|
|
417
423
|
return true
|
|
418
424
|
}
|
|
419
425
|
|
|
426
|
+
function requireActiveAuthority(current, state) {
|
|
427
|
+
const document = xmlValidator.parseDockerManTemplateXml(readFileSync(state.targetPath))
|
|
428
|
+
const gateway = document?.children.some((child) => child.name === "Config" && child.attributes.Target === "/run/ouro-authority")
|
|
429
|
+
if ((gateway || current.authority) && current.authority?.state !== "active") throw new Error("authority handoff is not verified")
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
function validateAuthorityPlan(raw) {
|
|
433
|
+
const plan = object(raw, "authority handoff plan")
|
|
434
|
+
exactKeys(plan, ["schemaVersion", "epochId", "packageDigest", "publicKeyDigest", "botId", "ownerUserId", "ownerChatId", "predecessorContract", "targetContract"], "authority handoff plan")
|
|
435
|
+
if (plan.schemaVersion !== 1 || !/^[A-Za-z0-9_-]{1,128}$/u.test(plan.epochId) || !IMAGE_ID.test(plan.packageDigest) || (plan.publicKeyDigest !== null && !IMAGE_ID.test(plan.publicKeyDigest))
|
|
436
|
+
|| !["botId", "ownerUserId", "ownerChatId"].every((key) => typeof plan[key] === "string" && /^[1-9][0-9]*$/u.test(plan[key]))
|
|
437
|
+
|| plan.ownerUserId !== plan.ownerChatId || plan.predecessorContract !== "canonical-pre-gateway" || plan.targetContract !== "canonical-gateway") throw new Error("authority handoff plan is invalid")
|
|
438
|
+
return plan
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
function authorityEffectIdentity(record, effect) {
|
|
442
|
+
return digest(Buffer.from(JSON.stringify([record.targetImageId, record.rollbackImageId, record.reviewedManifestDigest, record.authority.plan, effect.direction, effect.step, effect.beforeDigest, effect.afterDigest])))
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
function validateAuthorityHandoff(raw, record) {
|
|
446
|
+
const handoff = object(raw, "authority handoff")
|
|
447
|
+
exactKeys(handoff, ["plan", "state", "completed", "rollbackCompleted", "pending", "cancelled"], "authority handoff")
|
|
448
|
+
validateAuthorityPlan(handoff.plan)
|
|
449
|
+
if (!["installing", "active", "retiring", "retired"].includes(handoff.state) || !Array.isArray(handoff.completed) || !Array.isArray(handoff.rollbackCompleted) || !Array.isArray(handoff.cancelled)) throw new Error("authority handoff state is invalid")
|
|
450
|
+
const validateEffect = (effect, direction, step) => {
|
|
451
|
+
object(effect, "authority effect")
|
|
452
|
+
exactKeys(effect, ["direction", "step", "effectId", "beforeDigest", "afterDigest"], "authority effect")
|
|
453
|
+
if (effect.direction !== direction || effect.step !== step || !IMAGE_ID.test(effect.beforeDigest) || !IMAGE_ID.test(effect.afterDigest) || effect.beforeDigest === effect.afterDigest
|
|
454
|
+
|| effect.effectId !== authorityEffectIdentity(record, effect)) throw new Error("authority effect identity is invalid")
|
|
455
|
+
}
|
|
456
|
+
for (const [direction, entries, steps] of [["install", handoff.completed, AUTHORITY_INSTALL_STEPS], ["rollback", handoff.rollbackCompleted, AUTHORITY_ROLLBACK_STEPS]]) {
|
|
457
|
+
if (entries.length > steps.length) throw new Error("authority effect order is invalid")
|
|
458
|
+
entries.forEach((effect, index) => validateEffect(effect, direction, steps[index]))
|
|
459
|
+
}
|
|
460
|
+
const rollback = handoff.state === "retiring" || handoff.state === "retired"
|
|
461
|
+
if (handoff.cancelled.length > 1 || (handoff.cancelled.length !== 0 && !rollback)) throw new Error("authority cancelled effect is invalid")
|
|
462
|
+
for (const effect of handoff.cancelled) validateEffect(effect, "install", AUTHORITY_INSTALL_STEPS[handoff.completed.length])
|
|
463
|
+
if ((!rollback && handoff.rollbackCompleted.length !== 0) || (handoff.state === "active" && handoff.completed.length !== AUTHORITY_INSTALL_STEPS.length)
|
|
464
|
+
|| (handoff.state === "retired" && handoff.rollbackCompleted.length !== AUTHORITY_ROLLBACK_STEPS.length)) throw new Error("authority terminal handoff is invalid")
|
|
465
|
+
if (handoff.pending !== null) {
|
|
466
|
+
if (handoff.state === "active" || handoff.state === "retired") throw new Error("authority terminal handoff has a pending effect")
|
|
467
|
+
const steps = rollback ? AUTHORITY_ROLLBACK_STEPS : AUTHORITY_INSTALL_STEPS
|
|
468
|
+
const completed = rollback ? handoff.rollbackCompleted : handoff.completed
|
|
469
|
+
validateEffect(handoff.pending, rollback ? "rollback" : "install", steps[completed.length])
|
|
470
|
+
}
|
|
471
|
+
return handoff
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
function writeAuthorityHandoff(record, authority, state) {
|
|
475
|
+
const { priorBytes: _bytes, digest: _digest, ...unsigned } = record
|
|
476
|
+
const next = { ...unsigned, authority }
|
|
477
|
+
validateAuthorityHandoff(authority, next)
|
|
478
|
+
return writeJournal(next, state)
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
export function beginDockerManAuthorityHandoff(plan, options = {}) {
|
|
482
|
+
validateAuthorityPlan(plan)
|
|
483
|
+
const state = paths(options)
|
|
484
|
+
validateParents(state)
|
|
485
|
+
const current = readJournal(state)
|
|
486
|
+
if (!current || (!current.authority && current.state !== "rollback")) throw new Error("authority handoff requires the prepared deployment transaction")
|
|
487
|
+
assertJellyfinUnchanged(current.jellyfin)
|
|
488
|
+
if (current.authority) {
|
|
489
|
+
if (JSON.stringify(current.authority.plan) !== JSON.stringify(plan)) throw new Error("authority handoff plan changed")
|
|
490
|
+
return current.authority
|
|
491
|
+
}
|
|
492
|
+
return writeAuthorityHandoff(current, { plan, state: "installing", completed: [], rollbackCompleted: [], cancelled: [], pending: null }, state).authority
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
async function withAuthorityLease(options, work) {
|
|
496
|
+
const state = paths(options)
|
|
497
|
+
validateParents(state)
|
|
498
|
+
const withLease = options.withLease ?? (await import("../../dist/mind/session-transaction.js")).withSessionTurnLease
|
|
499
|
+
return withLease(state.journalPath, () => work(state), { timeoutMs: 0, confinementRoot: state.journalParent })
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
export async function runDockerManAuthorityEffect(name, effect, options = {}) {
|
|
503
|
+
return withAuthorityLease(options, (state) => runAuthorityEffectLocked(name, effect, state))
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
export async function beginDockerManAuthorityRollback(readback, options = {}) {
|
|
507
|
+
return withAuthorityLease(options, (state) => beginAuthorityRollbackLocked(readback, state))
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
async function beginAuthorityRollbackLocked(readback, state) {
|
|
511
|
+
const current = readJournal(state)
|
|
512
|
+
if (!current?.authority) throw new Error("authority handoff is absent")
|
|
513
|
+
const handoff = current.authority
|
|
514
|
+
if (handoff.state === "retired" || handoff.state === "retiring") return handoff
|
|
515
|
+
const pending = handoff.pending
|
|
516
|
+
const observed = pending ? await readback() : null
|
|
517
|
+
if (pending && observed !== pending.beforeDigest && observed !== pending.afterDigest) throw new Error("authority pending effect is ambiguous")
|
|
518
|
+
if (readJournal(state).digest !== current.digest) throw new Error("authority transaction changed during recovery")
|
|
519
|
+
assertJellyfinUnchanged(current.jellyfin)
|
|
520
|
+
return writeAuthorityHandoff(current, {
|
|
521
|
+
...handoff, state: "retiring", pending: null,
|
|
522
|
+
completed: pending && observed === pending.afterDigest ? [...handoff.completed, pending] : handoff.completed,
|
|
523
|
+
cancelled: pending && observed === pending.beforeDigest ? [...handoff.cancelled, pending] : handoff.cancelled,
|
|
524
|
+
}, state).authority
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
async function runAuthorityEffectLocked(name, effect, state) {
|
|
528
|
+
let current = readJournal(state)
|
|
529
|
+
if (!current?.authority) throw new Error("authority handoff is absent")
|
|
530
|
+
assertJellyfinUnchanged(current.jellyfin)
|
|
531
|
+
const direction = name.startsWith("rollback:") ? "rollback" : "install"
|
|
532
|
+
const step = direction === "rollback" ? name.slice("rollback:".length) : name
|
|
533
|
+
const steps = direction === "rollback" ? AUTHORITY_ROLLBACK_STEPS : AUTHORITY_INSTALL_STEPS
|
|
534
|
+
if (!steps.includes(step)) throw new Error("authority handoff step is invalid")
|
|
535
|
+
if (!IMAGE_ID.test(effect.beforeDigest) || !IMAGE_ID.test(effect.afterDigest) || effect.beforeDigest === effect.afterDigest) throw new Error("authority effect identity is invalid")
|
|
536
|
+
let handoff = current.authority
|
|
537
|
+
if (direction === "install" && ["retiring", "retired"].includes(handoff.state)) throw new Error("authority handoff is retiring")
|
|
538
|
+
const expected = { direction, step, beforeDigest: effect.beforeDigest, afterDigest: effect.afterDigest }
|
|
539
|
+
const intent = { ...expected, effectId: authorityEffectIdentity(current, expected) }
|
|
540
|
+
const completedKey = direction === "install" ? "completed" : "rollbackCompleted"
|
|
541
|
+
const completed = handoff[completedKey]
|
|
542
|
+
const prior = completed.find((entry) => entry.step === step)
|
|
543
|
+
if (prior) {
|
|
544
|
+
if (prior.effectId !== intent.effectId || await effect.readback() !== prior.afterDigest) throw new Error("authority completed effect changed")
|
|
545
|
+
return prior
|
|
546
|
+
}
|
|
547
|
+
if (steps[completed.length] !== step) throw new Error("authority handoff effect order is invalid")
|
|
548
|
+
if (handoff.pending && handoff.pending.effectId !== intent.effectId) throw new Error("authority pending effect changed; recovery is required before rollback")
|
|
549
|
+
if (!handoff.pending) {
|
|
550
|
+
handoff = { ...handoff, state: direction === "rollback" ? "retiring" : "installing", pending: intent }
|
|
551
|
+
current = writeAuthorityHandoff(current, handoff, state)
|
|
552
|
+
}
|
|
553
|
+
const observed = await effect.readback()
|
|
554
|
+
if (observed !== intent.beforeDigest && observed !== intent.afterDigest) throw new Error("authority handoff effect is ambiguous")
|
|
555
|
+
if (observed === intent.beforeDigest) await effect.apply(intent.effectId)
|
|
556
|
+
if (await effect.readback() !== intent.afterDigest) throw new Error("authority handoff effect readback failed")
|
|
557
|
+
const latest = readJournal(state)
|
|
558
|
+
if (latest.digest !== current.digest) throw new Error("authority transaction changed during effect")
|
|
559
|
+
assertJellyfinUnchanged(latest.jellyfin)
|
|
560
|
+
const entries = [...completed, intent]
|
|
561
|
+
handoff = { ...handoff, [completedKey]: entries, pending: null, state: entries.length === steps.length ? direction === "install" ? "active" : "retired" : direction === "install" ? "installing" : "retiring" }
|
|
562
|
+
writeAuthorityHandoff(latest, handoff, state)
|
|
563
|
+
return intent
|
|
564
|
+
}
|
|
565
|
+
|
|
420
566
|
export function verifyDockerManTemplateTransactionJellyfin(options = {}) {
|
|
421
567
|
const state = paths(options)
|
|
422
568
|
validateParents(state)
|
|
@@ -472,6 +618,9 @@ function readRootJson(path, label) {
|
|
|
472
618
|
|
|
473
619
|
export function runDockerManTemplateTransactionCli(argv, options = {}, write = (text) => process.stdout.write(text)) {
|
|
474
620
|
const { operation, values } = parseArguments(argv)
|
|
621
|
+
if (operation?.startsWith("authority-") && values.size === 0) {
|
|
622
|
+
return runRootLifecycle(operation, options).then((result) => write(`${JSON.stringify(result)}\n`))
|
|
623
|
+
}
|
|
475
624
|
let result
|
|
476
625
|
if (operation === "prepare" && values.size === 5) {
|
|
477
626
|
result = prepareDockerManTemplateTransaction({
|
|
@@ -523,4 +672,40 @@ export function runDockerManTemplateTransactionCli(argv, options = {}, write = (
|
|
|
523
672
|
write(`${JSON.stringify(result)}\n`)
|
|
524
673
|
}
|
|
525
674
|
|
|
526
|
-
|
|
675
|
+
async function runRootLifecycle(operation, options) {
|
|
676
|
+
const selected = {
|
|
677
|
+
"authority-install": AUTHORITY_INSTALL_STEPS.slice(0, 6),
|
|
678
|
+
"authority-activate": AUTHORITY_INSTALL_STEPS.slice(6),
|
|
679
|
+
"authority-retire": AUTHORITY_ROLLBACK_STEPS.slice(0, 6).map((step) => `rollback:${step}`),
|
|
680
|
+
"authority-restore": AUTHORITY_ROLLBACK_STEPS.slice(6).map((step) => `rollback:${step}`),
|
|
681
|
+
}[operation]
|
|
682
|
+
if (!selected) throw new Error("authority lifecycle operation is invalid")
|
|
683
|
+
return withAuthorityLease(options, async (state) => {
|
|
684
|
+
let record = readJournal(state)
|
|
685
|
+
if (!record) throw new Error("authority lifecycle requires the prepared deployment transaction")
|
|
686
|
+
const RootLifecycle = options.RootLifecycle ?? (await import("../../dist/heart/daemon/sanctuary-authority-root-lifecycle.js")).SanctuaryAuthorityRootLifecycle
|
|
687
|
+
const lifecycle = new RootLifecycle(record, options.rootLifecycleOptions)
|
|
688
|
+
beginDockerManAuthorityHandoff(lifecycle.plan(), options)
|
|
689
|
+
record = readJournal(state)
|
|
690
|
+
if (["authority-install", "authority-activate"].includes(operation) && ["retiring", "retired"].includes(record.authority.state)) throw new Error("authority epoch is retiring or retired")
|
|
691
|
+
if (operation === "authority-activate" && record.authority.completed.length < 6) throw new Error("gateway installation must finish before resident activation")
|
|
692
|
+
if (operation === "authority-restore" && record.authority.rollbackCompleted.length < 6) throw new Error("authority retirement must finish before resident restoration")
|
|
693
|
+
if (operation === "authority-retire") {
|
|
694
|
+
const pending = record.authority.pending
|
|
695
|
+
await beginAuthorityRollbackLocked(pending ? lifecycle.effect(pending.step).readback : null, state)
|
|
696
|
+
}
|
|
697
|
+
for (const name of selected) {
|
|
698
|
+
const rollback = name.startsWith("rollback:")
|
|
699
|
+
const step = rollback ? name.slice("rollback:".length) : name
|
|
700
|
+
const current = readJournal(state).authority
|
|
701
|
+
// Completed historical effects may have intentionally changed at later
|
|
702
|
+
// steps (a frozen resident becomes running). Recheck the next real boundary,
|
|
703
|
+
// never replay earlier physical work merely to reproduce a historical hash.
|
|
704
|
+
if (current[rollback ? "rollbackCompleted" : "completed"].some((entry) => entry.step === step)) continue
|
|
705
|
+
await runAuthorityEffectLocked(name, lifecycle.effect(name), state)
|
|
706
|
+
}
|
|
707
|
+
return readJournal(state).authority
|
|
708
|
+
})
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
if (process.argv[1] === fileURLToPath(import.meta.url)) await runDockerManTemplateTransactionCli(process.argv.slice(2))
|
|
@@ -6,7 +6,7 @@ VAULT_ENTRY='import("/opt/ouro/dist/heart/daemon/sanctuary-acceptance-adapter.js
|
|
|
6
6
|
REVOKED_ENTRY='import fs from "node:fs"; import("/opt/ouro/dist/heart/daemon/sanctuary-acceptance-adapter.js").then(async (module) => { const result = await module.executeSanctuaryAcceptanceRevokedProbe(process.argv[1], process.argv[2], fs.readFileSync(3, "utf8")); process.stdout.write(JSON.stringify(result)); }).catch(() => { process.exitCode = 1; });'
|
|
7
7
|
CALLBACK_ENTRY='import fs from "node:fs"; import("/opt/ouro/dist/heart/daemon/sanctuary-acceptance-adapter.js").then(async (module) => { const result = await module.executeSanctuaryAcceptanceCallbackProbe(JSON.parse(fs.readFileSync(0, "utf8")), process.argv[1] === "replay"); process.stdout.write(JSON.stringify(result)); }).catch(() => { process.exitCode = 1; });'
|
|
8
8
|
MATERIALIZE_ENTRY='import("/opt/ouro/dist/heart/daemon/sanctuary-acceptance-adapter.js").then(async (module) => { const payload = { operation: "materialize_config", command: process.argv[1] }; if (process.argv[2]) payload.phase = process.argv[2]; const result = await module.executeSanctuaryAcceptanceAdapter(payload); process.stdout.write(JSON.stringify(result)); }).catch(() => { process.exitCode = 1; });'
|
|
9
|
-
TELEGRAM_READINESS_ENTRY='import("/opt/ouro/dist/heart/daemon/sanctuary-acceptance-adapter.js").then(async (module) => { const result = await module.executeSanctuaryAcceptanceAdapter({ operation: "telegram_readiness" }); process.stdout.write(JSON.stringify(result)); }).catch((error) => { const categories = new Set(["Telegram
|
|
9
|
+
TELEGRAM_READINESS_ENTRY='import("/opt/ouro/dist/heart/daemon/sanctuary-acceptance-adapter.js").then(async (module) => { const result = await module.executeSanctuaryAcceptanceAdapter({ operation: "telegram_readiness" }); process.stdout.write(JSON.stringify(result)); }).catch((error) => { const categories = new Set(["Telegram gateway inventory or pins are unavailable; actor: agent-runnable; inspect root migration", "Telegram gateway health or identity probe failed; actor: agent-runnable; inspect root authority", "Telegram client cleanup failed; actor: agent-runnable; retry Telegram readiness", "Telegram bot identity mismatch; actor: human-required; repair root gateway identity"]); const message = String(error?.message ?? ""); const category = categories.has(message) ? message : "Telegram readiness failed; actor: agent-runnable; inspect packaged readiness"; process.stderr.write(`${category}\n`); process.exitCode = 1; });'
|
|
10
10
|
|
|
11
11
|
if test "${1:-}" = vault-probe; then
|
|
12
12
|
test "$#" -eq 3 || exit 2
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
"harnessExecutable": "/opt/ouro/deploy/unraid/sanctuary-acceptance-harness.sh",
|
|
4
4
|
"adapterExecutable": "/opt/ouro/deploy/unraid/sanctuary-acceptance-adapter.sh",
|
|
5
5
|
"adapterTimeoutMs": 240000,
|
|
6
|
-
"telegramTimeoutMs":
|
|
6
|
+
"telegramTimeoutMs": 65000,
|
|
7
7
|
"deploymentTargetProfiles": {
|
|
8
8
|
"staging": { "command": "sanctuary-unit16-run.sh", "acceptanceInvocation": "sanctuary-unit16-run.sh <image-id> ...", "containerName": "ouro-butler-staging", "requiredRunning": 1, "restartPolicy": "unless-stopped", "networkMode": "host", "inboundTcpListeners": 0, "inboundUdpListeners": 0, "loopbackTcpControls": [6876] },
|
|
9
9
|
"final": { "command": "sanctuary-unit18-target-audit.sh", "acceptanceInvocation": "sanctuary-unit16-run.sh <image-id> --profile final ...", "containerName": "ouro-butler", "requiredRunning": 1, "optionalStopped": "ouro-butler-rollback", "restartPolicy": "unless-stopped", "networkMode": "host", "inboundTcpListeners": 0, "inboundUdpListeners": 0, "loopbackTcpControls": [6876] }
|
|
@@ -11,9 +11,9 @@
|
|
|
11
11
|
"configMaterializer": {
|
|
12
12
|
"invocation": "sanctuary-acceptance-adapter.sh materialize-config <command> [before|after]",
|
|
13
13
|
"output": "complete harness config JSON on stdout",
|
|
14
|
-
"authority": "fixed-contract-
|
|
14
|
+
"authority": "fixed-contract-signed-gateway-cursor-inventory-evidence-read",
|
|
15
15
|
"modelReachable": false,
|
|
16
|
-
"timeoutMs":
|
|
16
|
+
"timeoutMs": 90000
|
|
17
17
|
},
|
|
18
18
|
"commands": {
|
|
19
19
|
"telegram-bootstrap": { "invocation": "sanctuary-acceptance-harness.sh telegram-bootstrap --config <private-json>" },
|
|
@@ -28,7 +28,7 @@
|
|
|
28
28
|
"evidence-bundle-verify": { "invocation": "sanctuary-acceptance-harness.sh evidence-bundle-verify --config <private-json>" }
|
|
29
29
|
},
|
|
30
30
|
"adapters": {
|
|
31
|
-
"telegram-poller-quiescence": { "operation": "quiesce_telegram_poller", "authority": "
|
|
31
|
+
"telegram-poller-quiescence": { "operation": "quiesce_telegram_poller", "authority": "fresh-pinned-gateway-health-and-root-lock-process-with-stopped-resident", "modelReachable": false, "timeoutMs": 120000 },
|
|
32
32
|
"cursor-snapshot": { "operation": "snapshot", "authority": "fixed-telegram-state-read", "modelReachable": false, "timeoutMs": 15000 },
|
|
33
33
|
"callback-live": { "operation": "callback_playback_preflight|inject_callbacks_concurrently|inject_callback_replay", "authority": "fixed-live-telegram-callback-probe", "modelReachable": false, "timeoutMs": 15000 },
|
|
34
34
|
"key-inventory": { "operation": "inventory_keys", "authority": "fixed-unraid-key-directory-read", "modelReachable": false, "timeoutMs": 15000 },
|
|
@@ -50,7 +50,7 @@
|
|
|
50
50
|
"callback-inject": { "operation": "callback-inject", "authority": "fixed-harness-orchestration", "modelReachable": false, "timeoutMs": 120000 },
|
|
51
51
|
"reboot-request": { "operation": "reboot-request", "authority": "fixed-harness-orchestration", "modelReachable": false, "timeoutMs": 120000 },
|
|
52
52
|
"unraid-key-rotate": { "operation": "unraid-key-rotate", "authority": "fixed-harness-orchestration", "modelReachable": false, "timeoutMs": 120000 },
|
|
53
|
-
"config-materializer": { "operation": "materialize_config", "authority": "fixed-contract-
|
|
53
|
+
"config-materializer": { "operation": "materialize_config", "authority": "fixed-contract-signed-gateway-cursor-inventory-evidence-read", "modelReachable": false, "timeoutMs": 120000 },
|
|
54
54
|
"scenario-capture": { "operation": "capture_acceptance_scenario", "authority": "fixed-runtime-audit-telegram-container-scenario", "modelReachable": false, "timeoutMs": 210000, "phases": ["begin", "poll"], "publicGateStatus": "/evidence/current-scenario-gate.json contains only label,gate,phase,startedAt", "response": "waiting:{state,checkpointDigest}|complete:{state,checkpointDigest,sourceDigests,assertions}" },
|
|
55
55
|
"scenario-finalize": { "operation": "finalize_acceptance_scenarios", "authority": "fixed-public-gate-private-marker-receipt-cleanup", "modelReachable": false, "timeoutMs": 120000, "response": "{finalized:true}" },
|
|
56
56
|
"health-probe-start": { "operation": "start_health_probe", "authority": "fixed-owner-bound-packaged-health-probe-start", "modelReachable": false, "timeoutMs": 170000, "response": "{state:started,operationDigest}" },
|
|
@@ -64,7 +64,7 @@
|
|
|
64
64
|
"identity-key": { "kind": "fixed-file", "path": "/home/ouro/AgentBundles/sanctuary.ouro/state/senses/telegram/identity.key", "redaction": "sha256-only" },
|
|
65
65
|
"identity-surface-audit": { "kind": "bounded-bundle-surface-audit", "path": "/home/ouro/AgentBundles/sanctuary.ouro", "redaction": "record-match-raw-leak-counts-and-surface-digest-only" },
|
|
66
66
|
"telegram-audit": { "kind": "fixed-mac-chain", "path": "/home/ouro/AgentBundles/sanctuary.ouro/state/acceptance/telegram-audit-chain.ndjson", "headPath": "/home/ouro/AgentBundles/sanctuary.ouro/state/acceptance/telegram-audit-chain.head.json", "redaction": "canonical-redacted-rows-with-hmac-chain" },
|
|
67
|
-
"telegram-offset": { "kind": "
|
|
67
|
+
"telegram-offset": { "kind": "signed-gateway-cursor", "operation": "telegram.cursor.snapshot", "redaction": "stable-logical-progress-digest-only" },
|
|
68
68
|
"approval-journal": { "kind": "fixed-sqlite", "path": "/home/ouro/AgentBundles/sanctuary.ouro/state/approvals/approvals.sqlite", "redaction": "typed-state-counters-and-digests-only" },
|
|
69
69
|
"approval-checkpoints": { "kind": "fixed-json", "path": "/home/ouro/AgentBundles/sanctuary.ouro/state/approvals/checkpoints.json", "redaction": "typed-state-counters-and-digests-only" },
|
|
70
70
|
"restart-attempt-ledger": { "kind": "fixed-ndjson", "path": "/home/ouro/AgentBundles/sanctuary.ouro/state/acceptance/restart-attempts.ndjson", "redaction": "scenario-action-target-digests-and-state-only" },
|
|
@@ -93,7 +93,7 @@
|
|
|
93
93
|
"deadlineMs": 600000,
|
|
94
94
|
"pollTimeoutSeconds": 20
|
|
95
95
|
},
|
|
96
|
-
"dynamic": ["expectedBotId:getMe.id", "expectedUsername:getMe.username", "currentOffset:
|
|
96
|
+
"dynamic": ["expectedBotId:pinned-gateway-getMe.id", "expectedUsername:pinned-gateway-getMe.username", "currentOffset:signed-gateway-cursor"]
|
|
97
97
|
},
|
|
98
98
|
"cursor-snapshot": {
|
|
99
99
|
"fixed": {
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
{
|
|
2
|
+
"schemaVersion": 1,
|
|
3
|
+
"rootUid": 0,
|
|
4
|
+
"rootGid": 0,
|
|
5
|
+
"residentUid": 10001,
|
|
6
|
+
"residentGid": 10001,
|
|
7
|
+
"stateRoot": "/mnt/user/appdata/ouro-authority",
|
|
8
|
+
"stateMode": 448,
|
|
9
|
+
"secretMode": 384,
|
|
10
|
+
"packageRoot": "/mnt/user/appdata/ouro-authority/package",
|
|
11
|
+
"activeConfigPath": "/mnt/user/appdata/ouro-authority/active.json",
|
|
12
|
+
"requestPath": "/mnt/user/appdata/ouro-authority/request.json",
|
|
13
|
+
"incomingTokenPath": "/mnt/user/appdata/ouro-authority/incoming-token",
|
|
14
|
+
"incomingPackagePath": "/mnt/user/appdata/ouro-authority/incoming-package",
|
|
15
|
+
"packageManifestPath": "/mnt/user/appdata/ouro-authority/package-manifest.json",
|
|
16
|
+
"bootPath": "/boot/config/custom/ouro-authority/start.sh",
|
|
17
|
+
"socketRoot": "/run/ouro-authority",
|
|
18
|
+
"socketDirectoryMode": 488,
|
|
19
|
+
"socketMode": 432,
|
|
20
|
+
"residentPinsMode": 416,
|
|
21
|
+
"socketPath": "/run/ouro-authority/authority.sock",
|
|
22
|
+
"residentPinsPath": "/run/ouro-authority/resident.json",
|
|
23
|
+
"stagingRoot": "/var/lib/ouro-authority/staging",
|
|
24
|
+
"cgroupRoot": "/sys/fs/cgroup/ouro-authority",
|
|
25
|
+
"controllers": ["cpu", "memory", "pids"],
|
|
26
|
+
"sourceMountContract": "canonical-pre-gateway",
|
|
27
|
+
"targetMountContract": "canonical-gateway",
|
|
28
|
+
"servicePath": "/mnt/user/appdata/ouro-authority/package/deploy/unraid/sanctuary-authority-service.sh",
|
|
29
|
+
"serviceMode": 448,
|
|
30
|
+
"nodePath": "/usr/local/bin/node",
|
|
31
|
+
"prlimitPath": "/usr/bin/prlimit",
|
|
32
|
+
"setsidPath": "/usr/bin/setsid",
|
|
33
|
+
"shellPath": "/bin/sh",
|
|
34
|
+
"transactionOwner": "docker-man-template-transaction.mjs",
|
|
35
|
+
"epochRetirementRequiredBeforeRollback": true
|
|
36
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
#!/bin/sh
|
|
2
|
+
set -eu
|
|
3
|
+
umask 077
|
|
4
|
+
test "$(id -u):$(id -g)" = 0:0
|
|
5
|
+
cd /
|
|
6
|
+
case "${1-}" in
|
|
7
|
+
--boot)
|
|
8
|
+
test "$#" -eq 1
|
|
9
|
+
# Unraid's go hook can run before the array and Docker are available.
|
|
10
|
+
attempt=0
|
|
11
|
+
while test ! -f /mnt/user/appdata/ouro-authority/active.json || test ! -S /var/run/docker.sock || ! /usr/bin/docker info >/dev/null 2>&1; do
|
|
12
|
+
attempt=$((attempt + 1))
|
|
13
|
+
test "$attempt" -le 300
|
|
14
|
+
sleep 1
|
|
15
|
+
done
|
|
16
|
+
exec /usr/bin/env -i PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin \
|
|
17
|
+
/usr/local/bin/node /mnt/user/appdata/ouro-authority/package/dist/heart/daemon/sanctuary-authority-root-lifecycle.js boot </dev/null
|
|
18
|
+
;;
|
|
19
|
+
"") test "$#" -eq 0 ;;
|
|
20
|
+
*) exit 2 ;;
|
|
21
|
+
esac
|
|
22
|
+
test -d /mnt/user/appdata/ouro-authority
|
|
23
|
+
test ! -L /mnt/user/appdata/ouro-authority
|
|
24
|
+
test "$(stat -c '%u:%g:%a' /mnt/user/appdata/ouro-authority)" = 0:0:700
|
|
25
|
+
test -f /mnt/user/appdata/ouro-authority/active.json
|
|
26
|
+
test ! -L /mnt/user/appdata/ouro-authority/active.json
|
|
27
|
+
test "$(stat -c '%u:%g:%a' /mnt/user/appdata/ouro-authority/active.json)" = 0:0:600
|
|
28
|
+
exec /usr/bin/env -i PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin \
|
|
29
|
+
/usr/local/bin/node /mnt/user/appdata/ouro-authority/package/dist/heart/daemon/sanctuary-telegram-authority-entry.js \
|
|
30
|
+
--config /mnt/user/appdata/ouro-authority/active.json </dev/null
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
import { spawn, spawnSync } from "node:child_process"
|
|
4
4
|
import { createHash, createHmac, timingSafeEqual } from "node:crypto"
|
|
5
|
-
import { chmodSync, chownSync, closeSync, constants, fstatSync, fsyncSync, mkdirSync, openSync, opendirSync, readFileSync, readSync, readdirSync, renameSync, rmSync, statSync, unlinkSync, writeFileSync } from "node:fs"
|
|
5
|
+
import { chmodSync, chownSync, closeSync, constants, fstatSync, fsyncSync, mkdirSync, openSync, opendirSync, readFileSync, readSync, readdirSync, realpathSync, renameSync, rmSync, statSync, unlinkSync, writeFileSync } from "node:fs"
|
|
6
6
|
import { createServer } from "node:net"
|
|
7
7
|
import { targetProfile } from "./sanctuary-deployment-target.mjs"
|
|
8
8
|
|
|
@@ -18,6 +18,7 @@ const RUNTIME_POLICY_FILE = "/opt/ouro/container-runtime.json"
|
|
|
18
18
|
const PRODUCTION_RUNTIME_SOURCE = "/mnt/user/appdata/ouro-butler/runtime/.ouro-cli"
|
|
19
19
|
const PRODUCTION_BUNDLE_SOURCE = "/mnt/user/appdata/ouro-butler/agent/sanctuary.ouro"
|
|
20
20
|
const PRODUCTION_EVENT_SPOOL_SOURCE = "/boot/config/custom/ouro-events/spool"
|
|
21
|
+
const AUTHORITY_ROOT = "/mnt/user/appdata/ouro-authority"
|
|
21
22
|
const GRAPHQL_ENDPOINT = "http://127.0.0.1/graphql"
|
|
22
23
|
const BOOT_ID = "/proc/sys/kernel/random/boot_id"
|
|
23
24
|
const MDCMD = "/usr/local/sbin/mdcmd"
|
|
@@ -361,8 +362,8 @@ function parseVaultStatus(output, succeeded) {
|
|
|
361
362
|
return match[1].split(", ").includes("apiKey") && match[2].split(", ").includes("baseUrl")
|
|
362
363
|
}
|
|
363
364
|
const unlocked = succeeded && /^local unlock: available$/mu.test(output)
|
|
364
|
-
&&
|
|
365
|
-
&& providerReady("
|
|
365
|
+
&& runtimeMatch !== null && !runtimeFields.includes("telegramBotToken")
|
|
366
|
+
&& providerReady("minimax")
|
|
366
367
|
return { vaultUnlocked: unlocked, manualAuthRequired: !unlocked }
|
|
367
368
|
}
|
|
368
369
|
|
|
@@ -503,6 +504,7 @@ async function containerSnapshot(expectedImage) {
|
|
|
503
504
|
{ destination: "/home/ouro/.ouro-cli", source: PRODUCTION_RUNTIME_SOURCE, propagation: "rprivate", rw: true, type: "bind" },
|
|
504
505
|
{ destination: "/home/ouro/AgentBundles/sanctuary.ouro", source: PRODUCTION_BUNDLE_SOURCE, propagation: "rprivate", rw: true, type: "bind" },
|
|
505
506
|
{ destination: "/run/ouro-events", source: PRODUCTION_EVENT_SPOOL_SOURCE, propagation: "rprivate", rw: false, type: "bind" },
|
|
507
|
+
{ destination: "/run/ouro-authority", source: "/run/ouro-authority", propagation: "rprivate", rw: false, type: "bind" },
|
|
506
508
|
]
|
|
507
509
|
const mountsExact = mounts.length === expectedMounts.length && expectedMounts.every((expected) => mounts.some((mount) => mount.destination === expected.destination && mount.source === expected.source && mount.propagation === expected.propagation && mount.rw === expected.rw && mount.type === expected.type))
|
|
508
510
|
const securityExact = value.privileged === false && (value.capAdd === null || (Array.isArray(value.capAdd) && value.capAdd.length === 0))
|
|
@@ -552,7 +554,7 @@ async function containerSnapshot(expectedImage) {
|
|
|
552
554
|
}
|
|
553
555
|
}
|
|
554
556
|
|
|
555
|
-
function inspectRebootOwner(containerId =
|
|
557
|
+
function inspectRebootOwner(containerId = activeContainerId) {
|
|
556
558
|
const template = '{"containerId":{{json .Id}},"name":{{json .Name}},"imageId":{{json .Image}},"running":{{json .State.Running}},"pid":{{json .State.Pid}},"startedAt":{{json .State.StartedAt}},"health":{{json .State.Health.Status}},"restartCount":{{json .RestartCount}}}'
|
|
557
559
|
const result = spawnSync(DOCKER, ["inspect", "--format", template, containerId], {
|
|
558
560
|
cwd: "/", encoding: "utf8", timeout: 20_000, maxBuffer: 64 * 1024,
|
|
@@ -562,9 +564,55 @@ function inspectRebootOwner(containerId = PRODUCTION_CONTAINER) {
|
|
|
562
564
|
return object(JSON.parse(result.stdout ?? ""), "reboot owner inspection")
|
|
563
565
|
}
|
|
564
566
|
|
|
567
|
+
function readAuthorityText(file) {
|
|
568
|
+
if (!file.startsWith(`${AUTHORITY_ROOT}/`) || realpathSync(file) !== file) throw new Error("root authority path is unsafe")
|
|
569
|
+
const fd = openSync(file, constants.O_RDONLY | constants.O_NOFOLLOW)
|
|
570
|
+
try {
|
|
571
|
+
const stat = fstatSync(fd)
|
|
572
|
+
if (!stat.isFile() || stat.uid !== 0 || stat.gid !== 0 || stat.nlink !== 1 || (stat.mode & 0o7777) !== 0o600 || stat.size < 1 || stat.size > 65536) throw new Error("root authority metadata is unsafe")
|
|
573
|
+
return readFileSync(fd, "utf8")
|
|
574
|
+
} finally { closeSync(fd) }
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
function telegramGatewayQuiescence() {
|
|
578
|
+
const observe = () => {
|
|
579
|
+
const config = object(JSON.parse(readAuthorityText(`${AUTHORITY_ROOT}/active.json`)), "root authority configuration")
|
|
580
|
+
const keyId = text(config.keyId, "root issuer", /^[A-Za-z0-9_-]{1,128}$/u)
|
|
581
|
+
const botId = text(config.botId, "root bot identity", /^[1-9][0-9]*$/u)
|
|
582
|
+
const publicKeyDigest = text(config.publicKeyDigest, "root issuer digest", /^sha256:[a-f0-9]{64}$/u)
|
|
583
|
+
const epochRoot = `${AUTHORITY_ROOT}/epochs/${keyId}`
|
|
584
|
+
if (config.lockPath !== `${epochRoot}/authority.lock`) throw new Error("root authority lock path changed")
|
|
585
|
+
const epoch = object(JSON.parse(readAuthorityText(`${epochRoot}/epoch.json`)), "root authority epoch")
|
|
586
|
+
if (epoch.schemaVersion !== 1 || epoch.state !== "prepared" || epoch.epochId !== keyId || epoch.botId !== botId || epoch.publicKeyDigest !== publicKeyDigest
|
|
587
|
+
|| epoch.revokedTokenStatus !== 401 || !/^sha256:[a-f0-9]{64}$/u.test(epoch.tokenDigest) || !/^sha256:[a-f0-9]{64}$/u.test(epoch.previousTokenDigest)
|
|
588
|
+
|| epoch.tokenDigest === epoch.previousTokenDigest) throw new Error("root authority token epoch is invalid")
|
|
589
|
+
const pid = Number(readAuthorityText(config.lockPath))
|
|
590
|
+
if (!Number.isSafeInteger(pid) || pid <= 0 || pid > 4_194_304) throw new Error("root authority lock PID is invalid")
|
|
591
|
+
const processes = spawnSync(PGREP, ["-f", "sanctuary-telegram-authority-entry[.]js"], {
|
|
592
|
+
cwd: "/", encoding: "utf8", timeout: 5_000, maxBuffer: 64 * 1024, stdio: ["ignore", "pipe", "ignore"],
|
|
593
|
+
})
|
|
594
|
+
if (processes.error || processes.status !== 0 || processes.stdout.trim() !== String(pid)) throw new Error("root authority does not have exactly one live poller")
|
|
595
|
+
const status = readBoundedProcStatus(`/proc/${pid}/status`)
|
|
596
|
+
if (!status.split("\n").includes("Uid:\t0\t0\t0\t0") || !status.split("\n").includes("Gid:\t0\t0\t0\t0")) throw new Error("root authority process identity is invalid")
|
|
597
|
+
const command = readBoundedProcStatus(`/proc/${pid}/cmdline`)
|
|
598
|
+
if (command !== ["/usr/local/bin/node", `${AUTHORITY_ROOT}/package/dist/heart/daemon/sanctuary-telegram-authority-entry.js`, "--config", `${AUTHORITY_ROOT}/active.json`, ""].join("\0")) throw new Error("root authority process command changed")
|
|
599
|
+
const processStartTime = parseProcStartTime(readBoundedProcStatus(`/proc/${pid}/stat`))
|
|
600
|
+
const resident = inspectRebootOwner(activeContainerId)
|
|
601
|
+
if (resident.containerId !== activeContainerId || resident.name !== `/${activeContainer}` || resident.imageId !== expectedImageId || resident.running !== false || resident.pid !== 0) throw new Error("resident Telegram owner is not stopped")
|
|
602
|
+
return { keyId, botId, publicKeyDigest, pid, processStartTime, bootId: readFileSync(BOOT_ID, "utf8"), resident }
|
|
603
|
+
}
|
|
604
|
+
const before = observe()
|
|
605
|
+
const after = observe()
|
|
606
|
+
if (JSON.stringify(before) !== JSON.stringify(after)) throw new Error("root authority process generation changed")
|
|
607
|
+
return {
|
|
608
|
+
schemaVersion: 1, activePollers: 1, residentStopped: true, keyId: after.keyId, botId: after.botId, publicKeyDigest: after.publicKeyDigest,
|
|
609
|
+
processBindingDigest: createHash("sha256").update(JSON.stringify(after)).digest("hex"), observedAt: new Date().toISOString(),
|
|
610
|
+
}
|
|
611
|
+
}
|
|
612
|
+
|
|
565
613
|
function runningRebootOwnerGeneration() {
|
|
566
614
|
const before = inspectRebootOwner()
|
|
567
|
-
if (before.name !== `/${
|
|
615
|
+
if (before.name !== `/${activeContainer}` || before.imageId !== expectedImageId || before.running !== true || before.health !== "healthy"
|
|
568
616
|
|| !Number.isSafeInteger(before.pid) || before.pid <= 0 || !Number.isSafeInteger(before.restartCount) || before.restartCount < 0) {
|
|
569
617
|
throw new Error("reboot owner generation is invalid")
|
|
570
618
|
}
|
|
@@ -591,7 +639,7 @@ function stopExactRebootOwner(expectedBinding) {
|
|
|
591
639
|
|
|
592
640
|
function verifyStoppedRebootOwner(proof) {
|
|
593
641
|
const value = inspectRebootOwner(text(proof.containerId, "stopped owner container id", SHA256))
|
|
594
|
-
if (value.containerId !== proof.containerId || value.name !== `/${
|
|
642
|
+
if (value.containerId !== proof.containerId || value.name !== `/${activeContainer}` || value.imageId !== proof.imageId
|
|
595
643
|
|| value.restartCount !== proof.restartCount || value.startedAt !== proof.startedAt || value.running !== false || value.pid !== 0) {
|
|
596
644
|
throw new Error("exact stopped production owner generation changed")
|
|
597
645
|
}
|
|
@@ -1280,7 +1328,7 @@ function createOwnerMutationCoordinator() {
|
|
|
1280
1328
|
const enqueue = (operation) => {
|
|
1281
1329
|
if (rebootReservation !== null) return Promise.reject(new Error("owner mutation refused by reboot reservation"))
|
|
1282
1330
|
pendingOperations += 1
|
|
1283
|
-
const task = ownerTail.
|
|
1331
|
+
const task = ownerTail.then(async () => {
|
|
1284
1332
|
try { return await operation() } finally { pendingOperations -= 1 }
|
|
1285
1333
|
})
|
|
1286
1334
|
ownerTail = task.then(() => {}, () => {})
|
|
@@ -1294,7 +1342,7 @@ function createOwnerMutationCoordinator() {
|
|
|
1294
1342
|
if (rebootReservation !== null) throw new Error("reboot reservation already exists")
|
|
1295
1343
|
rebootReservation = { id: reservationId, processBindingDigest, stoppedProof: null, attempted: false }
|
|
1296
1344
|
try {
|
|
1297
|
-
await ownerTail
|
|
1345
|
+
await ownerTail
|
|
1298
1346
|
if (pendingOperations !== 0 || activeHealth.size !== 0) throw new Error("reboot reservation could not drain owner mutations")
|
|
1299
1347
|
return await operation()
|
|
1300
1348
|
} catch (error) {
|
|
@@ -1376,7 +1424,7 @@ function createInteractiveRestartDriver() {
|
|
|
1376
1424
|
(error) => { records.set(key, { state: "failed", errorDigest: createHash("sha256").update(interactiveFailureCategory(error)).digest("hex") }) },
|
|
1377
1425
|
)
|
|
1378
1426
|
tasks.add(task)
|
|
1379
|
-
void task.finally(() => tasks.delete(task))
|
|
1427
|
+
void task.finally(() => tasks.delete(task))
|
|
1380
1428
|
},
|
|
1381
1429
|
async stopAndDrain() { await Promise.allSettled([...tasks]) },
|
|
1382
1430
|
}
|
|
@@ -1747,6 +1795,11 @@ async function dispatch(request, dependencies = {
|
|
|
1747
1795
|
}) {
|
|
1748
1796
|
const payload = object(request, "broker request")
|
|
1749
1797
|
const operation = text(payload.operation, "operation")
|
|
1798
|
+
if (operation === "telegram_gateway_quiescence") {
|
|
1799
|
+
exactKeys(payload, ["operation", "targetId"], operation)
|
|
1800
|
+
if (payload.targetId !== TARGET_HOST) throw new Error("target host is invalid")
|
|
1801
|
+
return telegramGatewayQuiescence()
|
|
1802
|
+
}
|
|
1750
1803
|
if (operation === "inventory_keys") {
|
|
1751
1804
|
exactKeys(payload, ["operation", "targetServerId"], operation)
|
|
1752
1805
|
if (payload.targetServerId !== TARGET_SERVER) throw new Error("target server is invalid")
|