@ouro.bot/cli 0.1.0-alpha.742 → 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.
- package/changelog.json +26 -0
- package/deploy/unraid/Dockerfile +7 -2
- package/deploy/unraid/README.txt +704 -64
- package/deploy/unraid/migrate-sanctuary-bundle.mjs +21 -0
- package/deploy/unraid/ouro-events/bootstrap-spool.sh +92 -0
- package/deploy/unraid/ouro-events/emit-event.mjs +310 -0
- package/deploy/unraid/ouro-events/emit-usenet-event.sh +54 -0
- package/deploy/unraid/ouro-events/install-usenet-guard.sh +434 -0
- package/deploy/unraid/ouro-events/usenet-health.sh +178 -0
- package/deploy/unraid/sanctuary-acceptance-contract.json +4 -3
- package/deploy/unraid/sanctuary-unit16-host-broker.mjs +5 -1
- package/deploy/unraid/sanctuary.ouro/bundle-meta.json +2 -2
- package/deploy/unraid/sanctuary.ouro/habits/sanctuary-health.md +1 -1
- package/deploy/unraid/sanctuary.ouro/psyche/ASPIRATIONS.md +3 -1
- package/deploy/unraid/sanctuary.ouro/psyche/IDENTITY.md +3 -1
- package/deploy/unraid/sanctuary.ouro/psyche/LORE.md +4 -0
- package/deploy/unraid/sanctuary.ouro/psyche/SOUL.md +6 -2
- package/deploy/unraid/sanctuary.ouro/psyche/TACIT.md +4 -2
- package/deploy/unraid/sanctuary.ouro/state/policy/steward.json +7 -0
- package/deploy/unraid/sanctuary.ouro/tool-profiles.json +19 -17
- package/deploy/unraid/sanctuary.xml +13 -10
- package/dist/arc/cares.js +171 -37
- package/dist/arc/obligations.js +38 -0
- package/dist/heart/awaiting/await-alert.js +4 -2
- package/dist/heart/awaiting/await-expiry.js +39 -4
- package/dist/heart/awaiting/await-parser.js +8 -0
- package/dist/heart/core.js +37 -7
- package/dist/heart/cross-chat-delivery.js +1 -0
- package/dist/heart/daemon/container-spec-auditor-main.js +11 -7
- package/dist/heart/daemon/container-spec-auditor.js +38 -11
- package/dist/heart/daemon/daemon-entry.js +19 -5
- package/dist/heart/daemon/daemon.js +154 -22
- package/dist/heart/daemon/sanctuary-acceptance-adapter.js +126 -42
- package/dist/heart/daemon/sanctuary-acceptance-harness.js +26 -29
- package/dist/heart/daemon/sanctuary-acceptance-scenarios.js +42 -51
- package/dist/heart/daemon/sanctuary-bundle-migration.js +398 -0
- package/dist/heart/external-events/router.js +1025 -43
- package/dist/heart/session-events.js +23 -5
- package/dist/heart/steward-policy.js +302 -0
- package/dist/heart/tool-approval.js +3 -1
- package/dist/heart/turn-context.js +1 -0
- package/dist/mind/context.js +8 -3
- package/dist/mind/pending.js +25 -0
- package/dist/mind/prompt.js +49 -1
- package/dist/repertoire/guardrails.js +2 -1
- package/dist/repertoire/relationship-authorization.js +194 -0
- package/dist/repertoire/tools-awaiting.js +229 -26
- package/dist/repertoire/tools-base.js +15 -0
- package/dist/repertoire/tools-continuity.js +259 -4
- package/dist/repertoire/tools-notes.js +29 -3
- package/dist/repertoire/tools-session.js +219 -4
- package/dist/repertoire/tools-steward-policy.js +88 -0
- package/dist/repertoire/tools-telegram-contacts.js +37 -0
- package/dist/repertoire/tools-unraid.js +113 -6
- package/dist/repertoire/tools.js +104 -9
- package/dist/repertoire/unraid-restart.js +86 -4
- package/dist/senses/private-runtime-worker.js +36 -3
- package/dist/senses/private-runtime.js +310 -63
- package/dist/senses/sanctuary-health-acceptance-probe.js +8 -6
- package/dist/senses/sanctuary-health-runner.js +47 -103
- package/dist/senses/sanctuary-health.js +53 -105
- package/dist/senses/sanctuary-runtime.js +36 -2
- package/dist/senses/sanctuary-sab.js +80 -0
- package/dist/senses/shared-turn.js +64 -9
- package/dist/senses/telegram-admission.js +684 -0
- package/dist/senses/telegram-approval-runtime.js +46 -6
- package/dist/senses/telegram-client.js +212 -66
- package/dist/senses/telegram-effect-adapter.js +787 -0
- package/dist/senses/telegram.js +728 -48
- package/npm-shrinkwrap.json +6 -6
- 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"
|