@ouro.bot/cli 0.1.0-alpha.814 → 0.1.0-alpha.815

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,13 @@
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.815",
6
+ "changes": [
7
+ "Protect Sanctuary's resident control socket from transient Telegram app disposal, and safely retry interrupted startup and cleanup without stealing another listener.",
8
+ "Preserve the order of overlapping startup and shutdown requests, and check socket modification time without mistaking permission changes for replacement."
9
+ ]
10
+ },
4
11
  {
5
12
  "version": "0.1.0-alpha.814",
6
13
  "changes": [
@@ -1,5 +1,5 @@
1
1
  {
2
- "runtimeVersion": "0.1.0-alpha.814",
2
+ "runtimeVersion": "0.1.0-alpha.815",
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.814</Repository>
4
+ <Repository>ghcr.io/ourostack/ouroboros-butler:0.1.0-alpha.815</Repository>
5
5
  <Registry>https://github.com/ourostack/ouroboros/pkgs/container/ouroboros-butler</Registry>
6
6
  <Network>host</Network>
7
7
  <Shell>sh</Shell>
@@ -229,70 +229,174 @@ function createSanctuaryInteractiveControl(options) {
229
229
  const socketPath = path.join(options.agentRoot, "state", "acceptance", "telegram-control.sock");
230
230
  const runRequest = options.runRequest ?? (async (operation) => operation());
231
231
  let server;
232
+ let endpoint;
233
+ let startPromise;
234
+ let stopPromise;
232
235
  let updateId = 2_100_000_000;
236
+ const readEndpoint = () => (0, node_fs_1.lstatSync)(socketPath, { bigint: true, throwIfNoEntry: false });
237
+ // Socket mtime, unlike ctime, survives permission-only changes.
238
+ const matches = (observed, current) => current !== undefined && current.isSocket() && current.dev === observed.dev && current.ino === observed.ino
239
+ && current.birthtimeNs === observed.birthtimeNs && current.mtimeNs === observed.mtimeNs;
240
+ const stopListener = async () => {
241
+ const active = server;
242
+ if (!active)
243
+ return;
244
+ if (active.listening) {
245
+ const current = readEndpoint();
246
+ // Native Server.close() also unlinks its pathname, so ownership must precede it.
247
+ if (current && (!endpoint || !matches(endpoint, current)))
248
+ throw new Error("Sanctuary interactive control socket ownership was replaced");
249
+ await new Promise((resolve, reject) => active.close((error) => error ? reject(error) : resolve()));
250
+ }
251
+ server = undefined;
252
+ endpoint = undefined;
253
+ };
233
254
  return {
234
255
  socketPath,
235
- async start() {
236
- if (server)
237
- return;
238
- (0, node_fs_1.mkdirSync)(path.dirname(socketPath), { recursive: true, mode: 0o700 });
239
- if ((0, node_fs_1.existsSync)(socketPath))
240
- (0, node_fs_1.unlinkSync)(socketPath);
241
- server = (0, node_net_1.createServer)({ allowHalfOpen: true }, (connection) => {
242
- let raw = "";
243
- connection.setEncoding("utf8");
244
- connection.on("error", () => undefined);
245
- connection.on("data", (chunk) => { raw += chunk; if (Buffer.byteLength(raw) > MAX_CONTROL_REQUEST)
246
- connection.destroy(); });
247
- connection.on("end", () => {
248
- void runRequest(async () => {
256
+ start() {
257
+ if (startPromise)
258
+ return startPromise;
259
+ const previousStop = stopPromise;
260
+ stopPromise = undefined;
261
+ const starting = (async () => {
262
+ if (previousStop)
263
+ await previousStop;
264
+ if (server) {
265
+ if (endpoint && matches(endpoint, readEndpoint()))
266
+ return;
267
+ await stopListener();
268
+ }
269
+ (0, node_fs_1.mkdirSync)(path.dirname(socketPath), { recursive: true, mode: 0o700 });
270
+ const previous = readEndpoint();
271
+ if (previous) {
272
+ if (!previous.isSocket())
273
+ throw new Error("Sanctuary interactive control endpoint is not a socket");
274
+ await new Promise((resolve, reject) => {
275
+ const socket = (0, node_net_1.createConnection)(socketPath);
276
+ const finish = (error) => { socket.destroy(); if (error)
277
+ reject(error);
278
+ else
279
+ resolve(); };
280
+ socket.setTimeout(1_000, () => finish(new Error("Sanctuary interactive control ownership probe timed out")));
281
+ socket.once("connect", () => finish(new Error("Sanctuary interactive control endpoint has another live listener")));
282
+ socket.once("error", (error) => finish(error.code === "ECONNREFUSED" ? undefined : error));
283
+ });
284
+ const current = readEndpoint();
285
+ if (!matches(previous, current) || current.ctimeNs !== previous.ctimeNs)
286
+ throw new Error("Sanctuary interactive control endpoint was replaced during the ownership probe");
287
+ (0, node_fs_1.unlinkSync)(socketPath);
288
+ }
289
+ server = (0, node_net_1.createServer)({ allowHalfOpen: true }, (connection) => {
290
+ let raw = "";
291
+ connection.setEncoding("utf8");
292
+ connection.on("error", () => undefined);
293
+ connection.on("data", (chunk) => { raw += chunk; if (Buffer.byteLength(raw) > MAX_CONTROL_REQUEST)
294
+ connection.destroy(); });
295
+ connection.on("end", () => {
296
+ void runRequest(async () => {
297
+ try {
298
+ (0, runtime_1.emitNervesEvent)({ component: "senses", event: "senses.sanctuary_interactive_control_request", message: "Sanctuary interactive control request received", meta: { bytes: Buffer.byteLength(raw) } });
299
+ const parsed = object(JSON.parse(raw), "interactive control request");
300
+ if (parsed.operation === "interactive_runtime_ready") {
301
+ exactKeys(parsed, ["operation", "label", "scenarioHandleDigest"], "interactive readiness request");
302
+ if (parsed.label !== "unit-16m-restart-continuation" || typeof parsed.scenarioHandleDigest !== "string" || !SHA256.test(parsed.scenarioHandleDigest))
303
+ throw new Error("interactive readiness binding is invalid");
304
+ connection.end(`${JSON.stringify({ ok: true, result: { ready: true } })}\n`);
305
+ return;
306
+ }
307
+ const result = await executeSanctuaryInteractiveEngine(parsed, {
308
+ agentRoot: options.agentRoot,
309
+ readApprovals: (digest) => (0, approval_store_1.readApprovalsByScenarioHandleDigest)(path.join(options.agentRoot, "state", "approvals", "approvals.sqlite"), digest),
310
+ readPending: () => new telegram_client_1.FileTelegramPendingApprovalStore(path.join(options.agentRoot, "state", "approvals", "telegram-pending.json")).load(),
311
+ createSession: async () => ({
312
+ handle: ({ callbackData, queryId, messageId }) => runRequest(() => options.transport.handleUpdate({
313
+ update_id: updateId++,
314
+ callback_query: {
315
+ id: queryId,
316
+ from: { id: Number(options.authorizedUserId) },
317
+ data: callbackData,
318
+ message: {
319
+ message_id: Number(messageId),
320
+ chat: { id: Number(options.authorizedChatId) },
321
+ },
322
+ },
323
+ })),
324
+ pendingApprovalIds: () => options.transport.listPendingDeliveries().map(({ approvalId }) => approvalId),
325
+ close: () => undefined,
326
+ }),
327
+ proveIndeterminateRecovery: (approval, digest) => proveSanctuaryAttemptedRecoveryWithoutRetry(options.agentRoot, digest, approval),
328
+ writeCredentialObserved: () => /credential|api[_-]?key|token|secret/iu.test(raw),
329
+ });
330
+ connection.end(`${JSON.stringify({ ok: true, result })}\n`);
331
+ }
332
+ catch {
333
+ connection.end(`${JSON.stringify({ ok: false, error: "interactive runtime operation failed" })}\n`);
334
+ }
335
+ }).catch(() => { if (!connection.destroyed)
336
+ connection.end(`${JSON.stringify({ ok: false, error: "interactive runtime operation failed" })}\n`); });
337
+ });
338
+ });
339
+ const active = server;
340
+ try {
341
+ await new Promise((resolve, reject) => {
342
+ const onError = (error) => { active.off("listening", onListening); reject(error); };
343
+ const onListening = () => {
344
+ active.off("error", onError);
345
+ try {
346
+ if (!endpoint || !matches(endpoint, readEndpoint()))
347
+ throw new Error("Sanctuary interactive control socket ownership was replaced during startup");
348
+ resolve();
349
+ }
350
+ catch (error) {
351
+ reject(error);
352
+ }
353
+ };
354
+ active.once("error", onError);
355
+ active.once("listening", onListening);
249
356
  try {
250
- (0, runtime_1.emitNervesEvent)({ component: "senses", event: "senses.sanctuary_interactive_control_request", message: "Sanctuary interactive control request received", meta: { bytes: Buffer.byteLength(raw) } });
251
- const parsed = object(JSON.parse(raw), "interactive control request");
252
- if (parsed.operation === "interactive_runtime_ready") {
253
- exactKeys(parsed, ["operation", "label", "scenarioHandleDigest"], "interactive readiness request");
254
- if (parsed.label !== "unit-16m-restart-continuation" || typeof parsed.scenarioHandleDigest !== "string" || !SHA256.test(parsed.scenarioHandleDigest))
255
- throw new Error("interactive readiness binding is invalid");
256
- connection.end(`${JSON.stringify({ ok: true, result: { ready: true } })}\n`);
257
- return;
357
+ active.listen(socketPath);
358
+ if (active.listening) {
359
+ endpoint = readEndpoint();
360
+ if (!endpoint?.isSocket())
361
+ throw new Error("Sanctuary interactive control startup ownership is unproven");
362
+ (0, node_fs_1.chmodSync)(socketPath, 0o600);
363
+ endpoint = readEndpoint();
258
364
  }
259
- const result = await executeSanctuaryInteractiveEngine(parsed, {
260
- agentRoot: options.agentRoot,
261
- readApprovals: (digest) => (0, approval_store_1.readApprovalsByScenarioHandleDigest)(path.join(options.agentRoot, "state", "approvals", "approvals.sqlite"), digest),
262
- readPending: () => new telegram_client_1.FileTelegramPendingApprovalStore(path.join(options.agentRoot, "state", "approvals", "telegram-pending.json")).load(),
263
- createSession: async () => ({
264
- handle: ({ callbackData, queryId, messageId }) => runRequest(() => options.transport.handleUpdate({
265
- update_id: updateId++,
266
- callback_query: {
267
- id: queryId,
268
- from: { id: Number(options.authorizedUserId) },
269
- data: callbackData,
270
- message: {
271
- message_id: Number(messageId),
272
- chat: { id: Number(options.authorizedChatId) },
273
- },
274
- },
275
- })),
276
- pendingApprovalIds: () => options.transport.listPendingDeliveries().map(({ approvalId }) => approvalId),
277
- close: () => undefined,
278
- }),
279
- proveIndeterminateRecovery: (approval, digest) => proveSanctuaryAttemptedRecoveryWithoutRetry(options.agentRoot, digest, approval),
280
- writeCredentialObserved: () => /credential|api[_-]?key|token|secret/iu.test(raw),
281
- });
282
- connection.end(`${JSON.stringify({ ok: true, result })}\n`);
283
365
  }
284
- catch {
285
- connection.end(`${JSON.stringify({ ok: false, error: "interactive runtime operation failed" })}\n`);
366
+ catch (error) {
367
+ active.off("error", onError);
368
+ onError(error);
286
369
  }
287
- }).catch(() => { if (!connection.destroyed)
288
- connection.end(`${JSON.stringify({ ok: false, error: "interactive runtime operation failed" })}\n`); });
289
- });
290
- });
291
- await new Promise((resolve, reject) => { server.once("error", reject); server.listen(socketPath, () => { server.off("error", reject); (0, node_fs_1.chmodSync)(socketPath, 0o600); resolve(); }); });
370
+ });
371
+ }
372
+ catch (error) {
373
+ try {
374
+ await stopListener();
375
+ }
376
+ catch (cleanupError) {
377
+ throw new AggregateError([error, cleanupError], "Sanctuary interactive control startup and ownership cleanup failed");
378
+ }
379
+ throw error;
380
+ }
381
+ })().finally(() => { if (startPromise === starting)
382
+ startPromise = undefined; });
383
+ startPromise = starting;
384
+ return starting;
385
+ },
386
+ stop() {
387
+ if (stopPromise)
388
+ return stopPromise;
389
+ const starting = startPromise;
390
+ startPromise = undefined;
391
+ const stopping = (async () => {
392
+ if (starting)
393
+ await starting;
394
+ await stopListener();
395
+ })().finally(() => { if (stopPromise === stopping)
396
+ stopPromise = undefined; });
397
+ stopPromise = stopping;
398
+ return stopping;
292
399
  },
293
- async stop() { const active = server; server = undefined; if (active)
294
- await new Promise((resolve) => active.close(() => resolve())); if ((0, node_fs_1.existsSync)(socketPath))
295
- (0, node_fs_1.unlinkSync)(socketPath); },
296
400
  };
297
401
  }
298
402
  async function sanctuaryInteractiveControlReady(socketPath, timeoutMs = 1_000) {
@@ -1669,6 +1669,26 @@ function createTelegramSenseApp(options) {
1669
1669
  catch (error) {
1670
1670
  releaseAcceptanceAudit(error);
1671
1671
  }
1672
+ const pendingCleanup = new Set([
1673
+ () => poll.stop(),
1674
+ async () => { await runPromise?.catch(() => undefined); },
1675
+ async () => { await Promise.all([...approvalReconciliationsInFlight]); },
1676
+ async () => { await interactiveControl?.stop(); },
1677
+ () => api.stop(),
1678
+ () => approvalRuntime?.close(),
1679
+ () => effectJournal?.close(),
1680
+ () => admissionStore?.close(),
1681
+ () => (0, tools_awaiting_1.resetAwaitToolDeps)(),
1682
+ () => runWithAcceptanceAuditOwner(() => {
1683
+ (0, runtime_1.emitNervesEvent)({
1684
+ component: "senses",
1685
+ event: "senses.telegram_poll_end",
1686
+ message: "Telegram long poll stopped",
1687
+ meta: { agentName: options.agentName, subject },
1688
+ });
1689
+ }),
1690
+ retireAcceptanceAudit,
1691
+ ]);
1672
1692
  return {
1673
1693
  run(signal) {
1674
1694
  if (runPromise)
@@ -1762,37 +1782,20 @@ function createTelegramSenseApp(options) {
1762
1782
  return stopPromise;
1763
1783
  stopPromise = (async () => {
1764
1784
  const errors = [];
1765
- const attempt = async (operation) => {
1785
+ for (const operation of pendingCleanup) {
1766
1786
  try {
1767
1787
  await operation();
1788
+ pendingCleanup.delete(operation);
1768
1789
  }
1769
1790
  catch (error) {
1770
1791
  errors.push(error);
1771
1792
  }
1772
- };
1773
- await attempt(() => poll.stop());
1774
- await attempt(async () => { await runPromise?.catch(() => undefined); });
1775
- await attempt(async () => { await Promise.all([...approvalReconciliationsInFlight]); });
1776
- await attempt(async () => { await interactiveControl?.stop(); });
1777
- await attempt(() => api.stop());
1778
- await attempt(() => approvalRuntime?.close());
1779
- await attempt(() => effectJournal?.close());
1780
- await attempt(() => admissionStore?.close());
1781
- await attempt(() => (0, tools_awaiting_1.resetAwaitToolDeps)());
1782
- await attempt(() => runWithAcceptanceAuditOwner(() => {
1783
- (0, runtime_1.emitNervesEvent)({
1784
- component: "senses",
1785
- event: "senses.telegram_poll_end",
1786
- message: "Telegram long poll stopped",
1787
- meta: { agentName: options.agentName, subject },
1788
- });
1789
- }));
1790
- await attempt(retireAcceptanceAudit);
1793
+ }
1791
1794
  if (errors.length === 1)
1792
1795
  throw errors[0];
1793
1796
  if (errors.length > 1)
1794
1797
  throw new AggregateError(errors, "Telegram sense cleanup failed");
1795
- })();
1798
+ })().catch((error) => { stopPromise = undefined; throw error; });
1796
1799
  return stopPromise;
1797
1800
  },
1798
1801
  };
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@ouro.bot/cli",
3
- "version": "0.1.0-alpha.814",
3
+ "version": "0.1.0-alpha.815",
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.814",
9
+ "version": "0.1.0-alpha.815",
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.814",
3
+ "version": "0.1.0-alpha.815",
4
4
  "engines": {
5
5
  "node": ">=22"
6
6
  },