@ouro.bot/cli 0.1.0-alpha.977 → 0.1.0-alpha.979

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,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.979",
6
+ "changes": [
7
+ "The Butler's psyche now belongs to the install: releases no longer overwrite or lock it, and a fresh install is seeded with the starter psyche once."
8
+ ]
9
+ },
10
+ {
11
+ "version": "0.1.0-alpha.978",
12
+ "changes": [
13
+ "Psyche edits now take effect on the next turn for every agent. Each prompt build compares the content of the psyche and house files with the cached copy and rereads on any change, so an edit by the agent, its owner or a sync pull no longer waits for a restart. Content is compared rather than mtimes, which FUSE shares such as Unraid's do not always update."
14
+ ]
15
+ },
4
16
  {
5
17
  "version": "0.1.0-alpha.977",
6
18
  "changes": [
@@ -1,16 +1,16 @@
1
1
  Ouro Butler operator runbook
2
2
 
3
- The production container is ouro-butler. It runs as UID/GID 10001, publishes no ports, uses host networking only so its loopback-only Unraid GraphQL client can reach 127.0.0.1, mounts the runtime and sanctuary.ouro bundle read-write, the bundle psyche folder read-only over it so the Butler can neither edit nor replace its psyche, plus the privileged event spool read-only, and uses restart policy unless-stopped. The canonical-gateway image appends /run/ouro-authority -> /run/ouro-authority, readonly, rprivate, and the psyche mount (<bundle>/psyche -> /home/ouro/AgentBundles/sanctuary.ouro/psyche, readonly, rprivate) and the operator trust mount (/mnt/user/appdata/ouro-butler/trust/sanctuary -> /etc/ouro/trust/sanctuary, readonly, rprivate), a root-owned directory outside the bundle that holds the grants the Butler honours. The pinned alpha.816 predecessor has only the first three mounts. Root token, issuer, package and authority state are never mounted into the resident.
3
+ The production container is ouro-butler. It runs as UID/GID 10001, publishes no ports, uses host networking only so its loopback-only Unraid GraphQL client can reach 127.0.0.1, mounts the runtime and sanctuary.ouro bundle read-write, plus the privileged event spool read-only, and uses restart policy unless-stopped. The canonical-gateway image appends /run/ouro-authority -> /run/ouro-authority, readonly, rprivate, and the operator trust mount (/mnt/user/appdata/ouro-butler/trust/sanctuary -> /etc/ouro/trust/sanctuary, readonly, rprivate), a root-owned directory outside the bundle that holds the grants the Butler honours. The pinned alpha.816 predecessor has only the first three mounts. Root token, issuer, package and authority state are never mounted into the resident. The psyche folder has no mount of its own: it is part of the writable bundle and belongs to the install. Releases up to alpha.978 also mounted <bundle>/psyche read-only over the bundle, so every audit still accepts that six-mount shape, and rolling an upgrade back to such a release recreates it with that mount.
4
4
 
5
5
  Root authority lifecycle
6
6
 
7
7
  The existing DockerMan transaction owns authority-install, authority-activate, authority-retire and authority-restore. A complete exact-image package, including dist and production dependencies, must remain at its package-relative paths; extracting only the old five deployment assets cannot execute this lifecycle. The reviewed root-only inputs are /mnt/user/appdata/ouro-authority/incoming-package, package-manifest.json, request.json and incoming-token. They are not a new resident credential policy. The human rotates the existing bot token directly into root custody; the root owner proves the previous token returns 401 and the fresh token belongs to the same numeric bot before continuing.
8
8
 
9
- First rollout of the psyche mount: the live container before this release has four mounts, but the canonical-gateway contract now requires six (the psyche folder read-only over the bundle and the operator trust directory). assert_update_source audits the live source container against its image's contract, so on that first update it fails with "effective mounts do not match the selected Sanctuary mount contract". The failure is expected and changes nothing. Recreate the live resident once by hand with the six-mount create (the canonical-gateway line in create_sanctuary_container, including the psyche and trust mounts), leave it stopped, then rerun the update. Psyche files stay owned by uid 10001 at mode 0600 because the resident's boot check requires exactly that; the read-only mount, not ownership, keeps the resident from editing or replacing them.
9
+ Psyche mount retired: alpha.978 and earlier mounted the bundle psyche folder read-only over the bundle (six mounts). The upgrade audits still accept that shape, so the running alpha.978 resident passes them; the upgrade then recreates the resident with five mounts (no separate psyche mount) and removes the psyche Config (Target /home/ouro/AgentBundles/sanctuary.ouro/psyche) from the installed DockerMan template /boot/config/plugins/dockerMan/templates-user/my-ouro-butler.xml, so a DockerMan Apply cannot add the read-only mount back. Rolling the upgrade back recreates the predecessor with the read-only psyche mount it expects. Psyche files stay owned by uid 10001 at mode 0600 because alpha.978's boot check requires exactly that after a rollback.
10
10
 
11
- Operator trust directory. Delegated-command and escalation grants live on the host in /mnt/user/appdata/ouro-butler/trust/sanctuary, owned by root:root, mode 0755, with every file in it root-owned and mode 0644, and the directory is outside the bundle so the upgrade's recursive chown cannot reach it. The container sees it read-only at /etc/ouro/trust/sanctuary; the Butler honours a grant only when the file and every directory above it up to / are real, root-owned and closed to group and other writes. Create it once as root before the first create: `install -d -o root -g root -m 0755 /mnt/user/appdata/ouro-butler /mnt/user/appdata/ouro-butler/trust /mnt/user/appdata/ouro-butler/trust/sanctuary` (this also makes /mnt/user/appdata/ouro-butler itself root-owned 0755, which the replay gate needs for its host folder below; the agent and runtime folders inside it keep their own owners). Manage grants on the host as root with a one-off container that mounts the directory read-write (the resident's mount is read-only): `docker run --rm --user 0:0 --entrypoint node --pull=never --network none -v /mnt/user/appdata/ouro-butler/trust/sanctuary:/etc/ouro/trust/sanctuary -v /mnt/user/appdata/ouro-butler/agent/sanctuary.ouro:/home/ouro/AgentBundles/sanctuary.ouro:ro <butler image> /opt/ouro/dist/heart/daemon/ouro-entry.js a2a delegated-commands grant --agent sanctuary --friend <friend id> --did <did:key> [--expires <ISO date>] [--source <text>]`, and likewise `a2a delegated-commands revoke|list` and `a2a escalation grant --friend <id> --did <did:key>|revoke|list`. Setting OURO_OPERATOR_TRUST_DIR replaces the trust root (/etc/ouro/trust) and keeps the per-agent folder, so the files sit in <dir>/<agent>/. Grant commands take an exclusive .lock file in the directory (the lock records the writer's pid and host; one older than two minutes is taken over only when that pid is no longer running on the same host, and one written on another host only after an hour; remove a stuck one by hand) and flush the directory after each rename. A one-off container cannot read the agent's own DID, so grant then compares --did with every friend record and refuses a DID a second record also carries, and prints a note saying so; revoke works by friend id even when the friend record is gone. Revoke never writes the friend record (it runs as root); when the record still carries a legacy delegationGrant it prints a REQUIRED FOLLOW-UP docker exec -u 10001 line that clears the legacy delegationGrant field as the resident user (friend records are never written as root). The replay gate grants its replay principal and escalation peer with an expiry equal to the replay window and removes them when it closes; the replay gate keeps a root-owned list of its replay peers in the trust directory (replay-identities.json), the Butler refuses any grant without an expiry for a friend on that list (the grant's source text and the bundle's own replay registry decide nothing), and the grant commands refuse a no-expiry grant, and any escalation grant, for a listed friend. Replay seeds and the provisioning record live outside the bundle, in the root-owned host folder /mnt/user/appdata/ouro-butler/replay-client (the gate's --client-dir overrides it), created by provision as root 0700 under a parent that is root-owned and closed to group and other writes (by default only that parent, /mnt/user/appdata/ouro-butler, is checked, because Unraid's shared /mnt/user/appdata is usually nobody:users 0777); the gate refuses a folder, a parent or a seed that root did not make, and never takes over one the Butler's user could have written. Only root-owned state may supply replay friend ids and DIDs: that folder and the trust directory's replay-identities.json. Nothing in the bundle's state/replay-client or state/replay does, and a provision.json found in the bundle is ignored (its friend ids are only printed, as unverified). A run copies the seeds into a root-only directory it makes inside the container with mktemp -d, drives the CLI as root from there, and deletes it when the window closes. An existing state/replay in the bundle that root does not own is moved aside as replay.untrusted-<time> and made fresh. One-time step on the upgrade that moves the replay seeds out of the bundle: run `node sanctuary-replay-gate.mjs provision --rotate-replay-identities` as root. Earlier replay seeds were written by the Butler's user and must be treated as known to it, so this mints three new seeds (root-owned 0600, in the host folder above), moves any old seed in that folder aside as *.retired-<time>, removes the retired peers' trusted grants, marks their friend records revoked, demotes them to stranger and drops their DID pin and A2A identity (the last two as the resident user, never as root), onboards the new peers and rewrites state/replay/identities.json from the root-owned list; the old friend ids stay in that registry so they stay blocked from owner-policy writes. The peers it retires are the ones the root-owned record and replay list name. The earlier bundle copy of provision.json is not trusted, so the first rotation prints the friend ids it names as unverified; for each one you have checked is a replay peer, add `--retire-friend <id>` (repeatable). Always pass the DID the holder really signs with (`ouro a2a identity --json` on the holder's side); the Butler refuses a message signed by any other DID. First rollout of the trust mount: the live container has five mounts, so the first update fails the six-mount audit exactly as the psyche rollout did; recreate the resident once by hand with the six-mount create and rerun the update.
11
+ Operator trust directory. Delegated-command and escalation grants live on the host in /mnt/user/appdata/ouro-butler/trust/sanctuary, owned by root:root, mode 0755, with every file in it root-owned and mode 0644, and the directory is outside the bundle so the upgrade's recursive chown cannot reach it. The container sees it read-only at /etc/ouro/trust/sanctuary; the Butler honours a grant only when the file and every directory above it up to / are real, root-owned and closed to group and other writes. Create it once as root before the first create: `install -d -o root -g root -m 0755 /mnt/user/appdata/ouro-butler /mnt/user/appdata/ouro-butler/trust /mnt/user/appdata/ouro-butler/trust/sanctuary` (this also makes /mnt/user/appdata/ouro-butler itself root-owned 0755, which the replay gate needs for its host folder below; the agent and runtime folders inside it keep their own owners). Manage grants on the host as root with a one-off container that mounts the directory read-write (the resident's mount is read-only): `docker run --rm --user 0:0 --entrypoint node --pull=never --network none -v /mnt/user/appdata/ouro-butler/trust/sanctuary:/etc/ouro/trust/sanctuary -v /mnt/user/appdata/ouro-butler/agent/sanctuary.ouro:/home/ouro/AgentBundles/sanctuary.ouro:ro <butler image> /opt/ouro/dist/heart/daemon/ouro-entry.js a2a delegated-commands grant --agent sanctuary --friend <friend id> --did <did:key> [--expires <ISO date>] [--source <text>]`, and likewise `a2a delegated-commands revoke|list` and `a2a escalation grant --friend <id> --did <did:key>|revoke|list`. Setting OURO_OPERATOR_TRUST_DIR replaces the trust root (/etc/ouro/trust) and keeps the per-agent folder, so the files sit in <dir>/<agent>/. Grant commands take an exclusive .lock file in the directory (the lock records the writer's pid and host; one older than two minutes is taken over only when that pid is no longer running on the same host, and one written on another host only after an hour; remove a stuck one by hand) and flush the directory after each rename. A one-off container cannot read the agent's own DID, so grant then compares --did with every friend record and refuses a DID a second record also carries, and prints a note saying so; revoke works by friend id even when the friend record is gone. Revoke never writes the friend record (it runs as root); when the record still carries a legacy delegationGrant it prints a REQUIRED FOLLOW-UP docker exec -u 10001 line that clears the legacy delegationGrant field as the resident user (friend records are never written as root). The replay gate grants its replay principal and escalation peer with an expiry equal to the replay window and removes them when it closes; the replay gate keeps a root-owned list of its replay peers in the trust directory (replay-identities.json), the Butler refuses any grant without an expiry for a friend on that list (the grant's source text and the bundle's own replay registry decide nothing), and the grant commands refuse a no-expiry grant, and any escalation grant, for a listed friend. Replay seeds and the provisioning record live outside the bundle, in the root-owned host folder /mnt/user/appdata/ouro-butler/replay-client (the gate's --client-dir overrides it), created by provision as root 0700 under a parent that is root-owned and closed to group and other writes (by default only that parent, /mnt/user/appdata/ouro-butler, is checked, because Unraid's shared /mnt/user/appdata is usually nobody:users 0777); the gate refuses a folder, a parent or a seed that root did not make, and never takes over one the Butler's user could have written. Only root-owned state may supply replay friend ids and DIDs: that folder and the trust directory's replay-identities.json. Nothing in the bundle's state/replay-client or state/replay does, and a provision.json found in the bundle is ignored (its friend ids are only printed, as unverified). A run copies the seeds into a root-only directory it makes inside the container with mktemp -d, drives the CLI as root from there, and deletes it when the window closes. An existing state/replay in the bundle that root does not own is moved aside as replay.untrusted-<time> and made fresh. One-time step on the upgrade that moves the replay seeds out of the bundle: run `node sanctuary-replay-gate.mjs provision --rotate-replay-identities` as root. Earlier replay seeds were written by the Butler's user and must be treated as known to it, so this mints three new seeds (root-owned 0600, in the host folder above), moves any old seed in that folder aside as *.retired-<time>, removes the retired peers' trusted grants, marks their friend records revoked, demotes them to stranger and drops their DID pin and A2A identity (the last two as the resident user, never as root), onboards the new peers and rewrites state/replay/identities.json from the root-owned list; the old friend ids stay in that registry so they stay blocked from owner-policy writes. The peers it retires are the ones the root-owned record and replay list name. The earlier bundle copy of provision.json is not trusted, so the first rotation prints the friend ids it names as unverified; for each one you have checked is a replay peer, add `--retire-friend <id>` (repeatable). Always pass the DID the holder really signs with (`ouro a2a identity --json` on the holder's side); the Butler refuses a message signed by any other DID. First rollout of the trust mount (alpha.887): the live container then had no trust mount, so the first update failed the mount audit exactly as the psyche rollout did; the fix was to recreate the resident once by hand with the runbook's canonical-gateway create and rerun the update. If a resident without the trust mount is ever found again, recreate the resident once by hand with the five-mount create and rerun the update.
12
12
 
13
- During installation the resident is stopped, the root gateway is installed and proven, tokenless readiness runs, and authority-activate starts the exact six-mount target. On failure, authority-retire must finish before restoring bundle/config/mounts or starting the exact three-mount predecessor; authority-restore proves that rollback before the outer template journal is removed. An uncertain or interrupted handoff leaves autostart disabled and preserves both journals and root inputs for the same reviewed recovery, never a raw old-template start. Retired keys, policy, history and Jellyfin remain untouched. Re-enabling authority requires a new human token rotation and issuer epoch, not reuse of retired authority.
13
+ During installation the resident is stopped, the root gateway is installed and proven, tokenless readiness runs, and authority-activate starts the exact five-mount target. On failure, authority-retire must finish before restoring bundle/config/mounts or starting the exact three-mount predecessor; authority-restore proves that rollback before the outer template journal is removed. An uncertain or interrupted handoff leaves autostart disabled and preserves both journals and root inputs for the same reviewed recovery, never a raw old-template start. Retired keys, policy, history and Jellyfin remain untouched. Re-enabling authority requires a new human token rotation and issuer epoch, not reuse of retired authority.
14
14
 
15
15
  On installed cold boot, the original unless-stopped and DockerMan autostart remain unchanged: /boot/config/custom/ouro-authority/start.sh --boot waits for the array and responsive Docker, then starts the fenced root gateway. Docker may already have started the tokenless resident; it fails closed until the gateway is ready. There is no pre-Docker gate, whole-array gate, resident direct poller or resident token fallback.
16
16
 
@@ -330,7 +330,7 @@ Effective-spec audit helper:
330
330
  test "$(sanctuary_image_mount_contract "$CREATE_IMAGE_ID")" = "$CREATE_MOUNT_CONTRACT" || return 1
331
331
  test "$(docker image inspect --format '{{.Id}}' "$CREATE_IMAGE_REFERENCE")" = "$CREATE_IMAGE_ID" || return 1
332
332
  case "$CREATE_MOUNT_CONTRACT" in
333
- canonical-gateway) set -- --mount "type=bind,src=/run/ouro-authority,dst=/run/ouro-authority,readonly" --mount "type=bind,src=/mnt/user/appdata/ouro-butler/agent/sanctuary.ouro/psyche,dst=/home/ouro/AgentBundles/sanctuary.ouro/psyche,readonly" --mount "type=bind,src=/mnt/user/appdata/ouro-butler/trust/sanctuary,dst=/etc/ouro/trust/sanctuary,readonly" ;;
333
+ canonical-gateway) set -- --mount "type=bind,src=/run/ouro-authority,dst=/run/ouro-authority,readonly" --mount "type=bind,src=/mnt/user/appdata/ouro-butler/trust/sanctuary,dst=/etc/ouro/trust/sanctuary,readonly" ;;
334
334
  canonical-pre-gateway) set -- ;;
335
335
  *) return 1 ;;
336
336
  esac
@@ -1673,7 +1673,7 @@ NODE
1673
1673
  TELEGRAM_READINESS_CONTRACT=$(sanctuary_image_mount_contract "$TELEGRAM_READINESS_IMAGE_ID") || return $?
1674
1674
  TELEGRAM_READINESS_CONTEXT=${2-}
1675
1675
  case "$TELEGRAM_READINESS_CONTRACT" in
1676
- canonical-gateway) TELEGRAM_READINESS_CONTEXT=${TELEGRAM_READINESS_CONTEXT:-strict}; set -- --mount "type=bind,src=/run/ouro-authority,dst=/run/ouro-authority,readonly" --mount "type=bind,src=/mnt/user/appdata/ouro-butler/agent/sanctuary.ouro/psyche,dst=/home/ouro/AgentBundles/sanctuary.ouro/psyche,readonly" --mount "type=bind,src=/mnt/user/appdata/ouro-butler/trust/sanctuary,dst=/etc/ouro/trust/sanctuary,readonly" ;;
1676
+ canonical-gateway) TELEGRAM_READINESS_CONTEXT=${TELEGRAM_READINESS_CONTEXT:-strict}; set -- --mount "type=bind,src=/run/ouro-authority,dst=/run/ouro-authority,readonly" --mount "type=bind,src=/mnt/user/appdata/ouro-butler/trust/sanctuary,dst=/etc/ouro/trust/sanctuary,readonly" ;;
1677
1677
  canonical-pre-gateway) TELEGRAM_READINESS_CONTEXT=${TELEGRAM_READINESS_CONTEXT:-live-precutover}; set -- ;;
1678
1678
  *) return 1 ;;
1679
1679
  esac
@@ -2236,7 +2236,7 @@ ouro-butler-rollback
2236
2236
  esac
2237
2237
  }
2238
2238
  Stop production, remove only a stopped stale rollback, rename the known-good container, verify it remains stopped, and apply the exact target image's package-managed bundle migration in one explicit preparation guard.
2239
- Package-managed files are exactly `provider-readiness.json`, `tool-profiles.json`, `habits/sanctuary-health.md`, `awaiting/house-care-sweep.md` (the Butler's daily house-care sweep, a never-resolving await), and the five canonical files under `psyche/`. The migration also merges the three bundle-meta version fields. It never installs or mutates steward policy authority. It preserves agent.json, all steward policy and audit bytes, relationships, sessions, and every other state path. Repeating it is a no-op.
2239
+ Package-managed files are exactly `provider-readiness.json`, `tool-profiles.json`, `habits/sanctuary-health.md` and `awaiting/house-care-sweep.md` (the Butler's daily house-care sweep, a never-resolving await). The `psyche/` folder belongs to the install: the migration seeds the five starter files (mode 0600) only when the bundle has no `psyche/` folder, and never touches an existing one. The migration also merges the three bundle-meta version fields. It never installs or mutates steward policy authority. It preserves agent.json, all steward policy and audit bytes, relationships, sessions, and every other state path. Repeating it is a no-op.
2240
2240
  Before its first managed write, the migrator atomically fsyncs one mode-0600 rollback record at `.sanctuary-package-managed-rollback.json`. That record binds the verified prior bytes, modes, and parent existence for every managed file plus bundle-meta to distinct exact rollback and target image IDs. It is the migration receipt, not a second bundle authority.
2241
2241
  A managed-file failure restores the prior bytes immediately. Every later container rollback also restores those bytes before the old container restarts, while retaining the receipt; only after the old production container is audited, ready, and back on autostart does `finalize-rollback` remove it without marking the new release committed.
2242
2242
  After an interrupted update, the same receipt removes partial new containers, discards only validated interrupted-write stages, restores the prior bundle idempotently, and permits only the recorded rollback image to resume as production. A malformed receipt, wrong image, or restore failure leaves autostart disabled and stops safely.
@@ -50,6 +50,7 @@ import { execFileSync, spawn, spawnSync } from "node:child_process"
50
50
  import { createHash } from "node:crypto"
51
51
  import { mkdtempSync, rmSync, writeFileSync, readFileSync, existsSync, statSync, lstatSync, chmodSync, realpathSync, readdirSync } from "node:fs"
52
52
  import { fileURLToPath } from "node:url"
53
+ import { createRequire } from "node:module"
53
54
  import { tmpdir } from "node:os"
54
55
  import { join } from "node:path"
55
56
 
@@ -58,6 +59,7 @@ const BUNDLE = "/mnt/user/appdata/ouro-butler/agent/sanctuary.ouro"
58
59
  const RUNTIME = "/mnt/user/appdata/ouro-butler/runtime/.ouro-cli"
59
60
  const CONTAINER = "ouro-butler"
60
61
  const TEMPLATE = "/boot/config/plugins/dockerMan/templates-user/my-ouro-butler.xml"
62
+ const PSYCHE_TARGET = "/home/ouro/AgentBundles/sanctuary.ouro/psyche"
61
63
  const JOURNAL = "/boot/config/custom/ouro-butler/docker-man-template-transaction.json"
62
64
  const POLICY = `${BUNDLE}/state/policy/steward.json`
63
65
  const PRIMITIVES = ["/usr/local/bin/node", "/bin/sh", "/usr/bin/prlimit", "/usr/bin/setsid"]
@@ -471,11 +473,6 @@ function enableResidentAutostart() {
471
473
  // verify-install and the final commit proof are all satisfied. The container is created
472
474
  // stopped; start-resident starts it. #target asserts: target image, user 10001:10001, no
473
475
  // token env, exactly one read-only /run/ouro-authority mount, no docker.sock/${ROOT} mounts.
474
- // The psyche folder is mounted read-only over the writable bundle mount, so its files cannot be edited and the folder cannot be
475
- // renamed, removed or swapped from inside the container. The files stay owned by the resident at mode 0600 because the
476
- // resident's boot check requires exactly that. On a normal boot the files already match the package, so the check writes
477
- // nothing; if they ever drift, its repair write fails with EROFS and startup fails loudly rather than silently rewriting psyche.
478
- export const PSYCHE_MOUNT = `${BUNDLE}/psyche:/home/ouro/AgentBundles/sanctuary.ouro/psyche:ro`
479
476
  // Operator-set grants (delegated commands, escalation) live outside the bundle, in a root-owned host directory mounted
480
477
  // read-only at /etc/ouro/trust/sanctuary. The bundle's recursive chown cannot reach it, and the resident cannot write it.
481
478
  export const TRUST_HOST_DIR = "/mnt/user/appdata/ouro-butler/trust/sanctuary"
@@ -546,7 +543,6 @@ function recreateResident(version) {
546
543
  "-v", "/mnt/user/appdata/ouro-butler/agent/sanctuary.ouro:/home/ouro/AgentBundles/sanctuary.ouro:rw",
547
544
  "-v", "/boot/config/custom/ouro-events/spool:/run/ouro-events:ro",
548
545
  "-v", "/run/ouro-authority:/run/ouro-authority:ro",
549
- "-v", PSYCHE_MOUNT,
550
546
  "-v", TRUST_MOUNT,
551
547
  image(version)])
552
548
  const ref = docker(["inspect", CONTAINER, "--format", "{{.Config.Image}}"]).trim()
@@ -583,10 +579,9 @@ export function restoreReplayRoot(bundle = BUNDLE, { lstat = lstatSync, realpath
583
579
  chmod(dir, 0o755)
584
580
  rm(`${dir}/window.json`, { force: true, recursive: true })
585
581
  }
586
- // The psyche files are package-managed: the resident's own boot check (prepareSanctuaryPackageManagedBundle) requires every one
587
- // to be exactly mode 0600, so they must stay owned by the resident (uid 10001) at 0600. What stops the resident editing or
588
- // replacing them is the read-only bind mount of psyche/ (PSYCHE_MOUNT), not file ownership. The recursive chown above already
589
- // handed them to 10001; this puts the modes back (directories 0755, files 0600) and is the state psycheProblems checks.
582
+ // The psyche files stay owned by the resident (uid 10001) at exactly mode 0600: alpha.978's boot check
583
+ // (prepareSanctuaryPackageManagedBundle) requires that, and a rollback may start that release again. The recursive chown above
584
+ // already handed them to 10001; this puts the modes back (directories 0755, files 0600) and is the state psycheProblems checks.
590
585
  export function restorePsycheModes(dir, { lstat = lstatSync, readdir = readdirSync, chmod = chmodSync } = {}) {
591
586
  const walk = (current) => {
592
587
  const stat = lstat(current)
@@ -618,11 +613,13 @@ export function psycheProblems(dir, { lstat = lstatSync, readdir = readdirSync }
618
613
  walk(dir)
619
614
  return problems
620
615
  }
621
- /** What is wrong with the resident's psyche mount, from `docker inspect` Mounts, or null when it is mounted read-only. */
616
+ /**
617
+ * What is wrong with the resident's psyche mount, from `docker inspect` Mounts, or null for a supported shape: no separate psyche
618
+ * mount (current releases), or a read-only one (alpha.978 and earlier, which a rollback recreates).
619
+ */
622
620
  export function psycheMountIssue(mounts) {
623
- const mount = (Array.isArray(mounts) ? mounts : []).find((entry) => entry?.Destination === "/home/ouro/AgentBundles/sanctuary.ouro/psyche")
624
- if (!mount) return "psyche is not mounted separately in the resident, so it could be renamed or replaced"
625
- return mount.RW === false ? null : "psyche is mounted read-write in the resident"
621
+ const mount = (Array.isArray(mounts) ? mounts : []).find((entry) => entry?.Destination === PSYCHE_TARGET)
622
+ return !mount || mount.RW === false ? null : "psyche is mounted read-write as a separate mount in the resident"
626
623
  }
627
624
 
628
625
  function migrateBundle(version, rollbackImage) {
@@ -1076,12 +1073,33 @@ function resumeSupervision() {
1076
1073
  installGatewaySupervisor()
1077
1074
  }
1078
1075
 
1079
- // Keep Unraid's DockerMan template (what the UI's Apply/Update uses) on the live image.
1076
+ /**
1077
+ * The template text without its psyche `<Config>` (Target exactly PSYCHE_TARGET), so a DockerMan Apply cannot put the old read-only
1078
+ * psyche mount back. The element is removed with the whitespace that starts its line and the newline that ends it; every other byte
1079
+ * is kept. `parse` is docker-man-template-xml.cjs's parseDockerManTemplateXml: a template it cannot read, or one without exactly one
1080
+ * such Config, comes back unchanged, as does one where the textual match does not remove exactly that element.
1081
+ */
1082
+ export function withoutPsycheConfig(text, parse) {
1083
+ const psycheConfigs = (document) => document?.children.filter((child) => child.name === "Config" && child.attributes.Target === PSYCHE_TARGET).length
1084
+ const before = parse(text)
1085
+ if (psycheConfigs(before) !== 1) return text
1086
+ const next = text.replace(new RegExp(`[ \\t]*<Config\\b[^<>]*\\sTarget="${PSYCHE_TARGET.replace(/[.]/gu, "\\.")}"[^<>]*(?:/>|>[^<]*</Config>)[ \\t]*\\r?\\n?`, "u"), "")
1087
+ const after = parse(next)
1088
+ return after && psycheConfigs(after) === 0 && after.children.length === before.children.length - 1 ? next : text
1089
+ }
1090
+
1091
+ // Keep Unraid's DockerMan template (what the UI's Apply/Update uses) on the live image, without the old psyche mount.
1092
+ // Runs after the commit, so the parser comes from the installed package (this script is staged on its own, without siblings).
1080
1093
  function pinTemplate(version) {
1081
1094
  if (!existsSync(TEMPLATE)) { console.log(" note: no DockerMan template to update"); return }
1082
1095
  const text = readFileSync(TEMPLATE, "utf8")
1083
- const next = text.replace(/<Repository>ghcr\.io\/ourostack\/ouroboros-butler:[^<]+<\/Repository>/u, `<Repository>${image(version)}</Repository>`)
1096
+ let next = text.replace(/<Repository>ghcr\.io\/ourostack\/ouroboros-butler:[^<]+<\/Repository>/u, `<Repository>${image(version)}</Repository>`)
1084
1097
  if (!next.includes(`<Repository>${image(version)}</Repository>`)) { console.log(" WARN: DockerMan template Repository not recognised; left unchanged"); return }
1098
+ try {
1099
+ const { parseDockerManTemplateXml } = createRequire(import.meta.url)(`${ROOT}/package/deploy/unraid/docker-man-template-xml.cjs`)
1100
+ next = withoutPsycheConfig(next, parseDockerManTemplateXml)
1101
+ } catch (error) { console.log(` WARN: could not load the DockerMan template parser (${error.message}); psyche Config left in place`) }
1102
+ if (next.includes(`Target="${PSYCHE_TARGET}"`)) console.log(" WARN: DockerMan template still has a psyche Config; remove it before the next DockerMan Apply")
1085
1103
  if (next !== text) { writeFileSync(`${TEMPLATE}.prev`, text); writeFileSync(TEMPLATE, next) }
1086
1104
  ok(`DockerMan template pins ${image(version)}`)
1087
1105
  }
@@ -1293,7 +1311,10 @@ function verify(withGate = false) {
1293
1311
  auth === 0 ? ok("no Telegram auth failures in the last 2m") : bad(`${auth} 401s in the last 2m`)
1294
1312
  existsSync(POLICY) ? ok(`steward policy ${sha12(POLICY)}`) : installedRequest()?.predecessorContract === "none" ? ok("no steward policy yet") : bad("steward policy missing")
1295
1313
  const psycheMount = JSON.parse(docker(["inspect", CONTAINER, "--format", "{{json .Mounts}}"]) || "[]")
1296
- psycheMountIssue(psycheMount) === null ? ok("psyche is mounted read-only in the resident") : bad(psycheMountIssue(psycheMount))
1314
+ psycheMountIssue(psycheMount) === null ? ok("psyche mount is in a supported shape") : bad(psycheMountIssue(psycheMount))
1315
+ existsSync(TEMPLATE) && readFileSync(TEMPLATE, "utf8").includes(`Target="${PSYCHE_TARGET}"`)
1316
+ ? bad("installed DockerMan template still mounts the psyche read-only; a DockerMan Apply would restore it")
1317
+ : ok("installed DockerMan template has no separate psyche mount")
1297
1318
  const trustMount = JSON.parse(docker(["inspect", CONTAINER, "--format", "{{json .Mounts}}"]) || "[]")
1298
1319
  trustMountIssue(trustMount) === null ? ok("the operator trust directory is mounted read-only in the resident") : bad(trustMountIssue(trustMount))
1299
1320
  const trustIssue = existsSync(TRUST_HOST_DIR) ? trustDirIssue(lstatSync(TRUST_HOST_DIR)) : `${TRUST_HOST_DIR} is missing`
@@ -1303,7 +1324,7 @@ function verify(withGate = false) {
1303
1324
  const psycheDir = `${BUNDLE}/psyche`
1304
1325
  if (existsSync(psycheDir)) {
1305
1326
  const psycheIssues = psycheProblems(psycheDir)
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("; ")}`)
1327
+ psycheIssues.length === 0 ? ok("psyche files are resident-owned 0600") : bad(`psyche files do not match what the resident boot check requires: ${psycheIssues.slice(0, 3).join("; ")}`)
1307
1328
  } else bad("psyche folder missing from the bundle")
1308
1329
  ok(containerExists("jellyfin") ? `jellyfin ${docker(["inspect", "jellyfin", "--format", "{{.State.Status}} restarts={{.RestartCount}}"]).trim()}` : "no jellyfin container")
1309
1330
  if (withGate) {
@@ -505,10 +505,12 @@ async function containerSnapshot(expectedImage) {
505
505
  { destination: "/home/ouro/AgentBundles/sanctuary.ouro", source: PRODUCTION_BUNDLE_SOURCE, propagation: "rprivate", rw: true, type: "bind" },
506
506
  { destination: "/run/ouro-events", source: PRODUCTION_EVENT_SPOOL_SOURCE, propagation: "rprivate", rw: false, type: "bind" },
507
507
  { destination: "/run/ouro-authority", source: "/run/ouro-authority", propagation: "rprivate", rw: false, type: "bind" },
508
- { destination: "/home/ouro/AgentBundles/sanctuary.ouro/psyche", source: `${PRODUCTION_BUNDLE_SOURCE}/psyche`, propagation: "rprivate", rw: false, type: "bind" },
509
508
  { destination: "/etc/ouro/trust/sanctuary", source: "/mnt/user/appdata/ouro-butler/trust/sanctuary", propagation: "rprivate", rw: false, type: "bind" },
510
509
  ]
511
- const mountsExact = mounts.length === expectedMounts.length && expectedMounts.every((expected) => mounts.some((mount) => mount.destination === expected.destination && mount.source === expected.source && mount.propagation === expected.propagation && mount.rw === expected.rw && mount.type === expected.type))
510
+ // alpha.978 and earlier (and a rollback to one) also mount the psyche folder read-only over the bundle; accept either set.
511
+ const psycheMount = { destination: "/home/ouro/AgentBundles/sanctuary.ouro/psyche", source: `${PRODUCTION_BUNDLE_SOURCE}/psyche`, propagation: "rprivate", rw: false, type: "bind" }
512
+ const mountsMatch = (expectedSet) => mounts.length === expectedSet.length && expectedSet.every((expected) => mounts.some((mount) => mount.destination === expected.destination && mount.source === expected.source && mount.propagation === expected.propagation && mount.rw === expected.rw && mount.type === expected.type))
513
+ const mountsExact = mountsMatch(expectedMounts) || mountsMatch([...expectedMounts, psycheMount])
512
514
  const securityExact = value.privileged === false && (value.capAdd === null || (Array.isArray(value.capAdd) && value.capAdd.length === 0))
513
515
  && (value.capDrop === null || (Array.isArray(value.capDrop) && value.capDrop.length === 0))
514
516
  && (value.securityOpt === null || (Array.isArray(value.securityOpt) && value.securityOpt.length === 0))
@@ -1,5 +1,5 @@
1
1
  {
2
- "runtimeVersion": "0.1.0-alpha.977",
2
+ "runtimeVersion": "0.1.0-alpha.979",
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.977</Repository>
4
+ <Repository>ghcr.io/ourostack/ouroboros-butler:0.1.0-alpha.979</Repository>
5
5
  <Registry>https://github.com/ourostack/ouroboros/pkgs/container/ouroboros-butler</Registry>
6
6
  <Network>host</Network>
7
7
  <Shell>sh</Shell>
@@ -25,6 +25,5 @@
25
25
  <Config Name="Agent bundle" Target="/home/ouro/AgentBundles/sanctuary.ouro" Default="/mnt/user/appdata/ouro-butler/agent/sanctuary.ouro" Mode="rw" Description="Sanctuary bundle" Type="Path" Display="always" Required="true" Mask="false">/mnt/user/appdata/ouro-butler/agent/sanctuary.ouro</Config>
26
26
  <Config Name="Privileged event spool" Target="/run/ouro-events" Default="/boot/config/custom/ouro-events/spool" Mode="ro" Description="Root-owned detector envelopes, read-only inside the Butler" Type="Path" Display="always" Required="true" Mask="false">/boot/config/custom/ouro-events/spool</Config>
27
27
  <Config Name="Root authority socket" Target="/run/ouro-authority" Default="/run/ouro-authority" Mode="ro" Description="Root-owned gateway socket and public issuer pins only" Type="Path" Display="always" Required="true" Mask="false">/run/ouro-authority</Config>
28
- <Config Name="Psyche (read-only)" Target="/home/ouro/AgentBundles/sanctuary.ouro/psyche" Default="/mnt/user/appdata/ouro-butler/agent/sanctuary.ouro/psyche" Mode="ro" Description="The Butler's psyche files, read-only inside the Butler so they can be neither edited nor replaced" Type="Path" Display="always" Required="true" Mask="false">/mnt/user/appdata/ouro-butler/agent/sanctuary.ouro/psyche</Config>
29
28
  <Config Name="Operator trust (read-only)" Target="/etc/ouro/trust/sanctuary" Default="/mnt/user/appdata/ouro-butler/trust/sanctuary" Mode="ro" Description="Root-owned grants the Butler honours (delegated commands, escalation). Read-only inside the Butler; only the host operator can change it" Type="Path" Display="always" Required="true" Mask="false">/mnt/user/appdata/ouro-butler/trust/sanctuary</Config>
30
29
  </Container>
@@ -10,7 +10,8 @@ const EXPECTED_BINDS = [
10
10
  "/mnt/user/appdata/ouro-butler/agent/sanctuary.ouro:/home/ouro/AgentBundles/sanctuary.ouro:rw",
11
11
  "/boot/config/custom/ouro-events/spool:/run/ouro-events:ro",
12
12
  "/run/ouro-authority:/run/ouro-authority:ro",
13
- // The psyche folder, read-only inside the Butler: a read-only mount point cannot be renamed or removed from inside the container.
13
+ // The psyche folder read-only over the bundle. Releases up to alpha.978 create it, and a rollback to one recreates it,
14
+ // so canonical-gateway accepts these binds with or without this row (GATEWAY_*_WITHOUT_PSYCHE).
14
15
  "/mnt/user/appdata/ouro-butler/agent/sanctuary.ouro/psyche:/home/ouro/AgentBundles/sanctuary.ouro/psyche:ro",
15
16
  // Operator-set grants, root-owned on the host and read-only inside the Butler (the Butler honours a grant only from here).
16
17
  "/mnt/user/appdata/ouro-butler/trust/sanctuary:/etc/ouro/trust/sanctuary:ro",
@@ -23,6 +24,10 @@ const EXPECTED_MOUNTS = [
23
24
  ["/mnt/user/appdata/ouro-butler/agent/sanctuary.ouro/psyche", "/home/ouro/AgentBundles/sanctuary.ouro/psyche", false, "rprivate"],
24
25
  ["/mnt/user/appdata/ouro-butler/trust/sanctuary", "/etc/ouro/trust/sanctuary", false, "rprivate"],
25
26
  ];
27
+ const PSYCHE_TARGET = "/home/ouro/AgentBundles/sanctuary.ouro/psyche";
28
+ /** The current resident shape: the canonical-gateway set without the separate read-only psyche mount. */
29
+ const GATEWAY_BINDS_WITHOUT_PSYCHE = EXPECTED_BINDS.filter((bind) => !bind.includes(`:${PSYCHE_TARGET}:`));
30
+ const GATEWAY_MOUNTS_WITHOUT_PSYCHE = EXPECTED_MOUNTS.filter((mount) => mount[1] !== PSYCHE_TARGET);
26
31
  const PRE_GATEWAY_BINDS = EXPECTED_BINDS.slice(0, 3);
27
32
  const PRE_GATEWAY_MOUNTS = EXPECTED_MOUNTS.slice(0, 3);
28
33
  const LEGACY_ALPHA742_IMAGE = "sha256:681449ad47a2621705cd339b481e6339236b31dc65e195b1cf5025d0f2191d7d";
@@ -66,7 +71,7 @@ function auditSanctuaryContainerSpec(value, options) {
66
71
  violations.push("unsupported mount contract");
67
72
  if (mountContract === "legacy-alpha742" && options.expectedImage !== LEGACY_ALPHA742_IMAGE)
68
73
  violations.push("legacy mount exception requires the pinned alpha.742 image ID");
69
- const expectedMounts = mountContract === "legacy-alpha742" ? LEGACY_ALPHA742_MOUNTS : mountContract === "canonical-pre-gateway" ? PRE_GATEWAY_MOUNTS : EXPECTED_MOUNTS;
74
+ const acceptedMounts = mountContract === "legacy-alpha742" ? [LEGACY_ALPHA742_MOUNTS] : mountContract === "canonical-pre-gateway" ? [PRE_GATEWAY_MOUNTS] : [EXPECTED_MOUNTS, GATEWAY_MOUNTS_WITHOUT_PSYCHE];
70
75
  const expectedArgs = mountContract !== "legacy-alpha742" ? PACKAGE_DAEMON_ARGS : LEGACY_DAEMON_ARGS;
71
76
  const expectedEntrypoint = mountContract !== "legacy-alpha742" ? PACKAGE_ENTRYPOINT : LEGACY_ENTRYPOINT;
72
77
  if (root.Image !== options.expectedImage)
@@ -137,8 +142,9 @@ function auditSanctuaryContainerSpec(value, options) {
137
142
  ? [item.Source, item.Destination, item.RW, item.Propagation]
138
143
  : null;
139
144
  });
145
+ const actualMounts = JSON.stringify(normalizedMounts.sort());
140
146
  if (normalizedMounts.some((mount) => mount === null)
141
- || JSON.stringify(normalizedMounts.sort()) !== JSON.stringify(expectedMounts.map((mount) => [...mount]).sort())) {
147
+ || !acceptedMounts.some((expectedMounts) => actualMounts === JSON.stringify(expectedMounts.map((mount) => [...mount]).sort()))) {
142
148
  violations.push("effective mounts do not match the selected Sanctuary mount contract");
143
149
  }
144
150
  }
@@ -174,7 +180,7 @@ function auditTemplate(input, expectedRepository, repositoryIsValid, repositoryV
174
180
  const violations = [];
175
181
  if (input.mountContract !== "canonical-pre-gateway" && input.mountContract !== "canonical-gateway")
176
182
  violations.push("unsupported template mount contract");
177
- const expectedBinds = input.mountContract === "canonical-pre-gateway" ? PRE_GATEWAY_BINDS : EXPECTED_BINDS;
183
+ const acceptedBinds = input.mountContract === "canonical-pre-gateway" ? [PRE_GATEWAY_BINDS] : [EXPECTED_BINDS, GATEWAY_BINDS_WITHOUT_PSYCHE];
178
184
  const template = parseDockerManTemplateXml(input.templateXml);
179
185
  if (!template)
180
186
  violations.push("canonical DockerMan XML structure is invalid");
@@ -194,8 +200,8 @@ function auditTemplate(input, expectedRepository, repositoryIsValid, repositoryV
194
200
  const { Type: type, Target: target, Mode: mode } = entry.attributes;
195
201
  return entry.form === "text" && type === "Path" && target && mode ? `${entry.text}:${target}:${mode}` : "invalid";
196
202
  });
197
- if (configEntries.length !== expectedBinds.length
198
- || JSON.stringify([...pathConfigs].sort()) !== JSON.stringify([...expectedBinds].sort())) {
203
+ const actualBinds = JSON.stringify([...pathConfigs].sort());
204
+ if (!acceptedBinds.some((expectedBinds) => configEntries.length === expectedBinds.length && actualBinds === JSON.stringify([...expectedBinds].sort()))) {
199
205
  violations.push("template Config entries must equal the canonical path binds");
200
206
  }
201
207
  const postArgs = directChildren(template, "PostArgs");
@@ -509,7 +509,9 @@ function validateSanctuaryUnit16EvidenceAssertions(label, raw) {
509
509
  integer(value.auditLifecyclePairCount, `${label} auditLifecyclePairCount`, 1);
510
510
  if (text(value.containerUser, `${label} containerUser`) !== "10001:10001" || text(value.liveProcessUser, `${label} liveProcessUser`) !== "10001:10001" || text(value.networkMode, `${label} networkMode`) !== "host")
511
511
  throw new Error(`${label} container identity or network is invalid`);
512
- requiredInteger(value, "mountCount", 6, label);
512
+ // Five mounts now; six for alpha.978 and earlier (and a rollback to one), which add the read-only psyche mount. mountsExact pins the set.
513
+ if (![5, 6].includes(integer(value.mountCount, `${label} mountCount`)))
514
+ throw new Error(`${label} mountCount must equal 5 or 6`);
513
515
  requiredInteger(value, "typedWriteExecutorCount", 1, label);
514
516
  allTrue(["writeApprovalPolicyExact", "mountsExact", "securityExact", "updaterDisabled"]);
515
517
  break;
@@ -132,7 +132,8 @@ function exactContainmentAudit(evidence) {
132
132
  && (0, sanctuary_acceptance_harness_1.exactSanctuaryContainmentProfileBoundaries)(evidence.profileBoundaries)
133
133
  && evidence.auditPathDigest === (0, node_crypto_1.createHash)("sha256").update(CONTAINMENT_AUDIT_PATH).digest("hex")
134
134
  && evidence.auditRecordCount >= 2 && evidence.auditLifecyclePairCount >= 1
135
- && evidence.containerUser === "10001:10001" && evidence.liveProcessUser === "10001:10001" && evidence.mountCount === 6 && evidence.publishedPortCount === 0
135
+ // Five mounts now; six for alpha.978 and earlier (and a rollback to one), which add the read-only psyche mount. mountsExact pins the set.
136
+ && evidence.containerUser === "10001:10001" && evidence.liveProcessUser === "10001:10001" && (evidence.mountCount === 5 || evidence.mountCount === 6) && evidence.publishedPortCount === 0
136
137
  && evidence.networkMode === "host" && evidence.readOnlyRoot === false && evidence.mountsExact && evidence.securityExact && evidence.updaterDisabled
137
138
  && !evidence.writableKeyExposure && evidence.rawWriteMaterialFieldCount === 0 && evidence.typedWriteExecutorCount === 1
138
139
  && evidence.writeApprovalPolicyExact && !evidence.sensitiveMaterialObserved
@@ -854,7 +854,7 @@ class SanctuaryAuthorityRootLifecycle {
854
854
  const steps = {
855
855
  stop: () => this.#halt(),
856
856
  switch: async () => { await this.#halt(); this.#switchPackage(journal); },
857
- resident: () => this.#recreateResident(journal.to.imageReference, journal.to.imageId),
857
+ resident: () => this.#recreateResident(journal.to.imageReference, journal.to.imageId, { psycheMount: false }),
858
858
  migrate: () => target().#bundle(["--operation", "migrate", "--rollback-image-id", journal.from.imageId, "--target-image-id", journal.to.imageId]),
859
859
  start: () => target().#startUpgraded(),
860
860
  };
@@ -916,7 +916,8 @@ class SanctuaryAuthorityRootLifecycle {
916
916
  const predecessor = new _a({ targetImageId: journal.from.imageId, rollbackImageId: journal.from.rollbackImageId }, this.#options);
917
917
  predecessor.#publishBoot();
918
918
  if (this.#containers().find((container) => container.Name === "/ouro-butler")?.Image !== journal.from.imageId) {
919
- predecessor.#recreateResident(journal.from.imageReference, journal.from.imageId);
919
+ // The predecessor release (up to alpha.978) expects its psyche read-only over the bundle; give it back that shape.
920
+ predecessor.#recreateResident(journal.from.imageReference, journal.from.imageId, { psycheMount: true });
920
921
  }
921
922
  await predecessor.#startUpgraded(PREDECESSOR_READY_TIMEOUT_MS);
922
923
  this.#remove(UPGRADE);
@@ -1025,14 +1026,15 @@ class SanctuaryAuthorityRootLifecycle {
1025
1026
  #publishBoot() {
1026
1027
  this.#write(BOOT, fs.readFileSync(this.#p(`${ROOT}/package/deploy/unraid/sanctuary-authority-service.sh`), "utf8"));
1027
1028
  }
1028
- #recreateResident(reference, imageId) {
1029
+ /** `psycheMount` adds the read-only psyche mount that releases up to alpha.978 expect; only a rollback passes true. */
1030
+ #recreateResident(reference, imageId, { psycheMount }) {
1029
1031
  if (this.#containers().some((container) => container.Name === "/ouro-butler"))
1030
1032
  this.#docker(["rm", "-f", "ouro-butler"]);
1031
1033
  this.#docker(["create", "--name", "ouro-butler", "--network", "host", "--restart", "unless-stopped", "--user", "10001:10001",
1032
1034
  "-l", "net.unraid.docker.managed=dockerman", "-l", `net.unraid.docker.icon=${ICON}`, "-l", "org.opencontainers.image.source=https://github.com/ourostack/ouroboros",
1033
1035
  "-v", `${RUNTIME}:/home/ouro/.ouro-cli:rw`, "-v", `${BUNDLE}:/home/ouro/AgentBundles/sanctuary.ouro:rw`,
1034
1036
  "-v", `${EVENTS}:/run/ouro-events:ro`, "-v", `${SOCKET}:${SOCKET}:ro`,
1035
- "-v", `${BUNDLE}/psyche:/home/ouro/AgentBundles/sanctuary.ouro/psyche:ro`,
1037
+ ...(psycheMount ? ["-v", `${BUNDLE}/psyche:/home/ouro/AgentBundles/sanctuary.ouro/psyche:ro`] : []),
1036
1038
  "-v", `${TRUST}:/etc/ouro/trust/sanctuary:ro`, reference]);
1037
1039
  const resident = this.#containers().find((container) => container.Name === "/ouro-butler");
1038
1040
  if (resident?.Image !== imageId || resident.State.Running)
@@ -33,11 +33,12 @@ var __importStar = (this && this.__importStar) || (function () {
33
33
  };
34
34
  })();
35
35
  Object.defineProperty(exports, "__esModule", { value: true });
36
- exports.SANCTUARY_BUNDLE_ROLLBACK_FILE = exports.SANCTUARY_PACKAGE_MANAGED_FILES = void 0;
36
+ exports.SANCTUARY_BUNDLE_ROLLBACK_FILE = exports.SANCTUARY_STARTER_PSYCHE_FILES = exports.SANCTUARY_PACKAGE_MANAGED_FILES = void 0;
37
37
  exports.inspectSanctuaryDirectoryFromBase = inspectSanctuaryDirectoryFromBase;
38
38
  exports.sanctuaryDirectoriesShareIdentity = sanctuaryDirectoriesShareIdentity;
39
39
  exports.inspectSanctuaryPackageManagedBundle = inspectSanctuaryPackageManagedBundle;
40
40
  exports.ensureSanctuaryPackageManagedBundle = ensureSanctuaryPackageManagedBundle;
41
+ exports.seedSanctuaryPsycheIfAbsent = seedSanctuaryPsycheIfAbsent;
41
42
  exports.inspectSanctuaryPackageManagedBundleRollback = inspectSanctuaryPackageManagedBundleRollback;
42
43
  exports.rollbackSanctuaryPackageManagedBundle = rollbackSanctuaryPackageManagedBundle;
43
44
  exports.commitSanctuaryPackageManagedBundle = commitSanctuaryPackageManagedBundle;
@@ -52,19 +53,20 @@ exports.SANCTUARY_PACKAGE_MANAGED_FILES = [
52
53
  "mcp/media-mcp.mjs",
53
54
  "habits/sanctuary-health.md",
54
55
  "awaiting/house-care-sweep.md",
55
- "psyche/ASPIRATIONS.md",
56
- "psyche/IDENTITY.md",
57
- "psyche/LORE.md",
58
- "psyche/SOUL.md",
59
- "psyche/TACIT.md",
60
56
  ];
57
+ // The psyche belongs to the install: it is seeded from these files only when `psyche/` is absent, and no release touches it again.
58
+ exports.SANCTUARY_STARTER_PSYCHE_FILES = ["psyche/ASPIRATIONS.md", "psyche/IDENTITY.md", "psyche/LORE.md", "psyche/SOUL.md", "psyche/TACIT.md"];
59
+ const PSYCHE_SEED_STAGE_PREFIX = ".psyche-seed.";
60
+ // mkdtemp appends six characters from [A-Za-z0-9]; any other name under the prefix belongs to the owner.
61
+ const PSYCHE_SEED_STAGE_NAME = /^\.psyche-seed\.[A-Za-z0-9]{6}$/u;
62
+ const STARTER_PSYCHE_NAMES = new Set(exports.SANCTUARY_STARTER_PSYCHE_FILES.map((relative) => path.basename(relative)));
61
63
  exports.SANCTUARY_BUNDLE_ROLLBACK_FILE = ".sanctuary-package-managed-rollback.json";
62
64
  const SANCTUARY_BUNDLE_COMMITTING_FILE = `${exports.SANCTUARY_BUNDLE_ROLLBACK_FILE}.committing`;
63
65
  const BUNDLE_META_VERSION_FIELDS = ["runtimeVersion", "bundleSchemaVersion", "lastUpdated"];
64
66
  class SanctuaryInspectionFault extends Error {
65
67
  code;
66
- constructor(code) {
67
- super(code);
68
+ constructor(code, message = code) {
69
+ super(message);
68
70
  this.code = code;
69
71
  }
70
72
  }
@@ -546,6 +548,9 @@ function inspectSanctuaryPackageManagedBundle(input) {
546
548
  }
547
549
  }
548
550
  function ensureSanctuaryPackageManagedBundle(input, deps = {}) {
551
+ const seedFailure = seedLivePsyche(input, deps.seed ?? seedSanctuaryPsycheIfAbsent);
552
+ if (seedFailure)
553
+ return seedFailure;
549
554
  const inspect = deps.inspect ?? inspectSanctuaryPackageManagedBundle;
550
555
  const before = inspect(input);
551
556
  if (!before.ok) {
@@ -577,6 +582,120 @@ function ensureSanctuaryPackageManagedBundle(input, deps = {}) {
577
582
  throw new Error("Sanctuary package-managed bundle did not converge");
578
583
  return after;
579
584
  }
585
+ // A live root that is not a canonical directory is left for inspection to report as invalid_live_root.
586
+ function seedLivePsyche(input, seed) {
587
+ try {
588
+ if (!inspectStandaloneSanctuaryDirectory(input.agentRoot))
589
+ return null;
590
+ const seeded = seed({ packageRoot: input.packageRoot, agentRoot: input.agentRoot });
591
+ return seeded.ok ? null : inspectionError(seeded.code);
592
+ }
593
+ catch (error) {
594
+ return inspectionError(error instanceof SanctuaryInspectionFault ? error.code : "inspection_unavailable");
595
+ }
596
+ }
597
+ function psycheState(agentRoot) {
598
+ let stat;
599
+ try {
600
+ stat = fs.lstatSync(path.join(agentRoot, "psyche"));
601
+ }
602
+ catch (error) {
603
+ if (isMissing(error))
604
+ return "absent";
605
+ throw error;
606
+ }
607
+ return stat.isDirectory() ? "directory" : "invalid";
608
+ }
609
+ function sameInode(left, right) {
610
+ return left.dev === right.dev && left.ino === right.ino;
611
+ }
612
+ // Returns the entries of a stage this seeder could have left, or null when anything about it says otherwise.
613
+ function interruptedPsycheSeedEntries(stage, root) {
614
+ const before = fs.lstatSync(stage);
615
+ const mode = before.mode & 0o777;
616
+ if (!before.isDirectory() || (mode !== 0o700 && mode !== 0o755) || before.uid !== root.uid || before.gid !== root.gid)
617
+ return null;
618
+ const handle = fs.openSync(stage, fs.constants.O_RDONLY | fs.constants.O_DIRECTORY | fs.constants.O_NOFOLLOW);
619
+ let entries = [];
620
+ try {
621
+ if (!sameInode(before, fs.fstatSync(handle)))
622
+ return null;
623
+ entries = fs.readdirSync(stage);
624
+ if (!sameInode(before, fs.lstatSync(stage)))
625
+ return null;
626
+ }
627
+ finally {
628
+ fs.closeSync(handle);
629
+ }
630
+ for (const entry of entries) {
631
+ if (!STARTER_PSYCHE_NAMES.has(entry))
632
+ return null;
633
+ const stat = fs.lstatSync(path.join(stage, entry));
634
+ if (!stat.isFile() || (stat.mode & 0o777) !== 0o600 || stat.uid !== root.uid)
635
+ return null;
636
+ }
637
+ return entries;
638
+ }
639
+ // A seed interrupted before its rename leaves a mkdtemp-named stage holding only starter files that never became the psyche.
640
+ // Anything that does not match that exact shape may be the owner's, so it is left alone.
641
+ function cleanupInterruptedPsycheSeeds(agentRoot) {
642
+ const root = fs.lstatSync(agentRoot);
643
+ let removed = false;
644
+ for (const name of fs.readdirSync(agentRoot).filter((entry) => PSYCHE_SEED_STAGE_NAME.test(entry)).sort()) {
645
+ const stage = path.join(agentRoot, name);
646
+ const entries = interruptedPsycheSeedEntries(stage, root);
647
+ if (!entries)
648
+ continue;
649
+ for (const entry of entries)
650
+ fs.unlinkSync(path.join(stage, entry));
651
+ fs.rmdirSync(stage);
652
+ removed = true;
653
+ }
654
+ if (removed)
655
+ syncDirectory(agentRoot);
656
+ }
657
+ function seedSanctuaryPsycheIfAbsent(input, deps = {}) {
658
+ validateAgentRoot(input.agentRoot);
659
+ cleanupInterruptedPsycheSeeds(input.agentRoot);
660
+ const state = psycheState(input.agentRoot);
661
+ if (state === "directory")
662
+ return { ok: true, seeded: false };
663
+ if (state === "invalid")
664
+ return { ok: false, code: "invalid_live_bundle" };
665
+ const sources = exports.SANCTUARY_STARTER_PSYCHE_FILES.map((relative) => {
666
+ if (!inspectRelativeFile(input.packageRoot, relative, "invalid_package_source"))
667
+ throw new SanctuaryInspectionFault("invalid_package_source", `starter psyche source is missing: ${relative}`);
668
+ return { name: path.basename(relative), content: fs.readFileSync(path.join(input.packageRoot, relative)) };
669
+ });
670
+ const writeFile = deps.writeFile ?? ((fd, content) => fs.writeFileSync(fd, content));
671
+ const stage = fs.mkdtempSync(path.join(input.agentRoot, PSYCHE_SEED_STAGE_PREFIX));
672
+ for (const source of sources) {
673
+ const fd = fs.openSync(path.join(stage, source.name), "wx", 0o600);
674
+ try {
675
+ writeFile(fd, source.content);
676
+ fs.fchmodSync(fd, 0o600);
677
+ fs.fsyncSync(fd);
678
+ }
679
+ finally {
680
+ fs.closeSync(fd);
681
+ }
682
+ }
683
+ const directory = fs.openSync(stage, fs.constants.O_RDONLY | fs.constants.O_DIRECTORY | fs.constants.O_NOFOLLOW);
684
+ try {
685
+ fs.fchmodSync(directory, 0o755);
686
+ fs.fsyncSync(directory);
687
+ }
688
+ finally {
689
+ fs.closeSync(directory);
690
+ }
691
+ if (psycheState(input.agentRoot) !== "absent")
692
+ throw new SanctuaryInspectionFault("invalid_live_bundle", "psyche appeared while the starter psyche was staged");
693
+ // POSIX rename can still replace an empty psyche directory created in this window, which loses no content; Node has no RENAME_NOREPLACE.
694
+ fs.renameSync(stage, path.join(input.agentRoot, "psyche"));
695
+ syncDirectory(input.agentRoot);
696
+ (0, runtime_1.emitNervesEvent)({ component: "daemon", event: "daemon.sanctuary_psyche_seeded", message: "seeded the starter psyche into a bundle that had none", meta: { agentRoot: input.agentRoot, files: sources.length } });
697
+ return { ok: true, seeded: true };
698
+ }
580
699
  function inspectSanctuaryPackageManagedBundleRollback(agentRoot) {
581
700
  validateAgentRoot(agentRoot);
582
701
  const record = readDurableRecord(agentRoot);
@@ -708,6 +827,8 @@ function migrateSanctuaryPackageManagedBundle(input) {
708
827
  fs.closeSync(meta);
709
828
  }
710
829
  }
830
+ if (!seedSanctuaryPsycheIfAbsent({ packageRoot: input.packageRoot, agentRoot: input.agentRoot }).ok)
831
+ throw new Error("psyche must be a real directory, not a symlink or file");
711
832
  const result = { managedFilesUpdated };
712
833
  (0, runtime_1.emitNervesEvent)({ component: "daemon", event: "daemon.sanctuary_bundle_migration_end", message: "completed Sanctuary package-managed bundle migration", meta: result });
713
834
  return result;
@@ -34,6 +34,7 @@ var __importStar = (this && this.__importStar) || (function () {
34
34
  })();
35
35
  Object.defineProperty(exports, "__esModule", { value: true });
36
36
  exports.flattenSystemPrompt = flattenSystemPrompt;
37
+ exports.loadPsycheAt = loadPsycheAt;
37
38
  exports.resetPsycheCache = resetPsycheCache;
38
39
  exports.buildSessionSummary = buildSessionSummary;
39
40
  exports.bodyMapSection = bodyMapSection;
@@ -62,6 +63,7 @@ exports.rhythmStatusSection = rhythmStatusSection;
62
63
  exports.buildSystem = buildSystem;
63
64
  const fs = __importStar(require("fs"));
64
65
  const path = __importStar(require("path"));
66
+ const crypto_1 = require("crypto");
65
67
  const core_1 = require("../heart/core");
66
68
  const ouro_version_manager_1 = require("../heart/versioning/ouro-version-manager");
67
69
  const tools_1 = require("../repertoire/tools");
@@ -94,9 +96,41 @@ function flattenSystemPrompt(sp) {
94
96
  const parts = [sp.stable, sp.volatile].filter(Boolean);
95
97
  return parts.join("\n\n");
96
98
  }
97
- // Lazy-loaded psyche text cache, scoped by agent root. The daemon can host
98
- // multiple agents, so identity text must never be cached process-wide.
99
- let _psycheCache = null;
99
+ // Psyche text cache, one entry per agent root (the daemon can host several agents). An entry is good only while
100
+ // the files it was read from are unchanged: every load hashes their content, so an edit by the agent, its owner or
101
+ // a sync pull takes effect on the very next prompt build. Content, not mtime or inode, because those are unreliable
102
+ // on FUSE shares such as Unraid's /mnt/user; the files are small, so hashing them is cheap.
103
+ const _psycheCache = new Map();
104
+ const PSYCHE_SOURCE_FILES = {
105
+ soul: ["psyche", "SOUL.md"],
106
+ identity: ["psyche", "IDENTITY.md"],
107
+ lore: ["psyche", "LORE.md"],
108
+ tacitKnowledge: ["psyche", "TACIT.md"],
109
+ aspirations: ["psyche", "ASPIRATIONS.md"],
110
+ house: ["house", "HOUSE.md"],
111
+ product: ["product", "PRODUCT.md"],
112
+ houseNames: ["house", "house.json"],
113
+ };
114
+ /** Every file the psyche is built from, read once; null when a file is absent or unreadable. */
115
+ function readPsycheSources(agentRoot) {
116
+ const sources = {};
117
+ for (const [key, parts] of Object.entries(PSYCHE_SOURCE_FILES)) {
118
+ try {
119
+ sources[key] = String(fs.readFileSync(path.join(agentRoot, ...parts), "utf-8"));
120
+ }
121
+ catch {
122
+ sources[key] = null;
123
+ }
124
+ }
125
+ return sources;
126
+ }
127
+ /** A content hash of the sources; an absent file hashes differently from an empty one. */
128
+ function psycheFingerprint(sources) {
129
+ const hash = (0, crypto_1.createHash)("sha256");
130
+ for (const text of Object.values(sources))
131
+ hash.update(text === null ? "-" : `+${text.length}:${text}`);
132
+ return hash.digest("hex");
133
+ }
100
134
  // A household install names its owner, its Butler and its home in house/house.json,
101
135
  // a file the package never overwrites. The packaged psyche writes {{owner}}, {{butler}}
102
136
  // and {{household}} where those names go, so one psyche serves every household.
@@ -105,59 +139,49 @@ const HOUSE_NAME_DEFAULTS = {
105
139
  butler: "the Butler",
106
140
  household: "the household",
107
141
  };
108
- function loadHouseNames(agentRoot) {
142
+ function parseHouseNames(text) {
143
+ const names = { ...HOUSE_NAME_DEFAULTS };
109
144
  try {
110
- const parsed = JSON.parse(fs.readFileSync(path.join(agentRoot, "house", "house.json"), "utf-8"));
111
- const names = { ...HOUSE_NAME_DEFAULTS };
145
+ const parsed = JSON.parse(text ?? "");
112
146
  for (const [key, field] of [["owner", "ownerName"], ["butler", "butlerName"], ["household", "householdName"]]) {
113
147
  const value = parsed[field];
114
148
  if (typeof value === "string" && value.trim())
115
149
  names[key] = value.trim();
116
150
  }
117
- return names;
118
151
  }
119
152
  catch {
120
- return { ...HOUSE_NAME_DEFAULTS };
153
+ // no house.json, or not JSON: the defaults stand
121
154
  }
155
+ return names;
122
156
  }
123
157
  function fillHouseNames(text, names) {
124
- return text.replace(/\{\{(owner|butler|household)\}\}/gu, (_match, key) => names[key]);
125
- }
126
- function loadPsycheFile(agentRoot, name, names) {
127
- try {
128
- const psycheDir = path.join(agentRoot, "psyche");
129
- return fillHouseNames(fs.readFileSync(path.join(psycheDir, name), "utf-8").trim(), names);
130
- }
131
- catch {
132
- return "";
133
- }
134
- }
135
- function loadHouseFile(agentRoot, names) {
136
- try {
137
- return fillHouseNames(fs.readFileSync(path.join(agentRoot, "house", "HOUSE.md"), "utf-8").trim(), names);
138
- }
139
- catch {
140
- return "";
141
- }
142
- }
143
- function loadPsyche() {
144
- const agentRoot = (0, identity_1.getAgentRoot)();
145
- if (_psycheCache?.agentRoot === agentRoot)
146
- return _psycheCache.psyche;
147
- const names = loadHouseNames(agentRoot);
158
+ return (text ?? "").trim().replace(/\{\{(owner|butler|household)\}\}/gu, (_match, key) => names[key]);
159
+ }
160
+ /** The psyche for one agent root. Each call reads its files once and rebuilds only when their content changed. */
161
+ function loadPsycheAt(agentRoot) {
162
+ const sources = readPsycheSources(agentRoot);
163
+ const fingerprint = psycheFingerprint(sources);
164
+ const cached = _psycheCache.get(agentRoot);
165
+ if (cached?.fingerprint === fingerprint)
166
+ return cached.psyche;
167
+ const names = parseHouseNames(sources.houseNames);
148
168
  const psyche = {
149
- soul: loadPsycheFile(agentRoot, "SOUL.md", names),
150
- identity: loadPsycheFile(agentRoot, "IDENTITY.md", names),
151
- lore: loadPsycheFile(agentRoot, "LORE.md", names),
152
- tacitKnowledge: loadPsycheFile(agentRoot, "TACIT.md", names),
153
- aspirations: loadPsycheFile(agentRoot, "ASPIRATIONS.md", names),
154
- house: loadHouseFile(agentRoot, names),
169
+ soul: fillHouseNames(sources.soul, names),
170
+ identity: fillHouseNames(sources.identity, names),
171
+ lore: fillHouseNames(sources.lore, names),
172
+ tacitKnowledge: fillHouseNames(sources.tacitKnowledge, names),
173
+ aspirations: fillHouseNames(sources.aspirations, names),
174
+ house: fillHouseNames(sources.house, names),
175
+ product: fillHouseNames(sources.product, names),
155
176
  };
156
- _psycheCache = { agentRoot, psyche };
177
+ _psycheCache.set(agentRoot, { fingerprint, psyche });
157
178
  return psyche;
158
179
  }
180
+ function loadPsyche() {
181
+ return loadPsycheAt((0, identity_1.getAgentRoot)());
182
+ }
159
183
  function resetPsycheCache() {
160
- _psycheCache = null;
184
+ _psycheCache.clear();
161
185
  }
162
186
  const DEFAULT_ACTIVE_THRESHOLD_MS = 24 * 60 * 60 * 1000; // 24 hours
163
187
  function buildSessionSummary(options) {
@@ -237,6 +261,12 @@ function houseSection() {
237
261
  return "";
238
262
  return `## my house\n${text}`;
239
263
  }
264
+ function productSection() {
265
+ const text = loadPsyche().product;
266
+ if (!text)
267
+ return "";
268
+ return `## my standing rules\n${text}`;
269
+ }
240
270
  function peerCoordinationGuidance(channel) {
241
271
  if (channel === "inner") {
242
272
  return `from a private-runtime turn, \`surface\` and \`send_message\` do different jobs.
@@ -1568,6 +1598,7 @@ async function buildSystem(channel = "cli", options, context) {
1568
1598
  authorizedPsyche ? houseSection() : "",
1569
1599
  relationshipFriendContextSection(context),
1570
1600
  trustContextSection(context),
1601
+ productSection(),
1571
1602
  "# my tools",
1572
1603
  toolsSection(channel, options, context),
1573
1604
  toolRestrictionSection(context),
@@ -1593,6 +1624,7 @@ async function buildSystem(channel = "cli", options, context) {
1593
1624
  runtimeInfoSection(channel, options),
1594
1625
  channelNatureSection((0, friends_1.getChannelCapabilities)(channel)),
1595
1626
  providerSection(channel, options),
1627
+ productSection(),
1596
1628
  // Group 3: my tools & capabilities
1597
1629
  "# my tools & capabilities",
1598
1630
  toolsSection(channel, options, context),
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@ouro.bot/cli",
3
- "version": "0.1.0-alpha.977",
3
+ "version": "0.1.0-alpha.979",
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.977",
9
+ "version": "0.1.0-alpha.979",
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.977",
3
+ "version": "0.1.0-alpha.979",
4
4
  "engines": {
5
5
  "node": ">=22"
6
6
  },