@ouro.bot/cli 0.1.0-alpha.982 → 0.1.0-alpha.984

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
@@ -1,6 +1,6 @@
1
1
  <!-- ourostack:brand:start -->
2
2
  <p align="center">
3
- <a href="https://github.com/ourostack"><img src="https://raw.githubusercontent.com/ourostack/.github/main/brand/ouro.png" alt="Ouro" width="160" height="160"></a>
3
+ <a href="https://github.com/ourostack"><img src="https://raw.githubusercontent.com/ourostack/.github/main/brand/headers/ouroboros.webp" alt="Ouro outside its round-doored home in a flowering hillside, with letters and speech bubbles floating out of the windows and a telephone and mailbox by the door" width="100%"></a>
4
4
  </p>
5
5
  <!-- ourostack:brand:end -->
6
6
 
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.984",
6
+ "changes": [
7
+ "The Butler learns house rules from anyone in the house, straight into TACIT."
8
+ ]
9
+ },
10
+ {
11
+ "version": "0.1.0-alpha.983",
12
+ "changes": [
13
+ "The Butler's psyche is his own: he learns into TACIT, his household can change the rest in a one-to-one chat, and his product rules ship separately. Reading his household's names now leaves a trace in the logs."
14
+ ]
15
+ },
4
16
  {
5
17
  "version": "0.1.0-alpha.982",
6
18
  "changes": [
@@ -6,7 +6,7 @@ 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
- 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.
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 an upgrade back to alpha.978 or earlier recreates the predecessor with the read-only psyche mount it expects; a rollback to alpha.979 or later recreates it with no psyche mount. Psyche files stay owned by uid 10001, not writable by group or others, with no symlinks (verify checks exactly that); the upgrade still resets them to 0600, because exact 0600 matters only for a rollback to alpha.978 or earlier, whose boot check requires it. From M1b the Butler writes its own TACIT and a family member in a one-to-one chat (Telegram, BlueBubbles, or Teams) can change the rest of its psyche; every gated upgrade plants psyche/.release-marker and reports psyche-survives-release (and psyche-survives-rollback after a gate failure), and a drill (b) style downgrade to alpha.979 runs with --no-gate.
10
10
 
11
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
 
@@ -47,8 +47,8 @@
47
47
  // sanctuary-butler-upgrade.mjs verify [--gate]
48
48
 
49
49
  import { execFileSync, spawn, spawnSync } from "node:child_process"
50
- import { createHash } from "node:crypto"
51
- import { mkdtempSync, rmSync, writeFileSync, readFileSync, existsSync, statSync, lstatSync, chmodSync, realpathSync, readdirSync } from "node:fs"
50
+ import { createHash, randomBytes } from "node:crypto"
51
+ import { mkdtempSync, rmSync, writeFileSync, readFileSync, existsSync, statSync, lstatSync, chmodSync, realpathSync, readdirSync, openSync, closeSync, fchownSync, fchmodSync, fstatSync, readSync, renameSync, unlinkSync, constants as fsConstants } from "node:fs"
52
52
  import { fileURLToPath } from "node:url"
53
53
  import { createRequire } from "node:module"
54
54
  import { tmpdir } from "node:os"
@@ -598,21 +598,128 @@ function restorePsycheRoot() {
598
598
  if (existsSync(dir)) restorePsycheModes(dir)
599
599
  }
600
600
 
601
- /** What is wrong with the psyche folder as the resident's boot check needs it: files owned by the resident at exactly 0600, directories not writable by group or others, no symlinks. Empty when it is sound. */
601
+ /** What is wrong with the psyche folder: anything not owned by the resident, writable by group or others, or a symlink. A file the Butler creates may be 0644. Empty when it is sound. */
602
602
  export function psycheProblems(dir, { lstat = lstatSync, readdir = readdirSync } = {}) {
603
603
  const problems = []
604
604
  const walk = (current) => {
605
605
  const stat = lstat(current)
606
606
  if (stat.isSymbolicLink()) { problems.push(`${current} is a symlink`); return }
607
607
  if (stat.uid !== 10001) problems.push(`${current} is owned by uid ${stat.uid}, not the resident (10001)`)
608
- if (stat.isDirectory()) {
609
- if ((stat.mode & 0o022) !== 0) problems.push(`${current} is writable by group or others (mode ${(stat.mode & 0o777).toString(8)})`)
610
- for (const name of readdir(current)) walk(`${current}/${name}`)
611
- } else if ((stat.mode & 0o777) !== 0o600) problems.push(`${current} is mode ${(stat.mode & 0o777).toString(8)}, not 600`)
608
+ if ((stat.mode & 0o022) !== 0) problems.push(`${current} is writable by group or others (mode ${(stat.mode & 0o777).toString(8)})`)
609
+ if (stat.isDirectory()) for (const name of readdir(current)) walk(`${current}/${name}`)
612
610
  }
613
611
  walk(dir)
614
612
  return problems
615
613
  }
614
+ // A gated upgrade plants a marker in the psyche and hashes the four identity files, then proves both survived the release
615
+ // and any rollback. TACIT is the Butler's own and may change, so only that it is there is checked.
616
+ // Root works inside a folder the Butler's user owns, which could rename psyche/ and put a symlink in its place at any moment.
617
+ // So every operation goes through one descriptor held on the real psyche/ (/proc/self/fd/<fd>/<name> on the Linux host),
618
+ // every read opens a regular file without following links or blocking, and before each write, rename or unlink the path
619
+ // psyche/ must still be that same directory.
620
+ export const PSYCHE_IDENTITY_FILES = ["SOUL.md", "IDENTITY.md", "LORE.md", "ASPIRATIONS.md"]
621
+ const PSYCHE_FILES = [...PSYCHE_IDENTITY_FILES, "TACIT.md"]
622
+ const PSYCHE_MARKER = ".release-marker"
623
+ const PSYCHE_READ_LIMIT = 1024 * 1024
624
+ export const psycheFdPath = (dirfd, name) => `/proc/self/fd/${dirfd}/${name}`
625
+
626
+ /** Read at most `limit` bytes from an open file; more than that is a problem, even if the file grew after it was checked. */
627
+ export function readPsycheBytes(fd, limit = PSYCHE_READ_LIMIT) {
628
+ const buffer = Buffer.alloc(limit + 1)
629
+ let length = 0
630
+ for (let read; length <= limit && (read = readSync(fd, buffer, length, limit + 1 - length, null)) > 0;) length += read
631
+ return length > limit ? { problem: "is larger than 1 MiB" } : { bytes: buffer.subarray(0, length) }
632
+ }
633
+
634
+ function readPsycheFile(dirfd, name, at) {
635
+ let fd
636
+ try { fd = openSync(at(dirfd, name), fsConstants.O_RDONLY | fsConstants.O_NOFOLLOW | fsConstants.O_NONBLOCK) } catch (e) {
637
+ const why = e.code === "ENOENT" ? "is missing" : e.code === "ELOOP" ? "is a symlink" : `could not be opened (${e.code ?? e.message})`
638
+ return { problem: `psyche/${name} ${why}` }
639
+ }
640
+ try {
641
+ if (!fstatSync(fd).isFile()) return { problem: `psyche/${name} is not a regular file` }
642
+ const read = readPsycheBytes(fd)
643
+ return read.problem ? { problem: `psyche/${name} ${read.problem}` } : read
644
+ } finally { closeSync(fd) }
645
+ }
646
+
647
+ /** Run `work` with a descriptor on the real psyche/ and a guard that throws if the path psyche/ is no longer that directory. */
648
+ function withPsycheDir(dir, work) {
649
+ let dirfd
650
+ try { dirfd = openSync(dir, fsConstants.O_RDONLY | fsConstants.O_DIRECTORY | fsConstants.O_NOFOLLOW) } catch { throw new Error(`${dir} is not a real directory`) }
651
+ try {
652
+ const held = fstatSync(dirfd)
653
+ const guard = () => {
654
+ let now = null
655
+ try { now = lstatSync(dir) } catch { /* gone counts as replaced */ }
656
+ if (!now?.isDirectory() || now.dev !== held.dev || now.ino !== held.ino) throw new Error(`${dir} was replaced while root was working in it; refusing`)
657
+ }
658
+ return work(dirfd, guard)
659
+ } finally { closeSync(dirfd) }
660
+ }
661
+
662
+ const hashOf = (bytes) => createHash("sha256").update(bytes).digest("hex")
663
+
664
+ /** Write psyche/.release-marker (0600, resident-owned, a random token) and hash the identity files. Refuses unless all five psyche files are regular files. */
665
+ export function plantPsycheMarker(dir, { uid, gid }, { at = psycheFdPath } = {}) {
666
+ return withPsycheDir(dir, (dirfd, guard) => {
667
+ const read = Object.fromEntries(PSYCHE_FILES.map((name) => [name, readPsycheFile(dirfd, name, at)]))
668
+ for (const name of PSYCHE_FILES) if (read[name].problem) throw new Error(read[name].problem)
669
+ const identity = Object.fromEntries(PSYCHE_IDENTITY_FILES.map((name) => [name, hashOf(read[name].bytes)]))
670
+ const token = randomBytes(16).toString("hex")
671
+ const staged = `${PSYCHE_MARKER}.${token}`
672
+ guard()
673
+ const fd = openSync(at(dirfd, staged), fsConstants.O_WRONLY | fsConstants.O_CREAT | fsConstants.O_EXCL | fsConstants.O_NOFOLLOW, 0o600)
674
+ try {
675
+ fchownSync(fd, uid, gid)
676
+ fchmodSync(fd, 0o600)
677
+ writeFileSync(fd, token)
678
+ } finally { closeSync(fd) }
679
+ try { guard() } catch (e) { try { unlinkSync(at(dirfd, staged)) } catch { /* the guard's error is the one to report */ } throw e }
680
+ renameSync(at(dirfd, staged), at(dirfd, PSYCHE_MARKER))
681
+ return { token, identity }
682
+ })
683
+ }
684
+
685
+ /** Null when the marker holds the token, all five psyche files are regular files and the four identity hashes match; otherwise the first problem. */
686
+ export function psycheSurvival(dir, planted, { at = psycheFdPath } = {}) {
687
+ try {
688
+ return withPsycheDir(dir, (dirfd) => {
689
+ const marker = readPsycheFile(dirfd, PSYCHE_MARKER, at)
690
+ if (marker.problem) return marker.problem
691
+ if (marker.bytes.toString("utf8") !== planted.token) return `psyche/${PSYCHE_MARKER} does not hold this upgrade's token`
692
+ const read = PSYCHE_FILES.map((name) => [name, readPsycheFile(dirfd, name, at)])
693
+ const unreadable = read.find(([, file]) => file.problem)
694
+ if (unreadable) return unreadable[1].problem
695
+ const changed = read.find(([name, file]) => name in planted.identity && hashOf(file.bytes) !== planted.identity[name])
696
+ return changed ? `psyche/${changed[0]} changed` : null
697
+ })
698
+ } catch (e) { return e.message }
699
+ }
700
+
701
+ /** Remove the marker only while it holds this upgrade's token, reading and unlinking through the same descriptor. Returns why it was left, or null. */
702
+ export function removePsycheMarker(dir, token, { at = psycheFdPath } = {}) {
703
+ try {
704
+ return withPsycheDir(dir, (dirfd, guard) => {
705
+ const marker = readPsycheFile(dirfd, PSYCHE_MARKER, at)
706
+ if (marker.problem) return marker.problem
707
+ if (marker.bytes.toString("utf8") !== token) return `psyche/${PSYCHE_MARKER} does not hold this upgrade's token`
708
+ guard()
709
+ unlinkSync(at(dirfd, PSYCHE_MARKER))
710
+ return null
711
+ })
712
+ } catch (e) { return e.message }
713
+ }
714
+
715
+ /** Print the survival check as a gate case line (the shape butler-ship's parseCases reports) and return the problem, if any. */
716
+ export function reportPsycheSurvival(id, dir, planted, log = console.log, options = {}) {
717
+ const problem = psycheSurvival(dir, planted, options)
718
+ const check = { name: "the psyche marker and all five psyche files survived, with the four identity files byte-identical", ok: problem === null, ...(problem ? { detail: problem } : {}) }
719
+ log(JSON.stringify({ id, status: problem ? "fail" : "pass", checks: [check] }))
720
+ return problem
721
+ }
722
+
616
723
  /**
617
724
  * What is wrong with the resident's psyche mount, from `docker inspect` Mounts, or null for a supported shape: no separate psyche
618
725
  * mount (current releases), or a read-only one (alpha.978 and earlier, which a rollback recreates).
@@ -1236,6 +1343,12 @@ async function upgrade(version, rehearse, { noGate = false, plant } = {}) {
1236
1343
  if (!telegram.ok) fail(`api.telegram.org is not reachable from this host (${telegram.detail}); the new gateway could not pass its Telegram identity check. Nothing was paused or changed; fix the network and re-run.`)
1237
1344
  if (telegram.skipped) console.log(" note: resuming a pending upgrade; Telegram reachability not re-checked")
1238
1345
  else ok(`api.telegram.org reachable (${telegram.detail})`)
1346
+ const psycheDir = `${BUNDLE}/psyche`
1347
+ let planted = null
1348
+ if (gated) {
1349
+ try { planted = plantPsycheMarker(psycheDir, { uid: 10001, gid: 10001 }) } catch (e) { fail(`could not plant the psyche marker before the gated upgrade; nothing was paused or changed: ${e.message}`) }
1350
+ ok("psyche marker planted and the four identity files hashed")
1351
+ }
1239
1352
  pauseSupervision()
1240
1353
  let failure = null
1241
1354
  try {
@@ -1264,36 +1377,45 @@ async function upgrade(version, rehearse, { noGate = false, plant } = {}) {
1264
1377
  if (gated && !failure) {
1265
1378
  say(`replay gate${plant ? ` (planted failure: ${plant})` : ""}`)
1266
1379
  const stillHeld = () => heldUpgradeProblem({ journalExists: existsSync(UPGRADE_JOURNAL), liveImage: butlerState().split(" ")[0], expectedImage: image(version) })
1267
- const outcome = completeGatedUpgrade({
1268
- runGate: () => {
1269
- if (preserved.length) return { ok: false, detail: preserved.join("; ") }
1270
- if (!waitForButler(image(version), 300)) return { ok: false, detail: `the new Butler did not become healthy on ${image(version)}` }
1271
- const result = runReplayGate(plant)
1272
- if (!result.ok) return result
1273
- const problem = stillHeld()
1274
- return problem ? { ok: false, detail: problem } : result
1275
- },
1276
- commit: () => {
1277
- pauseSupervision()
1278
- try {
1380
+ let outcome
1381
+ try {
1382
+ outcome = completeGatedUpgrade({
1383
+ runGate: () => {
1384
+ if (preserved.length) return { ok: false, detail: preserved.join("; ") }
1385
+ if (!waitForButler(image(version), 300)) return { ok: false, detail: `the new Butler did not become healthy on ${image(version)}` }
1386
+ const lost = reportPsycheSurvival("psyche-survives-release", psycheDir, planted)
1387
+ if (lost) return { ok: false, detail: `the psyche did not survive the release: ${lost}` }
1388
+ const result = runReplayGate(plant)
1389
+ if (!result.ok) return result
1279
1390
  const problem = stillHeld()
1280
- if (problem) throw new Error(`refusing to commit: ${problem}`)
1281
- ok(`commit: ${sh("/usr/local/bin/node", [lifecycle, "upgrade", id, image(version)], { stdio: ["ignore", "pipe", "pipe"] }).trim()}`)
1282
- } finally { resumeSupervision() }
1283
- },
1284
- rollback: () => {
1285
- pauseSupervision()
1286
- try { ok(`rollback: ${sh("/usr/local/bin/node", [lifecycle, "upgrade-rollback"], { stdio: ["ignore", "pipe", "pipe"] }).trim()}`) }
1287
- catch (e) { fail(ROLLBACK_FAILED(e, lifecycle)) }
1288
- resumeSupervision()
1289
- },
1290
- confirmRollback: () => {
1291
- if (!waitForButler(priorImage, 300)) fail(`rolled back, but ${priorImage} is not running healthy; inspect \`docker logs ${CONTAINER}\``)
1292
- return priorImage
1293
- },
1294
- pin: () => pinTemplate(version),
1295
- prune: () => pruneButlerImages(),
1296
- })
1391
+ return problem ? { ok: false, detail: problem } : result
1392
+ },
1393
+ commit: () => {
1394
+ pauseSupervision()
1395
+ try {
1396
+ const problem = stillHeld()
1397
+ if (problem) throw new Error(`refusing to commit: ${problem}`)
1398
+ ok(`commit: ${sh("/usr/local/bin/node", [lifecycle, "upgrade", id, image(version)], { stdio: ["ignore", "pipe", "pipe"] }).trim()}`)
1399
+ } finally { resumeSupervision() }
1400
+ },
1401
+ rollback: () => {
1402
+ pauseSupervision()
1403
+ try { ok(`rollback: ${sh("/usr/local/bin/node", [lifecycle, "upgrade-rollback"], { stdio: ["ignore", "pipe", "pipe"] }).trim()}`) }
1404
+ catch (e) { fail(ROLLBACK_FAILED(e, lifecycle)) }
1405
+ resumeSupervision()
1406
+ },
1407
+ confirmRollback: () => {
1408
+ if (!waitForButler(priorImage, 300)) fail(`rolled back, but ${priorImage} is not running healthy; inspect \`docker logs ${CONTAINER}\``)
1409
+ reportPsycheSurvival("psyche-survives-rollback", psycheDir, planted)
1410
+ return priorImage
1411
+ },
1412
+ pin: () => pinTemplate(version),
1413
+ prune: () => pruneButlerImages(),
1414
+ })
1415
+ } finally {
1416
+ const left = removePsycheMarker(psycheDir, planted.token)
1417
+ if (left) warn(`the psyche marker was left in place: ${left}`)
1418
+ }
1297
1419
  if (!outcome.passed) process.exit(1)
1298
1420
  console.log(`\nUPGRADE done — ${version} live and gate-verified. Run verify.`)
1299
1421
  return
@@ -1324,7 +1446,7 @@ function verify(withGate = false) {
1324
1446
  const psycheDir = `${BUNDLE}/psyche`
1325
1447
  if (existsSync(psycheDir)) {
1326
1448
  const psycheIssues = psycheProblems(psycheDir)
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("; ")}`)
1449
+ psycheIssues.length === 0 ? ok("psyche files are resident-owned and writable") : bad(`psyche files must be owned by the resident (uid 10001), not writable by group or others, and not symlinks: ${psycheIssues.slice(0, 3).join("; ")}`)
1328
1450
  } else bad("psyche folder missing from the bundle")
1329
1451
  ok(containerExists("jellyfin") ? `jellyfin ${docker(["inspect", "jellyfin", "--format", "{{.State.Status}} restarts={{.RestartCount}}"]).trim()}` : "no jellyfin container")
1330
1452
  if (withGate) {