@ouro.bot/cli 0.1.0-alpha.833 → 0.1.0-alpha.835
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 +17 -0
- package/deploy/unraid/sanctuary-butler-upgrade.mjs +151 -33
- package/deploy/unraid/sanctuary.ouro/bundle-meta.json +1 -1
- package/deploy/unraid/sanctuary.ouro/tool-profiles.json +69 -0
- package/deploy/unraid/sanctuary.xml +1 -1
- package/dist/a2a/client.js +56 -0
- package/dist/a2a/identity.js +34 -0
- package/dist/a2a/inbound-share.js +3 -0
- package/dist/a2a/onboarding.js +1 -6
- package/dist/a2a/sealed-chat.js +112 -0
- package/dist/a2a/server.js +62 -5
- package/dist/heart/daemon/cli-exec.js +27 -0
- package/dist/heart/daemon/cli-parse.js +52 -0
- package/dist/heart/daemon/sanctuary-authority-epoch.js +25 -0
- package/dist/heart/daemon/sanctuary-authority-root-lifecycle.js +282 -22
- package/dist/repertoire/tools.js +16 -4
- package/npm-shrinkwrap.json +6 -6
- package/package.json +2 -2
package/changelog.json
CHANGED
|
@@ -1,6 +1,23 @@
|
|
|
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.835",
|
|
6
|
+
"changes": [
|
|
7
|
+
"Sanctuary: in-place upgrade of an installed root authority (D-043). The authority keeps its epoch, token, issuer and Telegram cursor; only the reviewed package, its pins and the resident image change. The new package's lifecycle runs stop, switch, resident, migrate and start from a journal, backs up every root record it rewrites, and rolls back exactly on any failure. An interrupted upgrade rolls back on the next authority boot. No token rotation.",
|
|
8
|
+
"Upgrade orchestrator: new upgrade phase stages the package beside the live one, pauses host supervision behind a self-expiring maintenance flag, runs the lifecycle upgrade with automatic rollback, pins the DockerMan template, and resumes supervision. --rehearse <step> stops after a step and rolls back, proving the rollback on the real host. The host keeper now also restarts a resident left stopped.",
|
|
9
|
+
"Sanctuary root lifecycle failures are recorded with their real cause in a root-only log (the authority directory), and stderr names that file (D-034). A stale copy of the live token left in incoming-token is removed after an upgrade (D-044)."
|
|
10
|
+
]
|
|
11
|
+
},
|
|
12
|
+
{
|
|
13
|
+
"version": "0.1.0-alpha.834",
|
|
14
|
+
"changes": [
|
|
15
|
+
"A2A: authenticated agent chat. A verified friend's message (friends message kind, signed and sealed) becomes the agent's turn under the sender's relationship profile, and the reply is sealed back to the sender and stored sealed, so neither direction travels or persists in plaintext over any transport.",
|
|
16
|
+
"Sanctuary: remote turns are relationship-scoped. Any Sanctuary turn from outside the host (Telegram, A2A, future senses) without a known capability profile gets no tools; only the Butler's own inner, CLI, and MCP channels keep the unscoped tool set. Adds a sanctuary-agent-peer profile: the owner's chat tools minus host execution and approval-gated restarts.",
|
|
17
|
+
"CLI: ouro a2a identity mints a file-backed client key (0600, refused if group- or other-readable); ouro a2a message sends a sealed chat to an agent card and prints the verified reply.",
|
|
18
|
+
"A2A: sealed chat is observable (sealed, opened, and refused events), and a reply sealed as any other friends kind is refused before it can reach a store."
|
|
19
|
+
]
|
|
20
|
+
},
|
|
4
21
|
{
|
|
5
22
|
"version": "0.1.0-alpha.833",
|
|
6
23
|
"changes": [
|
|
@@ -19,11 +19,17 @@
|
|
|
19
19
|
// automatic rollback on ANY failure so the Butler is never left
|
|
20
20
|
// down. Requires a freshly rotated token at incoming-token.
|
|
21
21
|
// verify health + preservation (Jellyfin, steward policy) + readback.
|
|
22
|
+
// upgrade in-place upgrade of an INSTALLED authority (D-043): same epoch,
|
|
23
|
+
// token and cursor; new package, pins and resident image. The new
|
|
24
|
+
// package's own lifecycle does the work and rolls back exactly on
|
|
25
|
+
// any failure. `--rehearse <step>` stops after that step and rolls
|
|
26
|
+
// back, proving the rollback on real hardware. No token rotation.
|
|
22
27
|
//
|
|
23
|
-
// Run `preflight` and `prepare` freely. `install` is the
|
|
24
|
-
//
|
|
28
|
+
// Run `preflight` and `prepare` freely. `install` is the first-install phase and
|
|
29
|
+
// refuses without a rotated incoming-token. Run `upgrade` detached so an SSH drop
|
|
30
|
+
// cannot interrupt it: setsid nohup node <this> upgrade <version> > /var/log/ouro-upgrade.log 2>&1 &
|
|
25
31
|
//
|
|
26
|
-
// Usage: sanctuary-butler-upgrade.mjs <preflight|prepare|install|verify> <version>
|
|
32
|
+
// Usage: sanctuary-butler-upgrade.mjs <preflight|prepare|install|verify|upgrade> <version> [--rehearse <step>]
|
|
27
33
|
|
|
28
34
|
import { execFileSync } from "node:child_process"
|
|
29
35
|
import { createHash } from "node:crypto"
|
|
@@ -47,6 +53,13 @@ const REQUIRED_PROGRAMS = [
|
|
|
47
53
|
"deploy/unraid/sanctuary-authority-service.sh",
|
|
48
54
|
]
|
|
49
55
|
const MIN_FREE_GB = 2
|
|
56
|
+
const INCOMING_MANIFEST = `${ROOT}/incoming-package-manifest.json`
|
|
57
|
+
const INCOMING_REQUEST = `${ROOT}/incoming-request.json`
|
|
58
|
+
const UPGRADE_JOURNAL = `${ROOT}/upgrade.json`
|
|
59
|
+
// Host supervision skips its work while this flag is fresh (< 30 min), so a crashed
|
|
60
|
+
// upgrade can never leave self-heal off for longer than that.
|
|
61
|
+
const MAINTENANCE = "/run/ouro-authority-maintenance"
|
|
62
|
+
const UPGRADE_STEPS = ["stop", "switch", "resident", "migrate", "start"]
|
|
50
63
|
|
|
51
64
|
const sh = (file, args, opts = {}) => execFileSync(file, args, { encoding: "utf8", maxBuffer: 64 << 20, ...opts })
|
|
52
65
|
const docker = (args, opts = {}) => sh("/usr/bin/docker", args, opts)
|
|
@@ -109,8 +122,11 @@ function preflight(version) {
|
|
|
109
122
|
} catch { bad(`the ${CONTAINER} container is absent`) }
|
|
110
123
|
if (id && runningImage && id === runningImage) bad("target and running image are identical — nothing to upgrade")
|
|
111
124
|
|
|
125
|
+
const installed = existsSync(`${ROOT}/active.json`)
|
|
126
|
+
if (installed) console.log(" note: an authority is installed, so this is an in-place upgrade (run `upgrade`, no token rotation)")
|
|
127
|
+
|
|
112
128
|
say("no stale authority runtime state")
|
|
113
|
-
{
|
|
129
|
+
if (!installed) {
|
|
114
130
|
const stale = ["/sys/fs/cgroup/ouro-authority", "/var/lib/ouro-authority", "/run/ouro-authority", `${ROOT}/epochs`].filter((d) => existsSync(d))
|
|
115
131
|
if (stale.length && !existsSync(`${ROOT}/active.json`)) console.log(` note: stale dirs present, install will clear them: ${stale.join(", ")}`)
|
|
116
132
|
else if (!stale.length) ok("no stale authority runtime dirs")
|
|
@@ -119,7 +135,8 @@ function preflight(version) {
|
|
|
119
135
|
|
|
120
136
|
say("no upgrade already in flight")
|
|
121
137
|
existsSync(JOURNAL) ? bad(`a template transaction journal is pending at ${JOURNAL}; resolve it first`) : ok("no pending template transaction")
|
|
122
|
-
existsSync(
|
|
138
|
+
if (installed) existsSync(UPGRADE_JOURNAL) ? console.log(" note: an in-place upgrade is pending; `upgrade` resumes it") : ok("no pending in-place upgrade")
|
|
139
|
+
if (installed && existsSync(`${ROOT}/incoming-token`)) console.log(" note: a stale incoming-token copy is present; the upgrade removes it (D-044)")
|
|
123
140
|
|
|
124
141
|
say("host primitives (root-owned, not group/world-writable)")
|
|
125
142
|
for (const f of PRIMITIVES) {
|
|
@@ -140,7 +157,8 @@ function preflight(version) {
|
|
|
140
157
|
} catch { bad("could not inspect the image contents") }
|
|
141
158
|
|
|
142
159
|
say("D-018: the fenced vault read the install depends on")
|
|
143
|
-
if (
|
|
160
|
+
if (installed) ok("not needed: the installed gateway already holds the token in root custody")
|
|
161
|
+
else if (id) {
|
|
144
162
|
// Run the read fenced exactly as THIS version's install will fence it, so
|
|
145
163
|
// the rehearsal is faithful: an unfixed image is tested without the fix and
|
|
146
164
|
// correctly fails here rather than during a real install.
|
|
@@ -169,7 +187,7 @@ function preflight(version) {
|
|
|
169
187
|
catch { bad("jellyfin container not inspectable") }
|
|
170
188
|
|
|
171
189
|
console.log("")
|
|
172
|
-
if (RED === 0) console.log("PREFLIGHT GREEN — the upgrade can proceed. Next: prepare, rotate the token, install.")
|
|
190
|
+
if (RED === 0) console.log(installed ? "PREFLIGHT GREEN — run `upgrade` (detached)." : "PREFLIGHT GREEN — the upgrade can proceed. Next: prepare, rotate the token, install.")
|
|
173
191
|
else console.log(`PREFLIGHT RED — ${RED} blocker(s) above. Nothing was changed; fix these before rotating the token.`)
|
|
174
192
|
process.exit(RED === 0 ? 0 : 1)
|
|
175
193
|
}
|
|
@@ -215,7 +233,41 @@ function prepare(version) {
|
|
|
215
233
|
say(`prepare inputs for ${version}`)
|
|
216
234
|
const id = imageId(version)
|
|
217
235
|
if (!id) fail(`image ${image(version)} is not present; pull it first`)
|
|
236
|
+
if (existsSync(`${ROOT}/active.json`)) fail("an authority is installed; use `upgrade` (in place, no token rotation) instead of prepare/install")
|
|
218
237
|
const epochId = `${version.replace(/[^A-Za-z0-9_-]/g, "-")}-${new Date().toISOString().slice(0, 10).replace(/-/g, "")}`
|
|
238
|
+
const manifestBytes = stageIncomingPackage(version)
|
|
239
|
+
writePrivate(`${ROOT}/package-manifest.json`, manifestBytes)
|
|
240
|
+
const creds = JSON.parse(readFileSync(`/mnt/user/appdata/ouro-butler/runtime/container-credentials.json`, "utf8")).credentials[0].runtimeConfig
|
|
241
|
+
const request = {
|
|
242
|
+
schemaVersion: 1, epochId,
|
|
243
|
+
botId: String(creds.telegramBotToken).split(":")[0],
|
|
244
|
+
ownerUserId: String(creds.telegramAuthorizedUserId), ownerChatId: String(creds.telegramAuthorizedChatId),
|
|
245
|
+
...packagePins(manifestBytes),
|
|
246
|
+
}
|
|
247
|
+
if (request.ownerUserId !== request.ownerChatId) fail("owner user and chat must match")
|
|
248
|
+
writePrivate(`${ROOT}/request.json`, Buffer.from(JSON.stringify(request)))
|
|
249
|
+
ok(`epoch ${epochId}, package ${request.packageDigest.slice(7, 19)}…`)
|
|
250
|
+
|
|
251
|
+
say("verify the prepared package against the installer's own rules")
|
|
252
|
+
verifyPackage(`${ROOT}/incoming-package`, `${ROOT}/package-manifest.json`, `${ROOT}/request.json`)
|
|
253
|
+
ok("package verifies")
|
|
254
|
+
|
|
255
|
+
console.log("\nPREPARE done. Next: rotate the bot token into ${ROOT}/incoming-token, then install.".replace("${ROOT}", ROOT))
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// The package digest plus the host primitive pins every request carries.
|
|
259
|
+
function packagePins(manifestBytes) {
|
|
260
|
+
const d = (f) => digest(readFileSync(f))
|
|
261
|
+
return {
|
|
262
|
+
packageDigest: digest(manifestBytes),
|
|
263
|
+
nodeDigest: d("/usr/local/bin/node"), prlimitDigest: d("/usr/bin/prlimit"),
|
|
264
|
+
setsidDigest: d("/usr/bin/setsid"), shellDigest: d(sh("/bin/readlink", ["-f", "/bin/sh"]).trim()),
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
// Extract the exact package from the image into incoming-package, normalised to the
|
|
269
|
+
// installer's rules, and return its manifest bytes. The running Butler is untouched.
|
|
270
|
+
function stageIncomingPackage(version) {
|
|
219
271
|
const incoming = `${ROOT}/incoming-package`
|
|
220
272
|
|
|
221
273
|
say("extract the exact package from the image")
|
|
@@ -252,27 +304,8 @@ function prepare(version) {
|
|
|
252
304
|
}
|
|
253
305
|
}
|
|
254
306
|
walk("")
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
const creds = JSON.parse(readFileSync(`/mnt/user/appdata/ouro-butler/runtime/container-credentials.json`, "utf8")).credentials[0].runtimeConfig
|
|
258
|
-
const d = (f) => digest(readFileSync(f))
|
|
259
|
-
const request = {
|
|
260
|
-
schemaVersion: 1, epochId,
|
|
261
|
-
botId: String(creds.telegramBotToken).split(":")[0],
|
|
262
|
-
ownerUserId: String(creds.telegramAuthorizedUserId), ownerChatId: String(creds.telegramAuthorizedChatId),
|
|
263
|
-
packageDigest: digest(manifestBytes),
|
|
264
|
-
nodeDigest: d("/usr/local/bin/node"), prlimitDigest: d("/usr/bin/prlimit"),
|
|
265
|
-
setsidDigest: d("/usr/bin/setsid"), shellDigest: d(sh("/bin/readlink", ["-f", "/bin/sh"]).trim()),
|
|
266
|
-
}
|
|
267
|
-
if (request.ownerUserId !== request.ownerChatId) fail("owner user and chat must match")
|
|
268
|
-
writePrivate(`${ROOT}/request.json`, Buffer.from(JSON.stringify(request)))
|
|
269
|
-
ok(`epoch ${epochId}, ${Object.keys(files).length} files pinned, package ${request.packageDigest.slice(7, 19)}…`)
|
|
270
|
-
|
|
271
|
-
say("verify the prepared package against the installer's own rules")
|
|
272
|
-
verifyPackage(incoming, `${ROOT}/package-manifest.json`, `${ROOT}/request.json`)
|
|
273
|
-
ok("package verifies")
|
|
274
|
-
|
|
275
|
-
console.log("\nPREPARE done. Next: rotate the bot token into ${ROOT}/incoming-token, then install.".replace("${ROOT}", ROOT))
|
|
307
|
+
ok(`${Object.keys(files).length} files pinned`)
|
|
308
|
+
return Buffer.from(JSON.stringify({ schemaVersion: 1, files }))
|
|
276
309
|
}
|
|
277
310
|
|
|
278
311
|
function writePrivate(path, bytes) { writeFileSync(path, bytes, { mode: 0o600 }); sh("/bin/chown", ["0:0", path]); chmodSync(path, 0o600) }
|
|
@@ -440,18 +473,23 @@ start_gw() {
|
|
|
440
473
|
/bin/sh /boot/config/custom/ouro-authority/start.sh --boot >> "$LOG" 2>&1 \
|
|
441
474
|
|| echo "$(date) keeper: authority boot failed; will retry" >> "$LOG"
|
|
442
475
|
}
|
|
476
|
+
# An in-place upgrade pauses us with a flag; a stale flag (> 30 min) is ignored.
|
|
477
|
+
maintenance() { [ -n "$(find /run/ouro-authority-maintenance -mmin -30 2>/dev/null)" ]; }
|
|
443
478
|
pause_reaper; ensure_cg; ensure_sock
|
|
444
479
|
last_res=0
|
|
445
480
|
while true; do
|
|
481
|
+
if maintenance; then sleep 15; continue; fi
|
|
446
482
|
pause_reaper
|
|
447
483
|
ensure_cg
|
|
448
484
|
ensure_sock
|
|
449
485
|
[ -n "$(gw_pid)" ] || { start_gw; sleep 8; }
|
|
450
486
|
rd=$(jq -rc .status "$R"/epochs/*/readiness.json 2>/dev/null)
|
|
451
487
|
st=$(docker inspect ouro-butler --format '{{.State.Health.Status}}' 2>/dev/null)
|
|
488
|
+
ss=$(docker inspect ouro-butler --format '{{.State.Status}}' 2>/dev/null)
|
|
452
489
|
now=$(date +%s)
|
|
453
|
-
|
|
454
|
-
|
|
490
|
+
# Unhealthy, or left stopped (an interrupted upgrade or restart): the gateway is ready, so bring it back.
|
|
491
|
+
if [ "$rd" = ready ] && { [ "$st" = unhealthy ] || [ "$ss" = exited ] || [ "$ss" = created ]; } && [ $((now - last_res)) -gt 90 ]; then
|
|
492
|
+
echo "$(date) keeper: reconnecting resident ($ss/$st)" >> "$LOG"
|
|
455
493
|
docker restart ouro-butler >/dev/null 2>&1
|
|
456
494
|
last_res=$now
|
|
457
495
|
fi
|
|
@@ -468,6 +506,8 @@ const GATEWAY_WATCHDOG_SH = String.raw`#!/bin/sh
|
|
|
468
506
|
set -u
|
|
469
507
|
# The stop hook sets this during shutdown; never resurrect the supervisor then.
|
|
470
508
|
[ -e /run/ouro-authority-shutdown ] && exit 0
|
|
509
|
+
# An in-place upgrade pauses supervision; a stale flag (> 30 min) is ignored.
|
|
510
|
+
[ -n "$(find /run/ouro-authority-maintenance -mmin -30 2>/dev/null)" ] && exit 0
|
|
471
511
|
SUP=/boot/config/custom/ouro-authority/gateway-supervisor.sh
|
|
472
512
|
LOG=/var/log/ouro-gateway.log
|
|
473
513
|
if [ ! -f "$SUP" ]; then
|
|
@@ -737,6 +777,83 @@ function buildFinalProof(version, out) {
|
|
|
737
777
|
writeFileSync(out, JSON.stringify(proof), { mode: 0o600 })
|
|
738
778
|
}
|
|
739
779
|
|
|
780
|
+
// ---- upgrade (in place, installed authority) --------------------------------
|
|
781
|
+
|
|
782
|
+
function pauseSupervision() {
|
|
783
|
+
writeFileSync(MAINTENANCE, `${new Date().toISOString()} ${process.pid}\n`)
|
|
784
|
+
sh("/bin/sh", ["-c", "for p in $(pgrep -f '^/bin/sh /boot/config/custom/ouro-authority/[g]ateway-supervisor' || true); do kill $p; done"])
|
|
785
|
+
ok("host supervision paused (its flag expires on its own after 30 min)")
|
|
786
|
+
}
|
|
787
|
+
function resumeSupervision() {
|
|
788
|
+
rmSync(MAINTENANCE, { force: true })
|
|
789
|
+
installGatewaySupervisor()
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
// Keep Unraid's DockerMan template (what the UI's Apply/Update uses) on the live image.
|
|
793
|
+
function pinTemplate(version) {
|
|
794
|
+
if (!existsSync(TEMPLATE)) { console.log(" note: no DockerMan template to update"); return }
|
|
795
|
+
const text = readFileSync(TEMPLATE, "utf8")
|
|
796
|
+
const next = text.replace(/<Repository>ghcr\.io\/ourostack\/ouroboros-butler:[^<]+<\/Repository>/u, `<Repository>${image(version)}</Repository>`)
|
|
797
|
+
if (!next.includes(`<Repository>${image(version)}</Repository>`)) { console.log(" WARN: DockerMan template Repository not recognised; left unchanged"); return }
|
|
798
|
+
if (next !== text) { writeFileSync(`${TEMPLATE}.prev`, text); writeFileSync(TEMPLATE, next) }
|
|
799
|
+
ok(`DockerMan template pins ${image(version)}`)
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
function lifecycleFailure(e) {
|
|
803
|
+
const detail = [e.stderr, e.stdout].map((x) => (x ? String(x).trim() : "")).filter(Boolean).join(" | ")
|
|
804
|
+
let last = ""
|
|
805
|
+
try { last = readFileSync(`${ROOT}/lifecycle-failure.log`, "utf8").trim().split("\n").filter((l) => /^\d{4}-/.test(l)).at(-1) ?? "" } catch { /* none */ }
|
|
806
|
+
return `${detail || e.message}${last ? `\n last root failure: ${last}` : ""}`
|
|
807
|
+
}
|
|
808
|
+
|
|
809
|
+
function upgrade(version, rehearse) {
|
|
810
|
+
say(`in-place upgrade to ${version}${rehearse ? ` — REHEARSAL: stop after "${rehearse}", then roll back` : ""}`)
|
|
811
|
+
if (rehearse && !UPGRADE_STEPS.includes(rehearse)) fail(`--rehearse takes one of: ${UPGRADE_STEPS.join(", ")}`)
|
|
812
|
+
if (!existsSync(`${ROOT}/active.json`) || !existsSync(`${ROOT}/activation.json`)) fail("no installed authority; use prepare + install")
|
|
813
|
+
if (existsSync(JOURNAL)) fail("a template transaction is pending; resolve it first")
|
|
814
|
+
let id = imageId(version)
|
|
815
|
+
if (!id) { docker(["pull", image(version)]); id = imageId(version) }
|
|
816
|
+
if (!id) fail(`image ${image(version)} could not be pulled`)
|
|
817
|
+
ok(`image ${id.slice(0, 19)}…`)
|
|
818
|
+
if (existsSync(UPGRADE_JOURNAL)) console.log(" note: resuming the pending in-place upgrade with its staged package")
|
|
819
|
+
else {
|
|
820
|
+
const current = JSON.parse(readFileSync(`${ROOT}/request.json`, "utf8"))
|
|
821
|
+
const manifestBytes = stageIncomingPackage(version)
|
|
822
|
+
const pkgVersion = JSON.parse(readFileSync(`${ROOT}/incoming-package/deploy/unraid/sanctuary.ouro/bundle-meta.json`, "utf8")).runtimeVersion
|
|
823
|
+
if (pkgVersion !== version) fail(`staged package is ${pkgVersion}, not ${version}`)
|
|
824
|
+
writePrivate(INCOMING_MANIFEST, manifestBytes)
|
|
825
|
+
const request = { schemaVersion: 1, epochId: current.epochId, botId: current.botId, ownerUserId: current.ownerUserId, ownerChatId: current.ownerChatId, ...packagePins(manifestBytes) }
|
|
826
|
+
writePrivate(INCOMING_REQUEST, Buffer.from(JSON.stringify(request)))
|
|
827
|
+
verifyPackage(`${ROOT}/incoming-package`, INCOMING_MANIFEST, INCOMING_REQUEST)
|
|
828
|
+
ok("new package staged beside the live one and verified")
|
|
829
|
+
}
|
|
830
|
+
const lifecycle = `${ROOT}/incoming-package/dist/heart/daemon/sanctuary-authority-root-lifecycle.js`
|
|
831
|
+
const jellyfinBefore = docker(["inspect", "jellyfin", "--format", "{{.Id}}|{{.Image}}|{{.RestartCount}}|{{.State.StartedAt}}"]).trim()
|
|
832
|
+
const policyBefore = existsSync(POLICY) ? sha12(POLICY) : fail("steward policy missing")
|
|
833
|
+
pauseSupervision()
|
|
834
|
+
let failure = null
|
|
835
|
+
try {
|
|
836
|
+
say("lifecycle upgrade (stop → switch → resident → migrate → start)")
|
|
837
|
+
ok(sh("/usr/local/bin/node", [lifecycle, "upgrade", id, image(version), ...(rehearse ? ["--fail-after", rehearse] : [])], { stdio: ["ignore", "pipe", "pipe"] }).trim())
|
|
838
|
+
} catch (e) { failure = e }
|
|
839
|
+
if (failure) {
|
|
840
|
+
console.log(`\n!! ${rehearse ? "rehearsal stopped as planned" : "upgrade failed"}: ${lifecycleFailure(failure)}\n!! rolling back to the predecessor`)
|
|
841
|
+
try { ok(`rollback: ${sh("/usr/local/bin/node", [lifecycle, "upgrade-rollback"], { stdio: ["ignore", "pipe", "pipe"] }).trim()}`) }
|
|
842
|
+
catch (e) {
|
|
843
|
+
resumeSupervision()
|
|
844
|
+
fail(`ROLLBACK FAILED: ${lifecycleFailure(e)}\nThe journal is kept; the next authority boot (or \`upgrade-rollback\`) retries it.`)
|
|
845
|
+
}
|
|
846
|
+
} else pinTemplate(version)
|
|
847
|
+
resumeSupervision()
|
|
848
|
+
say("preservation")
|
|
849
|
+
docker(["inspect", "jellyfin", "--format", "{{.Id}}|{{.Image}}|{{.RestartCount}}|{{.State.StartedAt}}"]).trim() === jellyfinBefore ? ok("jellyfin unchanged") : fail("JELLYFIN CHANGED")
|
|
850
|
+
sha12(POLICY) === policyBefore ? ok("steward policy unchanged") : fail("STEWARD POLICY CHANGED")
|
|
851
|
+
const live = docker(["inspect", CONTAINER, "--format", "{{.Config.Image}} {{.State.Status}}/{{.State.Health.Status}}"]).trim()
|
|
852
|
+
ok(`butler: ${live}`)
|
|
853
|
+
if (failure && !rehearse) fail("upgrade rolled back; the Butler is on its prior version (see above)")
|
|
854
|
+
console.log(rehearse ? "\nREHEARSAL done — the rollback restored the predecessor." : `\nUPGRADE done — ${version} live. Run verify.`)
|
|
855
|
+
}
|
|
856
|
+
|
|
740
857
|
// ---- verify ---------------------------------------------------------------
|
|
741
858
|
|
|
742
859
|
function verify() {
|
|
@@ -755,9 +872,9 @@ function fail(m) { console.error(`\nREFUSING: ${m}`); process.exit(1) }
|
|
|
755
872
|
|
|
756
873
|
// ---- entry ----------------------------------------------------------------
|
|
757
874
|
|
|
758
|
-
const [phase, version] = process.argv.slice(2)
|
|
759
|
-
if (!phase || !["preflight", "prepare", "install", "verify"].includes(phase)) {
|
|
760
|
-
console.error("Usage: sanctuary-butler-upgrade.mjs <preflight|prepare|install|verify> <version>")
|
|
875
|
+
const [phase, version, flag, rehearse] = process.argv.slice(2)
|
|
876
|
+
if (!phase || !["preflight", "prepare", "install", "verify", "upgrade"].includes(phase) || (flag !== undefined && !(phase === "upgrade" && flag === "--rehearse" && rehearse))) {
|
|
877
|
+
console.error("Usage: sanctuary-butler-upgrade.mjs <preflight|prepare|install|verify|upgrade> <version> [--rehearse <step>]")
|
|
761
878
|
process.exit(2)
|
|
762
879
|
}
|
|
763
880
|
if (phase !== "verify" && !/^[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?$/.test(version || "")) {
|
|
@@ -770,3 +887,4 @@ if (phase === "preflight") preflight(version)
|
|
|
770
887
|
else if (phase === "prepare") prepare(version)
|
|
771
888
|
else if (phase === "install") install(version)
|
|
772
889
|
else if (phase === "verify") verify()
|
|
890
|
+
else if (phase === "upgrade") upgrade(version, rehearse)
|
|
@@ -130,6 +130,75 @@
|
|
|
130
130
|
"effectScopes": [
|
|
131
131
|
"telegram.owner_event"
|
|
132
132
|
]
|
|
133
|
+
},
|
|
134
|
+
"sanctuary-agent-peer": {
|
|
135
|
+
"version": 1,
|
|
136
|
+
"toolNames": [
|
|
137
|
+
"external_event_disposition",
|
|
138
|
+
"query_active_work",
|
|
139
|
+
"save_friend_note",
|
|
140
|
+
"telegram_contact_manage",
|
|
141
|
+
"query_cares",
|
|
142
|
+
"care_manage",
|
|
143
|
+
"await_condition",
|
|
144
|
+
"resolve_await",
|
|
145
|
+
"cancel_await",
|
|
146
|
+
"list_recent_attachments",
|
|
147
|
+
"materialize_attachment",
|
|
148
|
+
"describe_image",
|
|
149
|
+
"unraid_list_containers",
|
|
150
|
+
"unraid_get_container_logs",
|
|
151
|
+
"unraid_get_storage",
|
|
152
|
+
"sanctuary_get_media_optimization",
|
|
153
|
+
"sanctuary_search_media_catalog",
|
|
154
|
+
"unraid_get_disks",
|
|
155
|
+
"unraid_get_notifications",
|
|
156
|
+
"unraid_get_system",
|
|
157
|
+
"sanctuary_get_install_state",
|
|
158
|
+
"unraid_check_services",
|
|
159
|
+
"sanctuary_get_download_queue",
|
|
160
|
+
"sanctuary_resume_download_queue",
|
|
161
|
+
"steward_policy_manage",
|
|
162
|
+
"send_message",
|
|
163
|
+
"ponder",
|
|
164
|
+
"rest",
|
|
165
|
+
"settle",
|
|
166
|
+
"speak",
|
|
167
|
+
"shell",
|
|
168
|
+
"shell_status",
|
|
169
|
+
"shell_tail",
|
|
170
|
+
"read_file",
|
|
171
|
+
"write_file",
|
|
172
|
+
"edit_file",
|
|
173
|
+
"glob",
|
|
174
|
+
"grep",
|
|
175
|
+
"web_search",
|
|
176
|
+
"search_facts",
|
|
177
|
+
"consult_diary",
|
|
178
|
+
"consult_notes",
|
|
179
|
+
"get_friend_note",
|
|
180
|
+
"session_summary",
|
|
181
|
+
"query_session",
|
|
182
|
+
"set_reasoning_effort",
|
|
183
|
+
"restart_runtime",
|
|
184
|
+
"revive_sense",
|
|
185
|
+
"media_search",
|
|
186
|
+
"media_request",
|
|
187
|
+
"media_request_status",
|
|
188
|
+
"media_diagnose_and_fix",
|
|
189
|
+
"media_chain_health",
|
|
190
|
+
"media_play_or_resolve"
|
|
191
|
+
],
|
|
192
|
+
"contextScopes": [
|
|
193
|
+
"household.status",
|
|
194
|
+
"household.policy",
|
|
195
|
+
"household.private"
|
|
196
|
+
],
|
|
197
|
+
"effectScopes": [
|
|
198
|
+
"telegram.proactive",
|
|
199
|
+
"telegram.request_return",
|
|
200
|
+
"telegram.owner_event"
|
|
201
|
+
]
|
|
133
202
|
}
|
|
134
203
|
}
|
|
135
204
|
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
<?xml version="1.0"?>
|
|
2
2
|
<Container version="2">
|
|
3
3
|
<Name>ouro-butler</Name>
|
|
4
|
-
<Repository>ghcr.io/ourostack/ouroboros-butler:0.1.0-alpha.
|
|
4
|
+
<Repository>ghcr.io/ourostack/ouroboros-butler:0.1.0-alpha.835</Repository>
|
|
5
5
|
<Registry>https://github.com/ourostack/ouroboros/pkgs/container/ouroboros-butler</Registry>
|
|
6
6
|
<Network>host</Network>
|
|
7
7
|
<Shell>sh</Shell>
|
package/dist/a2a/client.js
CHANGED
|
@@ -1,12 +1,21 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.cardDid = cardDid;
|
|
3
4
|
exports.endpointForCard = endpointForCard;
|
|
4
5
|
exports.fetchA2AAgentCard = fetchA2AAgentCard;
|
|
5
6
|
exports.sendA2AMessage = sendA2AMessage;
|
|
6
7
|
exports.postA2AMessageEnvelope = postA2AMessageEnvelope;
|
|
7
8
|
exports.getA2ATask = getA2ATask;
|
|
9
|
+
exports.sendSealedA2AChat = sendSealedA2AChat;
|
|
8
10
|
const node_crypto_1 = require("node:crypto");
|
|
11
|
+
const a2a_client_1 = require("@ouro.bot/friends/a2a-client");
|
|
9
12
|
const runtime_1 = require("../nerves/runtime");
|
|
13
|
+
const sealed_chat_1 = require("./sealed-chat");
|
|
14
|
+
/** The trimmed `did:key` a card serves, or undefined when the card is did-less. */
|
|
15
|
+
function cardDid(card) {
|
|
16
|
+
const did = card.did;
|
|
17
|
+
return typeof did === "string" && did.trim() ? did.trim() : undefined;
|
|
18
|
+
}
|
|
10
19
|
function endpointForCard(card) {
|
|
11
20
|
const jsonRpc = card.supportedInterfaces?.find((entry) => (entry.protocolBinding ?? entry.transport)?.toUpperCase() === "JSONRPC");
|
|
12
21
|
if (jsonRpc?.url)
|
|
@@ -178,3 +187,50 @@ async function getA2ATask(input) {
|
|
|
178
187
|
}
|
|
179
188
|
return rpc.result;
|
|
180
189
|
}
|
|
190
|
+
/**
|
|
191
|
+
* Talk to an A2A agent as a verified friend: seal `text` to the agent's card DID,
|
|
192
|
+
* signed by `identity`, send it, and open the sealed reply. The card must serve a
|
|
193
|
+
* did:key bound to it (the same check onboarding applies), so the reply can only be
|
|
194
|
+
* accepted from that key. Neither direction ever travels in plaintext.
|
|
195
|
+
*/
|
|
196
|
+
async function sendSealedA2AChat(input) {
|
|
197
|
+
const fetchImpl = input.fetchImpl ?? fetch;
|
|
198
|
+
const sodium = input.sodium ?? await (0, a2a_client_1.ready)();
|
|
199
|
+
const card = await fetchA2AAgentCard(input.cardUrl, fetchImpl);
|
|
200
|
+
const peerDid = cardDid(card);
|
|
201
|
+
if (!peerDid)
|
|
202
|
+
throw new Error(`A2A card ${input.cardUrl} serves no DID; sealed chat needs one`);
|
|
203
|
+
const parsed = (0, a2a_client_1.parseDidKey)(peerDid);
|
|
204
|
+
if (!parsed || !(0, a2a_client_1.verifyCardDidBinding)({ card: { did: peerDid, url: input.cardUrl }, did: peerDid, didDoc: null })) {
|
|
205
|
+
throw new Error(`A2A card↔DID binding failed for ${peerDid}`);
|
|
206
|
+
}
|
|
207
|
+
const endpointUrl = endpointForCard(card);
|
|
208
|
+
const conversationId = input.conversationId ?? (0, node_crypto_1.randomUUID)();
|
|
209
|
+
const sealed = (0, sealed_chat_1.sealChatMessage)({
|
|
210
|
+
sodium,
|
|
211
|
+
from: input.identity,
|
|
212
|
+
recipientDid: peerDid,
|
|
213
|
+
recipientEd25519Pub: parsed.ed25519Pub,
|
|
214
|
+
text: input.text,
|
|
215
|
+
conversationId,
|
|
216
|
+
});
|
|
217
|
+
const request = {
|
|
218
|
+
jsonrpc: "2.0",
|
|
219
|
+
id: (0, node_crypto_1.randomUUID)(),
|
|
220
|
+
method: "SendMessage",
|
|
221
|
+
params: { message: { kind: "message", role: "ROLE_USER", messageId: (0, node_crypto_1.randomUUID)(), contextId: conversationId, parts: sealed.parts } },
|
|
222
|
+
};
|
|
223
|
+
(0, runtime_1.emitNervesEvent)({ component: "channels", event: "channel.a2a_chat_send_start", message: "sending sealed A2A chat message", meta: { endpointUrl, peerDid } });
|
|
224
|
+
const rpc = await postJsonRpc(endpointUrl, request, fetchImpl);
|
|
225
|
+
if ("error" in rpc)
|
|
226
|
+
throw new Error(`A2A error ${rpc.error.code}: ${rpc.error.message}`);
|
|
227
|
+
const task = rpc.result;
|
|
228
|
+
const replyMessage = task?.status?.message;
|
|
229
|
+
if (!replyMessage)
|
|
230
|
+
throw new Error("A2A chat returned no reply message");
|
|
231
|
+
const opened = await (0, sealed_chat_1.openChatMessage)({ sodium, self: input.identity, message: replyMessage, senderDid: peerDid, senderEd25519Pub: parsed.ed25519Pub });
|
|
232
|
+
if (!opened.ok)
|
|
233
|
+
throw new Error(`A2A chat reply rejected: ${opened.reason}`);
|
|
234
|
+
(0, runtime_1.emitNervesEvent)({ component: "channels", event: "channel.a2a_chat_send_end", message: "received sealed A2A chat reply", meta: { endpointUrl, peerDid, taskId: task.id } });
|
|
235
|
+
return { text: opened.text, conversationId, taskId: task.id, peerDid, peerName: card.name };
|
|
236
|
+
}
|
package/dist/a2a/identity.js
CHANGED
|
@@ -36,7 +36,10 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
36
36
|
exports.readStoredA2ASeed = readStoredA2ASeed;
|
|
37
37
|
exports.loadOrMintA2AIdentity = loadOrMintA2AIdentity;
|
|
38
38
|
exports.loadSelfA2AIdentity = loadSelfA2AIdentity;
|
|
39
|
+
exports.loadOrMintA2AIdentityFile = loadOrMintA2AIdentityFile;
|
|
39
40
|
const crypto = __importStar(require("node:crypto"));
|
|
41
|
+
const fs = __importStar(require("node:fs"));
|
|
42
|
+
const path = __importStar(require("node:path"));
|
|
40
43
|
const os = __importStar(require("node:os"));
|
|
41
44
|
const a2a_client_1 = require("@ouro.bot/friends/a2a-client");
|
|
42
45
|
const runtime_1 = require("../nerves/runtime");
|
|
@@ -168,3 +171,34 @@ async function loadSelfA2AIdentity(input) {
|
|
|
168
171
|
},
|
|
169
172
|
});
|
|
170
173
|
}
|
|
174
|
+
/**
|
|
175
|
+
* A file-backed A2A identity for a client that is not an ouro agent (for example a
|
|
176
|
+
* coding harness talking to an agent over A2A). The file holds the Ed25519 seed, so
|
|
177
|
+
* it is treated like an SSH private key: created 0600 inside a 0700 directory, and
|
|
178
|
+
* REFUSED if group or other can read it.
|
|
179
|
+
*/
|
|
180
|
+
async function loadOrMintA2AIdentityFile(input) {
|
|
181
|
+
const sodium = input.sodium ?? await (0, a2a_client_1.ready)();
|
|
182
|
+
return fs.existsSync(input.filePath) ? readA2AIdentityFile(sodium, input.filePath) : mintA2AIdentityFile(sodium, input.filePath);
|
|
183
|
+
}
|
|
184
|
+
function readA2AIdentityFile(sodium, filePath) {
|
|
185
|
+
const mode = fs.statSync(filePath).mode & 0o077;
|
|
186
|
+
if (mode !== 0)
|
|
187
|
+
throw new Error(`A2A identity file ${filePath} is readable by group or other; chmod 600 it`);
|
|
188
|
+
const parsed = JSON.parse(fs.readFileSync(filePath, "utf8"));
|
|
189
|
+
if (typeof parsed.seed !== "string")
|
|
190
|
+
throw new Error(`A2A identity file ${filePath} has no seed`);
|
|
191
|
+
return deriveIdentity(sodium, decodeSeed(parsed.seed));
|
|
192
|
+
}
|
|
193
|
+
function mintA2AIdentityFile(sodium, filePath) {
|
|
194
|
+
const identity = deriveIdentity(sodium, mintSeed());
|
|
195
|
+
fs.mkdirSync(path.dirname(filePath), { recursive: true, mode: 0o700 });
|
|
196
|
+
fs.writeFileSync(filePath, `${JSON.stringify({ version: 1, did: identity.did, seed: identity.seed }, null, 2)}\n`, { mode: 0o600 });
|
|
197
|
+
(0, runtime_1.emitNervesEvent)({
|
|
198
|
+
component: "channels",
|
|
199
|
+
event: "channel.a2a_client_identity_minted",
|
|
200
|
+
message: "minted file-backed A2A client identity",
|
|
201
|
+
meta: { did: identity.did },
|
|
202
|
+
});
|
|
203
|
+
return identity;
|
|
204
|
+
}
|
|
@@ -95,6 +95,9 @@ async function receiveInboundShare(message, deps) {
|
|
|
95
95
|
trust,
|
|
96
96
|
friendsKind: result.friendsKind,
|
|
97
97
|
status: result.status,
|
|
98
|
+
...(result.message ? { message: result.message } : {}),
|
|
99
|
+
/* v8 ignore next -- a completed receipt implies a trusted friend record: an unknown DID reads as stranger and is refused @preserve */
|
|
100
|
+
...(record ? { friend: record } : {}),
|
|
98
101
|
};
|
|
99
102
|
}
|
|
100
103
|
(0, runtime_1.emitNervesEvent)({
|
package/dist/a2a/onboarding.js
CHANGED
|
@@ -52,11 +52,6 @@ function urlAgentId(cardUrl) {
|
|
|
52
52
|
parsed.hash = "";
|
|
53
53
|
return parsed.toString();
|
|
54
54
|
}
|
|
55
|
-
/** The trimmed `did:key` a card serves, or undefined when the card is did-less. */
|
|
56
|
-
function cardDid(card) {
|
|
57
|
-
const did = card.did;
|
|
58
|
-
return typeof did === "string" && did.trim() ? did.trim() : undefined;
|
|
59
|
-
}
|
|
60
55
|
async function onboardA2APeer(options) {
|
|
61
56
|
// HTTP/URL glue stays harness-side: fetch the agent card, resolve its JSON-RPC
|
|
62
57
|
// endpoint, and derive the stable agentId. The record-shaping (mint/update the
|
|
@@ -77,7 +72,7 @@ async function onboardA2APeer(options) {
|
|
|
77
72
|
// an inbound envelope, so onboarding it would silently break the resolve later). A
|
|
78
73
|
// card whose DID is unparseable or fails the binding is REFUSED (no record
|
|
79
74
|
// written) — never TOFU-trust a mis-bound or malformed card.
|
|
80
|
-
const did = cardDid(card);
|
|
75
|
+
const did = (0, client_1.cardDid)(card);
|
|
81
76
|
if (did) {
|
|
82
77
|
const parsed = (0, a2a_client_1.parseDidKey)(did);
|
|
83
78
|
const bound = parsed !== null && (0, a2a_client_1.verifyCardDidBinding)({ card: { did, url: options.cardUrl }, did, didDoc: null });
|