@ouro.bot/cli 0.1.0-alpha.947 → 0.1.0-alpha.950
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 +13 -0
- package/deploy/unraid/sanctuary-butler-upgrade.mjs +33 -13
- package/deploy/unraid/sanctuary.ouro/bundle-meta.json +1 -1
- package/deploy/unraid/sanctuary.xml +1 -1
- package/dist/a2a/operator-trust.js +20 -3
- package/dist/heart/daemon/sanctuary-authority-root-lifecycle.js +20 -5
- package/dist/heart/providers/anthropic.js +4 -1
- package/npm-shrinkwrap.json +2 -2
- package/package.json +1 -1
package/changelog.json
CHANGED
|
@@ -1,6 +1,19 @@
|
|
|
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.950",
|
|
6
|
+
"changes": [
|
|
7
|
+
"Slugger's model calls work again: Ouro now identifies as a current Claude Code release, so the API stops refusing claude-opus-5-5, and Shepherd's check-ins and notices resume.",
|
|
8
|
+
"Saving an operator trust file twice in the same millisecond no longer fails: the second backup takes the next free name, and no backup is ever overwritten."
|
|
9
|
+
]
|
|
10
|
+
},
|
|
11
|
+
{
|
|
12
|
+
"version": "0.1.0-alpha.948",
|
|
13
|
+
"changes": [
|
|
14
|
+
"A Butler upgrade's automatic rollback no longer fails when the stopping gateway removes its lock file mid-read, and host supervision now stays paused for the whole upgrade, replay gate and rollback included, instead of waking up after 30 minutes."
|
|
15
|
+
]
|
|
16
|
+
},
|
|
4
17
|
{
|
|
5
18
|
"version": "0.1.0-alpha.947",
|
|
6
19
|
"changes": [
|
|
@@ -40,7 +40,7 @@
|
|
|
40
40
|
// Usage: sanctuary-butler-upgrade.mjs <preflight|prepare|install|verify|upgrade> <version> [--rehearse <step>] [--no-gate] [--plant <case>]
|
|
41
41
|
// sanctuary-butler-upgrade.mjs verify [--gate]
|
|
42
42
|
|
|
43
|
-
import { execFileSync, spawnSync } from "node:child_process"
|
|
43
|
+
import { execFileSync, spawn, spawnSync } from "node:child_process"
|
|
44
44
|
import { createHash } from "node:crypto"
|
|
45
45
|
import { mkdtempSync, rmSync, writeFileSync, readFileSync, existsSync, statSync, lstatSync, chmodSync, realpathSync, readdirSync } from "node:fs"
|
|
46
46
|
import { fileURLToPath } from "node:url"
|
|
@@ -67,7 +67,8 @@ const INCOMING_MANIFEST = `${ROOT}/incoming-package-manifest.json`
|
|
|
67
67
|
const INCOMING_REQUEST = `${ROOT}/incoming-request.json`
|
|
68
68
|
const UPGRADE_JOURNAL = `${ROOT}/upgrade.json`
|
|
69
69
|
// Host supervision skips its work while this flag is fresh (< 30 min), so a crashed
|
|
70
|
-
// upgrade can never leave self-heal off for longer than that.
|
|
70
|
+
// upgrade can never leave self-heal off for longer than that. A paused upgrade keeps it
|
|
71
|
+
// fresh with a heartbeat (holdSupervision), because the replay gate alone takes about an hour.
|
|
71
72
|
const MAINTENANCE = "/run/ouro-authority-maintenance"
|
|
72
73
|
// The stop hook's flag: every keeper watchdog version stands down while it exists.
|
|
73
74
|
const SHUTDOWN_FLAG = "/run/ouro-authority-shutdown"
|
|
@@ -958,6 +959,20 @@ function buildFinalProof(version, out) {
|
|
|
958
959
|
|
|
959
960
|
// ---- upgrade (in place, installed authority) --------------------------------
|
|
960
961
|
|
|
962
|
+
/**
|
|
963
|
+
* Keeps host supervision paused for as long as this upgrade process lives, gate and rollback included: a detached
|
|
964
|
+
* shell touches the maintenance flag every interval while the parent is alive. It never recreates a released flag
|
|
965
|
+
* (touch -c). Once the parent is gone, however it ended, the shell drops the shutdown flag (which never expires) and
|
|
966
|
+
* stops, so the maintenance flag ages out and self-heal returns within about 30 minutes of a crash.
|
|
967
|
+
*/
|
|
968
|
+
export const SUPERVISION_HOLD_SH = 'while kill -0 "$1" 2>/dev/null; do touch -c "$2"; sleep "$4"; done; rm -f "$3"'
|
|
969
|
+
export function holdSupervision({ spawnImpl = spawn, parentPid = process.pid, flag = MAINTENANCE, shutdownFlag = SHUTDOWN_FLAG, intervalSec = 60 } = {}) {
|
|
970
|
+
const child = spawnImpl("/bin/sh", ["-c", SUPERVISION_HOLD_SH, "ouro-supervision-hold", String(parentPid), flag, shutdownFlag, String(intervalSec)], { detached: true, stdio: "ignore" })
|
|
971
|
+
child.unref()
|
|
972
|
+
return child
|
|
973
|
+
}
|
|
974
|
+
let HOLD = null
|
|
975
|
+
|
|
961
976
|
// A host still running an older keeper has a cron watchdog that ignores the maintenance
|
|
962
977
|
// flag: it resurrected the paused supervisor, which restarted the resident mid-upgrade
|
|
963
978
|
// (the first 830 -> 835 rehearsal). So put this version's keeper scripts in place first
|
|
@@ -966,6 +981,7 @@ function buildFinalProof(version, out) {
|
|
|
966
981
|
function pauseSupervision() {
|
|
967
982
|
writeFileSync(MAINTENANCE, `${new Date().toISOString()} ${process.pid}\n`)
|
|
968
983
|
writeFileSync(SHUTDOWN_FLAG, `upgrade ${process.pid}\n`)
|
|
984
|
+
HOLD ??= holdSupervision()
|
|
969
985
|
for (const [path, body] of [[GATEWAY_SUPERVISOR, GATEWAY_SUPERVISOR_SH], [GATEWAY_WATCHDOG, GATEWAY_WATCHDOG_SH]]) {
|
|
970
986
|
writeFileSync(path, body)
|
|
971
987
|
sh("/bin/chown", ["0:0", path]); chmodSync(path, 0o700)
|
|
@@ -975,6 +991,7 @@ function pauseSupervision() {
|
|
|
975
991
|
ok("host supervision paused (keeper scripts current; flags expire on their own)")
|
|
976
992
|
}
|
|
977
993
|
function resumeSupervision() {
|
|
994
|
+
if (HOLD) { try { process.kill(-HOLD.pid, "SIGTERM") } catch { /* already gone; touch -c cannot recreate the flag */ } HOLD = null }
|
|
978
995
|
rmSync(MAINTENANCE, { force: true })
|
|
979
996
|
rmSync(SHUTDOWN_FLAG, { force: true })
|
|
980
997
|
installGatewaySupervisor()
|
|
@@ -1005,6 +1022,10 @@ function pruneButlerImages() {
|
|
|
1005
1022
|
ok(`pruned ${removed} old Butler image(s); kept the live and rollback images`)
|
|
1006
1023
|
}
|
|
1007
1024
|
|
|
1025
|
+
// A failed rollback exits with supervision still paused: the keeper's boot would retry the rollback alongside a manual
|
|
1026
|
+
// retry (the 945 rollback on 2026-10-25). The flag then ages out and the keeper's boot retries it on its own.
|
|
1027
|
+
const ROLLBACK_FAILED = (e, lifecycle) => `ROLLBACK FAILED: ${lifecycleFailure(e)}\nThe journal is kept. Host supervision stays paused for about 30 minutes; after that the keeper's authority boot retries the rollback. To retry by hand sooner, keep it paused while you do: \`touch ${MAINTENANCE}; node ${lifecycle} upgrade-rollback\` (repeat the touch if the retry is slow).`
|
|
1028
|
+
|
|
1008
1029
|
function lifecycleFailure(e) {
|
|
1009
1030
|
const detail = [e.stderr, e.stdout].map((x) => (x ? String(x).trim() : "")).filter(Boolean).join(" | ")
|
|
1010
1031
|
let last = ""
|
|
@@ -1131,12 +1152,10 @@ async function upgrade(version, rehearse, { noGate = false, plant } = {}) {
|
|
|
1131
1152
|
if (failure) {
|
|
1132
1153
|
console.log(`\n!! ${planned ? "rehearsal stopped as planned" : "upgrade FAILED"}: ${reason}\n!! rolling back to the predecessor`)
|
|
1133
1154
|
try { ok(`rollback: ${sh("/usr/local/bin/node", [lifecycle, "upgrade-rollback"], { stdio: ["ignore", "pipe", "pipe"] }).trim()}`) }
|
|
1134
|
-
catch (e) {
|
|
1135
|
-
resumeSupervision()
|
|
1136
|
-
fail(`ROLLBACK FAILED: ${lifecycleFailure(e)}\nThe journal is kept; the next authority boot (or \`upgrade-rollback\`) retries it.`)
|
|
1137
|
-
}
|
|
1155
|
+
catch (e) { fail(ROLLBACK_FAILED(e, lifecycle)) }
|
|
1138
1156
|
} else if (!gated) { pinTemplate(version); pruneButlerImages() }
|
|
1139
|
-
|
|
1157
|
+
// A held upgrade stays paused through the gate; commit or rollback resumes it.
|
|
1158
|
+
if (!gated || failure) resumeSupervision()
|
|
1140
1159
|
say("preservation")
|
|
1141
1160
|
const preserved = []
|
|
1142
1161
|
const jellyfinNow = docker(["inspect", "jellyfin", "--format", "{{.Id}}|{{.Image}}|{{.RestartCount}}|{{.State.StartedAt}}"]).trim()
|
|
@@ -1158,17 +1177,18 @@ async function upgrade(version, rehearse, { noGate = false, plant } = {}) {
|
|
|
1158
1177
|
return problem ? { ok: false, detail: problem } : result
|
|
1159
1178
|
},
|
|
1160
1179
|
commit: () => {
|
|
1161
|
-
const problem = stillHeld()
|
|
1162
|
-
if (problem) throw new Error(`refusing to commit: ${problem}`)
|
|
1163
1180
|
pauseSupervision()
|
|
1164
|
-
try {
|
|
1165
|
-
|
|
1181
|
+
try {
|
|
1182
|
+
const problem = stillHeld()
|
|
1183
|
+
if (problem) throw new Error(`refusing to commit: ${problem}`)
|
|
1184
|
+
ok(`commit: ${sh("/usr/local/bin/node", [lifecycle, "upgrade", id, image(version)], { stdio: ["ignore", "pipe", "pipe"] }).trim()}`)
|
|
1185
|
+
} finally { resumeSupervision() }
|
|
1166
1186
|
},
|
|
1167
1187
|
rollback: () => {
|
|
1168
1188
|
pauseSupervision()
|
|
1169
1189
|
try { ok(`rollback: ${sh("/usr/local/bin/node", [lifecycle, "upgrade-rollback"], { stdio: ["ignore", "pipe", "pipe"] }).trim()}`) }
|
|
1170
|
-
catch (e) { fail(
|
|
1171
|
-
|
|
1190
|
+
catch (e) { fail(ROLLBACK_FAILED(e, lifecycle)) }
|
|
1191
|
+
resumeSupervision()
|
|
1172
1192
|
},
|
|
1173
1193
|
confirmRollback: () => {
|
|
1174
1194
|
if (!waitForButler(priorImage, 300)) fail(`rolled back, but ${priorImage} is not running healthy; inspect \`docker logs ${CONTAINER}\``)
|
|
@@ -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.950</Repository>
|
|
5
5
|
<Registry>https://github.com/ourostack/ouroboros/pkgs/container/ouroboros-butler</Registry>
|
|
6
6
|
<Network>host</Network>
|
|
7
7
|
<Shell>sh</Shell>
|
|
@@ -329,6 +329,24 @@ function createExclusive(file, body) {
|
|
|
329
329
|
fs.closeSync(fd);
|
|
330
330
|
}
|
|
331
331
|
}
|
|
332
|
+
const MAX_BACKUP_SUFFIX = 99;
|
|
333
|
+
/**
|
|
334
|
+
* Keeps the previous bytes under the timestamped name, or under the first free `-1`, `-2`, ... variant when two writes
|
|
335
|
+
* land in the same millisecond. Every attempt is an exclusive create, so an existing backup is never overwritten.
|
|
336
|
+
*/
|
|
337
|
+
function createBackup(base, previous) {
|
|
338
|
+
for (let suffix = 0;; suffix++) {
|
|
339
|
+
const candidate = suffix === 0 ? base : `${base}-${suffix}`;
|
|
340
|
+
try {
|
|
341
|
+
createExclusive(candidate, previous);
|
|
342
|
+
return candidate;
|
|
343
|
+
}
|
|
344
|
+
catch (error) {
|
|
345
|
+
if (error.code !== "EEXIST" || suffix >= MAX_BACKUP_SUFFIX)
|
|
346
|
+
throw error;
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
}
|
|
332
350
|
function rawBytes(file) {
|
|
333
351
|
let fd;
|
|
334
352
|
try {
|
|
@@ -345,7 +363,7 @@ function rawBytes(file) {
|
|
|
345
363
|
}
|
|
346
364
|
/**
|
|
347
365
|
* Replaces one file in the trust directory. The previous bytes are kept beside it as a timestamped backup, created the
|
|
348
|
-
* same safe way. The new file is written to an exclusive random temporary name, owned and moded through its descriptor,
|
|
366
|
+
* same safe way and never overwriting an earlier one. The new file is written to an exclusive random temporary name, owned and moded through its descriptor,
|
|
349
367
|
* fsynced and renamed into place, so a reader never sees a half-written file and nothing already on disk is followed.
|
|
350
368
|
*/
|
|
351
369
|
function writeTrustedFile(agentRoot, fileName, body, now) {
|
|
@@ -355,8 +373,7 @@ function writeTrustedFile(agentRoot, fileName, body, now) {
|
|
|
355
373
|
const previous = rawBytes(file);
|
|
356
374
|
let backup = null;
|
|
357
375
|
if (previous) {
|
|
358
|
-
backup = `${file}.bak-${now.toISOString().replace(/[:.]/gu, "-")}
|
|
359
|
-
createExclusive(backup, previous);
|
|
376
|
+
backup = createBackup(`${file}.bak-${now.toISOString().replace(/[:.]/gu, "-")}`, previous);
|
|
360
377
|
fsyncDirectory(dir);
|
|
361
378
|
}
|
|
362
379
|
const tmp = path.join(dir, `.${fileName}.${(0, node_crypto_1.randomBytes)(8).toString("hex")}.tmp`);
|
|
@@ -75,6 +75,11 @@ const DIGEST = /^sha256:[a-f0-9]{64}$/u;
|
|
|
75
75
|
// page cache on Unraid's array that takes minutes, so readiness gets a generous budget
|
|
76
76
|
// (a 120 s budget failed a real rollback on 2026-09-24).
|
|
77
77
|
const GATEWAY_READY_TIMEOUT_MS = 900_000;
|
|
78
|
+
const RESIDENT_READY_TIMEOUT_MS = 300_000;
|
|
79
|
+
// A rolled-back predecessor older than 0.1.0-alpha.947 starts its senses behind a startup external-event turn and took about 8 minutes to become ready on 2026-10-25.
|
|
80
|
+
const PREDECESSOR_READY_TIMEOUT_MS = 900_000;
|
|
81
|
+
// How long an operator upgrade or rollback waits for the deployment lease while a keeper boot holds it. A boot can be running the whole rollback: halt, bundle finalize, gateway readiness and the predecessor's 15 minutes.
|
|
82
|
+
const OPERATOR_LEASE_WAIT_MS = 1_800_000;
|
|
78
83
|
const GATEWAY_START_BACKOFF_MS = [2_000, 10_000, 30_000];
|
|
79
84
|
const GATEWAY_LOG_TAIL_CHARS = 2_000;
|
|
80
85
|
// Read more than is shown, so masking sees whole tokens before the tail is cut.
|
|
@@ -495,7 +500,17 @@ class SanctuaryAuthorityRootLifecycle {
|
|
|
495
500
|
const lock = `${this.#epochRoot()}/authority.lock`;
|
|
496
501
|
if (!fs.existsSync(this.#p(lock)))
|
|
497
502
|
return null;
|
|
498
|
-
|
|
503
|
+
let text;
|
|
504
|
+
try {
|
|
505
|
+
text = this.#private(lock);
|
|
506
|
+
}
|
|
507
|
+
catch (error) {
|
|
508
|
+
// An exiting gateway unlinks its lock, which can land mid-read (ENOENT, or nlink 0). A lock that is gone means no gateway; one still present stays strict.
|
|
509
|
+
if (!fs.existsSync(this.#p(lock)))
|
|
510
|
+
return null;
|
|
511
|
+
throw error;
|
|
512
|
+
}
|
|
513
|
+
const pid = Number(text);
|
|
499
514
|
if (!Number.isSafeInteger(pid) || pid <= 0)
|
|
500
515
|
throw new Error("Sanctuary gateway process lock is invalid");
|
|
501
516
|
if (!fs.existsSync(this.#p(`/proc/${pid}`)))
|
|
@@ -861,7 +876,7 @@ class SanctuaryAuthorityRootLifecycle {
|
|
|
861
876
|
if (this.#containers().find((container) => container.Name === "/ouro-butler")?.Image !== journal.from.imageId) {
|
|
862
877
|
predecessor.#recreateResident(journal.from.imageReference, journal.from.imageId);
|
|
863
878
|
}
|
|
864
|
-
await predecessor.#startUpgraded();
|
|
879
|
+
await predecessor.#startUpgraded(PREDECESSOR_READY_TIMEOUT_MS);
|
|
865
880
|
this.#remove(UPGRADE);
|
|
866
881
|
this.#removeTree(PREVIOUS);
|
|
867
882
|
(0, runtime_1.emitNervesEvent)({ level: "warn", component: "daemon", event: "daemon.sanctuary_upgrade_rolled_back", message: "Sanctuary upgrade rolled back to its predecessor", meta: { from: journal.to.imageReference, to: journal.from.imageReference } });
|
|
@@ -1039,11 +1054,11 @@ class SanctuaryAuthorityRootLifecycle {
|
|
|
1039
1054
|
fs.rmSync(path.join(directory, "window.json"), { force: true, recursive: true });
|
|
1040
1055
|
}
|
|
1041
1056
|
/** Start the gateway, then the resident, and prove both. */
|
|
1042
|
-
async #startUpgraded() {
|
|
1057
|
+
async #startUpgraded(readyTimeoutMs = RESIDENT_READY_TIMEOUT_MS) {
|
|
1043
1058
|
this.#runtimeDirectories();
|
|
1044
1059
|
await this.#startGateway();
|
|
1045
1060
|
this.#docker(["start", "ouro-butler"]);
|
|
1046
|
-
await this.#wait(async () => this.#target().State.Running && this.#target().State.Health?.Status === "healthy" && await this.#ready(),
|
|
1061
|
+
await this.#wait(async () => this.#target().State.Running && this.#target().State.Health?.Status === "healthy" && await this.#ready(), readyTimeoutMs);
|
|
1047
1062
|
}
|
|
1048
1063
|
#removeTree(absolute) {
|
|
1049
1064
|
fs.rmSync(this.#p(absolute), { recursive: true, force: true });
|
|
@@ -1257,7 +1272,7 @@ async function runSanctuaryAuthorityRootCli(argv, write = (text) => process.stdo
|
|
|
1257
1272
|
}
|
|
1258
1273
|
else
|
|
1259
1274
|
await lifecycle.boot();
|
|
1260
|
-
}, { timeoutMs: upgrade || rollback ?
|
|
1275
|
+
}, { timeoutMs: upgrade || rollback ? OPERATOR_LEASE_WAIT_MS : 0, confinementRoot: "/boot/config/custom/ouro-butler" });
|
|
1261
1276
|
}
|
|
1262
1277
|
if (process.argv[1] && fs.existsSync(process.argv[1]) && fs.realpathSync(process.argv[1]) === __filename) {
|
|
1263
1278
|
void runSanctuaryAuthorityRootCli(process.argv.slice(2)).catch((error) => {
|
|
@@ -49,6 +49,9 @@ const error_classification_1 = require("./error-classification");
|
|
|
49
49
|
const ANTHROPIC_SETUP_TOKEN_PREFIX = "sk-ant-oat01-";
|
|
50
50
|
const ANTHROPIC_SETUP_TOKEN_MIN_LENGTH = 80;
|
|
51
51
|
const ANTHROPIC_OAUTH_BETA_HEADER = "claude-code-20250219,oauth-2025-04-20,fine-grained-tool-streaming-2025-05-14,interleaved-thinking-2025-05-14";
|
|
52
|
+
// The API refuses newer models to Claude Code versions it considers too old (claude-opus-5-5 needs 2.1.280 or newer),
|
|
53
|
+
// so this names a current release. Raise it when the API asks for a newer one.
|
|
54
|
+
const CLAUDE_CLI_USER_AGENT = "claude-cli/2.1.310 (external, cli)";
|
|
52
55
|
function getAnthropicAgentNameForGuidance() {
|
|
53
56
|
return (0, identity_1.getAgentName)();
|
|
54
57
|
}
|
|
@@ -455,7 +458,7 @@ function createAnthropicProviderRuntime(model, anthropicConfig = (0, config_1.ge
|
|
|
455
458
|
defaultHeaders: {
|
|
456
459
|
"anthropic-beta": ANTHROPIC_OAUTH_BETA_HEADER,
|
|
457
460
|
"anthropic-dangerous-direct-browser-access": "true",
|
|
458
|
-
"user-agent":
|
|
461
|
+
"user-agent": CLAUDE_CLI_USER_AGENT,
|
|
459
462
|
"x-app": "cli",
|
|
460
463
|
},
|
|
461
464
|
});
|
package/npm-shrinkwrap.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ouro.bot/cli",
|
|
3
|
-
"version": "0.1.0-alpha.
|
|
3
|
+
"version": "0.1.0-alpha.950",
|
|
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.
|
|
9
|
+
"version": "0.1.0-alpha.950",
|
|
10
10
|
"dependencies": {
|
|
11
11
|
"@anthropic-ai/sdk": "^0.78.0",
|
|
12
12
|
"@azure/identity": "^4.13.0",
|