@ouro.bot/cli 0.1.0-alpha.741 → 0.1.0-alpha.743

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.
Files changed (81) hide show
  1. package/changelog.json +42 -0
  2. package/deploy/unraid/Dockerfile +7 -2
  3. package/deploy/unraid/README.txt +717 -106
  4. package/deploy/unraid/migrate-sanctuary-bundle.mjs +21 -0
  5. package/deploy/unraid/ouro-events/bootstrap-spool.sh +92 -0
  6. package/deploy/unraid/ouro-events/emit-event.mjs +310 -0
  7. package/deploy/unraid/ouro-events/emit-usenet-event.sh +54 -0
  8. package/deploy/unraid/ouro-events/install-usenet-guard.sh +434 -0
  9. package/deploy/unraid/ouro-events/usenet-health.sh +178 -0
  10. package/deploy/unraid/sanctuary-acceptance-contract.json +4 -3
  11. package/deploy/unraid/sanctuary-unit16-host-broker.mjs +5 -1
  12. package/deploy/unraid/sanctuary.ouro/agent.json +2 -2
  13. package/deploy/unraid/sanctuary.ouro/bundle-meta.json +2 -2
  14. package/deploy/unraid/sanctuary.ouro/habits/sanctuary-health.md +1 -1
  15. package/deploy/unraid/sanctuary.ouro/provider-readiness.json +3 -10
  16. package/deploy/unraid/sanctuary.ouro/psyche/ASPIRATIONS.md +3 -1
  17. package/deploy/unraid/sanctuary.ouro/psyche/IDENTITY.md +3 -1
  18. package/deploy/unraid/sanctuary.ouro/psyche/LORE.md +4 -0
  19. package/deploy/unraid/sanctuary.ouro/psyche/SOUL.md +6 -2
  20. package/deploy/unraid/sanctuary.ouro/psyche/TACIT.md +4 -2
  21. package/deploy/unraid/sanctuary.ouro/state/policy/steward.json +7 -0
  22. package/deploy/unraid/sanctuary.ouro/tool-profiles.json +19 -17
  23. package/deploy/unraid/sanctuary.xml +12 -9
  24. package/dist/arc/cares.js +171 -37
  25. package/dist/arc/obligations.js +38 -0
  26. package/dist/heart/awaiting/await-alert.js +4 -2
  27. package/dist/heart/awaiting/await-expiry.js +39 -4
  28. package/dist/heart/awaiting/await-parser.js +8 -0
  29. package/dist/heart/context-loss-gauntlet.js +31 -2
  30. package/dist/heart/context-loss-sentinel.js +2 -0
  31. package/dist/heart/core.js +37 -7
  32. package/dist/heart/cross-chat-delivery.js +1 -0
  33. package/dist/heart/daemon/container-healthcheck.js +3 -1
  34. package/dist/heart/daemon/container-runtime.js +13 -0
  35. package/dist/heart/daemon/container-spec-auditor-main.js +11 -7
  36. package/dist/heart/daemon/container-spec-auditor.js +38 -11
  37. package/dist/heart/daemon/daemon-entry.js +19 -5
  38. package/dist/heart/daemon/daemon.js +154 -22
  39. package/dist/heart/daemon/sanctuary-acceptance-adapter.js +151 -87
  40. package/dist/heart/daemon/sanctuary-acceptance-harness.js +26 -29
  41. package/dist/heart/daemon/sanctuary-acceptance-scenarios.js +42 -51
  42. package/dist/heart/daemon/sanctuary-bundle-migration.js +398 -0
  43. package/dist/heart/daemon/sanctuary-scheduler-origin.js +11 -5
  44. package/dist/heart/daemon/supercronic-supervisor.js +16 -1
  45. package/dist/heart/external-events/router.js +1025 -43
  46. package/dist/heart/provider-models.js +10 -3
  47. package/dist/heart/session-events.js +23 -5
  48. package/dist/heart/steward-policy.js +302 -0
  49. package/dist/heart/tool-approval.js +3 -1
  50. package/dist/heart/turn-context.js +1 -0
  51. package/dist/mind/context.js +8 -3
  52. package/dist/mind/pending.js +25 -0
  53. package/dist/mind/prompt.js +49 -1
  54. package/dist/repertoire/guardrails.js +2 -1
  55. package/dist/repertoire/relationship-authorization.js +194 -0
  56. package/dist/repertoire/tools-awaiting.js +229 -26
  57. package/dist/repertoire/tools-base.js +15 -0
  58. package/dist/repertoire/tools-continuity.js +259 -4
  59. package/dist/repertoire/tools-notes.js +29 -3
  60. package/dist/repertoire/tools-session.js +219 -4
  61. package/dist/repertoire/tools-steward-policy.js +88 -0
  62. package/dist/repertoire/tools-telegram-contacts.js +37 -0
  63. package/dist/repertoire/tools-unraid.js +113 -6
  64. package/dist/repertoire/tools.js +104 -9
  65. package/dist/repertoire/unraid-restart.js +86 -4
  66. package/dist/senses/private-runtime-worker.js +36 -3
  67. package/dist/senses/private-runtime.js +310 -63
  68. package/dist/senses/sanctuary-health-acceptance-probe.js +8 -6
  69. package/dist/senses/sanctuary-health-runner.js +51 -96
  70. package/dist/senses/sanctuary-health.js +54 -93
  71. package/dist/senses/sanctuary-runtime.js +36 -2
  72. package/dist/senses/sanctuary-sab.js +80 -0
  73. package/dist/senses/shared-turn.js +64 -9
  74. package/dist/senses/telegram-admission.js +684 -0
  75. package/dist/senses/telegram-approval-runtime.js +46 -6
  76. package/dist/senses/telegram-client.js +236 -72
  77. package/dist/senses/telegram-effect-adapter.js +787 -0
  78. package/dist/senses/telegram-entry.js +4 -1
  79. package/dist/senses/telegram.js +728 -48
  80. package/npm-shrinkwrap.json +6 -6
  81. package/package.json +2 -2
@@ -0,0 +1,21 @@
1
+ #!/usr/bin/env node
2
+ import { commitSanctuaryPackageManagedBundle, inspectSanctuaryPackageManagedBundleRollback, migrateSanctuaryPackageManagedBundle, rollbackSanctuaryPackageManagedBundle } from "../../dist/heart/daemon/sanctuary-bundle-migration.js"
3
+
4
+ const values = new Map()
5
+ for (let index = 2; index < process.argv.length; index += 2) values.set(process.argv[index], process.argv[index + 1])
6
+ if ((values.size !== 3 && values.size !== 5) || !values.get("--package-root") || !values.get("--agent-root") || !values.get("--operation")) {
7
+ throw new Error("Usage: migrate-sanctuary-bundle.mjs --package-root <path> --agent-root <path> --operation <migrate|rollback|commit|status> [--rollback-image-id <sha256:id> --target-image-id <sha256:id>]")
8
+ }
9
+
10
+ const operation = values.get("--operation")
11
+ const result = operation === "migrate"
12
+ ? migrateSanctuaryPackageManagedBundle({ packageRoot: values.get("--package-root"), agentRoot: values.get("--agent-root"), retainRollback: true, rollbackImageId: values.get("--rollback-image-id"), targetImageId: values.get("--target-image-id") })
13
+ : operation === "rollback"
14
+ ? { rolledBack: rollbackSanctuaryPackageManagedBundle(values.get("--agent-root"), { retainRecord: true }) }
15
+ : operation === "commit"
16
+ ? { committed: commitSanctuaryPackageManagedBundle(values.get("--agent-root")) }
17
+ : operation === "status"
18
+ ? inspectSanctuaryPackageManagedBundleRollback(values.get("--agent-root"))
19
+ : (() => { throw new Error("operation must be migrate, rollback, commit, or status") })()
20
+ if (result === null || ("rolledBack" in result && !result.rolledBack) || ("committed" in result && !result.committed)) throw new Error(`${operation} found no pending Sanctuary bundle transaction`)
21
+ process.stdout.write(`${JSON.stringify(result)}\n`)
@@ -0,0 +1,92 @@
1
+ #!/bin/bash
2
+ set -euo pipefail
3
+
4
+ SPOOL_ROOT="/boot/config/custom/ouro-events/spool"
5
+ INSTALL_PATH="/boot/config/custom/ouro-events/bootstrap-spool.sh"
6
+ GO_FILE="/boot/config/go"
7
+ TMPFS_OPTIONS="size=4m,mode=0755,uid=0,gid=0,nodev,nosuid,noexec"
8
+ BOOT_HOOK="/bin/bash /boot/config/custom/ouro-events/bootstrap-spool.sh --mount"
9
+ ACTION="mount"
10
+
11
+ while (($# > 0)); do
12
+ case "$1" in
13
+ --mount|--install-only|--self-test) ACTION="${1#--}"; shift ;;
14
+ --go-file) GO_FILE="${2:?missing --go-file value}"; shift 2 ;;
15
+ --install-path) INSTALL_PATH="${2:?missing --install-path value}"; shift 2 ;;
16
+ *) echo "ouro event spool bootstrap: invalid argument: $1" >&2; exit 2 ;;
17
+ esac
18
+ done
19
+
20
+ mount_spool() {
21
+ local target="$1"
22
+ /bin/mkdir -p "$target"
23
+ /bin/chown 0:0 "$target"
24
+ /bin/chmod 0755 "$target"
25
+ if /bin/mountpoint -q "$target"; then
26
+ [[ "$(/bin/findmnt -n -o FSTYPE --target "$target")" == "tmpfs" ]] || { echo "ouro event spool bootstrap: canonical path is already a non-tmpfs mount" >&2; return 1; }
27
+ else
28
+ /bin/mount -t tmpfs -o "$TMPFS_OPTIONS" ouro-events-spool "$target"
29
+ fi
30
+ [[ "$(/bin/stat -c '%u:%g:%a' "$target")" == "0:0:755" ]] || { echo "ouro event spool bootstrap: tmpfs identity or mode is unsafe" >&2; return 1; }
31
+ local options
32
+ options="$(/bin/findmnt -n -o OPTIONS --target "$target")"
33
+ for required in nodev nosuid noexec; do
34
+ [[ ",$options," == *",$required,"* ]] || { echo "ouro event spool bootstrap: tmpfs is missing $required" >&2; return 1; }
35
+ done
36
+ local size_bytes
37
+ size_bytes="$(/bin/findmnt -bn -o SIZE --target "$target")"
38
+ [[ "$size_bytes" =~ ^[0-9]+$ && "$size_bytes" -le 4194304 ]] || { echo "ouro event spool bootstrap: tmpfs exceeds its 4 MiB bound" >&2; return 1; }
39
+ }
40
+
41
+ install_boot_hook() {
42
+ local install_dir resolved_source resolved_install
43
+ install_dir="$(dirname "$INSTALL_PATH")"
44
+ /bin/mkdir -p "$install_dir"
45
+ resolved_source="$(cd "$(dirname "$0")" && pwd -P)/$(basename "$0")"
46
+ resolved_install="$(cd "$install_dir" && pwd -P)/$(basename "$INSTALL_PATH")"
47
+ if [[ "$resolved_source" != "$resolved_install" ]]; then
48
+ install -m 0700 "$0" "$INSTALL_PATH"
49
+ else
50
+ /bin/chmod 0700 "$INSTALL_PATH"
51
+ fi
52
+ [[ -f "$GO_FILE" ]] || printf '#!/bin/bash\n' > "$GO_FILE"
53
+ if ! grep -Fqx "$BOOT_HOOK" "$GO_FILE"; then
54
+ local temporary="${GO_FILE}.ouro-events.$$"
55
+ awk -v hook="$BOOT_HOOK" 'NR == 1 && /^#!/ { print; print hook; next } NR == 1 { print hook } { print }' "$GO_FILE" > "$temporary"
56
+ /bin/chmod 0700 "$temporary"
57
+ /bin/mv "$temporary" "$GO_FILE"
58
+ fi
59
+ }
60
+
61
+ self_test() {
62
+ [[ "$(/usr/bin/id -u)" == "0" ]] || { echo "ouro event spool bootstrap self-test requires root" >&2; return 1; }
63
+ local test_root target readonly_view
64
+ test_root="$(/usr/bin/mktemp -d /tmp/ouro-event-spool.XXXXXX)"
65
+ target="$test_root/spool"
66
+ readonly_view="$test_root/readonly"
67
+ /bin/chmod 0755 "$test_root"
68
+ /bin/mkdir "$target" "$readonly_view"
69
+ cleanup() {
70
+ /bin/umount "$readonly_view" 2>/dev/null || true
71
+ /bin/umount "$target" 2>/dev/null || true
72
+ /bin/rmdir "$readonly_view" "$target" "$test_root" 2>/dev/null || true
73
+ }
74
+ trap cleanup EXIT
75
+ mount_spool "$target"
76
+ /usr/bin/printf 'probe\n' > "$target/probe"
77
+ /bin/chown 0:0 "$target/probe"
78
+ /bin/chmod 0444 "$target/probe"
79
+ /bin/mount --bind "$target" "$readonly_view"
80
+ /bin/mount -o remount,bind,ro,nodev,nosuid,noexec "$readonly_view"
81
+ /usr/bin/setpriv --reuid=10001 --regid=10001 --clear-groups /usr/bin/test -r "$readonly_view/probe"
82
+ [[ ",$(/bin/findmnt -n -o OPTIONS --target "$readonly_view")," == *",ro,"* ]] || { echo "ouro event spool bootstrap: test bind is not read-only" >&2; return 1; }
83
+ echo "uid10001-ro-bind: pass"
84
+ cleanup
85
+ trap - EXIT
86
+ }
87
+
88
+ case "$ACTION" in
89
+ mount) mount_spool "$SPOOL_ROOT" ;;
90
+ install-only) install_boot_hook ;;
91
+ self-test) self_test ;;
92
+ esac
@@ -0,0 +1,310 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { createHash, randomBytes } from "node:crypto"
4
+ import * as fs from "node:fs"
5
+ import * as path from "node:path"
6
+ import { fileURLToPath } from "node:url"
7
+
8
+ const SPOOL_ROOT = "/boot/config/custom/ouro-events/spool"
9
+ const MAX_BYTES = 32 * 1024
10
+ const MAX_SPOOL_FILES = 4_096
11
+ const DEFAULT_EXPIRY_GRACE_MS = 24 * 60 * 60_000
12
+ const ENVELOPE_KEYS = ["action", "actionReceipt", "agent", "createdAt", "critical", "eventType", "evidence", "expiresAt", "incidentKey", "nonce", "observationRevision", "protectiveStateDigest", "protectiveStateObservedAt", "protectiveStateVerified", "schemaVersion", "source", "summary", "transitionId"]
13
+
14
+ function id(value, name) {
15
+ if (typeof value !== "string" || !/^[a-zA-Z0-9._:-]{1,160}$/u.test(value)) throw new Error(`${name} is invalid`)
16
+ return value
17
+ }
18
+
19
+ function bounded(value, name, max) {
20
+ if (typeof value !== "string" || !value || Buffer.byteLength(value) > max) throw new Error(`${name} must be bounded`)
21
+ return value
22
+ }
23
+
24
+ function stableInput(envelope) {
25
+ const { createdAt: _createdAt, expiresAt: _expiresAt, nonce: _nonce, protectiveStateObservedAt: _protectiveStateObservedAt, ...stable } = envelope
26
+ return stable
27
+ }
28
+
29
+ function canonicalIso(value) {
30
+ return typeof value === "string" && Number.isFinite(Date.parse(value)) && new Date(value).toISOString() === value
31
+ }
32
+
33
+ function validateEnvelope(envelope) {
34
+ const eventShapeIsAllowed = envelope && typeof envelope === "object" && !Array.isArray(envelope) && ((envelope.eventType === "usenet.protective_action" && envelope.action === "sabnzbd.pause")
35
+ || (envelope.eventType === "usenet.health_observation" && envelope.action === "usenet.observe"))
36
+ const observationTransitionIsAllowed = envelope?.eventType !== "usenet.health_observation" || /^(?:auth-failed|stalled|recovered|indeterminate):\d{8}T\d{6}Z$/u.test(String(envelope.transitionId))
37
+ if (!envelope || typeof envelope !== "object" || Array.isArray(envelope) || Object.keys(envelope).sort().join("\0") !== [...ENVELOPE_KEYS].sort().join("\0")
38
+ || envelope.schemaVersion !== 1 || envelope.agent !== "sanctuary" || envelope.source !== "sanctuary-usenet"
39
+ || !eventShapeIsAllowed || !observationTransitionIsAllowed
40
+ || envelope.critical !== true || !/^[a-zA-Z0-9._:-]{1,160}$/u.test(String(envelope.incidentKey)) || !/^[a-zA-Z0-9._:-]{1,160}$/u.test(String(envelope.transitionId))
41
+ || !/^[a-f0-9]{64}$/u.test(String(envelope.observationRevision)) || !/^[a-f0-9]{64}$/u.test(String(envelope.nonce))
42
+ || typeof envelope.protectiveStateVerified !== "boolean" || !/^[a-f0-9]{64}$/u.test(String(envelope.protectiveStateDigest))
43
+ || !canonicalIso(envelope.protectiveStateObservedAt)
44
+ || typeof envelope.actionReceipt !== "string" || !envelope.actionReceipt || Buffer.byteLength(envelope.actionReceipt) > 512
45
+ || typeof envelope.summary !== "string" || !envelope.summary || Buffer.byteLength(envelope.summary) > 4_096
46
+ || !Array.isArray(envelope.evidence) || envelope.evidence.length > 16 || envelope.evidence.some((entry) => typeof entry !== "string" || !entry || Buffer.byteLength(entry) > 2_048)
47
+ || !canonicalIso(envelope.createdAt) || !canonicalIso(envelope.expiresAt)
48
+ || Date.parse(envelope.expiresAt) <= Date.parse(envelope.createdAt)
49
+ || Date.parse(envelope.expiresAt) - Date.parse(envelope.createdAt) > 60 * 60_000) {
50
+ throw new Error("event envelope is invalid")
51
+ }
52
+ const serialized = `${JSON.stringify(envelope)}\n`
53
+ if (Buffer.byteLength(serialized) > MAX_BYTES) throw new Error("event envelope must be bounded")
54
+ return serialized
55
+ }
56
+
57
+ function buildEnvelope(input, now, nonce) {
58
+ const createdAt = new Date(now()).toISOString()
59
+ const expiresAt = new Date(Date.parse(createdAt) + 15 * 60_000).toISOString()
60
+ const evidence = input.evidence ?? []
61
+ if (!Array.isArray(evidence) || evidence.length > 16) throw new Error("evidence must be bounded")
62
+ const envelope = {
63
+ schemaVersion: input.schemaVersion,
64
+ agent: input.agent,
65
+ source: input.source,
66
+ eventType: input.eventType,
67
+ incidentKey: id(input.incidentKey, "incident key"),
68
+ transitionId: id(input.transitionId, "transition id"),
69
+ observationRevision: input.observationRevision,
70
+ action: input.action,
71
+ actionReceipt: bounded(input.actionReceipt, "action receipt", 512),
72
+ protectiveStateVerified: input.protectiveStateVerified,
73
+ protectiveStateDigest: input.protectiveStateDigest,
74
+ protectiveStateObservedAt: input.protectiveStateObservedAt,
75
+ critical: input.critical,
76
+ summary: bounded(input.summary, "summary", 4_096),
77
+ evidence: evidence.map((entry) => bounded(entry, "evidence", 2_048)),
78
+ createdAt,
79
+ expiresAt,
80
+ nonce: nonce(),
81
+ }
82
+ return { envelope, serialized: validateEnvelope(envelope) }
83
+ }
84
+
85
+ function linuxProcessStart(pid) {
86
+ try {
87
+ const raw = fs.readFileSync(`/proc/${pid}/stat`, "utf8")
88
+ return raw.slice(raw.lastIndexOf(")") + 2).trim().split(/\s+/u)[19] ?? null
89
+ } catch { return null }
90
+ }
91
+
92
+ const currentProcessStart = linuxProcessStart(process.pid) ?? `fallback-${Math.floor(Date.now() - process.uptime() * 1_000)}`
93
+
94
+ function ownerIsLive(owner) {
95
+ if (!owner || typeof owner.token !== "string" || !Number.isSafeInteger(owner.pid) || typeof owner.processStart !== "string") return false
96
+ try { process.kill(owner.pid, 0) } catch (error) { return error?.code !== "ESRCH" }
97
+ const observedStart = owner.pid === process.pid ? currentProcessStart : linuxProcessStart(owner.pid)
98
+ return observedStart === null || observedStart === owner.processStart
99
+ }
100
+
101
+ function openSpool(options) {
102
+ const effectiveUid = options.effectiveUid ?? process.getuid?.()
103
+ if (effectiveUid !== 0) throw new Error("ouro event producer must run as root")
104
+ const spoolRoot = options.spoolRoot ?? SPOOL_ROOT
105
+ const expectedOwnerUid = options.expectedSpoolOwnerUid ?? 0
106
+ const directory = fs.openSync(spoolRoot, fs.constants.O_RDONLY | fs.constants.O_DIRECTORY | fs.constants.O_NOFOLLOW)
107
+ const openedRoot = fs.fstatSync(directory)
108
+ const assertRootIdentity = () => {
109
+ const namedRoot = fs.lstatSync(spoolRoot)
110
+ if (!openedRoot.isDirectory() || openedRoot.uid !== expectedOwnerUid || (openedRoot.mode & 0o777) !== 0o755
111
+ || !namedRoot.isDirectory() || namedRoot.isSymbolicLink() || namedRoot.uid !== expectedOwnerUid || (namedRoot.mode & 0o777) !== 0o755
112
+ || namedRoot.dev !== openedRoot.dev || namedRoot.ino !== openedRoot.ino) {
113
+ throw new Error(expectedOwnerUid === 0 ? "event spool root must be root-owned and mode 0755" : "event spool root is unsafe")
114
+ }
115
+ }
116
+ try {
117
+ assertRootIdentity()
118
+ return { spoolRoot, expectedOwnerUid, directory, anchoredRoot: process.platform === "linux" ? `/proc/self/fd/${directory}` : spoolRoot, assertRootIdentity }
119
+ } catch (error) {
120
+ fs.closeSync(directory)
121
+ throw error
122
+ }
123
+ }
124
+
125
+ function writeOwner(ownerPath, owner) {
126
+ fs.writeFileSync(ownerPath, `${JSON.stringify(owner)}\n`, { mode: 0o600, flag: "wx" })
127
+ const file = fs.openSync(ownerPath, "r")
128
+ try { fs.fsyncSync(file) } finally { fs.closeSync(file) }
129
+ }
130
+
131
+ function withProducerLock(context, operation) {
132
+ const lockPath = path.join(context.anchoredRoot, ".producer.lock")
133
+ const ownerPath = path.join(lockPath, "owner.json")
134
+ const token = randomBytes(32).toString("hex")
135
+ const cleanup = (target, verifyToken) => {
136
+ if (verifyToken) {
137
+ let owner
138
+ try { owner = JSON.parse(fs.readFileSync(path.join(target, "owner.json"), "utf8")) } catch { return }
139
+ if (owner.token !== token) return
140
+ }
141
+ for (const name of ["event.tmp", "owner.json"]) {
142
+ try { fs.unlinkSync(path.join(target, name)) } catch (error) { if (error?.code !== "ENOENT") throw error }
143
+ }
144
+ fs.rmdirSync(target)
145
+ }
146
+ const deadline = Date.now() + 30_000
147
+ while (true) {
148
+ try {
149
+ fs.mkdirSync(lockPath, { mode: 0o700 })
150
+ writeOwner(ownerPath, { token, pid: process.pid, processStart: currentProcessStart, leaseUntil: new Date(Date.now() + 30_000).toISOString() })
151
+ break
152
+ } catch (error) {
153
+ if (error?.code !== "EEXIST") throw error
154
+ let owner = null
155
+ try { owner = JSON.parse(fs.readFileSync(ownerPath, "utf8")) } catch { /* incomplete owner */ }
156
+ if (ownerIsLive(owner)) {
157
+ if (Date.now() >= deadline) throw new Error("event transition publication is busy")
158
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 10)
159
+ continue
160
+ }
161
+ let leaseActive
162
+ if (owner && canonicalIso(owner.leaseUntil)) {
163
+ leaseActive = Date.parse(owner.leaseUntil) > Date.now()
164
+ } else {
165
+ try {
166
+ leaseActive = Date.now() - fs.statSync(lockPath).mtimeMs <= 30_000
167
+ } catch (statError) {
168
+ if (statError?.code === "ENOENT") {
169
+ try {
170
+ fs.lstatSync(lockPath)
171
+ } catch (lstatError) {
172
+ if (lstatError?.code === "ENOENT") continue
173
+ throw lstatError
174
+ }
175
+ throw new Error("event transition publication lock is unsafe")
176
+ }
177
+ throw statError
178
+ }
179
+ }
180
+ if (leaseActive) {
181
+ if (Date.now() >= deadline) throw new Error("event transition publication is busy")
182
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 10)
183
+ continue
184
+ }
185
+ const stalePath = `${lockPath}.stale-${token}`
186
+ try { fs.renameSync(lockPath, stalePath) } catch { continue }
187
+ cleanup(stalePath, false)
188
+ }
189
+ }
190
+ try { return operation(lockPath) } finally { cleanup(lockPath, true) }
191
+ }
192
+
193
+ function parseStoredEnvelope(raw, fileName) {
194
+ if (Buffer.byteLength(raw) > MAX_BYTES) throw new Error("event envelope must be bounded")
195
+ const envelope = JSON.parse(raw)
196
+ validateEnvelope(envelope)
197
+ const expectedName = `${createHash("sha256").update(`${envelope.source}\0${envelope.incidentKey}\0${envelope.transitionId}`).digest("hex")}.json`
198
+ if (fileName !== expectedName) throw new Error("event envelope filename is invalid")
199
+ return envelope
200
+ }
201
+
202
+ function readSafeEnvelope(context, fileName, directoryEntry) {
203
+ const filePath = path.join(context.anchoredRoot, fileName)
204
+ const before = fs.lstatSync(filePath)
205
+ if ((directoryEntry && !directoryEntry.isFile()) || before.isSymbolicLink() || !before.isFile() || before.uid !== context.expectedOwnerUid
206
+ || before.nlink !== 1 || (before.mode & 0o777) !== 0o444 || before.size < 2 || before.size > MAX_BYTES) throw new Error("existing event spool file is unsafe")
207
+ const file = fs.openSync(filePath, fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW)
208
+ try {
209
+ const opened = fs.fstatSync(file)
210
+ if (!opened.isFile() || opened.dev !== before.dev || opened.ino !== before.ino || opened.size !== before.size || opened.uid !== context.expectedOwnerUid
211
+ || opened.nlink !== 1 || (opened.mode & 0o777) !== 0o444) throw new Error("existing event spool file changed")
212
+ return parseStoredEnvelope(fs.readFileSync(file, "utf8"), fileName)
213
+ } finally { fs.closeSync(file) }
214
+ }
215
+
216
+ function maintainUnderLock(context, options) {
217
+ const now = new Date(options.now?.() ?? new Date()).getTime()
218
+ const graceMs = options.graceMs ?? DEFAULT_EXPIRY_GRACE_MS
219
+ if (!Number.isSafeInteger(graceMs) || graceMs < 0) throw new Error("event spool expiry grace is invalid")
220
+ let pruned = 0
221
+ let preserved = 0
222
+ for (const entry of fs.readdirSync(context.anchoredRoot, { withFileTypes: true })) {
223
+ if (!entry.name.endsWith(".json")) continue
224
+ const filePath = path.join(context.anchoredRoot, entry.name)
225
+ try {
226
+ const envelope = readSafeEnvelope(context, entry.name, entry)
227
+ if (Date.parse(envelope.expiresAt) + graceMs >= now) { preserved += 1; continue }
228
+ fs.unlinkSync(filePath)
229
+ pruned += 1
230
+ } catch {
231
+ preserved += 1
232
+ }
233
+ }
234
+ if (pruned > 0) fs.fsyncSync(context.directory)
235
+ return { pruned, preserved }
236
+ }
237
+
238
+ export function maintainSpool(options = {}) {
239
+ const context = openSpool(options)
240
+ try { return withProducerLock(context, () => maintainUnderLock(context, options)) } finally { fs.closeSync(context.directory) }
241
+ }
242
+
243
+ export function emitEvent(input, options = {}) {
244
+ const context = openSpool(options)
245
+ try {
246
+ const { envelope, serialized } = buildEnvelope(input, options.now ?? (() => new Date()), options.nonce ?? (() => randomBytes(32).toString("hex")))
247
+ const digest = createHash("sha256").update(`${envelope.source}\0${envelope.incidentKey}\0${envelope.transitionId}`).digest("hex")
248
+ const fileName = `${digest}.json`
249
+ const filePath = path.join(context.spoolRoot, fileName)
250
+ return withProducerLock(context, (lockPath) => {
251
+ maintainUnderLock(context, options)
252
+ let existing = null
253
+ try {
254
+ existing = readSafeEnvelope(context, fileName)
255
+ } catch (error) { if (error?.code !== "ENOENT") throw error }
256
+ if (existing) {
257
+ if (JSON.stringify(stableInput(existing)) !== JSON.stringify(stableInput(envelope))) throw new Error("transition already exists with different content")
258
+ return { filePath, created: false }
259
+ }
260
+ const maxSpoolFiles = options.maxSpoolFiles ?? MAX_SPOOL_FILES
261
+ if (!Number.isSafeInteger(maxSpoolFiles) || maxSpoolFiles < 1) throw new Error("event spool capacity is invalid")
262
+ const currentFiles = fs.readdirSync(context.anchoredRoot, { withFileTypes: true }).filter((entry) => entry.isFile() && entry.name.endsWith(".json")).length
263
+ if (currentFiles >= maxSpoolFiles) throw new Error("event spool capacity is exhausted; unexpired and unsafe artifacts were preserved")
264
+ const temporary = path.join(lockPath, "event.tmp")
265
+ fs.writeFileSync(temporary, serialized, { encoding: "utf8", mode: 0o600, flag: "wx" })
266
+ const file = fs.openSync(temporary, "r")
267
+ try { fs.fsyncSync(file) } finally { fs.closeSync(file) }
268
+ fs.chmodSync(temporary, 0o444)
269
+ context.assertRootIdentity()
270
+ fs.renameSync(temporary, path.join(context.anchoredRoot, fileName))
271
+ fs.fsyncSync(context.directory)
272
+ context.assertRootIdentity()
273
+ return { filePath, created: true }
274
+ })
275
+ } finally { fs.closeSync(context.directory) }
276
+ }
277
+
278
+ function parseArgs(argv) {
279
+ const input = { schemaVersion: 1, critical: true, evidence: [] }
280
+ const names = new Map([
281
+ ["--agent", "agent"], ["--source", "source"], ["--event-type", "eventType"], ["--incident-key", "incidentKey"],
282
+ ["--transition-id", "transitionId"], ["--revision", "observationRevision"], ["--action", "action"],
283
+ ["--action-receipt", "actionReceipt"], ["--summary", "summary"],
284
+ ["--protective-state-digest", "protectiveStateDigest"], ["--protective-state-observed-at", "protectiveStateObservedAt"],
285
+ ])
286
+ for (let index = 0; index < argv.length; index += 2) {
287
+ const flag = argv[index]
288
+ const value = argv[index + 1]
289
+ if (flag === "--evidence" && value !== undefined) input.evidence.push(value)
290
+ else if (flag === "--protective-state-verified" && (value === "true" || value === "false")) input.protectiveStateVerified = value === "true"
291
+ else if (names.has(flag) && value !== undefined) input[names.get(flag)] = value
292
+ else throw new Error("event producer arguments are invalid")
293
+ }
294
+ return input
295
+ }
296
+
297
+ if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
298
+ try {
299
+ if (process.argv.length === 3 && process.argv[2] === "--maintain") {
300
+ const result = maintainSpool()
301
+ process.stdout.write(`maintained pruned=${result.pruned} preserved=${result.preserved}\n`)
302
+ } else {
303
+ const result = emitEvent(parseArgs(process.argv.slice(2)))
304
+ process.stdout.write(`${result.created ? "created" : "existing"} ${path.basename(result.filePath)}\n`)
305
+ }
306
+ } catch (error) {
307
+ process.stderr.write(`ouro event producer failed: ${error instanceof Error ? error.message : "unknown error"}\n`)
308
+ process.exitCode = 1
309
+ }
310
+ }
@@ -0,0 +1,54 @@
1
+ #!/bin/bash
2
+ set -euo pipefail
3
+
4
+ PRODUCER="/boot/config/custom/ouro-events/emit-event.mjs"
5
+
6
+ if [ "$#" -eq 2 ] && [ "$1" = "--self-test" ]; then
7
+ exec "$(command -v node)" "$2" --maintain
8
+ fi
9
+
10
+ if [ "$#" -ne 9 ]; then
11
+ echo "usage: emit-usenet-event.sh <action> <incident> <transition> <receipt> <summary> <evidence> <verified> <verification-digest> <observed-at>" >&2
12
+ exit 2
13
+ fi
14
+
15
+ ACTION="$1"
16
+ INCIDENT="$2"
17
+ TRANSITION="$3"
18
+ RECEIPT="$4"
19
+ SUMMARY="$5"
20
+ EVIDENCE="$6"
21
+ VERIFIED="$7"
22
+ VERIFICATION_DIGEST="$8"
23
+ VERIFIED_AT="$9"
24
+ EVENT_TYPE="usenet.protective_action"
25
+
26
+ case "$ACTION" in
27
+ sabnzbd.pause) ;;
28
+ usenet.observe) EVENT_TYPE="usenet.health_observation" ;;
29
+ *) echo "usenet event adapter: action is not allowlisted" >&2; exit 2 ;;
30
+ esac
31
+ case "$VERIFIED" in true|false) ;; *) echo "usenet event adapter: verification state is invalid" >&2; exit 2 ;; esac
32
+ [[ "$VERIFICATION_DIGEST" =~ ^[a-f0-9]{64}$ ]] || { echo "usenet event adapter: verification digest is invalid" >&2; exit 2; }
33
+
34
+ if [ "$ACTION" = "usenet.observe" ]; then
35
+ OBSERVED_STATE="${TRANSITION%%:*}"
36
+ REVISION="$(printf '%s\0%s\0%s' "$ACTION" "$INCIDENT" "$OBSERVED_STATE" | sha256sum | cut -d' ' -f1)"
37
+ else
38
+ REVISION="$(printf '%s\0%s\0%s\0%s' "$ACTION" "$INCIDENT" "$TRANSITION" "$RECEIPT" | sha256sum | cut -d' ' -f1)"
39
+ fi
40
+
41
+ exec /usr/local/bin/node "$PRODUCER" \
42
+ "--agent" "sanctuary" \
43
+ "--source" "sanctuary-usenet" \
44
+ "--event-type" "$EVENT_TYPE" \
45
+ "--incident-key" "$INCIDENT" \
46
+ "--transition-id" "$TRANSITION" \
47
+ "--revision" "$REVISION" \
48
+ "--action" "$ACTION" \
49
+ "--action-receipt" "$RECEIPT" \
50
+ "--summary" "$SUMMARY" \
51
+ "--evidence" "$EVIDENCE" \
52
+ "--protective-state-verified" "$VERIFIED" \
53
+ "--protective-state-digest" "$VERIFICATION_DIGEST" \
54
+ "--protective-state-observed-at" "$VERIFIED_AT"