@ouro.bot/cli 0.1.0-alpha.970 → 0.1.0-alpha.972

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/README.md CHANGED
@@ -162,6 +162,17 @@ Expected shape:
162
162
  - `npx ouro.bot@latest` and `ouro` report the same version.
163
163
  - `ouro status` shows the daemon overview plus discovered agents, senses, and workers.
164
164
 
165
+ ### Run Ouro Butler On Unraid
166
+
167
+ Ouro Butler is an agent that looks after an Unraid 7.2+ server with you over Telegram. It is an alpha. Install it from the Unraid web terminal:
168
+
169
+ ```bash
170
+ curl -fsSLo /tmp/ouro-butler-install.sh https://github.com/ourostack/ouroboros/releases/latest/download/install.sh
171
+ bash /tmp/ouro-butler-install.sh
172
+ ```
173
+
174
+ Read [deploy/unraid/INSTALL.md](deploy/unraid/INSTALL.md) first for what you need (an Anthropic API key and a Telegram bot) and the known limits.
175
+
165
176
  ### Work On The Harness
166
177
 
167
178
  From the repo:
package/changelog.json CHANGED
@@ -1,6 +1,18 @@
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.972",
6
+ "changes": [
7
+ "The Butler's rules are rewritten for clarity: it now states its job plainly (the household's media and the server it lives on), does what it is asked with its tools instead of describing the job back, says no in one line without a lecture, files a failure report only when it actually could not finish, and no longer carries duplicated or contradictory rules."
8
+ ]
9
+ },
10
+ {
11
+ "version": "0.1.0-alpha.971",
12
+ "changes": [
13
+ "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."
14
+ ]
15
+ },
4
16
  {
5
17
  "version": "0.1.0-alpha.970",
6
18
  "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.