@ouro.bot/cli 0.1.0-alpha.865 → 0.1.0-alpha.866

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,15 @@
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.866",
6
+ "changes": [
7
+ "The Sanctuary lifecycle now relaunches a gateway that exits before readiness (backoff of 2, 10 and 30 seconds) within one 15-minute readiness deadline and, if every attempt fails, throws promptly with the gateway.log tail (token-shaped text masked before the tail is cut) instead of waiting out a fresh budget.",
8
+ "The Sanctuary Telegram authority retries getMe with backoff on network-class errors (fetch failed, ECONNRESET, ETIMEDOUT, 5xx) and still fails at once on auth errors such as 401.",
9
+ "The Sanctuary authority socket server handles client connection errors by logging them and skips writes to destroyed connections, so a client that disconnects mid-response no longer crashes the gateway with write EPIPE.",
10
+ "The Butler upgrade script checks that api.telegram.org is reachable before pausing host supervision and aborts early with a clear message if it is not; it skips the check when an upgrade journal exists (resume or rollback)."
11
+ ]
12
+ },
4
13
  {
5
14
  "version": "0.1.0-alpha.865",
6
15
  "changes": [
@@ -909,7 +909,31 @@ function waitForButler(imageRef, seconds) {
909
909
  return false
910
910
  }
911
911
 
912
- function upgrade(version, rehearse, { noGate = false, plant } = {}) {
912
+ // The new gateway asks Telegram who it is (getMe) before it reports ready. When the host cannot reach
913
+ // Telegram, that fails and the whole upgrade rolls back after host supervision was already paused
914
+ // (the 863 -> 864 upgrade). Any HTTP answer from the API host proves the network path; no token is sent.
915
+ export const TELEGRAM_API_URL = "https://api.telegram.org/"
916
+ export async function telegramApiReachable({ fetchImpl = globalThis.fetch, sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)), tries = 3, timeoutMs = 8000, retryDelayMs = 3000 } = {}) {
917
+ let detail = "no attempt made"
918
+ for (let attempt = 1; attempt <= tries; attempt += 1) {
919
+ try {
920
+ const response = await fetchImpl(TELEGRAM_API_URL, { method: "GET", signal: AbortSignal.timeout(timeoutMs) })
921
+ return { ok: true, detail: `HTTP ${response.status}` }
922
+ } catch (error) {
923
+ detail = error instanceof Error ? (error.cause instanceof Error ? `${error.message}: ${error.cause.message}` : error.message) : String(error)
924
+ if (attempt < tries) await sleep(retryDelayMs)
925
+ }
926
+ }
927
+ return { ok: false, detail: `${detail} (${tries} attempts)` }
928
+ }
929
+
930
+ // A resume or rollback (an upgrade journal exists) must never be blocked by a network probe.
931
+ export async function checkTelegramBeforePause({ journalExists, probe = telegramApiReachable }) {
932
+ if (journalExists) return { skipped: true, ok: true, detail: "upgrade journal present" }
933
+ return { skipped: false, ...(await probe()) }
934
+ }
935
+
936
+ async function upgrade(version, rehearse, { noGate = false, plant } = {}) {
913
937
  say(`in-place upgrade to ${version}${rehearse ? ` — REHEARSAL: stop after "${rehearse}", then roll back` : ""}`)
914
938
  if (rehearse && !UPGRADE_STEPS.includes(rehearse)) fail(`--rehearse takes one of: ${UPGRADE_STEPS.join(", ")}`)
915
939
  const gated = !rehearse && !noGate
@@ -937,6 +961,10 @@ function upgrade(version, rehearse, { noGate = false, plant } = {}) {
937
961
  const priorImage = butlerState().split(" ")[0]
938
962
  const jellyfinBefore = docker(["inspect", "jellyfin", "--format", "{{.Id}}|{{.Image}}|{{.RestartCount}}|{{.State.StartedAt}}"]).trim()
939
963
  const policyBefore = existsSync(POLICY) ? sha12(POLICY) : fail("steward policy missing")
964
+ const telegram = await checkTelegramBeforePause({ journalExists: existsSync(UPGRADE_JOURNAL) })
965
+ 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.`)
966
+ if (telegram.skipped) console.log(" note: resuming a pending upgrade; Telegram reachability not re-checked")
967
+ else ok(`api.telegram.org reachable (${telegram.detail})`)
940
968
  pauseSupervision()
941
969
  let failure = null
942
970
  try {
@@ -1046,7 +1074,7 @@ export function parseUpgradeArgs(argv) {
1046
1074
  return parsed
1047
1075
  }
1048
1076
 
1049
- function run(argv) {
1077
+ async function run(argv) {
1050
1078
  let args
1051
1079
  try { args = parseUpgradeArgs(argv) } catch (error) { console.error(error.message); process.exit(2) }
1052
1080
  requireRoot()
@@ -1054,7 +1082,7 @@ function run(argv) {
1054
1082
  else if (args.phase === "prepare") prepare(args.version)
1055
1083
  else if (args.phase === "install") install(args.version)
1056
1084
  else if (args.phase === "verify") verify(args.gate)
1057
- else if (args.phase === "upgrade") upgrade(args.version, args.rehearse, { noGate: args.noGate, plant: args.plant })
1085
+ else if (args.phase === "upgrade") await upgrade(args.version, args.rehearse, { noGate: args.noGate, plant: args.plant })
1058
1086
  }
1059
1087
 
1060
- if (process.argv[1] && fileURLToPath(import.meta.url) === realpathSync(process.argv[1])) run(process.argv.slice(2))
1088
+ if (process.argv[1] && fileURLToPath(import.meta.url) === realpathSync(process.argv[1])) run(process.argv.slice(2)).catch((error) => { console.error(error); process.exit(1) })
@@ -1,5 +1,5 @@
1
1
  {
2
- "runtimeVersion": "0.1.0-alpha.865",
2
+ "runtimeVersion": "0.1.0-alpha.866",
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.865</Repository>
4
+ <Repository>ghcr.io/ourostack/ouroboros-butler:0.1.0-alpha.866</Repository>
5
5
  <Registry>https://github.com/ourostack/ouroboros/pkgs/container/ouroboros-butler</Registry>
6
6
  <Network>host</Network>
7
7
  <Shell>sh</Shell>
@@ -73,6 +73,10 @@ const DIGEST = /^sha256:[a-f0-9]{64}$/u;
73
73
  // page cache on Unraid's array that takes minutes, so readiness gets a generous budget
74
74
  // (a 120 s budget failed a real rollback on 2026-09-24).
75
75
  const GATEWAY_READY_TIMEOUT_MS = 900_000;
76
+ const GATEWAY_START_BACKOFF_MS = [2_000, 10_000, 30_000];
77
+ const GATEWAY_LOG_TAIL_CHARS = 2_000;
78
+ // Read more than is shown, so masking sees whole tokens before the tail is cut.
79
+ const GATEWAY_LOG_READ_BYTES = 4_096;
76
80
  const GATEWAY_LOG_MAX_BYTES = 5 * 1024 * 1024;
77
81
  const TEMPLATE_JOURNAL = "/boot/config/custom/ouro-butler/docker-man-template-transaction.json";
78
82
  // In-place upgrade of an installed authority: same epoch (token, issuer, cursor,
@@ -536,29 +540,69 @@ class SanctuaryAuthorityRootLifecycle {
536
540
  if (!this.#tokensAbsent() || !this.#rotated())
537
541
  throw new Error("Sanctuary root token custody is not exclusive");
538
542
  if (this.#pid() === null) {
539
- this.#remove(`${this.#epochRoot()}/readiness.json`);
540
- const log = this.#gatewayLog();
541
- let child;
542
- try {
543
- child = (0, node_child_process_1.spawn)(this.#p("/usr/local/bin/node"), [this.#p(`${ROOT}/package/dist/heart/daemon/sanctuary-telegram-authority-entry.js`), "--config", this.#p(`${ROOT}/active.json`)], {
544
- cwd: "/", env: { PATH: "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin" }, detached: true, stdio: ["ignore", log, log],
545
- });
546
- }
547
- finally {
548
- fs.closeSync(log);
543
+ // The gateway can exit within seconds of launch (a transient Telegram network error at
544
+ // getMe did, 863 -> 864). Retry the launch with backoff, and when every attempt fails
545
+ // say so now, with the gateway's own output, instead of waiting out the readiness budget.
546
+ // One deadline bounds every launch: a relaunch only gets the budget that remains.
547
+ const deadline = Date.now() + GATEWAY_READY_TIMEOUT_MS;
548
+ for (let attempt = 0;; attempt += 1) {
549
+ const failure = await this.#launchGatewayOnce(deadline - Date.now());
550
+ if (failure === null)
551
+ return;
552
+ if (attempt >= GATEWAY_START_BACKOFF_MS.length || Date.now() + GATEWAY_START_BACKOFF_MS[attempt] >= deadline) {
553
+ throw new Error(`Sanctuary gateway ${failure} before readiness after ${attempt + 1} attempts; gateway.log tail:\n${this.#gatewayLogTail()}`);
554
+ }
555
+ (0, runtime_1.emitNervesEvent)({ level: "warn", component: "daemon", event: "daemon.sanctuary_gateway_start_retry", message: "Sanctuary gateway exited before readiness; retrying launch", meta: { attempt: attempt + 1, delayMs: GATEWAY_START_BACKOFF_MS[attempt], failure } });
556
+ await new Promise((resolve) => setTimeout(resolve, GATEWAY_START_BACKOFF_MS[attempt]));
549
557
  }
550
- child.unref();
551
- let failed = false;
552
- child.once("error", () => { failed = true; });
553
- await this.#wait(async () => {
554
- if (failed)
555
- throw new Error("Sanctuary gateway launch failed");
556
- return this.#ready();
557
- }, GATEWAY_READY_TIMEOUT_MS);
558
558
  }
559
559
  else
560
560
  await this.#wait(() => this.#ready(), GATEWAY_READY_TIMEOUT_MS);
561
561
  }
562
+ /** One launch: resolves null once the gateway is ready, or "exited"/"failed to launch" if the process ends first. */
563
+ async #launchGatewayOnce(budgetMs) {
564
+ this.#remove(`${this.#epochRoot()}/readiness.json`);
565
+ const log = this.#gatewayLog();
566
+ let child;
567
+ try {
568
+ child = (0, node_child_process_1.spawn)(this.#p("/usr/local/bin/node"), [this.#p(`${ROOT}/package/dist/heart/daemon/sanctuary-telegram-authority-entry.js`), "--config", this.#p(`${ROOT}/active.json`)], {
569
+ cwd: "/", env: { PATH: "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin" }, detached: true, stdio: ["ignore", log, log],
570
+ });
571
+ }
572
+ finally {
573
+ fs.closeSync(log);
574
+ }
575
+ child.unref();
576
+ let ended = null;
577
+ child.once("error", () => { ended = "failed to launch"; });
578
+ child.once("exit", (code, signal) => { ended = `exited (${signal ?? `code ${code}`})`; });
579
+ let readiness = "ready";
580
+ await this.#wait(async () => {
581
+ if (ended !== null) {
582
+ readiness = "ended";
583
+ return true;
584
+ }
585
+ return this.#ready();
586
+ }, budgetMs);
587
+ return readiness === "ended" ? ended : null;
588
+ }
589
+ /** The last of gateway.log, with anything token-shaped masked, for an error message. */
590
+ #gatewayLogTail() {
591
+ const file = this.#p(`${ROOT}/gateway.log`);
592
+ if (!fs.existsSync(file))
593
+ return "(no gateway.log)";
594
+ const size = fs.statSync(file).size;
595
+ const fd = fs.openSync(file, fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW);
596
+ try {
597
+ const length = Math.min(size, GATEWAY_LOG_READ_BYTES);
598
+ const buffer = Buffer.alloc(length);
599
+ fs.readSync(fd, buffer, 0, length, size - length);
600
+ return buffer.toString("utf8").replace(/[0-9]{5,}:[A-Za-z0-9_-]{20,}/gu, "[token]").slice(-GATEWAY_LOG_TAIL_CHARS).trim() || "(gateway.log is empty)";
601
+ }
602
+ finally {
603
+ fs.closeSync(fd);
604
+ }
605
+ }
562
606
  /** The gateway's own output, kept root-only beside the token it guards and capped at two
563
607
  * generations. It used to go nowhere, so a gateway that exited at startup said nothing
564
608
  * (D-036: finding "host cgroup entry is invalid" took a hand-run gateway). */
@@ -199,6 +199,36 @@ async function readBoundedFileResponse(response) {
199
199
  const contentType = response.headers.get("content-type");
200
200
  return { body: Buffer.concat(chunks, total), ...(contentType ? { contentType } : {}) };
201
201
  }
202
+ const GET_ME_BACKOFF_MS = [5_000, 10_000, 20_000, 30_000];
203
+ const NETWORK_FAILURE = /fetch failed|ECONNRESET|ECONNREFUSED|ETIMEDOUT|ENOTFOUND|EAI_AGAIN|ENETUNREACH|EHOSTUNREACH|EPIPE|socket hang up|network|timed out/iu;
204
+ /** A getMe failure worth another try: Telegram 5xx, or no HTTP answer at all (the client reports a
205
+ * transport failure as a status-less TelegramApiError). Auth and other 4xx answers are never retried. */
206
+ function isTransientTelegramFailure(error) {
207
+ if (!(error instanceof telegram_client_1.TelegramApiError))
208
+ return false;
209
+ if (error.status !== null)
210
+ return error.status >= 500;
211
+ return error.errorCode === null && NETWORK_FAILURE.test(error.message);
212
+ }
213
+ async function getMeWithRetry(api, sleep) {
214
+ for (let attempt = 0;; attempt += 1) {
215
+ try {
216
+ return await api.request("getMe", {});
217
+ }
218
+ catch (error) {
219
+ if (attempt >= GET_ME_BACKOFF_MS.length || !isTransientTelegramFailure(error))
220
+ throw error;
221
+ (0, runtime_1.emitNervesEvent)({
222
+ level: "warn",
223
+ component: "daemon",
224
+ event: "daemon.sanctuary_authority_getme_retry",
225
+ message: "Telegram getMe failed with a transient error; retrying",
226
+ meta: { attempt: attempt + 1, delayMs: GET_ME_BACKOFF_MS[attempt] },
227
+ });
228
+ await sleep(GET_ME_BACKOFF_MS[attempt]);
229
+ }
230
+ }
231
+ }
202
232
  async function startSanctuaryTelegramAuthority(options) {
203
233
  (0, runtime_1.emitNervesEvent)({ component: "daemon", event: "daemon.sanctuary_authority_boot_requested", message: "Sanctuary authority boot requested; prerequisite verification pending" });
204
234
  const expectedUid = options.expectedUid ?? 0;
@@ -245,7 +275,7 @@ async function startSanctuaryTelegramAuthority(options) {
245
275
  const publicKey = (0, node_crypto_1.createPublicKey)(privateKey);
246
276
  if (!options.retireOnly) {
247
277
  api = (options.createApi ?? ((value) => (0, telegram_client_1.createTelegramBotApi)({ token: value })))(token);
248
- const identity = await api.request("getMe", {});
278
+ const identity = await getMeWithRetry(api, options.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms))));
249
279
  if (!isObject(identity) || String(identity.id) !== config.botId) {
250
280
  throw new Error("Sanctuary Telegram authority bot identity changed");
251
281
  }
@@ -520,6 +520,17 @@ function createSanctuaryTelegramAuthorityServer(options) {
520
520
  connection.setEncoding("utf8");
521
521
  connection.setTimeout(connectionTimeoutMs, () => connection.destroy());
522
522
  connection.once("close", () => connections.delete(connection));
523
+ // A client that vanishes mid-response (reset, timeout, crash) surfaces as EPIPE/ECONNRESET on this
524
+ // socket; unhandled, that killed the whole gateway. Log it and let the connection close.
525
+ connection.on("error", (error) => {
526
+ (0, runtime_1.emitNervesEvent)({
527
+ level: "warn",
528
+ component: "daemon",
529
+ event: "daemon.sanctuary_authority_connection_error",
530
+ message: "Sanctuary authority client connection failed; dropping it",
531
+ meta: { code: error.code ?? "unknown" },
532
+ });
533
+ });
523
534
  let buffer = "";
524
535
  connection.on("data", (chunk) => {
525
536
  buffer += chunk;
@@ -573,20 +584,22 @@ function createSanctuaryTelegramAuthorityServer(options) {
573
584
  const response = dispatchTail.then(async () => {
574
585
  try {
575
586
  const result = await options.dispatch(frame.method, frame.params);
576
- connection.write(`${JSON.stringify({
577
- protocolVersion: PROTOCOL_VERSION,
578
- id: frame.id,
579
- ok: true,
580
- result,
581
- })}\n`);
587
+ if (!connection.destroyed)
588
+ connection.write(`${JSON.stringify({
589
+ protocolVersion: PROTOCOL_VERSION,
590
+ id: frame.id,
591
+ ok: true,
592
+ result,
593
+ })}\n`);
582
594
  }
583
595
  catch {
584
- connection.write(`${JSON.stringify({
585
- protocolVersion: PROTOCOL_VERSION,
586
- id: frame.id,
587
- ok: false,
588
- error: { message: "Sanctuary authority request failed" },
589
- })}\n`);
596
+ if (!connection.destroyed)
597
+ connection.write(`${JSON.stringify({
598
+ protocolVersion: PROTOCOL_VERSION,
599
+ id: frame.id,
600
+ ok: false,
601
+ error: { message: "Sanctuary authority request failed" },
602
+ })}\n`);
590
603
  }
591
604
  });
592
605
  dispatchTail = response;
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@ouro.bot/cli",
3
- "version": "0.1.0-alpha.865",
3
+ "version": "0.1.0-alpha.866",
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.865",
9
+ "version": "0.1.0-alpha.866",
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.865",
3
+ "version": "0.1.0-alpha.866",
4
4
  "engines": {
5
5
  "node": ">=22"
6
6
  },