@ouro.bot/cli 0.1.0-alpha.970 → 0.1.0-alpha.971
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 +6 -0
- package/deploy/unraid/INSTALL.md +64 -0
- package/deploy/unraid/docker-man-template-transaction.mjs +20 -4
- package/deploy/unraid/install.sh +274 -0
- package/deploy/unraid/migrate-sanctuary-bundle.mjs +5 -2
- package/deploy/unraid/sanctuary-butler-upgrade.mjs +126 -44
- package/deploy/unraid/sanctuary.ouro/agent.json +4 -4
- package/deploy/unraid/sanctuary.ouro/bundle-meta.json +1 -1
- package/deploy/unraid/sanctuary.xml +1 -1
- package/dist/heart/daemon/cli-exec.js +16 -9
- package/dist/heart/daemon/cli-help.js +1 -1
- package/dist/heart/daemon/cli-parse.js +14 -4
- package/dist/heart/daemon/sanctuary-authority-epoch.js +21 -11
- package/dist/heart/daemon/sanctuary-authority-root-lifecycle.js +53 -11
- package/npm-shrinkwrap.json +2 -2
- package/package.json +1 -1
package/changelog.json
CHANGED
|
@@ -1,6 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"_note": "This changelog is maintained as part of the PR/version-bump workflow. Agent-curated, not auto-generated. Agents read this file directly via read_file to understand what changed between versions.",
|
|
3
3
|
"versions": [
|
|
4
|
+
{
|
|
5
|
+
"version": "0.1.0-alpha.971",
|
|
6
|
+
"changes": [
|
|
7
|
+
"Ouro Butler can be installed on a new Unraid 7.2+ server with one script (deploy/unraid/install.sh, attached to every release): it asks a few questions, reads secrets with the echo off, creates the vault, Anthropic access and Unraid API keys, finds the owner's Telegram id, and installs the root authority with no earlier Butler to hand over from. vault config set can now read a secret from --value-file (use /dev/stdin to pipe it) and store the top-level Unraid and SABnzbd machine keys."
|
|
8
|
+
]
|
|
9
|
+
},
|
|
4
10
|
{
|
|
5
11
|
"version": "0.1.0-alpha.970",
|
|
6
12
|
"changes": [
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
# Install Ouro Butler on Unraid
|
|
2
|
+
|
|
3
|
+
Ouro Butler is an agent that lives on your Unraid server and looks after it with you over Telegram. It answers questions about the array, disks and containers, tells you when something goes wrong, and can update containers when you ask. It is an **alpha**: expect rough edges, and keep your own backups.
|
|
4
|
+
|
|
5
|
+
## What you need
|
|
6
|
+
|
|
7
|
+
- Unraid **7.2 or newer**, with Docker enabled and a working internet connection.
|
|
8
|
+
- An **Anthropic API key** from [console.anthropic.com](https://console.anthropic.com/settings/keys), with credit on the account. The Butler uses Claude models.
|
|
9
|
+
- A **Telegram bot** for the Butler: message [@BotFather](https://t.me/BotFather), send `/newbot`, and keep the token it gives you.
|
|
10
|
+
- An **email address** for the Butler's vault account. The vault is a Bitwarden-compatible store at `vault.ouro.bot` (or your own server). Use an address no other Butler uses, such as `you+butler@example.com`; nothing is sent to it.
|
|
11
|
+
- About 10 minutes at the Unraid web terminal.
|
|
12
|
+
|
|
13
|
+
## Install
|
|
14
|
+
|
|
15
|
+
Open the Unraid web terminal (the `>_` icon at the top right), then download the installer, read it if you like, and run it:
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
curl -fsSLo /tmp/ouro-butler-install.sh https://github.com/ourostack/ouroboros/releases/latest/download/install.sh
|
|
19
|
+
less /tmp/ouro-butler-install.sh # optional: read it first
|
|
20
|
+
bash /tmp/ouro-butler-install.sh
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
The installer asks for your name, the Butler's name, the vault email, the Anthropic key and the bot token. Secrets are read with the terminal's echo off and never appear on a command line. It then:
|
|
24
|
+
|
|
25
|
+
1. pulls the Butler image `ghcr.io/ourostack/ouroboros-butler` and seeds the Butler's files in `/mnt/user/appdata/ouro-butler`;
|
|
26
|
+
2. creates the Butler's vault, asking you to choose an **unlock phrase** (write it down: it is the only way to recover the Butler's secrets if the server is lost), and stores the Anthropic key in it;
|
|
27
|
+
3. creates two Unraid API keys for the Butler, `Butler RO …` (read-only) and `Butler RW …` (read-only plus Docker update), and stores them in the vault;
|
|
28
|
+
4. asks you to send your bot any message on Telegram, so it learns your Telegram id and answers only you;
|
|
29
|
+
5. installs the root authority, a small service that holds the Telegram token outside the Butler's container, and starts the Butler as the `ouro-butler` container.
|
|
30
|
+
|
|
31
|
+
When it prints `Ouro Butler … is running`, say hello to your bot on Telegram.
|
|
32
|
+
|
|
33
|
+
To install a specific release instead of the newest one, pass `--version 0.1.0-alpha.N`. To use your own Vaultwarden server, pass `--vault-server https://vault.example.com`.
|
|
34
|
+
|
|
35
|
+
## Tell it about your home
|
|
36
|
+
|
|
37
|
+
The Butler reads two files you own and that updates never overwrite:
|
|
38
|
+
|
|
39
|
+
- `/mnt/user/appdata/ouro-butler/agent/sanctuary.ouro/house/house.json`: names (`ownerName`, `butlerName`, `householdName`), `publicEndpoints` it should check (each `{ "name", "url" }`), and `requiredContainers` it should treat as must-run.
|
|
40
|
+
- `/mnt/user/appdata/ouro-butler/agent/sanctuary.ouro/house/HOUSE.md`: anything else worth knowing, in plain words: who lives here, what each container is for, what matters most.
|
|
41
|
+
|
|
42
|
+
Edit them as root and keep the owner and mode (`chown 10001:10001`, `chmod 600`). The Butler picks up changes at its next conversation.
|
|
43
|
+
|
|
44
|
+
## Updates
|
|
45
|
+
|
|
46
|
+
Releases ship as new images. The Butler's maintainers upgrade with `deploy/unraid/sanctuary-butler-upgrade.mjs upgrade <version>`, which keeps the same Telegram token and rolls back on any failure. A friendlier update command is planned; until then, follow the release notes.
|
|
47
|
+
|
|
48
|
+
## Known limits of the alpha
|
|
49
|
+
|
|
50
|
+
- Unraid only, version 7.2 or newer. No docker compose or other hosts yet.
|
|
51
|
+
- Anthropic is the supported model provider.
|
|
52
|
+
- One owner per Butler; the Butler answers only the Telegram account that messaged it during install.
|
|
53
|
+
- Media (Jellyfin) and book integrations are optional extras that are not set up by the installer yet.
|
|
54
|
+
- The installer is for a first install. If it stops partway, fix the reported problem and run it again; it reuses the files and vault it already made. If it says a Butler is already installed, it will not touch it.
|
|
55
|
+
|
|
56
|
+
## Stopping and removing
|
|
57
|
+
|
|
58
|
+
To pause the Butler, stop the `ouro-butler` container on the Docker tab. To cut it off completely at any time, revoke its access at the source: delete the two `Butler RO …`/`Butler RW …` keys under **Settings → Management Access → API Keys**, revoke the bot token with @BotFather (`/revoke`), and delete the Anthropic key in the Anthropic console.
|
|
59
|
+
|
|
60
|
+
A one-command uninstall that also removes the root authority's boot hook and files is not in this alpha yet. If you need it before then, open an issue and we will walk you through it.
|
|
61
|
+
|
|
62
|
+
## Help
|
|
63
|
+
|
|
64
|
+
Open an issue at [github.com/ourostack/ouroboros](https://github.com/ourostack/ouroboros/issues). Please include the installer's output (it never prints your secrets) and, for install failures, `/var/log/ouro-butler-install.log`.
|
|
@@ -32,7 +32,9 @@ function exactKeys(value, expected, label) {
|
|
|
32
32
|
if (JSON.stringify(keys) !== JSON.stringify([...expected].sort())) throw new Error(`${label} has unexpected fields`)
|
|
33
33
|
}
|
|
34
34
|
|
|
35
|
+
// A household without Jellyfin checkpoints null: the container must stay absent.
|
|
35
36
|
function validateJellyfinState(raw, label = "Jellyfin checkpoint") {
|
|
37
|
+
if (raw === null) return null
|
|
36
38
|
const value = object(raw, label)
|
|
37
39
|
exactKeys(value, ["containerId", "imageId", "state", "restartCount"], label)
|
|
38
40
|
if (!CONTAINER_ID.test(value.containerId) || !IMAGE_ID.test(value.imageId) || !JELLYFIN_STATES.has(value.state) || !Number.isSafeInteger(value.restartCount) || value.restartCount < 0) throw new Error(`${label} is invalid`)
|
|
@@ -40,6 +42,13 @@ function validateJellyfinState(raw, label = "Jellyfin checkpoint") {
|
|
|
40
42
|
}
|
|
41
43
|
|
|
42
44
|
export function inspectCurrentJellyfinState() {
|
|
45
|
+
let present
|
|
46
|
+
try {
|
|
47
|
+
present = execFileSync("/usr/bin/docker", ["container", "ls", "--all", "--quiet", "--filter", "name=^/jellyfin$"], { encoding: "utf8", maxBuffer: 65_536, stdio: ["ignore", "pipe", "ignore"] }).trim() !== ""
|
|
48
|
+
} catch {
|
|
49
|
+
throw new Error("Jellyfin container inspection failed")
|
|
50
|
+
}
|
|
51
|
+
if (!present) return null
|
|
43
52
|
let inspected
|
|
44
53
|
try {
|
|
45
54
|
inspected = JSON.parse(execFileSync("/usr/bin/docker", ["container", "inspect", "--format", JELLYFIN_FORMAT, "jellyfin"], { encoding: "utf8", maxBuffer: 65_536, stdio: ["ignore", "pipe", "ignore"] }).trim())
|
|
@@ -57,6 +66,11 @@ function assertJellyfinUnchanged(expected) {
|
|
|
57
66
|
if (JSON.stringify(current) !== JSON.stringify(expected)) throw new Error("Jellyfin changed during the Butler transaction")
|
|
58
67
|
}
|
|
59
68
|
|
|
69
|
+
// A fresh install has no predecessor image; its rollback image is null.
|
|
70
|
+
function validRollbackImage(value) {
|
|
71
|
+
return value === null || IMAGE_ID.test(value)
|
|
72
|
+
}
|
|
73
|
+
|
|
60
74
|
function digest(bytes) {
|
|
61
75
|
return `sha256:${createHash("sha256").update(bytes).digest("hex")}`
|
|
62
76
|
}
|
|
@@ -217,7 +231,7 @@ function validateJournalRecord(raw, state) {
|
|
|
217
231
|
} else if (prior.bytesBase64 !== null || prior.metadata !== null || record.priorTemplateDigest !== null) {
|
|
218
232
|
throw new Error("template transaction journal absent prior state is invalid")
|
|
219
233
|
}
|
|
220
|
-
if (!VERSION_TAG.test(record.canonicalVersionTag) || !IMAGE_ID.test(record.reviewedManifestDigest) || !
|
|
234
|
+
if (!VERSION_TAG.test(record.canonicalVersionTag) || !IMAGE_ID.test(record.reviewedManifestDigest) || !validRollbackImage(record.rollbackImageId) || !IMAGE_ID.test(record.targetImageId) || record.rollbackImageId === record.targetImageId || !IMAGE_ID.test(record.targetTemplateDigest)) throw new Error("template transaction journal release identity is invalid")
|
|
221
235
|
const jellyfin = validateJellyfinState(record.jellyfin, "template transaction journal Jellyfin checkpoint")
|
|
222
236
|
if (!IMAGE_ID.test(record.digest) || record.digest !== recordDigest(record)) throw new Error("template transaction journal digest is invalid")
|
|
223
237
|
return { ...record, jellyfin, priorBytes }
|
|
@@ -305,7 +319,7 @@ function validateTemplateSource(input, state) {
|
|
|
305
319
|
if (metadataIfPresent(state.targetPath)) validateRegularFile(state.targetPath, state.expectedUid, state.expectedGid, "DockerMan template")
|
|
306
320
|
validateDirectory(dirname(input.sourceTemplatePath), state.expectedUid, state.expectedGid)
|
|
307
321
|
validateRegularFile(input.sourceTemplatePath, state.expectedUid, state.expectedGid, "source template")
|
|
308
|
-
if (!VERSION_TAG.test(input.canonicalVersionTag) || !IMAGE_ID.test(input.reviewedManifestDigest) || !
|
|
322
|
+
if (!VERSION_TAG.test(input.canonicalVersionTag) || !IMAGE_ID.test(input.reviewedManifestDigest) || !validRollbackImage(input.rollbackImageId) || !IMAGE_ID.test(input.targetImageId) || input.rollbackImageId === input.targetImageId) throw new Error("reviewed release identity is invalid")
|
|
309
323
|
const sourceBytes = readFileSync(input.sourceTemplatePath)
|
|
310
324
|
const identity = templateIdentity(sourceBytes, input.canonicalVersionTag)
|
|
311
325
|
return { sourceBytes, identity }
|
|
@@ -434,7 +448,7 @@ function validateAuthorityPlan(raw) {
|
|
|
434
448
|
exactKeys(plan, ["schemaVersion", "epochId", "packageDigest", "publicKeyDigest", "botId", "ownerUserId", "ownerChatId", "predecessorContract", "targetContract"], "authority handoff plan")
|
|
435
449
|
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
450
|
|| !["botId", "ownerUserId", "ownerChatId"].every((key) => typeof plan[key] === "string" && /^[1-9][0-9]*$/u.test(plan[key]))
|
|
437
|
-
|| plan.ownerUserId !== plan.ownerChatId ||
|
|
451
|
+
|| plan.ownerUserId !== plan.ownerChatId || !["canonical-pre-gateway", "none"].includes(plan.predecessorContract) || plan.targetContract !== "canonical-gateway") throw new Error("authority handoff plan is invalid")
|
|
438
452
|
return plan
|
|
439
453
|
}
|
|
440
454
|
|
|
@@ -446,6 +460,8 @@ function validateAuthorityHandoff(raw, record) {
|
|
|
446
460
|
const handoff = object(raw, "authority handoff")
|
|
447
461
|
exactKeys(handoff, ["plan", "state", "completed", "rollbackCompleted", "pending", "cancelled"], "authority handoff")
|
|
448
462
|
validateAuthorityPlan(handoff.plan)
|
|
463
|
+
// Only a fresh install (no predecessor) may lack a rollback image, and it must lack one.
|
|
464
|
+
if ((handoff.plan.predecessorContract === "none") !== (record.rollbackImageId === null)) throw new Error("authority handoff plan does not match the rollback image")
|
|
449
465
|
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
466
|
const validateEffect = (effect, direction, step) => {
|
|
451
467
|
object(effect, "authority effect")
|
|
@@ -627,7 +643,7 @@ export function runDockerManTemplateTransactionCli(argv, options = {}, write = (
|
|
|
627
643
|
sourceTemplatePath: values.get("--source-template"),
|
|
628
644
|
canonicalVersionTag: values.get("--version-tag"),
|
|
629
645
|
reviewedManifestDigest: values.get("--manifest-digest"),
|
|
630
|
-
rollbackImageId: values.get("--rollback-image-id"),
|
|
646
|
+
rollbackImageId: values.get("--rollback-image-id") === "none" ? null : values.get("--rollback-image-id"),
|
|
631
647
|
targetImageId: values.get("--target-image-id"),
|
|
632
648
|
}, options)
|
|
633
649
|
} else if (operation === "mark-committing" && values.size === 0) {
|
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
#!/bin/bash
|
|
2
|
+
# Ouro Butler installer for Unraid 7.2 or newer.
|
|
3
|
+
#
|
|
4
|
+
# Run it as root in the Unraid web terminal. Download it and read it first if you like:
|
|
5
|
+
# curl -fsSLo /tmp/ouro-butler-install.sh https://github.com/ourostack/ouroboros/releases/latest/download/install.sh
|
|
6
|
+
# bash /tmp/ouro-butler-install.sh
|
|
7
|
+
#
|
|
8
|
+
# What it does, in order (each step says what it is doing as it goes):
|
|
9
|
+
# 1. checks this is Unraid 7.2+, as root, with Docker running and no Butler installed yet
|
|
10
|
+
# 2. asks for your name, the Butler's name, a vault email, your Anthropic API key and your Telegram bot token
|
|
11
|
+
# (secrets are read with the terminal's echo off and never appear in a command line)
|
|
12
|
+
# 3. pulls the Butler image and seeds the Butler's files under /mnt/user/appdata/ouro-butler
|
|
13
|
+
# 4. creates the Butler's vault (you choose its unlock phrase) and stores the Anthropic key in it
|
|
14
|
+
# 5. creates two Unraid API keys for the Butler (read-only, and read plus Docker update) and stores them in the vault
|
|
15
|
+
# 6. finds your Telegram user id when you message the bot, then installs the root authority that holds the bot
|
|
16
|
+
# token and starts the Butler
|
|
17
|
+
#
|
|
18
|
+
# Options:
|
|
19
|
+
# --version <0.1.0-alpha.N> install this release instead of the newest one
|
|
20
|
+
# --vault-server <url> use your own Vaultwarden-compatible server instead of https://vault.ouro.bot
|
|
21
|
+
# --help print this header
|
|
22
|
+
set -euo pipefail
|
|
23
|
+
umask 077
|
|
24
|
+
|
|
25
|
+
IMAGE_REPO=ghcr.io/ourostack/ouroboros-butler
|
|
26
|
+
APPDATA=/mnt/user/appdata/ouro-butler
|
|
27
|
+
RUNTIME=$APPDATA/runtime/.ouro-cli
|
|
28
|
+
BUNDLE=$APPDATA/agent/sanctuary.ouro
|
|
29
|
+
STAGE=$APPDATA/installer
|
|
30
|
+
AUTHORITY=/mnt/user/appdata/ouro-authority
|
|
31
|
+
EVENTS=/boot/config/custom/ouro-events
|
|
32
|
+
CONTAINER=ouro-butler
|
|
33
|
+
AGENT=sanctuary
|
|
34
|
+
TTY=/dev/tty
|
|
35
|
+
VERSION=""
|
|
36
|
+
VAULT_SERVER=""
|
|
37
|
+
|
|
38
|
+
say() { printf '\n== %s\n' "$*"; }
|
|
39
|
+
ok() { printf ' ok %s\n' "$*"; }
|
|
40
|
+
die() { printf '\nSTOPPED: %s\n' "$*" >&2; exit 1; }
|
|
41
|
+
|
|
42
|
+
ask() { # ask <variable> <prompt> [default]
|
|
43
|
+
local answer
|
|
44
|
+
printf '%s%s: ' "$2" "${3:+ [$3]}" > "$TTY"
|
|
45
|
+
IFS= read -r answer < "$TTY" || die "no answer read from the terminal"
|
|
46
|
+
printf -v "$1" '%s' "${answer:-${3:-}}"
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
ask_secret() { # ask_secret <variable> <prompt>
|
|
50
|
+
local answer saved
|
|
51
|
+
saved=$(stty -g < "$TTY")
|
|
52
|
+
printf '%s (input is hidden): ' "$2" > "$TTY"
|
|
53
|
+
stty -echo < "$TTY"
|
|
54
|
+
IFS= read -r answer < "$TTY" || { stty "$saved" < "$TTY"; die "no answer read from the terminal"; }
|
|
55
|
+
stty "$saved" < "$TTY"
|
|
56
|
+
printf '\n' > "$TTY"
|
|
57
|
+
printf -v "$1" '%s' "$answer"
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
confirm() { local reply; ask reply "$1 (y/n)" n; case "$reply" in y|Y|yes|YES) return 0 ;; *) return 1 ;; esac; }
|
|
61
|
+
|
|
62
|
+
while (($# > 0)); do
|
|
63
|
+
case "$1" in
|
|
64
|
+
--version) VERSION="${2:?--version needs a value, for example 0.1.0-alpha.970}"; shift 2 ;;
|
|
65
|
+
--vault-server) VAULT_SERVER="${2:?--vault-server needs a URL}"; shift 2 ;;
|
|
66
|
+
--help|-h) sed -n '2,/^set -euo/p' "$0" | sed '$d; s/^# \{0,1\}//'; exit 0 ;;
|
|
67
|
+
*) die "unknown option $1 (see --help)" ;;
|
|
68
|
+
esac
|
|
69
|
+
done
|
|
70
|
+
|
|
71
|
+
# The ouro CLI from the Butler image, run against the Butler's own files as the Butler's user.
|
|
72
|
+
ouro() {
|
|
73
|
+
docker run --rm --pull=never --network host --user 10001:10001 \
|
|
74
|
+
--mount "type=bind,src=$RUNTIME,dst=/home/ouro/.ouro-cli" \
|
|
75
|
+
--mount "type=bind,src=$BUNDLE,dst=/home/ouro/AgentBundles/$AGENT.ouro" \
|
|
76
|
+
--entrypoint node "$IMAGE" /opt/ouro/dist/heart/daemon/ouro-entry.js "$@"
|
|
77
|
+
}
|
|
78
|
+
ouro_stdin() {
|
|
79
|
+
docker run --rm -i --pull=never --network host --user 10001:10001 \
|
|
80
|
+
--mount "type=bind,src=$RUNTIME,dst=/home/ouro/.ouro-cli" \
|
|
81
|
+
--mount "type=bind,src=$BUNDLE,dst=/home/ouro/AgentBundles/$AGENT.ouro" \
|
|
82
|
+
--entrypoint node "$IMAGE" /opt/ouro/dist/heart/daemon/ouro-entry.js "$@"
|
|
83
|
+
}
|
|
84
|
+
ouro_interactive() {
|
|
85
|
+
docker run --rm -it --pull=never --network host --user 10001:10001 \
|
|
86
|
+
--mount "type=bind,src=$RUNTIME,dst=/home/ouro/.ouro-cli" \
|
|
87
|
+
--mount "type=bind,src=$BUNDLE,dst=/home/ouro/AgentBundles/$AGENT.ouro" \
|
|
88
|
+
--entrypoint node "$IMAGE" /opt/ouro/dist/heart/daemon/ouro-entry.js "$@" < "$TTY"
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
# Write a private file owned by the Butler's user from stdin.
|
|
92
|
+
write_butler_file() {
|
|
93
|
+
local path=$1
|
|
94
|
+
cat > "$path.tmp.$$"
|
|
95
|
+
chown 10001:10001 "$path.tmp.$$"
|
|
96
|
+
chmod 0600 "$path.tmp.$$"
|
|
97
|
+
mv -f "$path.tmp.$$" "$path"
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
# ---------------------------------------------------------------------------------------------------------------
|
|
101
|
+
say "1. checking this host"
|
|
102
|
+
test "$(id -u)" = 0 || die "run this as root (the Unraid web terminal is root)"
|
|
103
|
+
test -r "$TTY" || die "this installer needs a terminal for its questions; run it in the Unraid web terminal"
|
|
104
|
+
test -r /etc/unraid-version || die "this is not Unraid (no /etc/unraid-version)"
|
|
105
|
+
UNRAID_VERSION=$(sed -n 's/^version="\([0-9.]*\).*/\1/p' /etc/unraid-version)
|
|
106
|
+
IFS=. read -r UNRAID_MAJOR UNRAID_MINOR _ <<< "$UNRAID_VERSION"
|
|
107
|
+
if ((UNRAID_MAJOR < 7 || (UNRAID_MAJOR == 7 && UNRAID_MINOR < 2))); then die "Unraid $UNRAID_VERSION is too old; the Butler needs Unraid 7.2 or newer"; fi
|
|
108
|
+
ok "Unraid $UNRAID_VERSION"
|
|
109
|
+
command -v node >/dev/null || die "node is missing (Unraid 7.2 ships it at /usr/local/bin/node)"
|
|
110
|
+
command -v unraid-api >/dev/null || die "unraid-api is missing (Unraid 7.2 ships it)"
|
|
111
|
+
docker info >/dev/null 2>&1 || die "Docker is not running; start it in Settings > Docker"
|
|
112
|
+
ok "node, unraid-api and Docker are available"
|
|
113
|
+
! docker container inspect "$CONTAINER" >/dev/null 2>&1 || die "an $CONTAINER container already exists; this installer is for a first install (upgrades use the Butler's own upgrade path)"
|
|
114
|
+
test ! -e "$AUTHORITY/active.json" || die "a Butler authority is already installed on this host"
|
|
115
|
+
ok "no Butler installed yet"
|
|
116
|
+
|
|
117
|
+
if test -z "$VERSION"; then
|
|
118
|
+
VERSION=$(node -e 'fetch("https://registry.npmjs.org/-/package/@ouro.bot/cli/dist-tags").then(r => r.json()).then(t => process.stdout.write(t.latest)).catch(() => process.exit(1))') \
|
|
119
|
+
|| die "could not look up the newest Butler release; pass --version"
|
|
120
|
+
fi
|
|
121
|
+
[[ "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[A-Za-z0-9.]+)?$ ]] || die "version $VERSION does not look like a release version"
|
|
122
|
+
IMAGE=$IMAGE_REPO:$VERSION
|
|
123
|
+
ok "installing Ouro Butler $VERSION"
|
|
124
|
+
|
|
125
|
+
# ---------------------------------------------------------------------------------------------------------------
|
|
126
|
+
say "2. a few questions"
|
|
127
|
+
printf 'The Butler lives on this server and talks to you on Telegram. You can change any name later in\n%s/house/house.json.\n\n' "$BUNDLE" > "$TTY"
|
|
128
|
+
ask OWNER_NAME "Your first name" ""
|
|
129
|
+
test -n "$OWNER_NAME" || die "a name is needed so the Butler knows who it works for"
|
|
130
|
+
ask BUTLER_NAME "What should the Butler call itself" "Butler"
|
|
131
|
+
ask HOUSEHOLD_NAME "A name for this household or server" "$(hostname)"
|
|
132
|
+
printf '\nThe Butler keeps its secrets in a Bitwarden-compatible vault. It gets its own vault account, so use an\nemail address no other Butler uses (for example you+butler@example.com). No mail is sent to it.\n' > "$TTY"
|
|
133
|
+
ask VAULT_EMAIL "Vault account email" ""
|
|
134
|
+
[[ "$VAULT_EMAIL" == *@*.* ]] || die "that does not look like an email address"
|
|
135
|
+
printf '\nThe Butler thinks with Anthropic models. Create an API key at https://console.anthropic.com/settings/keys\n' > "$TTY"
|
|
136
|
+
ask_secret ANTHROPIC_KEY "Anthropic API key"
|
|
137
|
+
[[ "$ANTHROPIC_KEY" == sk-ant-* ]] || die "Anthropic API keys start with sk-ant-"
|
|
138
|
+
printf '\nCreate a Telegram bot for the Butler: message @BotFather, send /newbot, and copy the token it gives you.\n' > "$TTY"
|
|
139
|
+
ask_secret BOT_TOKEN "Telegram bot token"
|
|
140
|
+
[[ "$BOT_TOKEN" =~ ^[0-9]+:[A-Za-z0-9_-]+$ ]] || die "that does not look like a Telegram bot token (digits, a colon, then letters)"
|
|
141
|
+
BOT_INFO=$(TOKEN="$BOT_TOKEN" node -e '
|
|
142
|
+
fetch(`https://api.telegram.org/bot${process.env.TOKEN}/getMe`).then(r => r.json()).then(b => {
|
|
143
|
+
if (!b.ok) process.exit(2)
|
|
144
|
+
process.stdout.write(`${b.result.id}\t${b.result.username}`)
|
|
145
|
+
}).catch(() => process.exit(1))') || die "Telegram did not accept that bot token"
|
|
146
|
+
BOT_USERNAME=${BOT_INFO#*$'\t'}
|
|
147
|
+
ok "Telegram knows the bot @$BOT_USERNAME"
|
|
148
|
+
|
|
149
|
+
# ---------------------------------------------------------------------------------------------------------------
|
|
150
|
+
say "3. Butler image and files"
|
|
151
|
+
docker pull "$IMAGE" >/dev/null || die "could not pull $IMAGE"
|
|
152
|
+
ok "pulled $IMAGE"
|
|
153
|
+
install -d -m 0700 -o 10001 -g 10001 "$APPDATA" "$APPDATA/runtime" "$APPDATA/agent" "$RUNTIME" "$BUNDLE"
|
|
154
|
+
install -d -m 0700 -o 0 -g 0 "$AUTHORITY" "$STAGE"
|
|
155
|
+
if test ! -f "$RUNTIME/machine.json"; then
|
|
156
|
+
NOW=$(date -u +%Y-%m-%dT%H:%M:%S.000Z)
|
|
157
|
+
printf '{\n "schemaVersion": 1,\n "machineId": "%s",\n "createdAt": "%s",\n "updatedAt": "%s",\n "hostnameAliases": []\n}\n' "$AGENT" "$NOW" "$NOW" | write_butler_file "$RUNTIME/machine.json"
|
|
158
|
+
fi
|
|
159
|
+
if test ! -f "$BUNDLE/agent.json"; then
|
|
160
|
+
test -z "$(find "$BUNDLE" -mindepth 1 -print -quit)" || die "$BUNDLE has files but no agent.json; move it aside and run the installer again"
|
|
161
|
+
docker run --rm --pull=never --network=none --user 10001:10001 \
|
|
162
|
+
--mount "type=bind,src=$BUNDLE,dst=/home/ouro/AgentBundles/$AGENT.ouro" \
|
|
163
|
+
--entrypoint /bin/sh "$IMAGE" -ceu '
|
|
164
|
+
cp -R /opt/ouro/deploy/unraid/sanctuary.ouro/. /home/ouro/AgentBundles/sanctuary.ouro/
|
|
165
|
+
find /home/ouro/AgentBundles/sanctuary.ouro -type d -exec chmod 0700 {} +
|
|
166
|
+
find /home/ouro/AgentBundles/sanctuary.ouro -type f -exec chmod 0600 {} +'
|
|
167
|
+
fi
|
|
168
|
+
ok "Butler files seeded in $BUNDLE"
|
|
169
|
+
install -d -m 0700 -o 10001 -g 10001 "$BUNDLE/house"
|
|
170
|
+
OWNER_NAME=$OWNER_NAME BUTLER_NAME=$BUTLER_NAME HOUSEHOLD_NAME=$HOUSEHOLD_NAME node -e '
|
|
171
|
+
const house = { ownerName: process.env.OWNER_NAME, butlerName: process.env.BUTLER_NAME, householdName: process.env.HOUSEHOLD_NAME, publicEndpoints: [], requiredContainers: [] }
|
|
172
|
+
process.stdout.write(`${JSON.stringify(house, null, 2)}\n`)' | write_butler_file "$BUNDLE/house/house.json"
|
|
173
|
+
if test ! -f "$BUNDLE/house/HOUSE.md"; then
|
|
174
|
+
printf '# %s\n\n%s owns this Unraid server and is the person %s works for.\n\nAdd anything the Butler should know about this household here: who lives here, what each container is for, what matters most. The Butler reads this file at the start of every conversation.\n' \
|
|
175
|
+
"$HOUSEHOLD_NAME" "$OWNER_NAME" "$BUTLER_NAME" | write_butler_file "$BUNDLE/house/HOUSE.md"
|
|
176
|
+
fi
|
|
177
|
+
ok "house description written to $BUNDLE/house"
|
|
178
|
+
|
|
179
|
+
ASSETS=ouro-butler-installer-assets
|
|
180
|
+
docker rm -f "$ASSETS" >/dev/null 2>&1 || true
|
|
181
|
+
docker create --name "$ASSETS" "$IMAGE" >/dev/null
|
|
182
|
+
docker cp "$ASSETS:/opt/ouro/deploy/unraid/sanctuary-butler-upgrade.mjs" "$STAGE/sanctuary-butler-upgrade.mjs" >/dev/null
|
|
183
|
+
docker cp "$ASSETS:/opt/ouro/deploy/unraid/ouro-events/bootstrap-spool.sh" "$STAGE/bootstrap-spool.sh" >/dev/null
|
|
184
|
+
docker rm -f "$ASSETS" >/dev/null
|
|
185
|
+
chown 0:0 "$STAGE"/*; chmod 0700 "$STAGE"/*
|
|
186
|
+
bash "$STAGE/bootstrap-spool.sh" --install-only
|
|
187
|
+
bash "$EVENTS/bootstrap-spool.sh" --mount
|
|
188
|
+
ok "event spool mounted and registered to mount at boot"
|
|
189
|
+
|
|
190
|
+
# ---------------------------------------------------------------------------------------------------------------
|
|
191
|
+
say "4. vault and model provider"
|
|
192
|
+
VAULT_STATUS=$(ouro vault status --agent "$AGENT" --store plaintext-file 2>&1 || true)
|
|
193
|
+
if [[ "$VAULT_STATUS" != *"local unlock: available"* ]]; then
|
|
194
|
+
printf 'Creating the vault. Choose an unlock phrase and keep it somewhere safe: it is the only way to recover\nthe Butler'"'"'s secrets if this server is lost.\n' > "$TTY"
|
|
195
|
+
ouro_interactive vault create --agent "$AGENT" --email "$VAULT_EMAIL" ${VAULT_SERVER:+--server "$VAULT_SERVER"} --store plaintext-file \
|
|
196
|
+
|| die "vault creation did not finish; run the installer again to retry"
|
|
197
|
+
fi
|
|
198
|
+
ok "vault ready"
|
|
199
|
+
printf '%s' "$ANTHROPIC_KEY" | ouro_stdin auth --agent "$AGENT" --provider anthropic --api-key-file /dev/stdin >/dev/null \
|
|
200
|
+
|| die "storing the Anthropic key failed"
|
|
201
|
+
unset ANTHROPIC_KEY
|
|
202
|
+
for LANE in outward inner; do
|
|
203
|
+
ouro check --agent "$AGENT" --lane "$LANE" >/dev/null 2>&1 || die "the $LANE model lane did not answer; check the Anthropic key and your account's credit"
|
|
204
|
+
done
|
|
205
|
+
ok "Anthropic key stored in the vault; both model lanes answer"
|
|
206
|
+
|
|
207
|
+
# ---------------------------------------------------------------------------------------------------------------
|
|
208
|
+
say "5. Unraid API access"
|
|
209
|
+
PORT=$(sed -n 's/^PORT="\{0,1\}\([0-9]*\)"\{0,1\}$/\1/p' /boot/config/ident.cfg 2>/dev/null | head -1)
|
|
210
|
+
GRAPHQL_URL="http://127.0.0.1${PORT:+$( [ "$PORT" = 80 ] || printf ':%s' "$PORT")}/graphql"
|
|
211
|
+
READ_PERMISSIONS="ARRAY:READ_ANY,DASHBOARD:READ_ANY,DISK:READ_ANY,DOCKER:READ_ANY,INFO:READ_ANY,LOGS:READ_ANY,NOTIFICATIONS:READ_ANY,SHARE:READ_ANY,VARS:READ_ANY"
|
|
212
|
+
STAMP=$(date +%Y%m%d%H%M%S)
|
|
213
|
+
create_unraid_key() { # create_unraid_key <name> <permissions> <vault field>
|
|
214
|
+
# Unraid 7.2's CLI refuses --permissions without a role ("Invalid data structure"); GUEST adds only own-profile access.
|
|
215
|
+
unraid-api apikey --create --name "$1" --roles GUEST --permissions "$2" --description "Ouro Butler $3" --json 2>/dev/null \
|
|
216
|
+
| node -e '
|
|
217
|
+
let text = ""; process.stdin.on("data", (c) => text += c).on("end", () => {
|
|
218
|
+
const start = text.indexOf("{"); if (start < 0) process.exit(1)
|
|
219
|
+
const key = JSON.parse(text.slice(start)).key; if (typeof key !== "string" || !key) process.exit(1)
|
|
220
|
+
process.stdout.write(key) })' \
|
|
221
|
+
| ouro_stdin vault config set --agent "$AGENT" --key "$3" --scope machine --value-file /dev/stdin >/dev/null \
|
|
222
|
+
|| die "creating the Unraid API key $1 failed"
|
|
223
|
+
}
|
|
224
|
+
create_unraid_key "Butler RO $STAMP" "$READ_PERMISSIONS" unraidReadApiKey
|
|
225
|
+
create_unraid_key "Butler RW $STAMP" "$READ_PERMISSIONS,DOCKER:UPDATE_ANY" unraidWriteApiKey
|
|
226
|
+
ouro vault config set --agent "$AGENT" --key unraidGraphqlUrl --scope machine --value "$GRAPHQL_URL" >/dev/null
|
|
227
|
+
ok "API keys \"Butler RO $STAMP\" and \"Butler RW $STAMP\" created (see Settings > Management Access > API Keys)"
|
|
228
|
+
|
|
229
|
+
# ---------------------------------------------------------------------------------------------------------------
|
|
230
|
+
say "6. Telegram and the root authority"
|
|
231
|
+
test ! -L "$AUTHORITY/incoming-token" || die "$AUTHORITY/incoming-token is a link; remove it and run again"
|
|
232
|
+
printf '%s' "$BOT_TOKEN" > "$AUTHORITY/incoming-token.tmp.$$"
|
|
233
|
+
chmod 0600 "$AUTHORITY/incoming-token.tmp.$$"
|
|
234
|
+
mv -f "$AUTHORITY/incoming-token.tmp.$$" "$AUTHORITY/incoming-token"
|
|
235
|
+
unset BOT_TOKEN
|
|
236
|
+
printf '\nOpen Telegram, find @%s and send it any message (for example "hello"). Waiting up to 3 minutes...\n' "$BOT_USERNAME" > "$TTY"
|
|
237
|
+
OWNER_INFO=$(node -e '
|
|
238
|
+
const token = require("node:fs").readFileSync(process.argv[1], "utf8").trim()
|
|
239
|
+
const api = (method, query) => fetch(`https://api.telegram.org/bot${token}/${method}?${query}`).then((r) => r.json())
|
|
240
|
+
const deadline = Date.now() + 180000
|
|
241
|
+
;(async () => {
|
|
242
|
+
while (Date.now() < deadline) {
|
|
243
|
+
const body = await api("getUpdates", "timeout=20&allowed_updates=%5B%22message%22%5D").catch(() => null)
|
|
244
|
+
const updates = body && body.ok ? body.result : []
|
|
245
|
+
const latest = updates.filter((u) => u.message && u.message.chat && u.message.chat.type === "private" && u.message.from).pop()
|
|
246
|
+
if (latest) {
|
|
247
|
+
await api("getUpdates", `offset=${latest.update_id + 1}&timeout=0`).catch(() => null)
|
|
248
|
+
const from = latest.message.from
|
|
249
|
+
process.stdout.write(`${from.id}\t${from.first_name || ""}${from.username ? ` (@${from.username})` : ""}`)
|
|
250
|
+
return
|
|
251
|
+
}
|
|
252
|
+
if (body && body.ok === false) { process.stderr.write(`Telegram: ${body.description}\n`); process.exit(2) }
|
|
253
|
+
}
|
|
254
|
+
process.exit(1)
|
|
255
|
+
})()' "$AUTHORITY/incoming-token") || die "no message reached the bot; run the installer again and message the bot when asked"
|
|
256
|
+
OWNER_ID=${OWNER_INFO%%$'\t'*}
|
|
257
|
+
confirm "The message came from ${OWNER_INFO#*$'\t'}. Is that you" || die "only the owner should message the bot during install; run the installer again"
|
|
258
|
+
ok "owner Telegram id found"
|
|
259
|
+
|
|
260
|
+
UPGRADE=(node "$STAGE/sanctuary-butler-upgrade.mjs")
|
|
261
|
+
"${UPGRADE[@]}" preflight "$VERSION" --fresh || die "preflight found a problem (above); nothing has been installed yet"
|
|
262
|
+
"${UPGRADE[@]}" prepare "$VERSION" --fresh --owner-id "$OWNER_ID" || die "prepare found a problem (above)"
|
|
263
|
+
LOG=/var/log/ouro-butler-install.log
|
|
264
|
+
printf 'Installing the authority and starting the Butler. This runs on even if this terminal closes; its log is %s.\n' "$LOG" > "$TTY"
|
|
265
|
+
setsid nohup "${UPGRADE[@]}" install "$VERSION" > "$LOG" 2>&1 < /dev/null &
|
|
266
|
+
INSTALL_PID=$!
|
|
267
|
+
tail -n +1 --pid="$INSTALL_PID" -f "$LOG"
|
|
268
|
+
wait "$INSTALL_PID" || die "install did not complete and was rolled back; the log is $LOG"
|
|
269
|
+
"${UPGRADE[@]}" verify || die "the Butler is installed but verify found a problem (above)"
|
|
270
|
+
|
|
271
|
+
say "done"
|
|
272
|
+
printf 'Ouro Butler %s is running. Say hello to @%s on Telegram.\n' "$VERSION" "$BOT_USERNAME"
|
|
273
|
+
printf 'Tell it about your household in %s/house/HOUSE.md.\n' "$BUNDLE"
|
|
274
|
+
printf 'Keep the vault unlock phrase you chose somewhere safe.\n'
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
import * as path from "node:path"
|
|
3
3
|
import { pathToFileURL } from "node:url"
|
|
4
4
|
|
|
5
|
-
const USAGE = "Usage: migrate-sanctuary-bundle.mjs --package-root <path> --agent-root <path> --operation <migrate|rollback|finalize-rollback|commit|status|inspect> [--rollback-image-id <sha256:id> --target-image-id <sha256:id>]"
|
|
5
|
+
const USAGE = "Usage: migrate-sanctuary-bundle.mjs --package-root <path> --agent-root <path> --operation <migrate|seed|rollback|finalize-rollback|commit|status|inspect> [--rollback-image-id <sha256:id> --target-image-id <sha256:id>]"
|
|
6
6
|
const BASE_KEYS = ["--package-root", "--agent-root", "--operation"]
|
|
7
7
|
const MIGRATE_KEYS = [...BASE_KEYS, "--rollback-image-id", "--target-image-id"]
|
|
8
8
|
|
|
@@ -16,7 +16,7 @@ function parseArguments(args) {
|
|
|
16
16
|
values.set(key, value)
|
|
17
17
|
}
|
|
18
18
|
const operation = values.get("--operation")
|
|
19
|
-
if (!["migrate", "rollback", "finalize-rollback", "commit", "status", "inspect"].includes(operation)) throw new Error(USAGE)
|
|
19
|
+
if (!["migrate", "seed", "rollback", "finalize-rollback", "commit", "status", "inspect"].includes(operation)) throw new Error(USAGE)
|
|
20
20
|
const expectedKeys = operation === "migrate" ? MIGRATE_KEYS : BASE_KEYS
|
|
21
21
|
if (values.size !== expectedKeys.length || expectedKeys.some((key) => !values.has(key)) || [...values.keys()].some((key) => !expectedKeys.includes(key))) throw new Error(USAGE)
|
|
22
22
|
return values
|
|
@@ -29,6 +29,9 @@ export function runSanctuaryBundleOperation(args, dependencies) {
|
|
|
29
29
|
const operation = values.get("--operation")
|
|
30
30
|
const result = operation === "migrate"
|
|
31
31
|
? dependencies.migrate({ packageRoot, agentRoot, retainRollback: true, rollbackImageId: values.get("--rollback-image-id"), targetImageId: values.get("--target-image-id") })
|
|
32
|
+
// A fresh install has no earlier image to roll back to, so it writes the packaged files without a rollback record.
|
|
33
|
+
: operation === "seed"
|
|
34
|
+
? dependencies.migrate({ packageRoot, agentRoot, retainRollback: false })
|
|
32
35
|
: operation === "rollback"
|
|
33
36
|
? { rolledBack: dependencies.rollback(agentRoot, { retainRecord: true }) }
|
|
34
37
|
// `rollback` keeps crash-retry evidence; the host uses `finalize-rollback` only after old production is audited, ready, and restored to autostart.
|
|
@@ -37,7 +37,13 @@
|
|
|
37
37
|
// --plant <case> make that gate case fail on purpose, proving the rollback end to end
|
|
38
38
|
// verify --gate run the gate against the live version without upgrading
|
|
39
39
|
//
|
|
40
|
+
// A fresh install (a host that never ran a Butler) passes --fresh to preflight and prepare. prepare --fresh takes the
|
|
41
|
+
// bot id from Telegram getMe on the token already at incoming-token and the owner from --owner-id, and writes
|
|
42
|
+
// predecessorContract "none" into request.json; install, upgrade and verify read that contract from there.
|
|
43
|
+
//
|
|
40
44
|
// Usage: sanctuary-butler-upgrade.mjs <preflight|prepare|install|verify|upgrade> <version> [--rehearse <step>] [--no-gate] [--plant <case>]
|
|
45
|
+
// sanctuary-butler-upgrade.mjs preflight <version> --fresh
|
|
46
|
+
// sanctuary-butler-upgrade.mjs prepare <version> --fresh --owner-id <telegram user id>
|
|
41
47
|
// sanctuary-butler-upgrade.mjs verify [--gate]
|
|
42
48
|
|
|
43
49
|
import { execFileSync, spawn, spawnSync } from "node:child_process"
|
|
@@ -124,23 +130,26 @@ function imageId(version) {
|
|
|
124
130
|
|
|
125
131
|
// ---- read-only rehearsal --------------------------------------------------
|
|
126
132
|
|
|
127
|
-
function preflight(version) {
|
|
128
|
-
say(`preflight for ${version} (read-only; nothing is changed)`)
|
|
133
|
+
function preflight(version, { fresh = false } = {}) {
|
|
134
|
+
say(`preflight for ${version}${fresh ? " (fresh install)" : ""} (read-only; nothing is changed)`)
|
|
129
135
|
|
|
130
136
|
say("target image present and pulled")
|
|
131
137
|
let id = imageId(version)
|
|
132
138
|
if (!id) { try { docker(["pull", image(version)]); id = imageId(version) } catch { /* reported below */ } }
|
|
133
139
|
id ? ok(`image ${id.slice(0, 19)}…`) : bad(`image ${image(version)} not present and could not be pulled`)
|
|
134
140
|
|
|
135
|
-
say("running Butler and rollback material")
|
|
141
|
+
say(fresh ? "no Butler on this host yet" : "running Butler and rollback material")
|
|
136
142
|
let runningImage = null
|
|
137
|
-
|
|
143
|
+
if (fresh) {
|
|
144
|
+
containerExists(CONTAINER) ? bad(`an ${CONTAINER} container already exists; a fresh install needs a host without one`) : ok(`no ${CONTAINER} container`)
|
|
145
|
+
existsSync(`${ROOT}/active.json`) ? bad("an authority is installed; this host is not fresh") : ok("no installed authority")
|
|
146
|
+
} else try {
|
|
138
147
|
runningImage = docker(["inspect", CONTAINER, "--format", "{{.Image}}"], { stdio: ["ignore", "pipe", "ignore"] }).trim()
|
|
139
148
|
ok(`running image ${runningImage.slice(0, 19)}… (this is the rollback target)`)
|
|
140
149
|
} catch { bad(`the ${CONTAINER} container is absent`) }
|
|
141
150
|
if (id && runningImage && id === runningImage) bad("target and running image are identical — nothing to upgrade")
|
|
142
151
|
|
|
143
|
-
const installed = existsSync(`${ROOT}/active.json`)
|
|
152
|
+
const installed = !fresh && existsSync(`${ROOT}/active.json`)
|
|
144
153
|
if (installed) console.log(" note: an authority is installed, so this is an in-place upgrade (run `upgrade`, no token rotation)")
|
|
145
154
|
|
|
146
155
|
say("no stale authority runtime state")
|
|
@@ -196,6 +205,7 @@ function preflight(version) {
|
|
|
196
205
|
|
|
197
206
|
say("D-018: the fenced vault read the install depends on")
|
|
198
207
|
if (installed) ok("not needed: the installed gateway already holds the token in root custody")
|
|
208
|
+
else if (fresh) ok("not needed: a fresh install hands the new token straight to the root authority")
|
|
199
209
|
else if (id) {
|
|
200
210
|
// Run the read fenced exactly as THIS version's install will fence it, so
|
|
201
211
|
// the rehearsal is faithful: an unfixed image is tested without the fix and
|
|
@@ -220,12 +230,12 @@ function preflight(version) {
|
|
|
220
230
|
} catch { bad("could not read disk free space") }
|
|
221
231
|
|
|
222
232
|
say("preservation baselines readable")
|
|
223
|
-
existsSync(POLICY) ? ok(`steward policy present (${sha12(POLICY)})`) : bad(`steward policy missing at ${POLICY}`)
|
|
224
|
-
try {
|
|
225
|
-
catch { bad("
|
|
233
|
+
existsSync(POLICY) ? ok(`steward policy present (${sha12(POLICY)})`) : fresh ? ok("no steward policy yet (fresh install)") : bad(`steward policy missing at ${POLICY}`)
|
|
234
|
+
try { ok(jellyfinState() ? "jellyfin inspectable for the unchanged-check" : "no jellyfin container; it must stay absent") }
|
|
235
|
+
catch { bad("docker could not list or inspect the jellyfin container") }
|
|
226
236
|
|
|
227
237
|
console.log("")
|
|
228
|
-
if (RED === 0) console.log(installed ? "PREFLIGHT GREEN — run `upgrade` (detached)." : "PREFLIGHT GREEN — the upgrade can proceed. Next: prepare, rotate the token, install.")
|
|
238
|
+
if (RED === 0) console.log(installed ? "PREFLIGHT GREEN — run `upgrade` (detached)." : fresh ? "PREFLIGHT GREEN — next: put the bot token at incoming-token, prepare --fresh, install." : "PREFLIGHT GREEN — the upgrade can proceed. Next: prepare, rotate the token, install.")
|
|
229
239
|
else console.log(`PREFLIGHT RED — ${RED} blocker(s) above. Nothing was changed; fix these before rotating the token.`)
|
|
230
240
|
process.exit(RED === 0 ? 0 : 1)
|
|
231
241
|
}
|
|
@@ -265,22 +275,69 @@ function fencedVaultRead(version, hasFix) {
|
|
|
265
275
|
|
|
266
276
|
function sha12(path) { return digest(readFileSync(path)).slice(7, 19) }
|
|
267
277
|
|
|
278
|
+
// ---- fresh install helpers (a host with no predecessor Butler) -------------
|
|
279
|
+
|
|
280
|
+
/** The root request for a fresh install: the bot comes from Telegram getMe on the new token, the owner from the installer. */
|
|
281
|
+
export function freshInstallRequest({ epochId, botId, ownerId, pins }) {
|
|
282
|
+
if (!/^[1-9][0-9]*$/u.test(String(botId))) throw new Error("the bot id from Telegram is invalid")
|
|
283
|
+
if (!/^[1-9][0-9]*$/u.test(String(ownerId))) throw new Error("the owner's Telegram id must be a positive number")
|
|
284
|
+
return { schemaVersion: 1, epochId, botId: String(botId), ownerUserId: String(ownerId), ownerChatId: String(ownerId), ...pins, predecessorContract: "none" }
|
|
285
|
+
}
|
|
286
|
+
/** The bot id a Telegram getMe answer names, checked against the token's own prefix. */
|
|
287
|
+
export function botIdFromGetMe(body, token) {
|
|
288
|
+
const id = body?.ok === true && Number.isSafeInteger(body.result?.id) ? String(body.result.id) : null
|
|
289
|
+
if (!id) throw new Error("Telegram did not accept the bot token (getMe failed)")
|
|
290
|
+
if (String(token).split(":")[0] !== id) throw new Error("the bot token does not belong to the bot Telegram names")
|
|
291
|
+
return id
|
|
292
|
+
}
|
|
293
|
+
/** The incoming request for an in-place upgrade: the same identity, new pins, and a fresh install's contract carried forward. */
|
|
294
|
+
export function upgradeRequest(current, pins) {
|
|
295
|
+
return {
|
|
296
|
+
schemaVersion: 1, epochId: current.epochId, botId: current.botId, ownerUserId: current.ownerUserId, ownerChatId: current.ownerChatId, ...pins,
|
|
297
|
+
...(current.predecessorContract === "none" ? { predecessorContract: "none" } : {}),
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
/** Whether a preserved file stayed as it was; one that was absent before had nothing to preserve. */
|
|
301
|
+
export function unchanged(before, after) { return before === null || before === after }
|
|
302
|
+
function containerExists(name) { return docker(["container", "ls", "--all", "--quiet", "--filter", `name=^/${name}$`]).trim() !== "" }
|
|
303
|
+
/** Jellyfin's identity for the unchanged-check, or null on a host without Jellyfin (it must then stay absent). */
|
|
304
|
+
function jellyfinState() {
|
|
305
|
+
return containerExists("jellyfin") ? docker(["inspect", "jellyfin", "--format", "{{.Id}}|{{.Image}}|{{.RestartCount}}|{{.State.StartedAt}}"]).trim() : null
|
|
306
|
+
}
|
|
307
|
+
function policyState() { return existsSync(POLICY) ? sha12(POLICY) : null }
|
|
308
|
+
function installedRequest() { try { return JSON.parse(readFileSync(`${ROOT}/request.json`, "utf8")) } catch { return null } }
|
|
309
|
+
|
|
268
310
|
// ---- prepare (idempotent staging; running Butler untouched) ---------------
|
|
269
311
|
|
|
270
|
-
function prepare(version) {
|
|
271
|
-
say(`prepare inputs for ${version}`)
|
|
312
|
+
async function prepare(version, { fresh = false, ownerId } = {}) {
|
|
313
|
+
say(`prepare inputs for ${version}${fresh ? " (fresh install)" : ""}`)
|
|
272
314
|
const id = imageId(version)
|
|
273
315
|
if (!id) fail(`image ${image(version)} is not present; pull it first`)
|
|
274
316
|
if (existsSync(`${ROOT}/active.json`)) fail("an authority is installed; use `upgrade` (in place, no token rotation) instead of prepare/install")
|
|
317
|
+
let botId = null
|
|
318
|
+
if (fresh) {
|
|
319
|
+
if (containerExists(CONTAINER)) fail(`an ${CONTAINER} container already exists; a fresh install needs a host without one`)
|
|
320
|
+
const tokenPath = `${ROOT}/incoming-token`
|
|
321
|
+
if (!existsSync(tokenPath) || statSync(tokenPath).size === 0) fail(`no bot token at ${tokenPath}; the installer puts it there first`)
|
|
322
|
+
const token = readFileSync(tokenPath, "utf8").trim()
|
|
323
|
+
const body = await fetch(`https://api.telegram.org/bot${token}/getMe`).then((response) => response.json()).catch(() => null)
|
|
324
|
+
try { botId = botIdFromGetMe(body, token) } catch (error) { fail(error.message) }
|
|
325
|
+
ok(`Telegram knows the bot (id ${botId})`)
|
|
326
|
+
}
|
|
275
327
|
const epochId = `${version.replace(/[^A-Za-z0-9_-]/g, "-")}-${new Date().toISOString().slice(0, 10).replace(/-/g, "")}`
|
|
276
328
|
const manifestBytes = stageIncomingPackage(version)
|
|
277
329
|
writePrivate(`${ROOT}/package-manifest.json`, manifestBytes)
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
330
|
+
let request
|
|
331
|
+
if (fresh) {
|
|
332
|
+
try { request = freshInstallRequest({ epochId, botId, ownerId, pins: packagePins(manifestBytes) }) } catch (error) { fail(error.message) }
|
|
333
|
+
} else {
|
|
334
|
+
const creds = JSON.parse(readFileSync(`/mnt/user/appdata/ouro-butler/runtime/container-credentials.json`, "utf8")).credentials[0].runtimeConfig
|
|
335
|
+
request = {
|
|
336
|
+
schemaVersion: 1, epochId,
|
|
337
|
+
botId: String(creds.telegramBotToken).split(":")[0],
|
|
338
|
+
ownerUserId: String(creds.telegramAuthorizedUserId), ownerChatId: String(creds.telegramAuthorizedChatId),
|
|
339
|
+
...packagePins(manifestBytes),
|
|
340
|
+
}
|
|
284
341
|
}
|
|
285
342
|
if (request.ownerUserId !== request.ownerChatId) fail("owner user and chat must match")
|
|
286
343
|
writePrivate(`${ROOT}/request.json`, Buffer.from(JSON.stringify(request)))
|
|
@@ -290,7 +347,7 @@ function prepare(version) {
|
|
|
290
347
|
verifyPackage(`${ROOT}/incoming-package`, `${ROOT}/package-manifest.json`, `${ROOT}/request.json`)
|
|
291
348
|
ok("package verifies")
|
|
292
349
|
|
|
293
|
-
console.log("\nPREPARE done. Next: rotate the bot token into ${ROOT}/incoming-token, then install.".replace("${ROOT}", ROOT))
|
|
350
|
+
console.log(fresh ? "\nPREPARE done. Next: install." : "\nPREPARE done. Next: rotate the bot token into ${ROOT}/incoming-token, then install.".replace("${ROOT}", ROOT))
|
|
294
351
|
}
|
|
295
352
|
|
|
296
353
|
// The package digest plus the host primitive pins every request carries.
|
|
@@ -573,10 +630,16 @@ function migrateBundle(version, rollbackImage) {
|
|
|
573
630
|
const pkgBundle = `${ROOT}/incoming-package/deploy/unraid/sanctuary.ouro`
|
|
574
631
|
const migrate = `${ROOT}/incoming-package/deploy/unraid/migrate-sanctuary-bundle.mjs`
|
|
575
632
|
if (!existsSync(`${pkgBundle}/provider-readiness.json`)) fail(`packaged bundle template missing provider-readiness.json at ${pkgBundle}`)
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
633
|
+
if (rollbackImage === null) {
|
|
634
|
+
// A fresh install has no earlier image to roll back to: write the packaged files with no rollback record.
|
|
635
|
+
sh("/usr/local/bin/node", [migrate, "--package-root", pkgBundle, "--agent-root", BUNDLE, "--operation", "seed"])
|
|
636
|
+
ok("bundle seeded from the package (fresh install)")
|
|
637
|
+
} else {
|
|
638
|
+
sh("/usr/local/bin/node", [migrate, "--package-root", pkgBundle, "--agent-root", BUNDLE, "--operation", "migrate",
|
|
639
|
+
"--rollback-image-id", rollbackImage, "--target-image-id", imageId(version)])
|
|
640
|
+
ok("bundle migrated (rollback retained)")
|
|
641
|
+
sh("/usr/local/bin/node", [migrate, "--package-root", pkgBundle, "--agent-root", BUNDLE, "--operation", "commit"])
|
|
642
|
+
}
|
|
580
643
|
// migrate/commit ran as root; the resident owns its bundle as uid 10001, so restore ownership
|
|
581
644
|
sh("/bin/chown", ["-R", "10001:10001", BUNDLE])
|
|
582
645
|
restoreReplayRoot()
|
|
@@ -760,6 +823,7 @@ function install(version) {
|
|
|
760
823
|
if (!existsSync(`${ROOT}/incoming-token`) || statSync(`${ROOT}/incoming-token`).size === 0) fail(`no rotated token at ${ROOT}/incoming-token — rotate it in BotFather first`)
|
|
761
824
|
if (existsSync(JOURNAL)) fail("a template transaction is already pending; resolve it first")
|
|
762
825
|
if (targetHasVaultFix(version) === false) fail(`${version} lacks the D-018 fenced-read fix; the install would strand Telegram (run preflight)`)
|
|
826
|
+
const fresh = installedRequest()?.predecessorContract === "none"
|
|
763
827
|
|
|
764
828
|
// Clear stale authority runtime dirs from a prior failed attempt so #runtimeDirectories
|
|
765
829
|
// starts clean (it skips a dir that already exists). Safe only when no authority is live
|
|
@@ -773,11 +837,12 @@ function install(version) {
|
|
|
773
837
|
ok("cleared stale authority runtime dirs (cgroup/staging/socket/epochs) and any leaked gateway")
|
|
774
838
|
}
|
|
775
839
|
const targetImage = imageId(version)
|
|
776
|
-
|
|
840
|
+
if (fresh && containerExists(CONTAINER)) fail(`an ${CONTAINER} container already exists; a fresh install needs a host without one`)
|
|
841
|
+
const rollbackImage = fresh ? null : docker(["inspect", CONTAINER, "--format", "{{.Image}}"]).trim()
|
|
777
842
|
if (!targetImage || targetImage === rollbackImage) fail("target/rollback image identity invalid")
|
|
778
|
-
const jellyfinBefore =
|
|
779
|
-
const policyBefore =
|
|
780
|
-
ok(`target ${targetImage.slice(0,19)}… rollback ${rollbackImage.slice(0,19)}
|
|
843
|
+
const jellyfinBefore = jellyfinState()
|
|
844
|
+
const policyBefore = fresh ? policyState() : policyState() ?? fail(`steward policy missing`)
|
|
845
|
+
ok(`target ${targetImage.slice(0,19)}… rollback ${rollbackImage ? `${rollbackImage.slice(0,19)}…` : "none (fresh install)"} policy ${policyBefore ?? "none yet"}`)
|
|
781
846
|
|
|
782
847
|
const srcTemplate = `${ROOT}/source-template.xml`
|
|
783
848
|
sh("/usr/bin/install", ["-m", "600", "-o", "0", "-g", "0", `${incoming}/deploy/unraid/sanctuary.xml`, srcTemplate])
|
|
@@ -787,7 +852,7 @@ function install(version) {
|
|
|
787
852
|
try {
|
|
788
853
|
say("transaction: prepare -> authority-install -> authority-activate -> commit")
|
|
789
854
|
tx("prepare", ["--source-template", srcTemplate, "--version-tag", image(version),
|
|
790
|
-
"--manifest-digest", manifestDigest, "--rollback-image-id", rollbackImage, "--target-image-id", targetImage])
|
|
855
|
+
"--manifest-digest", manifestDigest, "--rollback-image-id", rollbackImage ?? "none", "--target-image-id", targetImage])
|
|
791
856
|
ok("prepared")
|
|
792
857
|
tx("authority-install"); ok("authority installed (gateway up)")
|
|
793
858
|
recreateResident(version)
|
|
@@ -800,14 +865,17 @@ function install(version) {
|
|
|
800
865
|
} catch (e) {
|
|
801
866
|
resumeCgroupReaper()
|
|
802
867
|
console.log(`\n!! install failed:\n${e.message}\n!! auto-rolling back so the Butler is not left down`)
|
|
868
|
+
if (fresh) {
|
|
869
|
+
freshRollback()
|
|
870
|
+
fail("fresh install rolled back; nothing is left running. Fix the error above, then run prepare --fresh and install again.")
|
|
871
|
+
}
|
|
803
872
|
autoRollback(rollbackImage, version)
|
|
804
873
|
fail("install rolled back; the Butler is on its prior version. See output above.")
|
|
805
874
|
}
|
|
806
875
|
|
|
807
876
|
say("preservation")
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
sha12(POLICY) === policyBefore ? ok("steward policy unchanged") : fail("STEWARD POLICY CHANGED")
|
|
877
|
+
jellyfinState() === jellyfinBefore ? ok(jellyfinBefore ? "jellyfin unchanged" : "no jellyfin, as before") : fail("JELLYFIN CHANGED")
|
|
878
|
+
unchanged(policyBefore, policyState()) ? ok(policyBefore ? "steward policy unchanged" : "no steward policy before install") : fail("STEWARD POLICY CHANGED")
|
|
811
879
|
console.log(`\nINSTALL done — ${version} live. Run verify.`)
|
|
812
880
|
}
|
|
813
881
|
|
|
@@ -823,6 +891,17 @@ function killLeakedGateway() {
|
|
|
823
891
|
} catch { /* best effort */ }
|
|
824
892
|
}
|
|
825
893
|
|
|
894
|
+
// A fresh install rolls back to nothing: the authority retires and removes the resident it created, the template
|
|
895
|
+
// transaction removes the template it wrote, and nothing is restarted because nothing ran before.
|
|
896
|
+
function freshRollback() {
|
|
897
|
+
for (const op of ["authority-retire", "authority-restore", "rollback"]) {
|
|
898
|
+
try { tx(op) } catch (e) { console.log(` ${op}: ${String(e.message).split("\n")[0]}`) }
|
|
899
|
+
}
|
|
900
|
+
killLeakedGateway()
|
|
901
|
+
try { if (containerExists(CONTAINER)) docker(["rm", "-f", CONTAINER]) } catch (e) { console.log(` remove ${CONTAINER}: ${String(e.message).split("\n")[0]}`) }
|
|
902
|
+
console.log(containerExists(CONTAINER) ? ` ${CONTAINER} is still present — remove it by hand before retrying` : " rollback complete — no Butler container is left behind")
|
|
903
|
+
}
|
|
904
|
+
|
|
826
905
|
function autoRollback(rollbackImage, version) {
|
|
827
906
|
try { tx("authority-retire") } catch (e) { console.log(` authority-retire: ${String(e.message).split("\n")[0]}`) }
|
|
828
907
|
try { tx("authority-restore") } catch (e) { console.log(` authority-restore: ${String(e.message).split("\n")[0]}`) }
|
|
@@ -1119,22 +1198,22 @@ async function upgrade(version, rehearse, { noGate = false, plant } = {}) {
|
|
|
1119
1198
|
if (!id) { docker(["pull", image(version)]); id = imageId(version) }
|
|
1120
1199
|
if (!id) fail(`image ${image(version)} could not be pulled`)
|
|
1121
1200
|
ok(`image ${id.slice(0, 19)}…`)
|
|
1201
|
+
const current = JSON.parse(readFileSync(`${ROOT}/request.json`, "utf8"))
|
|
1122
1202
|
if (existsSync(UPGRADE_JOURNAL)) console.log(" note: resuming the pending in-place upgrade with its staged package")
|
|
1123
1203
|
else {
|
|
1124
|
-
const current = JSON.parse(readFileSync(`${ROOT}/request.json`, "utf8"))
|
|
1125
1204
|
const manifestBytes = stageIncomingPackage(version)
|
|
1126
1205
|
const pkgVersion = JSON.parse(readFileSync(`${ROOT}/incoming-package/deploy/unraid/sanctuary.ouro/bundle-meta.json`, "utf8")).runtimeVersion
|
|
1127
1206
|
if (pkgVersion !== version) fail(`staged package is ${pkgVersion}, not ${version}`)
|
|
1128
1207
|
writePrivate(INCOMING_MANIFEST, manifestBytes)
|
|
1129
|
-
const request =
|
|
1208
|
+
const request = upgradeRequest(current, packagePins(manifestBytes))
|
|
1130
1209
|
writePrivate(INCOMING_REQUEST, Buffer.from(JSON.stringify(request)))
|
|
1131
1210
|
verifyPackage(`${ROOT}/incoming-package`, INCOMING_MANIFEST, INCOMING_REQUEST)
|
|
1132
1211
|
ok("new package staged beside the live one and verified")
|
|
1133
1212
|
}
|
|
1134
1213
|
const lifecycle = `${ROOT}/incoming-package/dist/heart/daemon/sanctuary-authority-root-lifecycle.js`
|
|
1135
1214
|
const priorImage = butlerState().split(" ")[0]
|
|
1136
|
-
const jellyfinBefore =
|
|
1137
|
-
const policyBefore =
|
|
1215
|
+
const jellyfinBefore = jellyfinState()
|
|
1216
|
+
const policyBefore = current.predecessorContract === "none" ? policyState() : policyState() ?? fail("steward policy missing")
|
|
1138
1217
|
const telegram = await checkTelegramBeforePause({ journalExists: existsSync(UPGRADE_JOURNAL) })
|
|
1139
1218
|
if (!telegram.ok) fail(`api.telegram.org is not reachable from this host (${telegram.detail}); the new gateway could not pass its Telegram identity check. Nothing was paused or changed; fix the network and re-run.`)
|
|
1140
1219
|
if (telegram.skipped) console.log(" note: resuming a pending upgrade; Telegram reachability not re-checked")
|
|
@@ -1158,9 +1237,9 @@ async function upgrade(version, rehearse, { noGate = false, plant } = {}) {
|
|
|
1158
1237
|
if (!gated || failure) resumeSupervision()
|
|
1159
1238
|
say("preservation")
|
|
1160
1239
|
const preserved = []
|
|
1161
|
-
const jellyfinNow =
|
|
1162
|
-
jellyfinNow === jellyfinBefore ? ok("jellyfin unchanged") : (gated && !failure ? (bad("JELLYFIN CHANGED"), preserved.push("jellyfin changed")) : fail("JELLYFIN CHANGED"))
|
|
1163
|
-
|
|
1240
|
+
const jellyfinNow = jellyfinState()
|
|
1241
|
+
jellyfinNow === jellyfinBefore ? ok(jellyfinBefore ? "jellyfin unchanged" : "no jellyfin, as before") : (gated && !failure ? (bad("JELLYFIN CHANGED"), preserved.push("jellyfin changed")) : fail("JELLYFIN CHANGED"))
|
|
1242
|
+
unchanged(policyBefore, policyState()) ? ok("steward policy unchanged") : (gated && !failure ? (bad("STEWARD POLICY CHANGED"), preserved.push("steward policy changed")) : fail("STEWARD POLICY CHANGED"))
|
|
1164
1243
|
const live = docker(["inspect", CONTAINER, "--format", "{{.Config.Image}} {{.State.Status}}/{{.State.Health.Status}}"]).trim()
|
|
1165
1244
|
ok(`butler: ${live}`)
|
|
1166
1245
|
if (failure && !planned) fail("upgrade rolled back; the Butler is on its prior version (see above)")
|
|
@@ -1212,7 +1291,7 @@ function verify(withGate = false) {
|
|
|
1212
1291
|
ok(`butler: ${st}`)
|
|
1213
1292
|
const auth = docker(["logs", CONTAINER, "--since", "2m"], { stdio: ["ignore","pipe","pipe"] }).split("\n").filter((l) => l.includes("401")).length
|
|
1214
1293
|
auth === 0 ? ok("no Telegram auth failures in the last 2m") : bad(`${auth} 401s in the last 2m`)
|
|
1215
|
-
existsSync(POLICY) ? ok(`steward policy ${sha12(POLICY)}`) : bad("steward policy missing")
|
|
1294
|
+
existsSync(POLICY) ? ok(`steward policy ${sha12(POLICY)}`) : installedRequest()?.predecessorContract === "none" ? ok("no steward policy yet") : bad("steward policy missing")
|
|
1216
1295
|
const psycheMount = JSON.parse(docker(["inspect", CONTAINER, "--format", "{{json .Mounts}}"]) || "[]")
|
|
1217
1296
|
psycheMountIssue(psycheMount) === null ? ok("psyche is mounted read-only in the resident") : bad(psycheMountIssue(psycheMount))
|
|
1218
1297
|
const trustMount = JSON.parse(docker(["inspect", CONTAINER, "--format", "{{json .Mounts}}"]) || "[]")
|
|
@@ -1226,8 +1305,7 @@ function verify(withGate = false) {
|
|
|
1226
1305
|
const psycheIssues = psycheProblems(psycheDir)
|
|
1227
1306
|
psycheIssues.length === 0 ? ok("psyche files are resident-owned 0600 (read-only through the mount)") : bad(`psyche files do not match what the resident boot check requires: ${psycheIssues.slice(0, 3).join("; ")}`)
|
|
1228
1307
|
} else bad("psyche folder missing from the bundle")
|
|
1229
|
-
|
|
1230
|
-
ok(`jellyfin ${jf}`)
|
|
1308
|
+
ok(containerExists("jellyfin") ? `jellyfin ${docker(["inspect", "jellyfin", "--format", "{{.State.Status}} restarts={{.RestartCount}}"]).trim()}` : "no jellyfin container")
|
|
1231
1309
|
if (withGate) {
|
|
1232
1310
|
say("replay gate (live version, no upgrade)")
|
|
1233
1311
|
const gate = runReplayGate(undefined)
|
|
@@ -1241,21 +1319,25 @@ function fail(m) { console.error(`\nREFUSING: ${m}`); process.exit(1) }
|
|
|
1241
1319
|
// ---- entry ----------------------------------------------------------------
|
|
1242
1320
|
|
|
1243
1321
|
export function parseUpgradeArgs(argv) {
|
|
1244
|
-
const usage = "Usage: sanctuary-butler-upgrade.mjs <preflight|prepare|install|upgrade> <version> [--rehearse <step>] [--no-gate] [--plant <case>] | verify [--gate]"
|
|
1322
|
+
const usage = "Usage: sanctuary-butler-upgrade.mjs <preflight|prepare|install|upgrade> <version> [--rehearse <step>] [--no-gate] [--plant <case>] | preflight <version> --fresh | prepare <version> --fresh --owner-id <id> | verify [--gate]"
|
|
1245
1323
|
const [phase, ...rest] = argv
|
|
1246
1324
|
if (!phase || !["preflight", "prepare", "install", "verify", "upgrade"].includes(phase)) throw new Error(usage)
|
|
1247
|
-
const parsed = { phase, version: undefined, rehearse: undefined, noGate: false, plant: undefined, gate: false }
|
|
1325
|
+
const parsed = { phase, version: undefined, rehearse: undefined, noGate: false, plant: undefined, gate: false, fresh: false, ownerId: undefined }
|
|
1248
1326
|
for (let i = 0; i < rest.length; i += 1) {
|
|
1249
1327
|
const arg = rest[i]
|
|
1250
1328
|
if (arg === "--rehearse" && phase === "upgrade" && rest[i + 1]) parsed.rehearse = rest[++i]
|
|
1251
1329
|
else if (arg === "--no-gate" && phase === "upgrade") parsed.noGate = true
|
|
1252
1330
|
else if (arg === "--plant" && phase === "upgrade" && rest[i + 1]) parsed.plant = rest[++i]
|
|
1253
1331
|
else if (arg === "--gate" && phase === "verify") parsed.gate = true
|
|
1332
|
+
else if (arg === "--fresh" && (phase === "preflight" || phase === "prepare")) parsed.fresh = true
|
|
1333
|
+
else if (arg === "--owner-id" && phase === "prepare" && rest[i + 1]) parsed.ownerId = rest[++i]
|
|
1254
1334
|
else if (!arg.startsWith("--") && parsed.version === undefined && phase !== "verify") parsed.version = arg
|
|
1255
1335
|
else throw new Error(usage)
|
|
1256
1336
|
}
|
|
1257
1337
|
if (parsed.noGate && parsed.plant) throw new Error("--plant needs the gate; it cannot be combined with --no-gate")
|
|
1258
1338
|
if (parsed.rehearse && (parsed.noGate || parsed.plant)) throw new Error("--rehearse does not use the gate; do not combine it with --no-gate or --plant")
|
|
1339
|
+
if (phase === "prepare" && parsed.fresh !== (parsed.ownerId !== undefined)) throw new Error("prepare --fresh needs --owner-id <the owner's Telegram user id>, and --owner-id needs --fresh")
|
|
1340
|
+
if (parsed.ownerId !== undefined && !/^[1-9][0-9]*$/u.test(parsed.ownerId)) throw new Error("--owner-id must be a Telegram user id (a positive number)")
|
|
1259
1341
|
if (phase !== "verify" && !/^[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?$/.test(parsed.version || "")) throw new Error("a semver version is required, e.g. 0.1.0-alpha.829")
|
|
1260
1342
|
return parsed
|
|
1261
1343
|
}
|
|
@@ -1264,8 +1346,8 @@ async function run(argv) {
|
|
|
1264
1346
|
let args
|
|
1265
1347
|
try { args = parseUpgradeArgs(argv) } catch (error) { console.error(error.message); process.exit(2) }
|
|
1266
1348
|
requireRoot()
|
|
1267
|
-
if (args.phase === "preflight") preflight(args.version)
|
|
1268
|
-
else if (args.phase === "prepare") prepare(args.version)
|
|
1349
|
+
if (args.phase === "preflight") preflight(args.version, { fresh: args.fresh })
|
|
1350
|
+
else if (args.phase === "prepare") await prepare(args.version, { fresh: args.fresh, ownerId: args.ownerId })
|
|
1269
1351
|
else if (args.phase === "install") install(args.version)
|
|
1270
1352
|
else if (args.phase === "verify") verify(args.gate)
|
|
1271
1353
|
else if (args.phase === "upgrade") await upgrade(args.version, args.rehearse, { noGate: args.noGate, plant: args.plant })
|
|
@@ -2,12 +2,12 @@
|
|
|
2
2
|
"version": 2,
|
|
3
3
|
"enabled": true,
|
|
4
4
|
"humanFacing": {
|
|
5
|
-
"provider": "
|
|
6
|
-
"model": "
|
|
5
|
+
"provider": "anthropic",
|
|
6
|
+
"model": "claude-opus-5-5"
|
|
7
7
|
},
|
|
8
8
|
"agentFacing": {
|
|
9
|
-
"provider": "
|
|
10
|
-
"model": "
|
|
9
|
+
"provider": "anthropic",
|
|
10
|
+
"model": "claude-sonnet-5-5"
|
|
11
11
|
},
|
|
12
12
|
"context": {
|
|
13
13
|
"maxTokens": 80000,
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
<?xml version="1.0"?>
|
|
2
2
|
<Container version="2">
|
|
3
3
|
<Name>ouro-butler</Name>
|
|
4
|
-
<Repository>ghcr.io/ourostack/ouroboros-butler:0.1.0-alpha.
|
|
4
|
+
<Repository>ghcr.io/ourostack/ouroboros-butler:0.1.0-alpha.971</Repository>
|
|
5
5
|
<Registry>https://github.com/ourostack/ouroboros/pkgs/container/ouroboros-butler</Registry>
|
|
6
6
|
<Network>host</Network>
|
|
7
7
|
<Shell>sh</Shell>
|
|
@@ -2215,10 +2215,13 @@ async function executeVaultStatus(command, deps) {
|
|
|
2215
2215
|
}
|
|
2216
2216
|
const RUNTIME_CONFIG_KEY_SEGMENT = /^[A-Za-z][A-Za-z0-9_-]*$/;
|
|
2217
2217
|
const FORBIDDEN_RUNTIME_CONFIG_KEY_SEGMENTS = new Set(["__proto__", "prototype", "constructor"]);
|
|
2218
|
+
// The Sanctuary runtime reads these machine credentials at the top level of the config (sanctuary-runtime.ts); every
|
|
2219
|
+
// other key is a dotted path, so a mistyped key cannot replace a whole section with a string.
|
|
2220
|
+
const TOP_LEVEL_RUNTIME_CONFIG_KEYS = new Set(["unraidGraphqlUrl", "unraidReadApiKey", "unraidWriteApiKey", "sabnzbdApiKey"]);
|
|
2218
2221
|
function parseRuntimeConfigKey(key) {
|
|
2219
2222
|
const segments = key.split(".").map((segment) => segment.trim()).filter(Boolean);
|
|
2220
|
-
if (segments.length < 2) {
|
|
2221
|
-
throw new Error(
|
|
2223
|
+
if (segments.length < 2 && !(segments.length === 1 && TOP_LEVEL_RUNTIME_CONFIG_KEYS.has(segments[0]))) {
|
|
2224
|
+
throw new Error(`runtime config key must be a dotted path such as bluebubbles.password, or one of ${[...TOP_LEVEL_RUNTIME_CONFIG_KEYS].join(", ")}`);
|
|
2222
2225
|
}
|
|
2223
2226
|
for (const segment of segments) {
|
|
2224
2227
|
if (!RUNTIME_CONFIG_KEY_SEGMENT.test(segment) || FORBIDDEN_RUNTIME_CONFIG_KEY_SEGMENTS.has(segment)) {
|
|
@@ -2353,6 +2356,9 @@ async function withCliTimeout(timeoutMs, label, run) {
|
|
|
2353
2356
|
async function promptRuntimeConfigValue(command, deps) {
|
|
2354
2357
|
if (command.value !== undefined)
|
|
2355
2358
|
return command.value;
|
|
2359
|
+
// A file (or /dev/stdin) keeps a secret out of argv, so a script can store one without a terminal.
|
|
2360
|
+
if (command.valueFile !== undefined)
|
|
2361
|
+
return readSecretValueFile("--value-file", command.valueFile);
|
|
2356
2362
|
if (isSensitiveRuntimeConfigKey(command.key)) {
|
|
2357
2363
|
if (!deps.promptSecret) {
|
|
2358
2364
|
throw new Error("secret entry requires an interactive terminal so the value can be hidden. Re-run without --value in a terminal, or pass --value only from a trusted non-logged script.");
|
|
@@ -5648,21 +5654,22 @@ async function executeProviderRefresh(command, deps) {
|
|
|
5648
5654
|
deps.writeStdout(message);
|
|
5649
5655
|
return message;
|
|
5650
5656
|
}
|
|
5651
|
-
/** Reads
|
|
5652
|
-
function
|
|
5657
|
+
/** Reads a secret from a file (or /dev/stdin) so it never appears in argv. */
|
|
5658
|
+
function readSecretValueFile(flag, filePath) {
|
|
5653
5659
|
let raw;
|
|
5654
5660
|
try {
|
|
5655
5661
|
raw = fs.readFileSync(filePath, "utf8");
|
|
5656
5662
|
}
|
|
5657
5663
|
catch (error) {
|
|
5658
5664
|
/* v8 ignore next -- fs.readFileSync only throws Error objects @preserve */
|
|
5659
|
-
throw new Error(`could not read
|
|
5665
|
+
throw new Error(`could not read ${flag} ${filePath}: ${error instanceof Error ? error.message : String(error)}`);
|
|
5660
5666
|
}
|
|
5661
|
-
const
|
|
5662
|
-
if (!
|
|
5663
|
-
throw new Error(
|
|
5664
|
-
return
|
|
5667
|
+
const value = raw.trim();
|
|
5668
|
+
if (!value)
|
|
5669
|
+
throw new Error(`${flag} ${filePath} is empty.`);
|
|
5670
|
+
return value;
|
|
5665
5671
|
}
|
|
5672
|
+
function readApiKeyFile(filePath) { return readSecretValueFile("--api-key-file", filePath); }
|
|
5666
5673
|
async function executeAuthRun(command, deps) {
|
|
5667
5674
|
const provider = command.provider ?? (0, auth_flow_1.readAgentConfigForAgent)(command.agent, deps.bundlesRoot).config.humanFacing.provider;
|
|
5668
5675
|
const apiKey = command.apiKeyFile ? readApiKeyFile(command.apiKeyFile) : undefined;
|
|
@@ -626,7 +626,7 @@ const SUBCOMMAND_HELP = {
|
|
|
626
626
|
},
|
|
627
627
|
"vault config set": {
|
|
628
628
|
description: "Write runtime configuration into the agent credential vault without printing values",
|
|
629
|
-
usage: "ouro vault config set [--agent <name>] --key <path> [--value <value>] [--scope agent|machine]",
|
|
629
|
+
usage: "ouro vault config set [--agent <name>] --key <path> [--value <value> | --value-file <path>] [--scope agent|machine]",
|
|
630
630
|
example: "ouro vault config set --key teams.clientSecret",
|
|
631
631
|
},
|
|
632
632
|
"vault config status": {
|
|
@@ -265,7 +265,7 @@ function usage() {
|
|
|
265
265
|
" ouro vault recover [--agent <name>] --from <json> [--from <json>] [--email <email>] [--server <url>] [--store <store>]",
|
|
266
266
|
" ouro vault unlock [--agent <name>] [--store auto|macos-keychain|windows-dpapi|linux-secret-service|plaintext-file]",
|
|
267
267
|
" ouro vault status [--agent <name>] [--store auto|macos-keychain|windows-dpapi|linux-secret-service|plaintext-file]",
|
|
268
|
-
" ouro vault config set [--agent <name>] --key <path> [--value <value>] [--scope agent|machine]",
|
|
268
|
+
" ouro vault config set [--agent <name>] --key <path> [--value <value> | --value-file <path>] [--scope agent|machine]",
|
|
269
269
|
" ouro vault config status [--agent <name>] [--scope agent|machine|all]",
|
|
270
270
|
" ouro vault item set [--agent <name>] --item <path> --secret-field <name> [--public-field <key=value>] [--note <text>]",
|
|
271
271
|
" ouro vault item status [--agent <name>] --item <path>",
|
|
@@ -1173,9 +1173,15 @@ function parseVaultConfigCommand(args) {
|
|
|
1173
1173
|
const { agent, rest } = extractAgentFlag(args.slice(1));
|
|
1174
1174
|
let key;
|
|
1175
1175
|
let value;
|
|
1176
|
+
let valueFile;
|
|
1176
1177
|
let scope;
|
|
1177
1178
|
for (let i = 0; i < rest.length; i += 1) {
|
|
1178
1179
|
const token = rest[i];
|
|
1180
|
+
if (token === "--value-file") {
|
|
1181
|
+
valueFile = rest[i + 1] ?? "";
|
|
1182
|
+
i += 1;
|
|
1183
|
+
continue;
|
|
1184
|
+
}
|
|
1179
1185
|
if (token === "--key") {
|
|
1180
1186
|
key = rest[i + 1];
|
|
1181
1187
|
i += 1;
|
|
@@ -1201,7 +1207,7 @@ function parseVaultConfigCommand(args) {
|
|
|
1201
1207
|
throw new Error("Usage: ouro vault config set [--agent <name>] --key <path> [--value <value>] OR ouro vault config status [--agent <name>]");
|
|
1202
1208
|
}
|
|
1203
1209
|
if (sub === "status") {
|
|
1204
|
-
if (key || value) {
|
|
1210
|
+
if (key || value || valueFile) {
|
|
1205
1211
|
throw new Error("Usage: ouro vault config status [--agent <name>]");
|
|
1206
1212
|
}
|
|
1207
1213
|
return { kind: "vault.config.status", ...(agent ? { agent } : {}), ...(scope ? { scope } : {}) };
|
|
@@ -1209,9 +1215,13 @@ function parseVaultConfigCommand(args) {
|
|
|
1209
1215
|
if (scope === "all")
|
|
1210
1216
|
throw new Error("vault config --scope all is only valid for status");
|
|
1211
1217
|
if (!key) {
|
|
1212
|
-
throw new Error("Usage: ouro vault config set [--agent <name>] --key <path> [--value <value>]");
|
|
1218
|
+
throw new Error("Usage: ouro vault config set [--agent <name>] --key <path> [--value <value> | --value-file <path>]");
|
|
1213
1219
|
}
|
|
1214
|
-
|
|
1220
|
+
if (value !== undefined && valueFile !== undefined)
|
|
1221
|
+
throw new Error("vault config set takes --value or --value-file, not both");
|
|
1222
|
+
if (valueFile !== undefined && !valueFile)
|
|
1223
|
+
throw new Error("vault config set --value-file needs a path (use /dev/stdin to pipe the value)");
|
|
1224
|
+
return { kind: "vault.config.set", ...(agent ? { agent } : {}), key, ...(value !== undefined ? { value } : {}), ...(valueFile !== undefined ? { valueFile } : {}), ...(scope ? { scope } : {}) };
|
|
1215
1225
|
}
|
|
1216
1226
|
function normalizeConnectTarget(value) {
|
|
1217
1227
|
if (!value)
|
|
@@ -81,9 +81,12 @@ function validate(value) {
|
|
|
81
81
|
|| typeof epoch.epochId !== "string" || !/^[A-Za-z0-9_-]{1,128}$/u.test(epoch.epochId)
|
|
82
82
|
|| ![epoch.botId, epoch.ownerUserId, epoch.ownerChatId].every((id) => typeof id === "string" && /^[1-9][0-9]*$/u.test(id)) || epoch.ownerUserId !== epoch.ownerChatId
|
|
83
83
|
|| !Number.isSafeInteger(epoch.predecessorCursor) || epoch.predecessorCursor < 0
|
|
84
|
-
|| ![epoch.packageDigest, epoch.tokenDigest, epoch.
|
|
84
|
+
|| ![epoch.packageDigest, epoch.tokenDigest, epoch.publicKeyDigest].every((value) => typeof value === "string" && DIGEST.test(value))
|
|
85
|
+
|| (epoch.previousTokenDigest === null
|
|
86
|
+
? epoch.revokedTokenStatus !== null || epoch.predecessorCursor !== 0
|
|
87
|
+
: typeof epoch.previousTokenDigest !== "string" || !DIGEST.test(epoch.previousTokenDigest) || epoch.revokedTokenStatus !== 401)
|
|
85
88
|
|| epoch.tokenDigest === epoch.previousTokenDigest || typeof epoch.tokenPath !== "string" || !path.isAbsolute(epoch.tokenPath)
|
|
86
|
-
|| typeof epoch.publicKeyPem !== "string"
|
|
89
|
+
|| typeof epoch.publicKeyPem !== "string"
|
|
87
90
|
|| typeof epoch.createdAt !== "string" || !Number.isFinite(Date.parse(epoch.createdAt)) || new Date(epoch.createdAt).toISOString() !== epoch.createdAt
|
|
88
91
|
|| (epoch.state === "prepared" ? epoch.terminalCursor !== null : !Number.isSafeInteger(epoch.terminalCursor) || epoch.terminalCursor < epoch.predecessorCursor))
|
|
89
92
|
throw new Error("Sanctuary authority epoch is invalid");
|
|
@@ -154,19 +157,26 @@ async function prepareSanctuaryAuthorityEpoch(input, options) {
|
|
|
154
157
|
|| !Number.isSafeInteger(input.predecessorCursor) || input.predecessorCursor < 0 || !DIGEST.test(input.packageDigest))
|
|
155
158
|
throw new Error("Sanctuary authority epoch input is invalid");
|
|
156
159
|
const token = privateBytes(input.tokenPath, options).toString("utf8").trim();
|
|
157
|
-
|
|
158
|
-
|
|
160
|
+
// A fresh install has no predecessor token to prove revoked, and therefore no cursor to carry.
|
|
161
|
+
const previousToken = input.previousTokenPath === null ? null : privateBytes(input.previousTokenPath, options).toString("utf8").trim();
|
|
162
|
+
if (previousToken === null && input.predecessorCursor !== 0)
|
|
163
|
+
throw new Error("Sanctuary authority epoch input is invalid");
|
|
164
|
+
if (!TOKEN.test(token) || token.split(":")[0] !== input.botId
|
|
165
|
+
|| (previousToken !== null && (!TOKEN.test(previousToken) || previousToken.split(":")[0] !== input.botId || token === previousToken)))
|
|
159
166
|
throw new Error("Sanctuary authority requires a fresh same-bot token");
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
167
|
+
if (previousToken !== null) {
|
|
168
|
+
const previous = await options.probe(previousToken);
|
|
169
|
+
if (previous.status !== 401 || previous.botId !== null)
|
|
170
|
+
throw new Error("Sanctuary previous Telegram token is not proven revoked");
|
|
171
|
+
}
|
|
163
172
|
const current = await options.probe(token);
|
|
164
173
|
if (current.status !== 200 || current.botId !== input.botId)
|
|
165
174
|
throw new Error("Sanctuary new Telegram token identity is invalid");
|
|
166
175
|
const epochPath = path.join(input.root, "epoch.json");
|
|
167
176
|
const keyPath = path.join(input.root, "issuer.pem");
|
|
168
177
|
const intentPath = path.join(input.root, "issuer-intent.json");
|
|
169
|
-
const
|
|
178
|
+
const previousTokenDigest = previousToken === null ? null : sha(previousToken);
|
|
179
|
+
const inputDigest = sha(JSON.stringify({ ...input, tokenDigest: sha(token), previousTokenDigest }));
|
|
170
180
|
return (0, session_transaction_1.withImmediateSessionTurnLease)(epochPath, (lease) => {
|
|
171
181
|
const transaction = (0, session_transaction_1.readSessionTransaction)(epochPath, lease);
|
|
172
182
|
let existing = false;
|
|
@@ -186,7 +196,7 @@ async function prepareSanctuaryAuthorityEpoch(input, options) {
|
|
|
186
196
|
if (epoch[key] !== input[key])
|
|
187
197
|
throw new Error("Sanctuary authority epoch input changed");
|
|
188
198
|
}
|
|
189
|
-
if (epoch.previousTokenDigest !==
|
|
199
|
+
if (epoch.previousTokenDigest !== previousTokenDigest)
|
|
190
200
|
throw new Error("Sanctuary authority predecessor token changed");
|
|
191
201
|
if (fs.existsSync(intentPath)) {
|
|
192
202
|
const intent = JSON.parse(privateBytes(intentPath, options).toString("utf8"));
|
|
@@ -218,8 +228,8 @@ async function prepareSanctuaryAuthorityEpoch(input, options) {
|
|
|
218
228
|
}
|
|
219
229
|
const epoch = validate({
|
|
220
230
|
schemaVersion: 1, state: "prepared", epochId: input.epochId, botId: input.botId, ownerUserId: input.ownerUserId, ownerChatId: input.ownerChatId,
|
|
221
|
-
predecessorCursor: input.predecessorCursor, packageDigest: input.packageDigest, tokenPath: input.tokenPath, tokenDigest: sha(token), previousTokenDigest
|
|
222
|
-
...issuer(input.root, options), revokedTokenStatus: 401, createdAt: intent.createdAt, terminalCursor: null,
|
|
231
|
+
predecessorCursor: input.predecessorCursor, packageDigest: input.packageDigest, tokenPath: input.tokenPath, tokenDigest: sha(token), previousTokenDigest,
|
|
232
|
+
...issuer(input.root, options), revokedTokenStatus: previousTokenDigest === null ? null : 401, createdAt: intent.createdAt, terminalCursor: null,
|
|
223
233
|
});
|
|
224
234
|
(0, session_transaction_1.writeSessionTransaction)(epochPath, epoch, { lease, expectedRevision: transaction.revision });
|
|
225
235
|
fs.unlinkSync(intentPath);
|
|
@@ -103,7 +103,10 @@ const EPOCH_RECORDS = ["migration.json", "stage.json", "configured.json"];
|
|
|
103
103
|
const digest = (value) => `sha256:${(0, node_crypto_1.createHash)("sha256").update(value).digest("hex")}`;
|
|
104
104
|
function validateRequest(value) {
|
|
105
105
|
const request = value;
|
|
106
|
-
|
|
106
|
+
const keys = "botId,epochId,nodeDigest,ownerChatId,ownerUserId,packageDigest,prlimitDigest,schemaVersion,setsidDigest,shellDigest";
|
|
107
|
+
if (!request || typeof request !== "object"
|
|
108
|
+
|| Object.keys(request).filter((key) => key !== "predecessorContract").sort().join(",") !== keys
|
|
109
|
+
|| (Object.hasOwn(request, "predecessorContract") && request.predecessorContract !== "none")
|
|
107
110
|
|| request.schemaVersion !== 1 || !/^[A-Za-z0-9_-]{1,128}$/u.test(request.epochId)
|
|
108
111
|
|| ![request.botId, request.ownerUserId, request.ownerChatId].every((id) => typeof id === "string" && /^[1-9][0-9]*$/u.test(id))
|
|
109
112
|
|| request.ownerUserId !== request.ownerChatId
|
|
@@ -128,17 +131,20 @@ class SanctuaryAuthorityRootLifecycle {
|
|
|
128
131
|
this.#socketGid = options.socketGroupId ?? 10001;
|
|
129
132
|
if (process.getuid() !== this.#uid || process.getgid() !== this.#gid)
|
|
130
133
|
throw new Error("Sanctuary root lifecycle requires root");
|
|
131
|
-
if (!DIGEST.test(transaction.targetImageId) || !DIGEST.test(transaction.rollbackImageId) || transaction.targetImageId === transaction.rollbackImageId)
|
|
132
|
-
throw new Error("Sanctuary root lifecycle image identity is invalid");
|
|
133
|
-
this.#transaction = { targetImageId: transaction.targetImageId, rollbackImageId: transaction.rollbackImageId };
|
|
134
134
|
this.#directory(ROOT);
|
|
135
135
|
this.#request = validateRequest(JSON.parse(this.#private(`${ROOT}/request.json`)));
|
|
136
|
+
const rollbackValid = transaction.rollbackImageId === null ? this.#fresh() : DIGEST.test(transaction.rollbackImageId);
|
|
137
|
+
if (!DIGEST.test(transaction.targetImageId) || !rollbackValid || transaction.targetImageId === transaction.rollbackImageId)
|
|
138
|
+
throw new Error("Sanctuary root lifecycle image identity is invalid");
|
|
139
|
+
this.#transaction = { targetImageId: transaction.targetImageId, rollbackImageId: transaction.rollbackImageId };
|
|
136
140
|
(0, runtime_1.emitNervesEvent)({ component: "daemon", event: "daemon.sanctuary_root_lifecycle_loaded", message: "Sanctuary root lifecycle identity loaded", meta: { epochId: this.#request.epochId, targetImageId: transaction.targetImageId } });
|
|
137
141
|
}
|
|
138
142
|
plan() {
|
|
139
143
|
const { epochId, botId, ownerUserId, ownerChatId, packageDigest } = this.#request;
|
|
140
|
-
return { schemaVersion: 1, epochId, botId, ownerUserId, ownerChatId, packageDigest, publicKeyDigest: null, predecessorContract: "canonical-pre-gateway", targetContract: "canonical-gateway" };
|
|
144
|
+
return { schemaVersion: 1, epochId, botId, ownerUserId, ownerChatId, packageDigest, publicKeyDigest: null, predecessorContract: this.#fresh() ? "none" : "canonical-pre-gateway", targetContract: "canonical-gateway" };
|
|
141
145
|
}
|
|
146
|
+
/** Whether this is a fresh install, which hands over nothing from a predecessor. */
|
|
147
|
+
#fresh() { return this.#request.predecessorContract === "none"; }
|
|
142
148
|
async boot() {
|
|
143
149
|
if (!fs.existsSync(this.#p(`${ROOT}/activation.json`)))
|
|
144
150
|
return false;
|
|
@@ -212,9 +218,15 @@ class SanctuaryAuthorityRootLifecycle {
|
|
|
212
218
|
"rollback:reconcile-executions": { read: () => this.#retiredProcess(), apply: () => this.#retireProcess() },
|
|
213
219
|
"rollback:stop-gateway": { read: () => this.#retiredProcess() && this.#pid() === null, apply: () => this.#stopGateway() },
|
|
214
220
|
"rollback:end-epoch": { read: () => this.#epoch().state === "retired", apply: () => this.#endEpoch() },
|
|
215
|
-
"rollback:restore-token-cursor":
|
|
216
|
-
|
|
217
|
-
|
|
221
|
+
"rollback:restore-token-cursor": this.#fresh()
|
|
222
|
+
? { read: () => this.#epoch().state === "retired", apply: () => this.#requireStopped() }
|
|
223
|
+
: { read: () => this.#restoredToken(), apply: () => this.#restoreToken() },
|
|
224
|
+
"rollback:restore-resident": this.#fresh()
|
|
225
|
+
? { read: () => this.#residentAbsent(), apply: () => this.#removeResident() }
|
|
226
|
+
: { read: () => this.#rollback().State.Running, apply: () => this.#restoreResident() },
|
|
227
|
+
"rollback:verify-rollback": this.#fresh()
|
|
228
|
+
? { read: () => fs.existsSync(this.#p(`${this.#epochRoot()}/rollback.json`)) && this.#freshRollbackClean(), apply: () => this.#verifyFreshRollback() }
|
|
229
|
+
: { read: () => this.#rollbackVerified(), apply: () => this.#verifyRollback() },
|
|
218
230
|
};
|
|
219
231
|
const effect = effects[step];
|
|
220
232
|
if (!effect)
|
|
@@ -248,6 +260,13 @@ class SanctuaryAuthorityRootLifecycle {
|
|
|
248
260
|
this.#migration();
|
|
249
261
|
return;
|
|
250
262
|
}
|
|
263
|
+
if (this.#fresh()) {
|
|
264
|
+
// A fresh install starts from nothing: an installed authority here means this is not one.
|
|
265
|
+
if (fs.existsSync(this.#p(`${ROOT}/active.json`)) || fs.existsSync(this.#p(`${ROOT}/activation.json`)))
|
|
266
|
+
throw new Error("Sanctuary fresh install found an installed authority");
|
|
267
|
+
this.#write(`${this.#epochRoot()}/migration.json`, JSON.stringify({ ...this.plan(), ...this.#transaction, predecessorCursor: 0 }));
|
|
268
|
+
return;
|
|
269
|
+
}
|
|
251
270
|
if (fs.existsSync(this.#p(`${ROOT}/active.json`))) {
|
|
252
271
|
const previous = JSON.parse(this.#private(`${ROOT}/active.json`));
|
|
253
272
|
const epoch = (0, sanctuary_authority_epoch_1.readSanctuaryAuthorityEpoch)(previous.epochRoot, { expectedUid: this.#uid, expectedGid: this.#gid });
|
|
@@ -430,7 +449,7 @@ class SanctuaryAuthorityRootLifecycle {
|
|
|
430
449
|
this.#write(`${this.#epochRoot()}/current-token`, token);
|
|
431
450
|
await (0, sanctuary_authority_epoch_1.prepareSanctuaryAuthorityEpoch)({
|
|
432
451
|
root: this.#p(this.#epochRoot()), epochId: this.#request.epochId, tokenPath: this.#p(`${this.#epochRoot()}/current-token`),
|
|
433
|
-
previousTokenPath: this.#p(`${this.#epochRoot()}/previous-token`), botId: this.#request.botId,
|
|
452
|
+
previousTokenPath: this.#fresh() ? null : this.#p(`${this.#epochRoot()}/previous-token`), botId: this.#request.botId,
|
|
434
453
|
ownerUserId: this.#request.ownerUserId, ownerChatId: this.#request.ownerChatId,
|
|
435
454
|
predecessorCursor: this.#migration().predecessorCursor, packageDigest: this.#request.packageDigest,
|
|
436
455
|
}, {
|
|
@@ -781,6 +800,29 @@ class SanctuaryAuthorityRootLifecycle {
|
|
|
781
800
|
this.#docker(["start", "ouro-butler"]);
|
|
782
801
|
await this.#wait(() => this.#rollback().State.Health?.Status === "healthy" && this.#rollback().State.Running);
|
|
783
802
|
}
|
|
803
|
+
// A fresh install rolls back to nothing: its token stays in root custody for a retry,
|
|
804
|
+
// and the resident it created is removed rather than restored.
|
|
805
|
+
#residentAbsent() {
|
|
806
|
+
return !this.#containers().some((container) => container.Name === "/ouro-butler");
|
|
807
|
+
}
|
|
808
|
+
#removeResident() {
|
|
809
|
+
this.#assertNoForeignPoller();
|
|
810
|
+
if (this.#epoch().state !== "retired" || this.#pid() !== null)
|
|
811
|
+
throw new Error("Sanctuary epoch must retire before the fresh resident is removed");
|
|
812
|
+
const resident = this.#containers().find((container) => container.Name === "/ouro-butler");
|
|
813
|
+
if (resident && resident.Image !== this.#transaction.targetImageId)
|
|
814
|
+
throw new Error("Sanctuary resident image changed");
|
|
815
|
+
if (resident)
|
|
816
|
+
this.#docker(["rm", "--force", "ouro-butler"]);
|
|
817
|
+
}
|
|
818
|
+
#freshRollbackClean() {
|
|
819
|
+
return this.#epoch().state === "retired" && this.#pid() === null && this.#residentAbsent();
|
|
820
|
+
}
|
|
821
|
+
#verifyFreshRollback() {
|
|
822
|
+
if (!this.#freshRollbackClean())
|
|
823
|
+
throw new Error("Sanctuary rollback readback failed");
|
|
824
|
+
this.#write(`${this.#epochRoot()}/rollback.json`, JSON.stringify({ epochId: this.#request.epochId, state: "retired" }));
|
|
825
|
+
}
|
|
784
826
|
#rollbackVerified() {
|
|
785
827
|
return fs.existsSync(this.#p(`${this.#epochRoot()}/rollback.json`)) && this.#restoredToken() && this.#pid() === null && this.#rollback().State.Running && this.#rollback().State.Health?.Status === "healthy";
|
|
786
828
|
}
|
|
@@ -889,7 +931,7 @@ class SanctuaryAuthorityRootLifecycle {
|
|
|
889
931
|
|| !journal.completed.every((step) => exports.SANCTUARY_UPGRADE_STEPS.includes(step))
|
|
890
932
|
|| (journal.rollingBack !== undefined && journal.rollingBack !== true)
|
|
891
933
|
|| !sides.every((side) => side && DIGEST.test(side.imageId) && DIGEST.test(side.packageDigest) && IMAGE_REFERENCE.test(side.imageReference))
|
|
892
|
-
|| !DIGEST.test(journal.from.rollbackImageId))
|
|
934
|
+
|| !(journal.from.rollbackImageId === null ? this.#fresh() : DIGEST.test(journal.from.rollbackImageId)))
|
|
893
935
|
throw new Error("Sanctuary upgrade journal is invalid");
|
|
894
936
|
return journal;
|
|
895
937
|
}
|
|
@@ -901,7 +943,7 @@ class SanctuaryAuthorityRootLifecycle {
|
|
|
901
943
|
if (this.#docker(["image", "inspect", "--format", "{{.Id}}", input.imageReference]).trim() !== input.targetImageId)
|
|
902
944
|
throw new Error("Sanctuary upgrade image identity does not match its reference");
|
|
903
945
|
const request = validateRequest(JSON.parse(this.#private(INCOMING_REQUEST)));
|
|
904
|
-
if (["epochId", "botId", "ownerUserId", "ownerChatId"].some((key) => request[key] !== this.#request[key]))
|
|
946
|
+
if (["epochId", "botId", "ownerUserId", "ownerChatId", "predecessorContract"].some((key) => request[key] !== this.#request[key]))
|
|
905
947
|
throw new Error("Sanctuary upgrade would change the authority identity");
|
|
906
948
|
if (request.packageDigest === this.#request.packageDigest)
|
|
907
949
|
throw new Error("Sanctuary upgrade package is already installed");
|
package/npm-shrinkwrap.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ouro.bot/cli",
|
|
3
|
-
"version": "0.1.0-alpha.
|
|
3
|
+
"version": "0.1.0-alpha.971",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "@ouro.bot/cli",
|
|
9
|
-
"version": "0.1.0-alpha.
|
|
9
|
+
"version": "0.1.0-alpha.971",
|
|
10
10
|
"dependencies": {
|
|
11
11
|
"@anthropic-ai/sdk": "^0.78.0",
|
|
12
12
|
"@azure/identity": "^4.13.0",
|