@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.
- package/changelog.json +17 -0
- package/deploy/unraid/sanctuary-butler-upgrade.mjs +179 -24
- package/deploy/unraid/sanctuary-replay-gate.mjs +533 -0
- package/deploy/unraid/sanctuary.ouro/bundle-meta.json +1 -1
- package/deploy/unraid/sanctuary.xml +1 -1
- package/dist/a2a/delegated-command.js +9 -3
- package/dist/a2a/replay-harness.js +147 -0
- package/dist/heart/awaiting/a2a-await-delivery.js +6 -0
- package/dist/heart/daemon/sanctuary-authority-root-lifecycle.js +71 -22
- package/dist/heart/daemon/sanctuary-telegram-authority-entry.js +31 -1
- package/dist/heart/daemon/sanctuary-telegram-authority-service.js +25 -12
- package/dist/senses/a2a-entry.js +2 -0
- package/dist/senses/private-runtime.js +1 -1
- package/npm-shrinkwrap.json +2 -2
- package/package.json +1 -1
|
@@ -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,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.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
|
|
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
|
-
|
|
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");
|