@ouro.bot/cli 0.1.0-alpha.733 → 0.1.0-alpha.734

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.
Files changed (89) hide show
  1. package/changelog.json +25 -0
  2. package/deploy/unraid/Dockerfile +27 -0
  3. package/deploy/unraid/README.txt +1368 -0
  4. package/deploy/unraid/audit-container-spec.sh +4 -0
  5. package/deploy/unraid/container-runtime.json +1 -0
  6. package/deploy/unraid/sanctuary-acceptance-adapter.sh +37 -0
  7. package/deploy/unraid/sanctuary-acceptance-contract.json +200 -0
  8. package/deploy/unraid/sanctuary-acceptance-harness.sh +23 -0
  9. package/deploy/unraid/sanctuary-deployment-target.mjs +705 -0
  10. package/deploy/unraid/sanctuary-safe-rename.py +24 -0
  11. package/deploy/unraid/sanctuary-unit16-host-broker.mjs +1777 -0
  12. package/deploy/unraid/sanctuary-unit16-run.sh +502 -0
  13. package/deploy/unraid/sanctuary-unit18-target-audit.sh +27 -0
  14. package/deploy/unraid/sanctuary.ouro/agent.json +24 -0
  15. package/deploy/unraid/sanctuary.ouro/arc/README.md +3 -0
  16. package/deploy/unraid/sanctuary.ouro/bundle-meta.json +5 -0
  17. package/deploy/unraid/sanctuary.ouro/habits/sanctuary-health.md +8 -0
  18. package/deploy/unraid/sanctuary.ouro/provider-readiness.json +18 -0
  19. package/deploy/unraid/sanctuary.ouro/psyche/ASPIRATIONS.md +3 -0
  20. package/deploy/unraid/sanctuary.ouro/psyche/IDENTITY.md +3 -0
  21. package/deploy/unraid/sanctuary.ouro/psyche/LORE.md +3 -0
  22. package/deploy/unraid/sanctuary.ouro/psyche/SOUL.md +5 -0
  23. package/deploy/unraid/sanctuary.ouro/psyche/TACIT.md +7 -0
  24. package/deploy/unraid/sanctuary.ouro/tool-profiles.json +21 -0
  25. package/deploy/unraid/sanctuary.xml +25 -0
  26. package/dist/heart/approval-files.js +113 -0
  27. package/dist/heart/approval-store.js +168 -9
  28. package/dist/heart/auth/auth-flow.js +17 -1
  29. package/dist/heart/autonomy-budget.js +44 -1
  30. package/dist/heart/awaiting/await-scheduler.js +6 -2
  31. package/dist/heart/config.js +8 -0
  32. package/dist/heart/core.js +117 -132
  33. package/dist/heart/daemon/cli-exec.js +73 -1
  34. package/dist/heart/daemon/cli-help.js +5 -0
  35. package/dist/heart/daemon/cli-parse.js +6 -4
  36. package/dist/heart/daemon/container-credential-bootstrap.js +185 -0
  37. package/dist/heart/daemon/container-healthcheck.js +94 -0
  38. package/dist/heart/daemon/container-runtime.js +84 -0
  39. package/dist/heart/daemon/container-spec-auditor-main.js +156 -0
  40. package/dist/heart/daemon/container-spec-auditor.js +156 -0
  41. package/dist/heart/daemon/daemon-bootstrap-startup.js +41 -0
  42. package/dist/heart/daemon/daemon-entry.js +116 -28
  43. package/dist/heart/daemon/daemon-health.js +10 -0
  44. package/dist/heart/daemon/daemon.js +92 -5
  45. package/dist/heart/daemon/runtime-logging.js +6 -0
  46. package/dist/heart/daemon/runtime-mode.js +13 -2
  47. package/dist/heart/daemon/sanctuary-acceptance-adapter.js +2365 -0
  48. package/dist/heart/daemon/sanctuary-acceptance-harness.js +1624 -0
  49. package/dist/heart/daemon/sanctuary-acceptance-marker.js +267 -0
  50. package/dist/heart/daemon/sanctuary-acceptance-scenarios.js +985 -0
  51. package/dist/heart/daemon/sanctuary-scheduler-liveness.js +227 -0
  52. package/dist/heart/daemon/sanctuary-scheduler-origin.js +126 -0
  53. package/dist/heart/daemon/sense-manager.js +25 -6
  54. package/dist/heart/daemon/supercronic-supervisor.js +267 -0
  55. package/dist/heart/habits/habit-scheduler.js +7 -3
  56. package/dist/heart/identity.js +13 -3
  57. package/dist/heart/provider-credentials.js +9 -1
  58. package/dist/heart/provider-models.js +8 -0
  59. package/dist/heart/provider-ping.js +5 -2
  60. package/dist/heart/providers/openai-compatible.js +177 -0
  61. package/dist/heart/runtime-credentials.js +217 -8
  62. package/dist/heart/sense-truth.js +2 -0
  63. package/dist/heart/tool-approval.js +1 -0
  64. package/dist/heart/turn-context.js +8 -0
  65. package/dist/mind/prompt.js +9 -0
  66. package/dist/nerves/coverage/source-scanner.js +4 -4
  67. package/dist/nerves/index.js +75 -9
  68. package/dist/nerves/runtime.js +12 -0
  69. package/dist/repertoire/tool-arguments.js +39 -0
  70. package/dist/repertoire/tools-unraid.js +353 -0
  71. package/dist/repertoire/tools.js +19 -1
  72. package/dist/repertoire/unraid-client.js +155 -0
  73. package/dist/repertoire/unraid-restart.js +130 -0
  74. package/dist/senses/private-runtime-worker.js +3 -2
  75. package/dist/senses/sanctuary-grounding.js +118 -0
  76. package/dist/senses/sanctuary-health-acceptance-probe-entry.js +12 -0
  77. package/dist/senses/sanctuary-health-acceptance-probe.js +772 -0
  78. package/dist/senses/sanctuary-health-runner.js +146 -0
  79. package/dist/senses/sanctuary-health.js +447 -0
  80. package/dist/senses/sanctuary-interactive-control.js +306 -0
  81. package/dist/senses/sanctuary-runtime.js +206 -0
  82. package/dist/senses/shared-turn.js +11 -3
  83. package/dist/senses/telegram-approval-runtime.js +526 -0
  84. package/dist/senses/telegram-audit-ledger.js +220 -0
  85. package/dist/senses/telegram-client.js +1471 -0
  86. package/dist/senses/telegram-entry.js +80 -0
  87. package/dist/senses/telegram.js +1232 -0
  88. package/npm-shrinkwrap.json +6558 -0
  89. package/package.json +4 -2
@@ -0,0 +1,705 @@
1
+ #!/usr/local/bin/node
2
+
3
+ import { spawn, spawnSync } from "node:child_process"
4
+ import { closeSync, constants, existsSync, fstatSync, mkdirSync, openSync, readFileSync, readlinkSync, readdirSync, statSync, writeFileSync } from "node:fs"
5
+ import { fileURLToPath } from "node:url"
6
+
7
+ const DOCKER = "/usr/bin/docker"
8
+ const KEY_ROOT = "/boot/config/plugins/dynamix.my.servers/keys"
9
+ const GRAPHQL_ENDPOINT = "http://127.0.0.1/graphql"
10
+ const IMAGE_ID = /^sha256:[0-9a-f]{64}$/u
11
+ const CONTAINER_ID = /^[0-9a-f]{64}$/u
12
+ const CANONICAL_NAMES = ["ouro-butler", "ouro-butler-staging", "ouro-butler-rollback"]
13
+ const PROFILES = Object.freeze({
14
+ staging: Object.freeze({ name: "staging", containerName: "ouro-butler-staging", requiredStopped: [], forbidden: ["ouro-butler", "ouro-butler-rollback"] }),
15
+ final: Object.freeze({ name: "final", containerName: "ouro-butler", requiredStopped: ["ouro-butler-rollback"], forbidden: ["ouro-butler-staging"] }),
16
+ })
17
+ const DOCUMENTED_UNIX_CONTROLS = [
18
+ "/tmp/ouroboros-daemon.sock",
19
+ "/home/ouro/AgentBundles/sanctuary.ouro/state/acceptance/telegram-control.sock",
20
+ ]
21
+ const DOCUMENTED_LOOPBACK_TCP_CONTROLS = new Set([6876])
22
+ const TERMINAL_CONTAINMENT_MAX_SAMPLES = 8
23
+ const TARGET_THAW_MAX_ATTEMPTS = 3
24
+ const WATCHDOG_POLL_MS = 100
25
+ const WATCHDOG_PARENT_DEATH_ENFORCEMENT_MS = 25_000
26
+ const RO_PERMISSIONS = ["ARRAY", "DASHBOARD", "DISK", "DOCKER", "INFO", "LOGS", "NOTIFICATIONS", "SHARE", "VARS"]
27
+ .map((resource) => `${resource}:READ_ANY`).sort()
28
+
29
+ function object(value, label) {
30
+ if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error(`${label} must be an object`)
31
+ return value
32
+ }
33
+
34
+ function targetProfile(name) {
35
+ const profile = PROFILES[name]
36
+ if (!profile) throw new Error("deployment target profile is invalid")
37
+ return profile
38
+ }
39
+
40
+ function exactSet(left, right) {
41
+ return left.size === right.size && [...left].every((entry) => right.has(entry))
42
+ }
43
+
44
+ function readCanonicalReadDescriptor() {
45
+ const root = statSync(KEY_ROOT)
46
+ if (!root.isDirectory() || root.uid !== 0 || (root.mode & 0o077) !== 0) throw new Error("Unraid key directory metadata is invalid")
47
+ const matches = []
48
+ for (const entry of readdirSync(KEY_ROOT, { withFileTypes: true })) {
49
+ if (!entry.isFile() || !entry.name.endsWith(".json")) throw new Error("unexpected Unraid key directory entry")
50
+ const fd = openSync(`${KEY_ROOT}/${entry.name}`, constants.O_RDONLY | constants.O_NOFOLLOW)
51
+ let record
52
+ try {
53
+ const metadata = fstatSync(fd)
54
+ if (!metadata.isFile() || metadata.uid !== 0 || (metadata.mode & 0o777) !== 0o600) throw new Error("Unraid key file metadata is invalid")
55
+ record = object(JSON.parse(readFileSync(fd, "utf8")), "Unraid key record")
56
+ } finally { closeSync(fd) }
57
+ const permissions = Array.isArray(record.permissions)
58
+ ? record.permissions.flatMap((raw) => {
59
+ const permission = object(raw, "Unraid key permission")
60
+ return Array.isArray(permission.actions) ? permission.actions.map((action) => `${permission.resource}:${action}`) : []
61
+ }).sort()
62
+ : []
63
+ if (record.name === "Butler RO" && Array.isArray(record.roles) && record.roles.length === 0 && JSON.stringify(permissions) === JSON.stringify(RO_PERMISSIONS) && typeof record.key === "string" && record.key) matches.push(record.key)
64
+ }
65
+ if (matches.length !== 1) throw new Error("canonical read-only Unraid key is absent or ambiguous")
66
+ return matches[0]
67
+ }
68
+
69
+ async function queryGraphqlAutostart(fetchImpl = fetch, readDescriptor = readCanonicalReadDescriptor) {
70
+ const response = await fetchImpl(GRAPHQL_ENDPOINT, {
71
+ method: "POST",
72
+ headers: { "content-type": "application/json", "x-api-key": readDescriptor() },
73
+ body: JSON.stringify({ query: "query AcceptanceContainerTopology { vars { id } docker { containers(skipCache: true) { id names autoStart } } }", variables: {} }),
74
+ signal: AbortSignal.timeout(10_000),
75
+ })
76
+ if (!response.ok) throw new Error("Unraid container topology query failed")
77
+ const envelope = object(await response.json(), "Unraid container topology response")
78
+ if (envelope.errors || !envelope.data) throw new Error("Unraid container topology query was rejected")
79
+ const data = object(envelope.data, "Unraid topology data")
80
+ const serverIdentity = /^([0-9a-f]{64}):vars$/u.exec(object(data.vars, "Unraid topology vars").id)
81
+ if (!serverIdentity) throw new Error("Unraid server identity is invalid")
82
+ const containers = object(data.docker, "Unraid topology docker").containers
83
+ if (!Array.isArray(containers)) throw new Error("Unraid container topology is invalid")
84
+ const result = new Map()
85
+ for (const raw of containers) {
86
+ const container = object(raw, "Unraid topology container")
87
+ if (!Array.isArray(container.names) || container.names.some((name) => typeof name !== "string")) throw new Error("Unraid container topology identity is invalid")
88
+ const canonical = container.names.map((name) => name.replace(/^\//u, "")).filter((name) => CANONICAL_NAMES.includes(name))
89
+ const identity = typeof container.id === "string" ? /^([0-9a-f]{64}):([0-9a-f]{64})$/u.exec(container.id) : null
90
+ if (canonical.length > 1 || (canonical.length === 1 && (!identity || identity[1] !== serverIdentity[1]))) throw new Error("Unraid container topology identity is ambiguous or belongs to another server")
91
+ if (canonical.length === 1 && typeof container.autoStart !== "boolean") throw new Error("Unraid container autostart state is invalid")
92
+ if (canonical.length === 1) {
93
+ if (result.has(canonical[0])) throw new Error("Unraid autostart topology is ambiguous")
94
+ result.set(canonical[0], { containerId: identity[2], autoStart: container.autoStart })
95
+ }
96
+ }
97
+ return result
98
+ }
99
+
100
+ function normalizeRecord(raw, label) {
101
+ const value = object(raw, label)
102
+ if (!CONTAINER_ID.test(value.id) || !Array.isArray(value.names) || value.names.length !== 1 || typeof value.names[0] !== "string") throw new Error(`${label} identity is invalid`)
103
+ const name = value.names[0].replace(/^\//u, "")
104
+ if (!CANONICAL_NAMES.includes(name)) throw new Error(`${label} name is not canonical`)
105
+ if (!IMAGE_ID.test(value.imageId) || typeof value.running !== "boolean" || typeof value.autoStart !== "boolean" || value.restartPolicy !== "unless-stopped" || value.networkMode !== "host") throw new Error(`${label} effective configuration is invalid`)
106
+ if (!Number.isSafeInteger(value.pid) || value.pid < 0 || value.running !== (value.pid > 0)) throw new Error(`${label} PID state is invalid`)
107
+ return { id: value.id, name, imageId: value.imageId, running: value.running, autoStart: value.autoStart, restartPolicy: value.restartPolicy, networkMode: value.networkMode, pid: value.pid }
108
+ }
109
+
110
+ function records(value, label) {
111
+ if (!Array.isArray(value)) throw new Error(`${label} must be an array`)
112
+ const normalized = value.map((entry, index) => normalizeRecord(entry, `${label}[${index}]`))
113
+ if (new Set(normalized.map(({ name }) => name)).size !== normalized.length || new Set(normalized.map(({ id }) => id)).size !== normalized.length) throw new Error(`${label} is duplicate or ambiguous`)
114
+ return normalized.sort((left, right) => left.name.localeCompare(right.name))
115
+ }
116
+
117
+ function signature(value) {
118
+ return JSON.stringify(value.map(({ id, name, imageId, running, autoStart, restartPolicy, networkMode, pid }) => ({ id, name, imageId, running, autoStart, restartPolicy, networkMode, pid })))
119
+ }
120
+
121
+ function attestDeploymentTarget(input) {
122
+ const value = object(input, "deployment target input")
123
+ const profile = targetProfile(value.profile)
124
+ if (!IMAGE_ID.test(value.expectedImageId)) throw new Error("expected image ID is invalid")
125
+ const before = records(value.topologyBefore, "topology before")
126
+ const inspected = records(value.inspected, "canonical inspections")
127
+ const after = records(value.topologyAfter, "topology after")
128
+ if (signature(before) !== signature(after) || signature(before) !== signature(inspected)) throw new Error("canonical deployment topology changed or was incompletely inspected")
129
+ const byName = new Map(inspected.map((entry) => [entry.name, entry]))
130
+ const target = byName.get(profile.containerName)
131
+ if (!target || target.imageId !== value.expectedImageId || !target.running || !target.autoStart) throw new Error("deployment target identity, image, state, or autostart is invalid")
132
+ for (const name of profile.requiredStopped) {
133
+ const stopped = byName.get(name)
134
+ if (!stopped || stopped.running || stopped.autoStart) throw new Error("required rollback target state is invalid")
135
+ }
136
+ for (const name of profile.forbidden) if (byName.has(name)) throw new Error("mixed deployment target state is invalid")
137
+ const activeRunningCardinality = inspected.filter(({ running }) => running).length
138
+ if (activeRunningCardinality !== 1) throw new Error("active Butler running cardinality must equal one")
139
+ return { schemaVersion: "sanctuary-deployment-target-v1", profile: profile.name, targetContainerName: profile.containerName, targetContainerId: target.id, targetImageId: target.imageId, targetPid: target.pid, activeRunningCardinality }
140
+ }
141
+
142
+ function attestOwnedListeners(input) {
143
+ const value = object(input, "listener ownership input")
144
+ if (!Number.isSafeInteger(value.rootPid) || value.rootPid <= 0 || !/^net:\[[0-9]+\]$/u.test(value.netnsBefore) || value.netnsBefore !== value.netnsAfter) throw new Error("target network namespace is invalid or changed")
145
+ const processIdsBefore = value.processIdsBefore
146
+ const processIdsAfter = value.processIdsAfter
147
+ const validProcesses = (pids) => Array.isArray(pids) && pids.length > 0 && pids.includes(value.rootPid) && pids.every((pid) => Number.isSafeInteger(pid) && pid > 0) && new Set(pids).size === pids.length
148
+ if (!validProcesses(processIdsBefore) || !validProcesses(processIdsAfter) || JSON.stringify([...processIdsBefore].sort((a, b) => a - b)) !== JSON.stringify([...processIdsAfter].sort((a, b) => a - b))) throw new Error("target cgroup process membership changed or is invalid")
149
+ const socketSet = (raw, label) => {
150
+ if (!Array.isArray(raw) || raw.some((inode) => !/^[0-9]+$/u.test(inode))) throw new Error(`${label} is invalid`)
151
+ return new Set(raw)
152
+ }
153
+ const inventory = (suffix) => {
154
+ const owned = socketSet(value[`socketInodes${suffix}`], `socket ownership ${suffix.toLowerCase()}`)
155
+ const select = (kind, fallback) => value[kind] ?? value[`${fallback}${suffix}`]
156
+ const tcpRaw = select("tcpListeners", "tcpListeners")
157
+ const udpRaw = select("udpListeners", "udpListeners")
158
+ const unixRaw = select("unixSockets", "unixSockets")
159
+ if (!Array.isArray(tcpRaw) || !Array.isArray(udpRaw) || !Array.isArray(unixRaw)) throw new Error("listener inventory is invalid")
160
+ const tcp = tcpRaw.filter((raw) => owned.has(object(raw, "TCP listener").inode))
161
+ const udp = udpRaw.filter((raw) => owned.has(object(raw, "UDP listener").inode))
162
+ const unix = unixRaw.filter((raw) => owned.has(object(raw, "Unix socket").inode))
163
+ const sortRecords = (records) => [...records].sort((left, right) => JSON.stringify(left).localeCompare(JSON.stringify(right)))
164
+ const signature = JSON.stringify({ tcp: sortRecords(tcp), udp: sortRecords(udp), unix: sortRecords(unix) })
165
+ return { owned, tcp, udp, unix, signature }
166
+ }
167
+ const before = inventory("Before")
168
+ const after = inventory("After")
169
+ const terminalFields = ["socketInodesTerminal", "tcpListenersTerminal", "udpListenersTerminal", "unixSocketsTerminal"]
170
+ const providedTerminalFields = terminalFields.filter((field) => value[field] !== undefined)
171
+ if (providedTerminalFields.length !== 0 && providedTerminalFields.length !== terminalFields.length) throw new Error("terminal listener inventory is incomplete")
172
+ const terminal = providedTerminalFields.length === terminalFields.length ? inventory("Terminal") : after
173
+ if (!exactSet(before.owned, after.owned) || !exactSet(after.owned, terminal.owned)) throw new Error("target socket ownership changed")
174
+ if (before.signature !== after.signature || after.signature !== terminal.signature) throw new Error("target listener inventory changed")
175
+ const { owned, tcp, udp, unix } = terminal
176
+ for (const listener of tcp) {
177
+ if (listener.localAddress !== "127.0.0.1" || !DOCUMENTED_LOOPBACK_TCP_CONTROLS.has(listener.port)) throw new Error("target runtime owns an inbound TCP listener")
178
+ }
179
+ if (udp.length !== 0) throw new Error("target runtime owns an inbound UDP listener")
180
+ let unixControlSocketCount = 0
181
+ for (const socket of unix) {
182
+ if (socket.path === "" && socket.flags !== "00010000") continue
183
+ if (!DOCUMENTED_UNIX_CONTROLS.includes(socket.path) || socket.flags !== "00010000" || socket.type !== "0001" || socket.state !== "01") throw new Error("target runtime owns an undocumented Unix endpoint")
184
+ unixControlSocketCount += 1
185
+ }
186
+ return { schemaVersion: "sanctuary-listener-containment-v1", targetRootPid: value.rootPid, networkNamespace: value.netnsBefore, processCount: processIdsAfter.length, ownedSocketCount: owned.size, inboundTcpListenerCount: 0, inboundUdpListenerCount: 0, loopbackTcpControlCount: tcp.length, unixControlSocketCount }
187
+ }
188
+
189
+ function runDocker(args, timeoutMs = 20_000) {
190
+ if (!Number.isSafeInteger(timeoutMs) || timeoutMs <= 0 || timeoutMs > 20_000) throw new Error("Docker command timeout is invalid")
191
+ const result = spawnSync(DOCKER, args, { cwd: "/", encoding: "utf8", timeout: timeoutMs, maxBuffer: 1024 * 1024, env: { PATH: "/usr/local/sbin:/usr/sbin:/usr/bin:/sbin:/bin" }, stdio: ["ignore", "pipe", "ignore"] })
192
+ if (result.error || result.status !== 0) throw new Error("bounded deployment target inspection failed")
193
+ return result.stdout
194
+ }
195
+
196
+ function pausedTargetState(target, run, timeoutMs = 20_000) {
197
+ const template = '{"containerId":{{json .Id}},"running":{{json .State.Running}},"paused":{{json .State.Paused}},"restarting":{{json .State.Restarting}},"dead":{{json .State.Dead}},"pid":{{json .State.Pid}}}'
198
+ const state = object(JSON.parse(run(["inspect", "--format", template, target.targetContainerId], timeoutMs)), "target pause state")
199
+ if (!CONTAINER_ID.test(state.containerId) || typeof state.running !== "boolean" || typeof state.paused !== "boolean"
200
+ || typeof state.restarting !== "boolean" || typeof state.dead !== "boolean" || !Number.isSafeInteger(state.pid) || state.pid < 0) {
201
+ throw new Error("target pause state is invalid")
202
+ }
203
+ if (state.containerId !== target.targetContainerId || state.pid !== target.targetPid) throw new Error("target pause identity changed")
204
+ return state
205
+ }
206
+
207
+ function restorePausedTarget(target, run) {
208
+ let lastError
209
+ for (let attempt = 0; attempt < TARGET_THAW_MAX_ATTEMPTS; attempt += 1) {
210
+ try { run(["unpause", target.targetContainerId]) } catch (error) { lastError = error }
211
+ try {
212
+ const resumed = pausedTargetState(target, run)
213
+ if (resumed.running && !resumed.paused && !resumed.restarting && !resumed.dead) return
214
+ lastError = new Error("target resumed state is invalid")
215
+ } catch (error) {
216
+ lastError = error
217
+ }
218
+ }
219
+ throw new Error("target did not resume after bounded thaw attempts", { cause: lastError })
220
+ }
221
+
222
+ function syncWait(milliseconds) {
223
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, milliseconds)
224
+ }
225
+
226
+ function watchdogReceipt(root, expected, dependencies = {}) {
227
+ const fileExists = dependencies.existsSync ?? existsSync
228
+ const readFile = dependencies.readFileSync ?? readFileSync
229
+ const wait = dependencies.syncWait ?? syncWait
230
+ const file = `${root}/watchdog-terminal.json`
231
+ for (let attempt = 0; attempt < 100 && !fileExists(file); attempt += 1) wait(50)
232
+ const receipt = object(JSON.parse(readFile(file, "utf8")), "thaw watchdog terminal receipt")
233
+ if (receipt.status !== expected.status || receipt.containerId !== expected.containerId || receipt.pid !== expected.pid
234
+ || receipt.parentBootId !== expected.parentBootId || receipt.parentStarttime !== expected.parentStarttime
235
+ || expected.watchdogPid !== undefined && (receipt.watchdogPid !== expected.watchdogPid || receipt.watchdogBootId !== expected.watchdogBootId
236
+ || receipt.watchdogStarttime !== expected.watchdogStarttime)) throw new Error("thaw watchdog terminal receipt is invalid")
237
+ return receipt
238
+ }
239
+
240
+ function parseProcStatIdentity(content, expectedPid) {
241
+ if (typeof content !== "string" || content.includes("\0") || !Number.isSafeInteger(expectedPid) || expectedPid <= 0) throw new Error("parent process identity is invalid")
242
+ const prefix = `${expectedPid} (`
243
+ const close = content.lastIndexOf(") ")
244
+ if (!content.startsWith(prefix) || close < prefix.length || close + 4 > content.length) throw new Error("parent process identity is invalid")
245
+ const state = content[close + 2]
246
+ if (!/^[A-Za-z]$/u.test(state) || content[close + 3] !== " ") throw new Error("parent process identity is invalid")
247
+ const fields = content.slice(close + 4).trim().split(/\s+/u)
248
+ const starttime = fields[18]
249
+ if (!starttime || !/^[0-9]+$/u.test(starttime) || starttime === "0") throw new Error("parent process identity is invalid")
250
+ return { state, starttime }
251
+ }
252
+
253
+ function terminalProcState(state) {
254
+ return state === "Z" || state === "X" || state === "x"
255
+ }
256
+
257
+ function monotonicMilliseconds() {
258
+ return Number(process.hrtime.bigint() / 1_000_000n)
259
+ }
260
+
261
+ function readParentIdentity(pid) {
262
+ const bootId = readFileSync("/proc/sys/kernel/random/boot_id", "utf8").trim()
263
+ if (!/^[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12}$/u.test(bootId)) throw new Error("parent boot identity is invalid")
264
+ return { bootId, ...parseProcStatIdentity(readFileSync(`/proc/${pid}/stat`, "utf8"), pid) }
265
+ }
266
+
267
+ function armThawWatchdog(target, dependencies = {}) {
268
+ const now = dependencies.now ?? Date.now
269
+ const readIdentity = dependencies.readParentIdentity ?? readParentIdentity
270
+ const makeDirectory = dependencies.mkdirSync ?? mkdirSync
271
+ const spawnChild = dependencies.spawn ?? spawn
272
+ const fileExists = dependencies.existsSync ?? existsSync
273
+ const wait = dependencies.syncWait ?? syncWait
274
+ const writeFile = dependencies.writeFileSync ?? writeFileSync
275
+ const readFile = dependencies.readFileSync ?? readFileSync
276
+ const parentIdentity = readIdentity(process.pid)
277
+ if (terminalProcState(parentIdentity.state)) throw new Error("thaw watchdog parent is not alive")
278
+ const root = `/run/ouro-thaw-watchdog.${process.pid}.${now()}`
279
+ makeDirectory(root, { mode: 0o700 })
280
+ const child = spawnChild(process.execPath, [fileURLToPath(import.meta.url), "--thaw-watchdog", target.targetContainerId, String(target.targetPid), String(process.pid), parentIdentity.bootId, parentIdentity.starttime, root], {
281
+ cwd: "/", detached: true, stdio: "ignore",
282
+ })
283
+ child.unref()
284
+ for (let attempt = 0; attempt < 100 && !fileExists(`${root}/ready`); attempt += 1) wait(50)
285
+ if (!fileExists(`${root}/ready`)) throw new Error("thaw watchdog did not arm")
286
+ if (!Number.isSafeInteger(child.pid) || child.pid <= 0) {
287
+ if (!dependencies.spawn) throw new Error("thaw watchdog process identity is invalid")
288
+ return { assertLive() {}, disarm() { writeFile(`${root}/disarm`, "\n", { flag: "wx", mode: 0o600 }); return { status: "test-disarmed" } } }
289
+ }
290
+ const ready = object(JSON.parse(readFile(`${root}/ready`, "utf8")), "thaw watchdog ready receipt")
291
+ const watchdogIdentity = { pid: child.pid, bootId: ready.watchdogBootId, starttime: ready.watchdogStarttime }
292
+ if (ready.watchdogPid !== watchdogIdentity.pid || ready.readyAt === undefined || !Number.isSafeInteger(ready.readyAt)
293
+ || watchdogIdentity.bootId !== parentIdentity.bootId || !/^[0-9]+$/u.test(watchdogIdentity.starttime) || watchdogIdentity.starttime === "0") {
294
+ throw new Error("thaw watchdog ready identity is invalid")
295
+ }
296
+ const assertLive = () => {
297
+ let observed
298
+ try { observed = readIdentity(watchdogIdentity.pid) } catch (error) { throw new Error("thaw watchdog child exited", { cause: error }) }
299
+ if (observed.bootId !== watchdogIdentity.bootId || observed.starttime !== watchdogIdentity.starttime || terminalProcState(observed.state)) throw new Error("thaw watchdog child identity is not alive")
300
+ }
301
+ assertLive()
302
+ return {
303
+ assertLive,
304
+ disarm() {
305
+ assertLive()
306
+ writeFile(`${root}/disarm`, "\n", { flag: "wx", mode: 0o600 })
307
+ return watchdogReceipt(root, { status: "disarmed", containerId: target.targetContainerId, pid: target.targetPid, parentBootId: parentIdentity.bootId, parentStarttime: parentIdentity.starttime, watchdogPid: watchdogIdentity.pid, watchdogBootId: watchdogIdentity.bootId, watchdogStarttime: watchdogIdentity.starttime }, dependencies)
308
+ },
309
+ }
310
+ }
311
+
312
+ function runKillableCommand(executable, args, timeoutMs, dependencies = {}) {
313
+ if (typeof executable !== "string" || !executable.startsWith("/") || !Array.isArray(args) || args.some((arg) => typeof arg !== "string")
314
+ || !Number.isSafeInteger(timeoutMs) || timeoutMs <= 0 || timeoutMs > 20_000) return Promise.reject(new Error("killable command deadline is invalid"))
315
+ const spawnChild = dependencies.spawn ?? spawn
316
+ const killGroup = dependencies.killProcessGroup ?? ((pid, signal) => {
317
+ try { process.kill(-pid, signal) } catch {
318
+ try { process.kill(pid, signal) } catch {}
319
+ }
320
+ })
321
+ return new Promise((resolve, reject) => {
322
+ let settled = false
323
+ let stdout = ""
324
+ const child = spawnChild(executable, args, {
325
+ cwd: "/", detached: true, stdio: ["ignore", "pipe", "ignore"],
326
+ env: { PATH: "/usr/local/sbin:/usr/sbin:/usr/bin:/sbin:/bin" },
327
+ })
328
+ const finish = (error, value) => {
329
+ if (settled) return
330
+ settled = true
331
+ clearTimeout(termTimer)
332
+ clearTimeout(timer)
333
+ if (error) reject(error)
334
+ else resolve(value)
335
+ }
336
+ child.stdout?.on("data", (chunk) => {
337
+ stdout += String(chunk)
338
+ if (Buffer.byteLength(stdout, "utf8") > 1024 * 1024) {
339
+ killGroup(child.pid, "SIGKILL")
340
+ finish(new Error("killable command output exceeded its bound"))
341
+ }
342
+ })
343
+ child.once("error", () => finish(new Error("killable command failed to launch")))
344
+ child.once("close", (status, signal) => {
345
+ if (status === 0 && signal === null) finish(undefined, stdout)
346
+ else finish(new Error("killable command failed"))
347
+ })
348
+ const graceMs = Math.min(100, Math.max(1, Math.floor(timeoutMs / 4)))
349
+ const termTimer = setTimeout(() => { killGroup(child.pid, "SIGTERM") }, timeoutMs - graceMs)
350
+ const timer = setTimeout(() => {
351
+ killGroup(child.pid, "SIGKILL")
352
+ finish(new Error("killable command hard deadline timed out"))
353
+ }, timeoutMs)
354
+ })
355
+ }
356
+
357
+ async function runDockerWatchdog(args, timeoutMs) {
358
+ return await runKillableCommand(DOCKER, args, timeoutMs)
359
+ }
360
+
361
+ async function pausedTargetStateAsync(target, run, timeoutMs) {
362
+ const template = '{"containerId":{{json .Id}},"running":{{json .State.Running}},"paused":{{json .State.Paused}},"restarting":{{json .State.Restarting}},"dead":{{json .State.Dead}},"pid":{{json .State.Pid}}}'
363
+ const state = object(JSON.parse(await run(["inspect", "--format", template, target.targetContainerId], timeoutMs)), "target pause state")
364
+ if (!CONTAINER_ID.test(state.containerId) || typeof state.running !== "boolean" || typeof state.paused !== "boolean"
365
+ || typeof state.restarting !== "boolean" || typeof state.dead !== "boolean" || !Number.isSafeInteger(state.pid) || state.pid < 0) throw new Error("target pause state is invalid")
366
+ if (state.containerId !== target.targetContainerId || state.pid !== target.targetPid) throw new Error("target pause identity changed")
367
+ return state
368
+ }
369
+
370
+ async function runThawWatchdog(targetContainerId, targetPid, parentPid, parentBootId, parentStarttime, root, dependencies = {}) {
371
+ const target = { targetContainerId, targetPid }
372
+ const wallNow = dependencies.now ?? Date.now
373
+ const monotonicNow = dependencies.monotonicNow ?? monotonicMilliseconds
374
+ const sleep = dependencies.sleep ?? ((milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds)))
375
+ const fileExists = dependencies.existsSync ?? existsSync
376
+ const writeFile = dependencies.writeFileSync ?? writeFileSync
377
+ const readIdentity = dependencies.readParentIdentity ?? readParentIdentity
378
+ const run = dependencies.runDocker ?? runDockerWatchdog
379
+ const enforcementMs = dependencies.enforcementMs ?? WATCHDOG_PARENT_DEATH_ENFORCEMENT_MS
380
+ const recoveryPollMs = dependencies.recoveryPollMs ?? 250
381
+ const rootIdentity = /^\/run\/ouro-thaw-watchdog\.([0-9]+)\.([0-9]+)$/u.exec(root)
382
+ if (!CONTAINER_ID.test(targetContainerId) || !Number.isSafeInteger(targetPid) || targetPid <= 0
383
+ || !Number.isSafeInteger(parentPid) || parentPid <= 0
384
+ || !/^[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12}$/u.test(parentBootId)
385
+ || !/^[0-9]+$/u.test(parentStarttime) || parentStarttime === "0"
386
+ || !rootIdentity || Number(rootIdentity[1]) !== parentPid
387
+ || !Number.isSafeInteger(enforcementMs) || enforcementMs <= 0 || !Number.isSafeInteger(recoveryPollMs) || recoveryPollMs <= 0) throw new Error("thaw watchdog lease is invalid")
388
+ const initial = await pausedTargetStateAsync(target, run, 20_000)
389
+ if (!initial.running || initial.paused || initial.restarting || initial.dead) throw new Error("thaw watchdog initial state is invalid")
390
+ const selfIdentity = dependencies.readSelfIdentity?.() ?? (dependencies.readParentIdentity
391
+ ? { bootId: parentBootId, state: "S", starttime: "1" }
392
+ : readParentIdentity(process.pid))
393
+ if (terminalProcState(selfIdentity.state) || selfIdentity.bootId !== parentBootId || !/^[0-9]+$/u.test(selfIdentity.starttime) || selfIdentity.starttime === "0") throw new Error("thaw watchdog process identity is invalid")
394
+ const readyAt = wallNow()
395
+ writeFile(`${root}/ready`, `${JSON.stringify({ watchdogPid: process.pid, watchdogBootId: selfIdentity.bootId, watchdogStarttime: selfIdentity.starttime, readyAt })}\n`, { flag: "wx", mode: 0o600 })
396
+ const terminalReceipt = (status) => writeFile(`${root}/watchdog-terminal.json`, `${JSON.stringify({ status, containerId: targetContainerId, pid: targetPid, parentBootId, parentStarttime, watchdogPid: process.pid, watchdogBootId: selfIdentity.bootId, watchdogStarttime: selfIdentity.starttime })}\n`, { flag: "wx", mode: 0o600 })
397
+ let transientIdentityFailures = 0
398
+ for (;;) {
399
+ if (fileExists(`${root}/disarm`)) {
400
+ const restored = await pausedTargetStateAsync(target, run, 20_000)
401
+ if (!restored.running || restored.paused || restored.restarting || restored.dead) throw new Error("thaw watchdog disarm state is invalid")
402
+ terminalReceipt("disarmed")
403
+ return
404
+ }
405
+ let parentAlive = false
406
+ try {
407
+ const observed = readIdentity(parentPid)
408
+ parentAlive = observed.bootId === parentBootId && observed.starttime === parentStarttime && !terminalProcState(observed.state)
409
+ transientIdentityFailures = 0
410
+ } catch (error) {
411
+ const code = error && typeof error === "object" && "code" in error ? error.code : undefined
412
+ const confirmedMissing = code === "ENOENT" || code === "ESRCH" || error instanceof Error && /^(?:ENOENT|ESRCH)$/u.test(error.message)
413
+ if (confirmedMissing) break
414
+ transientIdentityFailures += 1
415
+ if (transientIdentityFailures >= 3) throw new Error("thaw watchdog parent identity remained unreadable", { cause: error })
416
+ await sleep(WATCHDOG_POLL_MS)
417
+ continue
418
+ }
419
+ if (!parentAlive) break
420
+ await sleep(WATCHDOG_POLL_MS)
421
+ }
422
+ const deadline = monotonicNow() + enforcementMs
423
+ const remaining = () => {
424
+ const milliseconds = deadline - monotonicNow()
425
+ if (!Number.isSafeInteger(milliseconds) || milliseconds <= 0) throw new Error("thaw watchdog recovery deadline expired")
426
+ return Math.min(20_000, milliseconds)
427
+ }
428
+ for (;;) {
429
+ const state = await pausedTargetStateAsync(target, run, remaining())
430
+ remaining()
431
+ if (!state.running || state.restarting || state.dead) throw new Error("thaw watchdog target state drifted")
432
+ if (state.paused) {
433
+ await run(["unpause", targetContainerId], remaining())
434
+ remaining()
435
+ }
436
+ else if (deadline - monotonicNow() <= recoveryPollMs) {
437
+ remaining()
438
+ terminalReceipt("parent-death-recovered")
439
+ return
440
+ }
441
+ const sleepMs = Math.min(recoveryPollMs, deadline - monotonicNow())
442
+ if (sleepMs <= 0) throw new Error("thaw watchdog recovery deadline expired")
443
+ await sleep(sleepMs)
444
+ }
445
+ }
446
+
447
+ function withPausedTarget(target, operation, dependencies = {}) {
448
+ const run = dependencies.runDocker ?? runDocker
449
+ const armWatchdog = dependencies.armWatchdog ?? (dependencies.runDocker ? () => ({ disarm: () => ({ status: "test-disarmed" }) }) : armThawWatchdog)
450
+ const original = pausedTargetState(target, run)
451
+ if (!original.running || original.paused || original.restarting || original.dead) throw new Error("target original running state is invalid")
452
+ const watchdog = armWatchdog(target)
453
+ watchdog.assertLive?.()
454
+ let result
455
+ let operationError
456
+ let restoreError
457
+ try {
458
+ run(["pause", target.targetContainerId])
459
+ watchdog.assertLive?.()
460
+ const paused = pausedTargetState(target, run)
461
+ if (!paused.running || !paused.paused || paused.restarting || paused.dead) throw new Error("target paused state is invalid")
462
+ watchdog.assertLive?.()
463
+ result = operation()
464
+ watchdog.assertLive?.()
465
+ } catch (error) {
466
+ operationError = error
467
+ } finally {
468
+ try {
469
+ restorePausedTarget(target, run)
470
+ } catch (error) {
471
+ restoreError = error
472
+ }
473
+ }
474
+ if (!restoreError) {
475
+ try { watchdog.disarm() } catch (error) { restoreError = error }
476
+ }
477
+ if (restoreError && operationError) throw new AggregateError([operationError, restoreError], "target scan and restoration both failed")
478
+ if (restoreError) throw new Error("target failed to restore its original running state", { cause: restoreError })
479
+ if (operationError) throw operationError
480
+ return result
481
+ }
482
+
483
+ function dockerTopology() {
484
+ const rows = runDocker(["container", "ls", "-a", "--no-trunc", "--format", "{{json .}}"])
485
+ return rows.split(/\r?\n/u).filter(Boolean).map((line) => JSON.parse(line))
486
+ .filter((row) => typeof row.Names === "string" && CANONICAL_NAMES.includes(row.Names))
487
+ .map((row) => {
488
+ if (!CONTAINER_ID.test(row.ID)) throw new Error("canonical Butler list identity is invalid")
489
+ return { id: row.ID, name: row.Names }
490
+ })
491
+ }
492
+
493
+ function autostartNames() {
494
+ const content = readFileSync("/var/lib/docker/unraid-autostart", "utf8")
495
+ const names = content.split(/\r?\n/u).map((line) => line.trim().split(/\s+/u)[0]).filter((name) => CANONICAL_NAMES.includes(name))
496
+ if (new Set(names).size !== names.length) throw new Error("canonical autostart inventory is ambiguous")
497
+ return new Set(names)
498
+ }
499
+
500
+ async function captureCanonicalRecords(dependencies = {}) {
501
+ const listed = (dependencies.dockerTopology ?? dockerTopology)()
502
+ if (new Set(listed.map(({ name }) => name)).size !== listed.length || new Set(listed.map(({ id }) => id)).size !== listed.length) throw new Error("canonical Butler topology is ambiguous")
503
+ if (listed.length === 0) throw new Error("canonical Butler topology is empty")
504
+ const inspections = dependencies.inspectCanonical
505
+ ? dependencies.inspectCanonical(listed.map(({ id }) => id))
506
+ : JSON.parse(runDocker(["inspect", ...listed.map(({ id }) => id)]).trim())
507
+ if (!Array.isArray(inspections) || inspections.length !== listed.length) throw new Error("canonical Butler inspection is incomplete")
508
+ const starts = new Set((dependencies.autostartNames ?? (() => [...autostartNames()]))())
509
+ const graphqlRecords = await (dependencies.graphqlAutostartNames ?? queryGraphqlAutostart)()
510
+ if (!(graphqlRecords instanceof Map)) throw new Error("Unraid GraphQL autostart state is invalid")
511
+ const records = inspections.map((entry) => {
512
+ const name = String(entry.Name).replace(/^\//u, "")
513
+ const listedRecord = listed.find(({ id }) => id === entry.Id)
514
+ if (!listedRecord || listedRecord.name !== name) throw new Error("canonical Butler identity changed during atomic inspection")
515
+ return {
516
+ id: entry.Id,
517
+ names: [entry.Name],
518
+ imageId: entry.Image,
519
+ running: entry.State?.Running,
520
+ autoStart: starts.has(name),
521
+ restartPolicy: entry.HostConfig?.RestartPolicy?.Name,
522
+ pid: entry.State?.Pid,
523
+ networkMode: entry.HostConfig?.NetworkMode,
524
+ }
525
+ })
526
+ const recordNames = new Set(records.map(({ names }) => names[0].replace(/^\//u, "")))
527
+ if (!exactSet(recordNames, new Set(graphqlRecords.keys()))) throw new Error("Unraid GraphQL canonical topology presence disagrees with Docker")
528
+ const graphqlStarts = new Set([...graphqlRecords].filter(([, identity]) => identity?.autoStart === true).map(([name]) => name))
529
+ if (!exactSet(starts, graphqlStarts)) throw new Error("Unraid GraphQL and durable autostart state disagree")
530
+ for (const [name, identity] of graphqlRecords) {
531
+ const record = records.find((candidate) => candidate.names[0].replace(/^\//u, "") === name)
532
+ if (!record || record.id !== identity?.containerId || record.autoStart !== identity?.autoStart) throw new Error("Unraid GraphQL autostart identity disagrees with Docker")
533
+ }
534
+ return records
535
+ }
536
+
537
+ function parseProcNet(content, ipv6) {
538
+ return content.split(/\r?\n/u).slice(1).filter(Boolean).map((line) => line.trim().split(/\s+/u)).filter((fields) => fields[3] === "0A").map((fields) => {
539
+ const [address, portHex] = fields[1].split(":")
540
+ const localAddress = ipv6 ? address : [6, 4, 2, 0].map((offset) => Number.parseInt(address.slice(offset, offset + 2), 16)).join(".")
541
+ return { inode: fields[9], localAddress, port: Number.parseInt(portHex, 16) }
542
+ })
543
+ }
544
+
545
+ function parseProcUdp(content, ipv6) {
546
+ return content.split(/\r?\n/u).slice(1).filter(Boolean).map((line) => line.trim().split(/\s+/u)).map((fields) => {
547
+ const [address, portHex] = fields[1].split(":")
548
+ const localAddress = ipv6 ? address : [6, 4, 2, 0].map((offset) => Number.parseInt(address.slice(offset, offset + 2), 16)).join(".")
549
+ return { inode: fields[9], localAddress, port: Number.parseInt(portHex, 16) }
550
+ }).filter(({ port }) => port !== 0)
551
+ }
552
+
553
+ function cgroupProcessIds(rootPid, containerId, dependencies = {}) {
554
+ if (!Number.isSafeInteger(rootPid) || rootPid <= 0 || !CONTAINER_ID.test(containerId)) throw new Error("target cgroup identity is invalid")
555
+ const readCgroup = dependencies.readCgroup ?? ((pid) => readFileSync(`/proc/${pid}/cgroup`, "utf8"))
556
+ const readCgroupMembership = dependencies.readCgroupMembership ?? ((file) => readFileSync(file, "utf8"))
557
+ const lines = readCgroup(rootPid).split(/\r?\n/u).filter(Boolean)
558
+ const expectedPath = `/docker/${containerId}`
559
+ if (lines.length !== 1 || lines[0] !== `0::${expectedPath}`) throw new Error("target cgroup is not the exact Docker cgroup-v2 identity")
560
+ const membership = (file, label) => {
561
+ const raw = readCgroupMembership(`/sys/fs/cgroup${expectedPath}/${file}`).trim()
562
+ const ids = raw ? raw.split(/\s+/u).map(Number) : []
563
+ if (ids.length === 0 || ids.some((pid) => !Number.isSafeInteger(pid) || pid <= 0) || new Set(ids).size !== ids.length) throw new Error(`target cgroup ${label} membership is invalid`)
564
+ return ids.sort((left, right) => left - right)
565
+ }
566
+ const processIds = membership("cgroup.procs", "process")
567
+ const threadIds = membership("cgroup.threads", "thread")
568
+ if (!processIds.includes(rootPid)) throw new Error("target root PID is absent from its cgroup")
569
+ if (!processIds.every((pid) => threadIds.includes(pid))) throw new Error("target cgroup thread membership is incomplete")
570
+ return { path: expectedPath, processIds, threadIds }
571
+ }
572
+
573
+ function ownedSocketInodes(threadIds, dependencies = {}) {
574
+ const listFileDescriptors = dependencies.listFileDescriptors ?? ((tid) => readdirSync(`/proc/${tid}/fd`))
575
+ const readDescriptorLink = dependencies.readDescriptorLink ?? ((tid, fd) => readlinkSync(`/proc/${tid}/fd/${fd}`))
576
+ const inodes = []
577
+ for (const tid of threadIds) for (const fd of listFileDescriptors(tid)) {
578
+ const match = /^socket:\[([0-9]+)\]$/u.exec(readDescriptorLink(tid, fd))
579
+ if (match) inodes.push(match[1])
580
+ }
581
+ return inodes
582
+ }
583
+
584
+
585
+ function parseProcUnix(content) {
586
+ return content.split(/\r?\n/u).slice(1).filter(Boolean).map((line) => line.trim().split(/\s+/u))
587
+ .filter((fields) => /^[0-9]+$/u.test(fields[6] ?? ""))
588
+ .map((fields) => ({ inode: fields[6], path: fields.slice(7).join(" "), flags: fields[3], type: fields[4], state: fields[5] }))
589
+ }
590
+
591
+ function sortedUnique(values) {
592
+ return [...new Set(values)].sort((left, right) => String(left).localeCompare(String(right)))
593
+ }
594
+
595
+ function sortedRecords(values) {
596
+ return [...values].sort((left, right) => JSON.stringify(left).localeCompare(JSON.stringify(right)))
597
+ }
598
+
599
+ function sameMembership(left, right) {
600
+ return left.path === right.path
601
+ && exactSet(new Set(left.processIds), new Set(right.processIds))
602
+ && exactSet(new Set(left.threadIds), new Set(right.threadIds))
603
+ }
604
+
605
+ function completeContainmentSnapshot(provisional, readers) {
606
+ const netnsBefore = readers.readNetns(provisional.targetPid)
607
+ const membershipBefore = readers.readMembership(provisional.targetPid, provisional.targetContainerId)
608
+ const socketInodesBefore = sortedUnique(readers.readSockets(membershipBefore.threadIds))
609
+ const tcpListenersRaw = readers.readTcp(provisional.targetPid)
610
+ const udpListenersRaw = readers.readUdp(provisional.targetPid)
611
+ const unixSocketsRaw = readers.readUnix(provisional.targetPid)
612
+ const membershipAfter = readers.readMembership(provisional.targetPid, provisional.targetContainerId)
613
+ const socketInodesAfter = sortedUnique(readers.readSockets(membershipAfter.threadIds))
614
+ const netnsAfter = readers.readNetns(provisional.targetPid)
615
+ if (netnsBefore !== netnsAfter || !sameMembership(membershipBefore, membershipAfter)
616
+ || !exactSet(new Set(socketInodesBefore), new Set(socketInodesAfter))) return null
617
+ const owned = new Set(socketInodesAfter)
618
+ return {
619
+ netns: netnsAfter,
620
+ membership: membershipAfter,
621
+ socketInodes: socketInodesAfter,
622
+ tcpListeners: sortedRecords(tcpListenersRaw.filter((listener) => owned.has(listener.inode))),
623
+ udpListeners: sortedRecords(udpListenersRaw.filter((listener) => owned.has(listener.inode))),
624
+ unixSockets: sortedRecords(unixSocketsRaw.filter((socket) => owned.has(socket.inode))),
625
+ }
626
+ }
627
+
628
+ function convergedTerminalContainment(provisional, readers) {
629
+ let previous = null
630
+ for (let attempt = 0; attempt < TERMINAL_CONTAINMENT_MAX_SAMPLES; attempt += 1) {
631
+ const current = completeContainmentSnapshot(provisional, readers)
632
+ if (current && previous && JSON.stringify(current) === JSON.stringify(previous)) return current
633
+ previous = current
634
+ }
635
+ throw new Error("terminal containment snapshot did not converge")
636
+ }
637
+
638
+ async function runDeploymentTargetAudit(profileName, expectedImageId, dependencies = {}) {
639
+ const capture = dependencies.captureCanonicalRecords ?? captureCanonicalRecords
640
+ const before = await capture()
641
+ const provisional = attestDeploymentTarget({ profile: profileName, expectedImageId, topologyBefore: before, inspected: before, topologyAfter: before })
642
+ const readNetns = dependencies.readNetns ?? ((pid) => readlinkSync(`/proc/${pid}/ns/net`))
643
+ const readMembership = dependencies.cgroupProcessIds ?? cgroupProcessIds
644
+ const readSockets = dependencies.ownedSocketInodes ?? ownedSocketInodes
645
+ const readTcp = dependencies.readTcpListeners ?? ((pid) => [...parseProcNet(readFileSync(`/proc/${pid}/net/tcp`, "utf8"), false), ...parseProcNet(readFileSync(`/proc/${pid}/net/tcp6`, "utf8"), true)])
646
+ const readUdp = dependencies.readUdpListeners ?? ((pid) => [...parseProcUdp(readFileSync(`/proc/${pid}/net/udp`, "utf8"), false), ...parseProcUdp(readFileSync(`/proc/${pid}/net/udp6`, "utf8"), true)])
647
+ const readUnix = dependencies.readUnixSockets ?? ((pid) => parseProcUnix(readFileSync(`/proc/${pid}/net/unix`, "utf8")))
648
+ const quiesceTarget = dependencies.quiesceTarget ?? withPausedTarget
649
+ const containment = quiesceTarget(provisional, () => {
650
+ const netnsBefore = readNetns(provisional.targetPid)
651
+ const membershipBefore = readMembership(provisional.targetPid, provisional.targetContainerId)
652
+ const processIdsBefore = membershipBefore.processIds
653
+ const socketInodesBefore = readSockets(membershipBefore.threadIds)
654
+ const tcpListenersBefore = readTcp(provisional.targetPid)
655
+ const udpListenersBefore = readUdp(provisional.targetPid)
656
+ const unixSocketsBefore = readUnix(provisional.targetPid)
657
+ const membershipAfter = readMembership(provisional.targetPid, provisional.targetContainerId)
658
+ if (membershipBefore.path !== membershipAfter.path) throw new Error("target cgroup changed")
659
+ if (JSON.stringify(processIdsBefore) !== JSON.stringify(membershipAfter.processIds)) throw new Error("target cgroup process membership changed")
660
+ if (JSON.stringify(membershipBefore.threadIds) !== JSON.stringify(membershipAfter.threadIds)) throw new Error("target cgroup thread membership changed")
661
+ const processIdsAfter = membershipAfter.processIds
662
+ const socketInodesAfter = readSockets(membershipAfter.threadIds)
663
+ const tcpListenersAfter = readTcp(provisional.targetPid)
664
+ const udpListenersAfter = readUdp(provisional.targetPid)
665
+ const unixSocketsAfter = readUnix(provisional.targetPid)
666
+ const netnsAfter = readNetns(provisional.targetPid)
667
+ const terminal = convergedTerminalContainment(provisional, { readNetns, readMembership, readSockets, readTcp, readUdp, readUnix })
668
+ return { membershipBefore, processIdsBefore, socketInodesBefore, tcpListenersBefore, udpListenersBefore, unixSocketsBefore, membershipAfter, processIdsAfter, socketInodesAfter, tcpListenersAfter, udpListenersAfter, unixSocketsAfter, netnsBefore, netnsAfter, terminal }
669
+ })
670
+ const after = await capture()
671
+ const { membershipBefore, processIdsBefore, socketInodesBefore, tcpListenersBefore, udpListenersBefore, unixSocketsBefore, membershipAfter, processIdsAfter, socketInodesAfter, tcpListenersAfter, udpListenersAfter, unixSocketsAfter, netnsBefore, netnsAfter, terminal } = containment
672
+ const membershipTerminal = terminal.membership
673
+ const socketInodesTerminal = terminal.socketInodes
674
+ const tcpListenersTerminal = terminal.tcpListeners
675
+ const udpListenersTerminal = terminal.udpListeners
676
+ const unixSocketsTerminal = terminal.unixSockets
677
+ const sameIds = (left, right) => exactSet(new Set(left), new Set(right))
678
+ if (netnsBefore !== netnsAfter || netnsAfter !== terminal.netns) throw new Error("target network namespace changed")
679
+ if (membershipBefore.path !== membershipTerminal.path || membershipAfter.path !== membershipTerminal.path) throw new Error("target cgroup changed")
680
+ if (!sameIds(processIdsBefore, membershipTerminal.processIds) || !sameIds(processIdsAfter, membershipTerminal.processIds)) throw new Error("target cgroup process membership changed")
681
+ if (!sameIds(membershipBefore.threadIds, membershipTerminal.threadIds) || !sameIds(membershipAfter.threadIds, membershipTerminal.threadIds)) throw new Error("target cgroup thread membership changed")
682
+ if (!exactSet(new Set(socketInodesBefore), new Set(socketInodesTerminal)) || !exactSet(new Set(socketInodesAfter), new Set(socketInodesTerminal))) throw new Error("target socket ownership changed")
683
+ const deployment = attestDeploymentTarget({ profile: profileName, expectedImageId, topologyBefore: before, inspected: before, topologyAfter: after })
684
+ const listeners = { ...attestOwnedListeners({ rootPid: deployment.targetPid, netnsBefore, netnsAfter, processIdsBefore, processIdsAfter, socketInodesBefore, socketInodesAfter, socketInodesTerminal, tcpListenersBefore, tcpListenersAfter, tcpListenersTerminal, udpListenersBefore, udpListenersAfter, udpListenersTerminal, unixSocketsBefore, unixSocketsAfter, unixSocketsTerminal }), cgroupPath: membershipTerminal.path, threadCount: membershipTerminal.threadIds.length }
685
+ return { schemaVersion: "sanctuary-effective-deployment-v1", deployment, listeners }
686
+ }
687
+
688
+ if (process.argv[1]?.endsWith("sanctuary-deployment-target.mjs") && process.argv[2] === "--thaw-watchdog") {
689
+ const [targetContainerId, rawTargetPid, rawParentPid, parentBootId, parentStarttime, root] = process.argv.slice(3)
690
+ try { await runThawWatchdog(targetContainerId, Number(rawTargetPid), Number(rawParentPid), parentBootId, parentStarttime, root) } catch (error) {
691
+ if (/^\/run\/ouro-thaw-watchdog\.[0-9]+\.[0-9]+$/u.test(root ?? "") && !existsSync(`${root}/watchdog-terminal.json`)) {
692
+ try { writeFileSync(`${root}/watchdog-terminal.json`, `${JSON.stringify({ status: "failed", containerId: targetContainerId, pid: Number(rawTargetPid), parentBootId, parentStarttime, error: error instanceof Error ? error.message.slice(0, 240) : "unknown" })}\n`, { flag: "wx", mode: 0o600 }) } catch {}
693
+ }
694
+ process.exitCode = 1
695
+ }
696
+ } else if (process.argv[1]?.endsWith("sanctuary-deployment-target.mjs")) {
697
+ const [profile, expectedImageId] = process.argv.slice(2)
698
+ if (process.argv.length !== 4) process.exitCode = 2
699
+ else {
700
+ try { process.stdout.write(`${JSON.stringify(await runDeploymentTargetAudit(profile, expectedImageId))}\n`) }
701
+ catch { process.exitCode = 1 }
702
+ }
703
+ }
704
+
705
+ export { attestDeploymentTarget, attestOwnedListeners, armThawWatchdog, captureCanonicalRecords, cgroupProcessIds, dockerTopology, ownedSocketInodes, parseProcNet, parseProcStatIdentity, parseProcUdp, parseProcUnix, queryGraphqlAutostart, runDeploymentTargetAudit, runKillableCommand, runThawWatchdog, targetProfile, withPausedTarget }