@ouro.bot/cli 0.1.0-alpha.864 → 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.
@@ -0,0 +1,533 @@
1
+ #!/usr/bin/env node
2
+ // Replay gate for the Sanctuary Butler: exercises the live Butler's behaviour over its real A2A path after an
3
+ // upgrade, and reads the outcome back from machine state (tool traces, policy hashes, ledgers, queues), never from
4
+ // reply text alone. Host-side, root, plain Node, no dependencies. Subcommands:
5
+ //
6
+ // provision mint two replay peers (a granted principal, an ungranted stranger); idempotent
7
+ // run [--cases a,b] [--plant <id>] open the replay window, run the cases, close the window, print one JSON line per case
8
+ // self-test run every case's readback against built-in pass and fail fixtures (used by CI)
9
+ //
10
+ // The replay window routes owner notices and A2A await deliveries for the two replay peers to
11
+ // <bundle>/state/replay/notices.ndjson instead of Telegram (src/a2a/replay-harness.ts). The window file is written
12
+ // here, as root, into a root-owned directory the Butler process cannot modify; it expires on its own.
13
+ import { execFileSync } from "node:child_process"
14
+ import { createHash, randomUUID } from "node:crypto"
15
+ import { chmodSync, chownSync, existsSync, mkdirSync, readdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs"
16
+ import path from "node:path"
17
+ import { fileURLToPath } from "node:url"
18
+
19
+ export const DEFAULT_BUNDLE = "/mnt/user/appdata/ouro-butler/agent/sanctuary.ouro"
20
+ export const CONTAINER_BUNDLE = "/home/ouro/AgentBundles/sanctuary.ouro"
21
+ export const CONTAINER = "ouro-butler"
22
+ export const CLI_ENTRY = "/opt/ouro/dist/heart/daemon/ouro-entry.js"
23
+ export const DEFAULT_WINDOW_MINUTES = 30
24
+ export const AWAIT_TIMEOUT_MS = 6 * 60 * 1000
25
+ export const AWAIT_POLL_MS = 15 * 1000
26
+ export const MESSAGE_TIMEOUT_MS = 330 * 1000
27
+ const PEER_PROFILE = "sanctuary-agent-peer"
28
+
29
+ const sha256 = (buffer) => createHash("sha256").update(buffer).digest("hex")
30
+
31
+ // ---- pure readback helpers ---------------------------------------------------------------------------------------
32
+
33
+ /** Tool calls of one session in order, each with its arguments text and the tool result that answered it. */
34
+ export function extractTrace(session) {
35
+ const events = Array.isArray(session?.events) ? session.events : []
36
+ const results = new Map()
37
+ for (const event of events) if (event.role === "tool" && event.toolCallId) results.set(event.toolCallId, typeof event.content === "string" ? event.content : JSON.stringify(event.content ?? ""))
38
+ const trace = []
39
+ for (const event of events) {
40
+ if (event.role !== "assistant") continue
41
+ for (const call of event.toolCalls ?? []) trace.push({ name: call.function?.name ?? "", args: call.function?.arguments ?? "", result: results.get(call.id) ?? "" })
42
+ }
43
+ return trace
44
+ }
45
+
46
+ const check = (name, ok, detail = "") => ({ name, ok: Boolean(ok), ...(detail ? { detail } : {}) })
47
+ const sinkFor = (sink, friendId, predicate) => sink.filter((line) => line.friendId === friendId && predicate(line))
48
+ const isDelegatedNotice = (line) => String(line.noticeId).startsWith("delegated:")
49
+
50
+ /** Up/down claim of a reply about one service. null when the reply makes no clear claim. */
51
+ export function upDownClaim(text) {
52
+ const t = String(text ?? "")
53
+ const negated = /\b(not|isn't|aren't|no longer)\s+(up|running|online|available|on)\b/gi
54
+ const negatedDown = /\b(not|isn't|aren't|no longer)\s+(down|offline|stopped)\b/gi
55
+ const down = /\b(down|offline|stopped)\b/i.test(t.replace(negatedDown, " ")) || negated.test(t)
56
+ const up = /\b(up|running|online|is on|available)\b/i.test(t.replace(negated, " ")) || negatedDown.test(t)
57
+ if (up && !down) return "up"
58
+ if (down && !up) return "down"
59
+ return null
60
+ }
61
+
62
+ /** A queue record that Sonarr itself flags as stuck or failing. */
63
+ export function isStalledQueueItem(item) {
64
+ const flagged = (value) => ["warning", "error", "failed"].includes(String(value ?? "").toLowerCase())
65
+ return flagged(item?.trackedDownloadStatus) || flagged(item?.status) || (Array.isArray(item?.statusMessages) && item.statusMessages.length > 0)
66
+ }
67
+
68
+ /** Telegram effect records written during [start, end] that would have reached Ari's chat from a replay. */
69
+ export function telegramLeaks(effects, start, end) {
70
+ return effects.filter((effect) => {
71
+ const at = Date.parse(effect.createdAt)
72
+ return Number.isFinite(at) && at >= start && at <= end && String(effect.idempotencyKey ?? "").startsWith("owner-notice:")
73
+ })
74
+ }
75
+
76
+ // ---- the cases ---------------------------------------------------------------------------------------------------
77
+ // Each case: id, words, sender, delegated, optional applicable(before) -> reason|null, optional poll, readback(ctx) -> checks.
78
+ // ctx: { trace, reply, error, before, after, sink (lines inside this case's window), friends: { principal, stranger } }
79
+
80
+ const callsNamed = (trace, pattern) => trace.filter((call) => pattern.test(call.name))
81
+ const shellBooks = (trace) => trace.filter((call) => /(^|[\s/])books\s+(get|search|series)\b/.test(`${call.name === "shell" ? call.args : ""}`.replace(/\\"/g, '"')))
82
+
83
+ export const CASES = [
84
+ {
85
+ id: "chef-question",
86
+ words: "hi bud!! why don't we have all eps of chef?",
87
+ sender: "principal",
88
+ delegated: false,
89
+ readback: ({ trace, reply }) => {
90
+ const media = trace.filter((call) => /^(media_|sanctuary_search_media_catalog)/.test(call.name) && /\b(seriesId|tmdbId)\W{1,4}(191|89557)\b/i.test(`${call.args} ${call.result}`))
91
+ return [
92
+ check("a media tool call names Sonarr series 191 or TMDB 89557", media.length > 0, `${trace.length} tool calls`),
93
+ check("the reply names the Chef Show", /chef show/i.test(reply ?? "")),
94
+ ]
95
+ },
96
+ },
97
+ {
98
+ id: "books-up",
99
+ words: "is Books up?",
100
+ sender: "principal",
101
+ delegated: false,
102
+ readback: ({ trace, reply, after }) => {
103
+ const claim = upDownClaim(reply)
104
+ const actual = after.containers["calibre-web"] ? "up" : "down"
105
+ return [
106
+ check("the container list tool was used", callsNamed(trace, /^unraid_list_containers$/).length > 0),
107
+ check("the reply's up/down claim matches docker ps", claim === actual, `claimed ${claim}, docker says ${actual}`),
108
+ ]
109
+ },
110
+ },
111
+ {
112
+ id: "books-on-idempotent",
113
+ words: "Books stays on; record calibre-web on and calibre off",
114
+ sender: "principal",
115
+ delegated: true,
116
+ readback: ({ error, before, after, sink, friends }) => [
117
+ check("the command was admitted", !error, error ?? ""),
118
+ check("the steward policy is unchanged", before.stewardSha !== null && before.stewardSha === after.stewardSha, `${before.stewardSha?.slice(0, 12)} -> ${after.stewardSha?.slice(0, 12)}`),
119
+ check("the owner notice went to the sink", sinkFor(sink, friends.principal, isDelegatedNotice).length > 0),
120
+ ],
121
+ },
122
+ {
123
+ id: "ungranted-refused",
124
+ words: "turn Books off",
125
+ sender: "stranger",
126
+ delegated: true,
127
+ readback: ({ error, before, after, sink, friends }) => [
128
+ check("the command was refused", /delegated command refused/i.test(error ?? ""), error ?? "no error returned"),
129
+ check("the steward policy is unchanged", before.stewardSha !== null && before.stewardSha === after.stewardSha),
130
+ check("no notice was written for the stranger", sinkFor(sink, friends.stranger, () => true).length === 0),
131
+ ],
132
+ },
133
+ {
134
+ id: "book-dry-run",
135
+ words: "find Theft of Swords by Michael J. Sullivan for me but don't send it anywhere",
136
+ sender: "principal",
137
+ delegated: false,
138
+ readback: ({ trace, before, after }) => {
139
+ const books = shellBooks(trace)
140
+ return [
141
+ check("the books tool was used to get or search", books.length > 0),
142
+ check("no books call delivers", !trace.some((call) => call.name === "shell" && /\bbooks\b/.test(call.args) && /--deliver/.test(call.args))),
143
+ check("the ledger line count is unchanged", before.ledgerLines === after.ledgerLines, `${before.ledgerLines} -> ${after.ledgerLines}`),
144
+ ]
145
+ },
146
+ },
147
+ {
148
+ id: "stall-kept",
149
+ words: "the Chef Show season 2 download looks stuck, what should we do?",
150
+ sender: "principal",
151
+ delegated: false,
152
+ // An unreadable queue is not "nothing stalled": the case runs and its readback fails.
153
+ applicable: (before) => {
154
+ if (!Array.isArray(before.queue)) return null
155
+ return before.queue.some(isStalledQueueItem) ? null : "no stalled item in the Sonarr queue"
156
+ },
157
+ readback: ({ trace, before, after }) => {
158
+ const ids = (queue) => (Array.isArray(queue) ? queue.map((item) => item.id).sort((a, b) => a - b).join(",") : null)
159
+ return [
160
+ check("the Sonarr queue ids are unchanged", ids(before.queue) !== null && ids(before.queue) === ids(after.queue), `${ids(before.queue)} -> ${ids(after.queue)}`),
161
+ check("nothing was blocklisted or deleted", callsNamed(trace, /^media_blocklist/).length === 0 && !trace.some((call) => /"method"\s*:\s*"DELETE"|\bDELETE\b/.test(call.args))),
162
+ ]
163
+ },
164
+ },
165
+ {
166
+ id: "await-self-resolve",
167
+ words: "file an await that resolves once the ouro-butler container is running, check every 1m, max 15m",
168
+ sender: "principal",
169
+ delegated: false,
170
+ poll: {
171
+ timeoutMs: AWAIT_TIMEOUT_MS,
172
+ intervalMs: AWAIT_POLL_MS,
173
+ until: ({ after, before }) => newAwaits(before, after).length > 0 && newAwaits(before, after).every((entry) => entry.done),
174
+ },
175
+ readback: ({ before, after, sink, friends }) => {
176
+ const filed = newAwaits(before, after)
177
+ return [
178
+ check("an await was filed", filed.length > 0),
179
+ check("every new await was archived as resolved", filed.length > 0 && filed.every((entry) => entry.done && entry.status === "resolved"), filed.map((entry) => `${entry.name}:${entry.done ? entry.status : "pending"}`).join(",")),
180
+ check("its delivery went to the sink", sinkFor(sink, friends.principal, (line) => !isDelegatedNotice(line)).length > 0),
181
+ ]
182
+ },
183
+ },
184
+ ]
185
+
186
+ /** Awaits present after the case that were not present before it, with their archive state. */
187
+ export function newAwaits(before, after) {
188
+ const known = new Set([...before.awaiting.map((entry) => entry.name), ...before.done.map((entry) => entry.name)])
189
+ const pending = after.awaiting.filter((entry) => !known.has(entry.name)).map((entry) => ({ name: entry.name, done: false, status: entry.status }))
190
+ const archived = after.done.filter((entry) => !known.has(entry.name)).map((entry) => ({ name: entry.name, done: true, status: entry.status }))
191
+ return [...pending, ...archived]
192
+ }
193
+
194
+ // ---- orchestration (all I/O is behind `host`) --------------------------------------------------------------------
195
+
196
+ export async function runCase(host, testCase, { plant } = {}) {
197
+ const startedAt = host.now()
198
+ const before = await host.observe()
199
+ const skipReason = testCase.applicable ? testCase.applicable(before) : null
200
+ if (skipReason) {
201
+ // A planted failure on a case that never ran would pass vacuously and prove nothing about the rollback.
202
+ if (plant === testCase.id) return { id: testCase.id, status: "fail", reason: `planted case was skipped (${skipReason}); choose a case that runs`, checks: [check("planted case ran", false, skipReason)] }
203
+ return { id: testCase.id, status: "skipped", reason: skipReason, checks: [] }
204
+ }
205
+ const context = randomUUID()
206
+ const sent = await host.send({ who: testCase.sender, text: testCase.words, delegated: testCase.delegated, context })
207
+ let after = await host.observe()
208
+ if (testCase.poll) {
209
+ const deadline = host.now() + testCase.poll.timeoutMs
210
+ while (!testCase.poll.until({ before, after }) && host.now() < deadline) {
211
+ await host.sleep(testCase.poll.intervalMs)
212
+ after = await host.observe()
213
+ }
214
+ }
215
+ const endedAt = host.now()
216
+ const session = await host.readSession(context)
217
+ const trace = extractTrace(session)
218
+ const sink = after.sink.filter((line) => Date.parse(line.at) >= startedAt - 1000 && Date.parse(line.at) <= endedAt + 1000)
219
+ let checks
220
+ try {
221
+ checks = testCase.readback({ trace, reply: sent.text ?? "", error: sent.error ?? null, before, after, sink, friends: host.friends })
222
+ } catch (error) {
223
+ checks = [check("readback ran", false, String(error?.message ?? error))]
224
+ }
225
+ if (plant === testCase.id) checks = [...checks, check("planted failure (--plant)", false, "this case was told to fail on purpose")]
226
+ const pass = checks.every((entry) => entry.ok)
227
+ return { id: testCase.id, status: pass ? "pass" : "fail", context, checks, startedAt: new Date(startedAt).toISOString(), endedAt: new Date(endedAt).toISOString() }
228
+ }
229
+
230
+ export async function runSuite(host, { cases = CASES.map((entry) => entry.id), plant, windowMinutes = DEFAULT_WINDOW_MINUTES } = {}) {
231
+ const unknown = [...cases, ...(plant ? [plant] : [])].filter((id) => !CASES.some((entry) => entry.id === id))
232
+ if (unknown.length) throw new Error(`unknown case(s): ${unknown.join(", ")}. Known: ${CASES.map((entry) => entry.id).join(", ")}`)
233
+ const selected = CASES.filter((entry) => cases.includes(entry.id))
234
+ await host.waitReady?.()
235
+ const runStart = host.now()
236
+ const results = []
237
+ host.openWindow(windowMinutes)
238
+ try {
239
+ for (const testCase of selected) results.push(await runCase(host, testCase, { plant }))
240
+ } finally {
241
+ host.closeWindow()
242
+ }
243
+ const runEnd = host.now()
244
+ const finalObservation = await host.observe()
245
+ const leaks = telegramLeaks(finalObservation.effects, runStart, runEnd)
246
+ const readable = finalObservation.effectsReadable === true
247
+ const clean = readable && leaks.length === 0
248
+ const telegram = { id: "no-telegram", status: clean ? "pass" : "fail", checks: [check("no owner-notice Telegram effect was recorded during the run", clean, readable ? leaks.map((leak) => leak.idempotencyKey).join(",") : "state/telegram/effects is not readable, so the absence of a leak cannot be shown")] }
249
+ const all = [...results, telegram]
250
+ const failed = all.filter((entry) => entry.status === "fail").map((entry) => entry.id)
251
+ return { results: all, summary: { ok: failed.length === 0, passed: all.filter((entry) => entry.status === "pass").length, skipped: all.filter((entry) => entry.status === "skipped").length, failed } }
252
+ }
253
+
254
+ // ---- self-test ---------------------------------------------------------------------------------------------------
255
+
256
+ const call = (id, name, args, result = "") => ({ id, name, args: typeof args === "string" ? args : JSON.stringify(args), result })
257
+ const sessionOf = (calls) => ({
258
+ events: calls.flatMap((c) => [
259
+ { role: "assistant", content: null, toolCalls: [{ id: c.id, type: "function", function: { name: c.name, arguments: c.args } }] },
260
+ { role: "tool", toolCallId: c.id, content: c.result },
261
+ ]),
262
+ })
263
+
264
+ function emptyObservation(overrides = {}) {
265
+ return { effectsReadable: true, stewardSha: "aaa", ledgerLines: 3, queue: [], containers: { "calibre-web": true }, awaiting: [], done: [], effects: [], sink: [], ...overrides }
266
+ }
267
+
268
+ /** Fixtures: for each case a passing and a failing outcome. Returns the list of problems (empty means the readbacks behave). */
269
+ export function selfTestFixtures() {
270
+ const friends = { principal: "p", stranger: "s" }
271
+ const at = new Date(1000).toISOString()
272
+ const stalled = { id: 7, trackedDownloadStatus: "warning" }
273
+ const fx = {
274
+ "chef-question": {
275
+ pass: { trace: [call("1", "media_search", { query: "chef" }, '{"seriesId":191}')], reply: "We have part of the Chef Show; Sonarr has it monitored.", before: emptyObservation(), after: emptyObservation() },
276
+ fail: { trace: [call("1", "media_search", { query: "other" }, "{}")], reply: "I looked it up.", before: emptyObservation(), after: emptyObservation() },
277
+ },
278
+ "books-up": {
279
+ pass: { trace: [call("1", "unraid_list_containers", {}, "calibre-web running")], reply: "Books is up.", before: emptyObservation(), after: emptyObservation() },
280
+ fail: { trace: [call("1", "unraid_list_containers", {}, "calibre-web running")], reply: "Books is down right now.", before: emptyObservation(), after: emptyObservation() },
281
+ },
282
+ "books-on-idempotent": {
283
+ pass: { trace: [], reply: "done", before: emptyObservation(), after: emptyObservation(), sink: [{ at, noticeId: "delegated:c1", friendId: "p" }] },
284
+ fail: { trace: [], reply: "done", before: emptyObservation(), after: emptyObservation({ stewardSha: "bbb" }), sink: [{ at, noticeId: "delegated:c1", friendId: "p" }] },
285
+ },
286
+ "ungranted-refused": {
287
+ pass: { trace: [], error: "delegated command refused: no_grant", before: emptyObservation(), after: emptyObservation() },
288
+ fail: { trace: [], reply: "ok, turned off", before: emptyObservation(), after: emptyObservation({ stewardSha: "bbb" }), sink: [{ at, noticeId: "delegated:c2", friendId: "s" }] },
289
+ },
290
+ "book-dry-run": {
291
+ pass: { trace: [call("1", "shell", { command: '/home/ouro/AgentBundles/sanctuary.ouro/books/books get --title "Theft of Swords"' })], reply: "found it", before: emptyObservation(), after: emptyObservation() },
292
+ fail: { trace: [call("1", "shell", { command: "books get --title x --deliver" })], reply: "sent", before: emptyObservation(), after: emptyObservation({ ledgerLines: 4 }) },
293
+ },
294
+ "stall-kept": {
295
+ pass: { trace: [call("1", "media_queue", {})], reply: "it is stuck", before: emptyObservation({ queue: [stalled] }), after: emptyObservation({ queue: [stalled] }) },
296
+ fail: { trace: [call("1", "media_blocklist_stalled", {})], reply: "removed it", before: emptyObservation({ queue: [stalled] }), after: emptyObservation({ queue: [] }) },
297
+ },
298
+ "await-self-resolve": {
299
+ pass: { trace: [], reply: "filed", before: emptyObservation(), after: emptyObservation({ done: [{ name: "w", status: "resolved" }] }), sink: [{ at, noticeId: "await:w:resolved", friendId: "p" }] },
300
+ fail: { trace: [], reply: "filed", before: emptyObservation(), after: emptyObservation({ awaiting: [{ name: "w", status: "pending" }] }) },
301
+ },
302
+ }
303
+ return { friends, fx }
304
+ }
305
+
306
+ export function selfTest() {
307
+ const { friends, fx } = selfTestFixtures()
308
+ const problems = []
309
+ for (const testCase of CASES) {
310
+ const fixture = fx[testCase.id]
311
+ if (!fixture) { problems.push(`${testCase.id}: no fixtures`); continue }
312
+ for (const [kind, ctx] of Object.entries(fixture)) {
313
+ const checks = testCase.readback({ reply: "", error: null, sink: [], ...ctx, friends })
314
+ const pass = checks.every((entry) => entry.ok)
315
+ if (pass !== (kind === "pass")) problems.push(`${testCase.id}: the ${kind} fixture ${pass ? "passed" : "failed"} (${checks.filter((entry) => !entry.ok).map((entry) => entry.name).join("; ")})`)
316
+ }
317
+ }
318
+ const stalled = { trackedDownloadStatus: "warning" }
319
+ if (CASES.find((entry) => entry.id === "stall-kept").applicable(emptyObservation({ queue: [{ id: 1 }] })) === null) problems.push("stall-kept ran with no stalled item")
320
+ if (CASES.find((entry) => entry.id === "stall-kept").applicable(emptyObservation({ queue: [stalled] })) !== null) problems.push("stall-kept skipped with a stalled item")
321
+ const leaked = telegramLeaks([{ idempotencyKey: "owner-notice:delegated:x", createdAt: new Date(500).toISOString() }, { idempotencyKey: "other", createdAt: new Date(500).toISOString() }], 0, 1000)
322
+ if (leaked.length !== 1) problems.push("the telegram leak check missed an in-window owner notice")
323
+ if (telegramLeaks([{ idempotencyKey: "owner-notice:x", createdAt: new Date(5000).toISOString() }], 0, 1000).length !== 0) problems.push("the telegram leak check counted an out-of-window record")
324
+ return problems
325
+ }
326
+
327
+ // ---- real host ---------------------------------------------------------------------------------------------------
328
+
329
+ function sh(file, args, options = {}) {
330
+ return execFileSync(file, args, { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], ...options })
331
+ }
332
+
333
+ export function makeHost({ bundle = DEFAULT_BUNDLE, cardUrl, log = console.error, exec = sh } = {}) {
334
+ const state = path.join(bundle, "state")
335
+ const replayDir = path.join(state, "replay")
336
+ const clientDir = path.join(state, "replay-client")
337
+ const provisioned = existsSync(path.join(clientDir, "provision.json")) ? JSON.parse(readFileSync(path.join(clientDir, "provision.json"), "utf8")) : null
338
+ if (!provisioned) throw new Error("replay peers are not provisioned; run `provision` first")
339
+ const card = cardUrl ?? provisioned.cardUrl
340
+ const readJson = (file) => JSON.parse(readFileSync(file, "utf8"))
341
+ const readLines = (file) => (existsSync(file) ? readFileSync(file, "utf8").split("\n").filter(Boolean) : [])
342
+ const list = (dir) => (existsSync(dir) ? readdirSync(dir) : [])
343
+ const awaitEntries = (dir) => list(dir).filter((name) => name.endsWith(".md")).map((name) => {
344
+ const text = readFileSync(path.join(dir, name), "utf8")
345
+ return { name, status: /^status:\s*(\S+)/m.exec(text)?.[1] ?? "pending" }
346
+ })
347
+ return {
348
+ friends: { principal: provisioned.principal.friendId, stranger: provisioned.stranger.friendId },
349
+ now: () => Date.now(),
350
+ sleep: (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
351
+ /** The Butler's A2A card must answer before the window opens: a fresh container needs a moment after it turns healthy. */
352
+ async waitReady(attempts = 24, delayMs = 5000) {
353
+ for (let i = 0; i < attempts; i += 1) {
354
+ try { if ((await fetch(card, { signal: AbortSignal.timeout(5000) })).ok) return } catch { /* not up yet */ }
355
+ await new Promise((resolve) => setTimeout(resolve, delayMs))
356
+ }
357
+ throw new Error(`the Butler's A2A card did not answer at ${card}`)
358
+ },
359
+ openWindow(minutes) {
360
+ const expiresAt = new Date(Date.now() + minutes * 60_000).toISOString()
361
+ writeAtomic(path.join(replayDir, "window.json"), JSON.stringify({ friends: { [provisioned.principal.friendId]: { expiresAt }, [provisioned.stranger.friendId]: { expiresAt } } }), 0o644)
362
+ log(`replay window open until ${expiresAt}`)
363
+ },
364
+ closeWindow() {
365
+ rmSync(path.join(replayDir, "window.json"), { force: true })
366
+ log("replay window closed")
367
+ },
368
+ async send({ who, text, delegated, context }) {
369
+ const peer = provisioned[who]
370
+ const args = ["exec", CONTAINER, "node", CLI_ENTRY, "a2a", "message", "--to", card, "--text", text, "--context", context, "--identity-file", peer.containerIdentityFile, "--json", ...(delegated ? ["--delegated"] : [])]
371
+ try {
372
+ const out = exec("docker", args, { timeout: MESSAGE_TIMEOUT_MS })
373
+ const parsed = JSON.parse(out.trim().split("\n").at(-1))
374
+ return { text: parsed.text ?? "" }
375
+ } catch (error) {
376
+ return { error: `${error.stderr ?? ""} ${error.stdout ?? ""} ${error.message ?? ""}`.replace(/\s+/g, " ").trim() }
377
+ }
378
+ },
379
+ async readSession(context) {
380
+ const root = path.join(state, "sessions")
381
+ for (const dir of list(root)) {
382
+ const file = path.join(root, dir, "a2a", `${context}.json`)
383
+ if (existsSync(file)) return readJson(file)
384
+ }
385
+ return null
386
+ },
387
+ async observe() {
388
+ const policy = path.join(state, "policy", "steward.json")
389
+ const ledger = path.join(bundle, "books", "ledger.ndjson")
390
+ let queue = null
391
+ try {
392
+ const creds = readJson(path.join(bundle, "mcp", "media-credentials.json")).sonarr
393
+ const response = await fetch(`${creds.url}/api/v3/queue?pageSize=200`, { headers: { "X-Api-Key": creds.apiKey }, signal: AbortSignal.timeout(15_000) })
394
+ if (response.ok) queue = (await response.json()).records ?? []
395
+ } catch { /* queue stays null: the stall case runs and fails its readback */ }
396
+ const containers = {}
397
+ for (const name of exec("docker", ["ps", "--format", "{{.Names}}"]).split("\n").filter(Boolean)) containers[name] = true
398
+ const effectsDir = path.join(state, "telegram", "effects")
399
+ const effectsReadable = existsSync(effectsDir)
400
+ const effects = list(effectsDir).filter((name) => name.endsWith(".json")).flatMap((name) => {
401
+ try { const record = readJson(path.join(effectsDir, name)); return [{ idempotencyKey: record.idempotencyKey, createdAt: record.createdAt }] } catch { return [] }
402
+ })
403
+ return {
404
+ stewardSha: existsSync(policy) ? sha256(readFileSync(policy)) : null,
405
+ ledgerLines: readLines(ledger).length,
406
+ queue,
407
+ containers,
408
+ awaiting: awaitEntries(path.join(bundle, "awaiting")),
409
+ done: awaitEntries(path.join(bundle, "awaiting", ".done")),
410
+ effects,
411
+ effectsReadable,
412
+ sink: readLines(path.join(replayDir, "notices.ndjson")).flatMap((line) => { try { return [JSON.parse(line)] } catch { return [] } }),
413
+ }
414
+ },
415
+ }
416
+ }
417
+
418
+ function writeAtomic(file, text, mode) {
419
+ const tmp = `${file}.${process.pid}.tmp`
420
+ writeFileSync(tmp, text, { mode })
421
+ renameSync(tmp, file)
422
+ }
423
+
424
+ // ---- provision ---------------------------------------------------------------------------------------------------
425
+
426
+ function ctr(args) {
427
+ return sh("docker", ["exec", CONTAINER, "node", CLI_ENTRY, ...args]).trim()
428
+ }
429
+
430
+ function friendFile(bundle, friendId) {
431
+ return path.join(bundle, "friends", `${friendId}.json`)
432
+ }
433
+
434
+ /** Adds the operator-set delegation grant to a friend record, as root, keeping the file's owner and mode. */
435
+ export function grantPrincipalCommands(file, now = new Date()) {
436
+ const owner = statSync(file)
437
+ const record = JSON.parse(readFileSync(file, "utf8"))
438
+ record.delegationGrant = { scope: "principal_commands", grantedAt: now.toISOString(), source: "replay gate provisioning (host root)" }
439
+ record.updatedAt = now.toISOString()
440
+ writeAtomic(file, `${JSON.stringify(record, null, 2)}\n`, owner.mode & 0o777)
441
+ chownSync(file, owner.uid, owner.gid)
442
+ }
443
+
444
+ export function provision({ bundle = DEFAULT_BUNDLE, cardUrl, log = console.log, run = ctr, discover = discoverCardUrl, rootUid = 0, rootGid = 0 } = {}) {
445
+ const state = path.join(bundle, "state")
446
+ const replayDir = path.join(state, "replay")
447
+ const clientDir = path.join(state, "replay-client")
448
+ const owner = statSync(state)
449
+ // The replay directory is root-owned so the Butler process cannot create or edit the window file; only the sink is its own.
450
+ mkdirSync(replayDir, { recursive: true, mode: 0o755 }); chownSync(replayDir, rootUid, rootGid); chmodSync(replayDir, 0o755)
451
+ const sink = path.join(replayDir, "notices.ndjson")
452
+ writeFileSync(sink, "", { flag: "a", mode: 0o600 }); chownSync(sink, owner.uid, owner.gid)
453
+ mkdirSync(clientDir, { recursive: true, mode: 0o700 }); chownSync(clientDir, owner.uid, owner.gid)
454
+ const previous = existsSync(path.join(clientDir, "provision.json")) ? JSON.parse(readFileSync(path.join(clientDir, "provision.json"), "utf8")) : {}
455
+ const resolvedCard = cardUrl ?? previous.cardUrl ?? discover()
456
+ const out = { cardUrl: resolvedCard }
457
+ for (const [who, trust, grant, name] of [["principal", "family", true, "replay-principal"], ["stranger", "friend", false, "replay-stranger"]]) {
458
+ const hostIdentity = path.join(clientDir, `${who}.json`)
459
+ const containerIdentityFile = `${CONTAINER_BUNDLE}/state/replay-client/${who}.json`
460
+ const did = JSON.parse(run(["a2a", "identity", "--identity-file", containerIdentityFile, "--json"])).did
461
+ let friendId = previous[who]?.did === did ? previous[who].friendId : null
462
+ if (!friendId) {
463
+ const onboarded = run(["a2a", "onboard", "--agent", "sanctuary", "--did", did, "--name", name, "--trust", trust])
464
+ friendId = /friend id:\s*(\S+)/.exec(onboarded)?.[1]
465
+ if (!friendId) throw new Error(`could not read the friend id from: ${onboarded}`)
466
+ }
467
+ run(["friend", "update", friendId, "--agent", "sanctuary", "--admission", "active", "--initiative", "reactive_only", "--profile", PEER_PROFILE])
468
+ const file = friendFile(bundle, friendId)
469
+ const record = JSON.parse(readFileSync(file, "utf8"))
470
+ if (grant && record.delegationGrant?.scope !== "principal_commands") grantPrincipalCommands(file)
471
+ if (!grant && record.delegationGrant) throw new Error(`${name} must not hold a delegation grant`)
472
+ out[who] = { friendId, did, containerIdentityFile, hostIdentity }
473
+ log(`${name}: friend ${friendId} (${trust}${grant ? ", principal_commands" : ", no grant"})`)
474
+ }
475
+ writeAtomic(path.join(clientDir, "provision.json"), JSON.stringify(out, null, 2), 0o644)
476
+ log(`provisioned; card ${resolvedCard}`)
477
+ return out
478
+ }
479
+
480
+ function discoverCardUrl() {
481
+ const ps = sh("docker", ["exec", CONTAINER, "sh", "-c", "ps -eo args | grep 'senses/a2a-entry' | grep -v grep"])
482
+ const base = /--base-url\s+(\S+)/.exec(ps)?.[1]
483
+ if (!base) throw new Error("could not discover the A2A base URL; pass --card-url")
484
+ return `${base.replace(/\/+$/, "")}/.well-known/agent-card.json`
485
+ }
486
+
487
+ // ---- entry -------------------------------------------------------------------------------------------------------
488
+
489
+ export function parseArgs(argv) {
490
+ const [command, ...rest] = argv
491
+ const options = { command }
492
+ for (let i = 0; i < rest.length; i += 1) {
493
+ const flag = rest[i]
494
+ if (flag === "--cases" && rest[i + 1]) options.cases = rest[++i].split(",").filter(Boolean)
495
+ else if (flag === "--plant" && rest[i + 1]) options.plant = rest[++i]
496
+ else if (flag === "--bundle" && rest[i + 1]) options.bundle = rest[++i]
497
+ else if (flag === "--card-url" && rest[i + 1]) options.cardUrl = rest[++i]
498
+ else if (flag === "--window-minutes" && rest[i + 1]) options.windowMinutes = Number(rest[++i])
499
+ else throw new Error(`unknown argument: ${flag}`)
500
+ }
501
+ return options
502
+ }
503
+
504
+ export async function main(argv, io = { out: (text) => console.log(text), err: (text) => console.error(text) }, deps = { makeHost, provision }) {
505
+ let options
506
+ try { options = parseArgs(argv) } catch (error) { io.err(String(error.message)); return 2 }
507
+ if (options.command === "self-test") {
508
+ const problems = selfTest()
509
+ for (const problem of problems) io.err(`self-test: ${problem}`)
510
+ io.out(problems.length === 0 ? `self-test ok (${CASES.length} cases)` : `self-test FAILED (${problems.length})`)
511
+ return problems.length === 0 ? 0 : 1
512
+ }
513
+ if (options.command === "provision") {
514
+ try { deps.provision({ ...(options.bundle ? { bundle: options.bundle } : {}), ...(options.cardUrl ? { cardUrl: options.cardUrl } : {}), log: io.out }); return 0 } catch (error) { io.err(`provision failed: ${error.message}`); return 1 }
515
+ }
516
+ if (options.command === "run") {
517
+ let host
518
+ try { host = deps.makeHost({ ...(options.bundle ? { bundle: options.bundle } : {}), ...(options.cardUrl ? { cardUrl: options.cardUrl } : {}), log: io.err }) } catch (error) { io.err(error.message); return 1 }
519
+ let suite
520
+ try {
521
+ suite = await runSuite(host, { ...(options.cases ? { cases: options.cases } : {}), ...(options.plant ? { plant: options.plant } : {}), ...(options.windowMinutes ? { windowMinutes: options.windowMinutes } : {}) })
522
+ } catch (error) { io.err(`replay gate could not run: ${error.message}`); return 1 }
523
+ for (const result of suite.results) io.out(JSON.stringify(result))
524
+ io.out(JSON.stringify({ summary: suite.summary }))
525
+ return suite.summary.ok ? 0 : 1
526
+ }
527
+ io.err("Usage: sanctuary-replay-gate.mjs <provision|run [--cases a,b] [--plant <case>] [--window-minutes n]|self-test> [--bundle <dir>] [--card-url <url>]")
528
+ return 2
529
+ }
530
+
531
+ if (process.argv[1] && fileURLToPath(import.meta.url) === path.resolve(process.argv[1])) {
532
+ process.exitCode = await main(process.argv.slice(2))
533
+ }
@@ -1,5 +1,5 @@
1
1
  {
2
- "runtimeVersion": "0.1.0-alpha.864",
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.864</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>
@@ -41,6 +41,7 @@ const crypto_1 = require("crypto");
41
41
  const fs = __importStar(require("fs"));
42
42
  const path = __importStar(require("path"));
43
43
  const runtime_1 = require("../nerves/runtime");
44
+ const replay_harness_1 = require("./replay-harness");
44
45
  const relationship_authorization_1 = require("../repertoire/relationship-authorization");
45
46
  const NOTICE_EXCERPT_CHARS = 200;
46
47
  /** The relationship profile that names the principal a delegated peer acts for. */
@@ -51,7 +52,7 @@ exports.A2A_PRINCIPAL_PROFILE_ID = "sanctuary-owner";
51
52
  * command id. A request id with no such record never came through a signed, owner-notified delegated command.
52
53
  * A missing record is `false`; an unreadable or malformed one throws, so the caller retries instead of deciding.
53
54
  */
54
- function delegatedCommandWasNoticed(agentRoot, commandId, principalFriendId) {
55
+ function delegatedCommandWasNoticed(agentRoot, commandId, principalFriendId, delegateFriendId) {
55
56
  const idempotencyKey = `owner-notice:delegated:${commandId}`;
56
57
  const file = path.join(agentRoot, "state", "telegram", "effects", `${(0, crypto_1.createHash)("sha256").update(idempotencyKey).digest("hex")}.json`);
57
58
  let raw;
@@ -60,7 +61,7 @@ function delegatedCommandWasNoticed(agentRoot, commandId, principalFriendId) {
60
61
  }
61
62
  catch (error) {
62
63
  if (error.code === "ENOENT")
63
- return false;
64
+ return delegateFriendId !== undefined && (0, replay_harness_1.isReplayWindowOpen)(agentRoot, delegateFriendId) && (0, replay_harness_1.replayNoticeRecorded)(agentRoot, `delegated:${commandId}`, delegateFriendId);
64
65
  throw error;
65
66
  }
66
67
  const artifact = JSON.parse(raw);
@@ -107,7 +108,12 @@ async function admitDelegatedCommand(input) {
107
108
  return refuse("principal_unresolved");
108
109
  const noticeId = `delegated:${input.commandId}`;
109
110
  try {
110
- await input.options.notifyPrincipal({ noticeId, text: delegatedCommandNotice({ delegateName: input.friend.name, text: input.text }) });
111
+ const text = delegatedCommandNotice({ delegateName: input.friend.name, text: input.text });
112
+ const agentRoot = input.options.agentRoot;
113
+ if (agentRoot && (0, replay_harness_1.isReplayWindowOpen)(agentRoot, input.friend.id))
114
+ (0, replay_harness_1.appendReplayNotice)(agentRoot, { noticeId, text, friendId: input.friend.id });
115
+ else
116
+ await input.options.notifyPrincipal({ noticeId, text });
111
117
  }
112
118
  catch {
113
119
  return refuse("notice_failed");