@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 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) || !IMAGE_ID.test(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")
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) || !IMAGE_ID.test(input.rollbackImageId) || !IMAGE_ID.test(input.targetImageId) || input.rollbackImageId === input.targetImageId) throw new Error("reviewed release identity is invalid")
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 || plan.predecessorContract !== "canonical-pre-gateway" || plan.targetContract !== "canonical-gateway") throw new Error("authority handoff plan is invalid")
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
- try {
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 { docker(["inspect", "jellyfin", "--format", "{{.Id}}"], { stdio: ["ignore", "pipe", "ignore"] }); ok("jellyfin inspectable for the unchanged-check") }
225
- catch { bad("jellyfin container not inspectable") }
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
- const creds = JSON.parse(readFileSync(`/mnt/user/appdata/ouro-butler/runtime/container-credentials.json`, "utf8")).credentials[0].runtimeConfig
279
- const request = {
280
- schemaVersion: 1, epochId,
281
- botId: String(creds.telegramBotToken).split(":")[0],
282
- ownerUserId: String(creds.telegramAuthorizedUserId), ownerChatId: String(creds.telegramAuthorizedChatId),
283
- ...packagePins(manifestBytes),
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
- sh("/usr/local/bin/node", [migrate, "--package-root", pkgBundle, "--agent-root", BUNDLE, "--operation", "migrate",
577
- "--rollback-image-id", rollbackImage, "--target-image-id", imageId(version)])
578
- ok("bundle migrated (rollback retained)")
579
- sh("/usr/local/bin/node", [migrate, "--package-root", pkgBundle, "--agent-root", BUNDLE, "--operation", "commit"])
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
- const rollbackImage = docker(["inspect", CONTAINER, "--format", "{{.Image}}"]).trim()
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 = docker(["inspect", "jellyfin", "--format", "{{.Id}}|{{.Image}}|{{.RestartCount}}|{{.State.StartedAt}}"]).trim()
779
- const policyBefore = existsSync(POLICY) ? sha12(POLICY) : fail(`steward policy missing`)
780
- ok(`target ${targetImage.slice(0,19)}… rollback ${rollbackImage.slice(0,19)}… policy ${policyBefore}`)
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
- const jellyfinAfter = docker(["inspect", "jellyfin", "--format", "{{.Id}}|{{.Image}}|{{.RestartCount}}|{{.State.StartedAt}}"]).trim()
809
- jellyfinBefore === jellyfinAfter ? ok("jellyfin unchanged") : fail("JELLYFIN CHANGED")
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 = { schemaVersion: 1, epochId: current.epochId, botId: current.botId, ownerUserId: current.ownerUserId, ownerChatId: current.ownerChatId, ...packagePins(manifestBytes) }
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 = docker(["inspect", "jellyfin", "--format", "{{.Id}}|{{.Image}}|{{.RestartCount}}|{{.State.StartedAt}}"]).trim()
1137
- const policyBefore = existsSync(POLICY) ? sha12(POLICY) : fail("steward policy missing")
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 = docker(["inspect", "jellyfin", "--format", "{{.Id}}|{{.Image}}|{{.RestartCount}}|{{.State.StartedAt}}"]).trim()
1162
- jellyfinNow === jellyfinBefore ? ok("jellyfin unchanged") : (gated && !failure ? (bad("JELLYFIN CHANGED"), preserved.push("jellyfin changed")) : fail("JELLYFIN CHANGED"))
1163
- sha12(POLICY) === policyBefore ? ok("steward policy unchanged") : (gated && !failure ? (bad("STEWARD POLICY CHANGED"), preserved.push("steward policy changed")) : fail("STEWARD POLICY CHANGED"))
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
- const jf = docker(["inspect", "jellyfin", "--format", "{{.State.Status}} restarts={{.RestartCount}}"]).trim()
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": "minimax",
6
- "model": "MiniMax-M3"
5
+ "provider": "anthropic",
6
+ "model": "claude-opus-5-5"
7
7
  },
8
8
  "agentFacing": {
9
- "provider": "minimax",
10
- "model": "MiniMax-M3"
9
+ "provider": "anthropic",
10
+ "model": "claude-sonnet-5-5"
11
11
  },
12
12
  "context": {
13
13
  "maxTokens": 80000,
@@ -1,5 +1,5 @@
1
1
  {
2
- "runtimeVersion": "0.1.0-alpha.970",
2
+ "runtimeVersion": "0.1.0-alpha.971",
3
3
  "bundleSchemaVersion": 3,
4
4
  "lastUpdated": "2026-09-03T00:00:00.000Z"
5
5
  }
@@ -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.970</Repository>
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("runtime config key must be a dotted path such as bluebubbles.password");
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 an API key from a file (or /dev/stdin) so it never appears in argv. */
5652
- function readApiKeyFile(filePath) {
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 --api-key-file ${filePath}: ${error instanceof Error ? error.message : String(error)}`);
5665
+ throw new Error(`could not read ${flag} ${filePath}: ${error instanceof Error ? error.message : String(error)}`);
5660
5666
  }
5661
- const key = raw.trim();
5662
- if (!key)
5663
- throw new Error(`--api-key-file ${filePath} is empty.`);
5664
- return key;
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
- return { kind: "vault.config.set", ...(agent ? { agent } : {}), key, ...(value !== undefined ? { value } : {}), ...(scope ? { scope } : {}) };
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.previousTokenDigest, epoch.publicKeyDigest].every((value) => typeof value === "string" && DIGEST.test(value))
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" || epoch.revokedTokenStatus !== 401
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
- const previousToken = privateBytes(input.previousTokenPath, options).toString("utf8").trim();
158
- if (!TOKEN.test(token) || !TOKEN.test(previousToken) || token.split(":")[0] !== input.botId || previousToken.split(":")[0] !== input.botId || token === previousToken)
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
- const previous = await options.probe(previousToken);
161
- if (previous.status !== 401 || previous.botId !== null)
162
- throw new Error("Sanctuary previous Telegram token is not proven revoked");
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 inputDigest = sha(JSON.stringify({ ...input, tokenDigest: sha(token), previousTokenDigest: sha(previousToken) }));
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 !== sha(previousToken))
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: sha(previousToken),
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
- if (!request || typeof request !== "object" || Object.keys(request).sort().join(",") !== "botId,epochId,nodeDigest,ownerChatId,ownerUserId,packageDigest,prlimitDigest,schemaVersion,setsidDigest,shellDigest"
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": { read: () => this.#restoredToken(), apply: () => this.#restoreToken() },
216
- "rollback:restore-resident": { read: () => this.#rollback().State.Running, apply: () => this.#restoreResident() },
217
- "rollback:verify-rollback": { read: () => this.#rollbackVerified(), apply: () => this.#verifyRollback() },
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");
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@ouro.bot/cli",
3
- "version": "0.1.0-alpha.970",
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.970",
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",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ouro.bot/cli",
3
- "version": "0.1.0-alpha.970",
3
+ "version": "0.1.0-alpha.971",
4
4
  "engines": {
5
5
  "node": ">=22"
6
6
  },