@ouro.bot/cli 0.1.0-alpha.935 → 0.1.0-alpha.936
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 +11 -0
- package/deploy/unraid/sanctuary.ouro/bundle-meta.json +1 -1
- package/deploy/unraid/sanctuary.xml +1 -1
- package/dist/heart/daemon/cli-defaults.js +5 -0
- package/dist/heart/daemon/cli-exec.js +9 -0
- package/dist/heart/daemon/cli-help.js +2 -2
- package/dist/heart/daemon/cli-parse.js +2 -1
- package/dist/heart/daemon/daemon.js +14 -0
- package/dist/heart/daemon/ouro-entry.js +4 -1
- package/dist/heart/daemon/runtime-logging.js +1 -1
- package/dist/heart/daemon/sense-manager.js +53 -0
- package/dist/heart/mailbox/mailbox-http-hooks.js +1 -0
- package/dist/heart/mailbox/mailbox-http-routes.js +8 -0
- package/dist/heart/mailbox/mailbox-read.js +3 -1
- package/dist/heart/mailbox/mailbox-supervision.js +131 -0
- package/dist/heart/shepherd/custody-types.js +2 -0
- package/dist/heart/shepherd/mandates.js +352 -0
- package/dist/heart/shepherd/operation-commands.js +19 -0
- package/dist/heart/shepherd/operations.js +135 -0
- package/dist/heart/shepherd/supervision.js +114 -0
- package/dist/heart/shepherd/types.js +2 -0
- package/dist/mailbox-ui/assets/index-DsiKaPyH.css +1 -0
- package/dist/mailbox-ui/assets/index-xU5krmwW.js +1 -0
- package/dist/mailbox-ui/index.html +2 -2
- package/dist/nerves/coverage/file-completeness.js +4 -0
- package/dist/senses/shepherd/checkin-store.js +209 -41
- package/dist/senses/shepherd/cmux.js +69 -23
- package/dist/senses/shepherd/guidance.js +5 -2
- package/dist/senses/shepherd/herdr.js +84 -2
- package/dist/senses/shepherd/observation-publisher.js +320 -0
- package/dist/senses/shepherd/operations-defaults.js +86 -0
- package/dist/senses/shepherd/operations.js +159 -0
- package/dist/senses/shepherd/recovery.js +543 -0
- package/dist/senses/shepherd/sense.js +8 -0
- package/npm-shrinkwrap.json +2 -2
- package/package.json +1 -1
- package/dist/mailbox-ui/assets/index-Cyv4mw3Y.js +0 -1
- package/dist/mailbox-ui/assets/index-Du_9G9WO.css +0 -1
|
@@ -0,0 +1,543 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
3
|
+
if (k2 === undefined) k2 = k;
|
|
4
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
5
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
6
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
7
|
+
}
|
|
8
|
+
Object.defineProperty(o, k2, desc);
|
|
9
|
+
}) : (function(o, m, k, k2) {
|
|
10
|
+
if (k2 === undefined) k2 = k;
|
|
11
|
+
o[k2] = m[k];
|
|
12
|
+
}));
|
|
13
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
14
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
15
|
+
}) : function(o, v) {
|
|
16
|
+
o["default"] = v;
|
|
17
|
+
});
|
|
18
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
19
|
+
var ownKeys = function(o) {
|
|
20
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
21
|
+
var ar = [];
|
|
22
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
23
|
+
return ar;
|
|
24
|
+
};
|
|
25
|
+
return ownKeys(o);
|
|
26
|
+
};
|
|
27
|
+
return function (mod) {
|
|
28
|
+
if (mod && mod.__esModule) return mod;
|
|
29
|
+
var result = {};
|
|
30
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
31
|
+
__setModuleDefault(result, mod);
|
|
32
|
+
return result;
|
|
33
|
+
};
|
|
34
|
+
})();
|
|
35
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
|
+
exports.readCheckInSnapshot = readCheckInSnapshot;
|
|
37
|
+
exports.readMaintenanceCheckInSnapshot = readMaintenanceCheckInSnapshot;
|
|
38
|
+
exports.reconcileShepherdDispatch = reconcileShepherdDispatch;
|
|
39
|
+
exports.maintainShepherdCustody = maintainShepherdCustody;
|
|
40
|
+
const node_crypto_1 = require("node:crypto");
|
|
41
|
+
const fs = __importStar(require("node:fs"));
|
|
42
|
+
const path = __importStar(require("node:path"));
|
|
43
|
+
const file_lock_1 = require("../../heart/file-lock");
|
|
44
|
+
const runtime_1 = require("../../nerves/runtime");
|
|
45
|
+
const checkin_store_1 = require("./checkin-store");
|
|
46
|
+
const redact_1 = require("./redact");
|
|
47
|
+
const refuse = checkin_store_1.refuseCustody;
|
|
48
|
+
const object = (value) => value !== null && typeof value === "object" && !Array.isArray(value);
|
|
49
|
+
const identity = (value) => typeof value === "string" && value.length > 0 && value.length <= 256 && value === value.trim() && !/[\x00-\x1f\x7f-\x9f]/.test(value);
|
|
50
|
+
const timestamp = (value) => typeof value === "string" && Number.isFinite(Date.parse(value));
|
|
51
|
+
const digest = (value) => (0, node_crypto_1.createHash)("sha256").update(value).digest("hex");
|
|
52
|
+
function canonical(value) {
|
|
53
|
+
return JSON.stringify(value, (_name, item) => object(item) ? Object.fromEntries(Object.entries(item).sort(([a], [b]) => a.localeCompare(b))) : item);
|
|
54
|
+
}
|
|
55
|
+
function validActor(request) {
|
|
56
|
+
return object(request.actor) && request.actor.kind === "local_owner_administration" && identity(request.actor.actorId)
|
|
57
|
+
&& identity(request.requestId) && typeof request.expectedRevision === "number" && Number.isSafeInteger(request.expectedRevision) && request.expectedRevision >= 0;
|
|
58
|
+
}
|
|
59
|
+
function fresh(value) {
|
|
60
|
+
return timestamp(value) && Date.parse(value) <= Date.now() && Date.now() - Date.parse(value) <= 30_000;
|
|
61
|
+
}
|
|
62
|
+
function validRetirementShape(value) {
|
|
63
|
+
return object(value) && value.kind === "host_process_inventory" && identity(value.host) && identity(value.session)
|
|
64
|
+
&& (value.instance === null || identity(value.instance)) && timestamp(value.checkedAt) && value.gone === true && typeof value.wholeSessionRetired === "boolean";
|
|
65
|
+
}
|
|
66
|
+
const validRetirement = (value) => validRetirementShape(value) && fresh(value.checkedAt);
|
|
67
|
+
function copyRetirement(proof) {
|
|
68
|
+
const { kind, host, session, instance, checkedAt, gone, wholeSessionRetired } = proof;
|
|
69
|
+
return { kind, host, session, instance, checkedAt, gone, wholeSessionRetired };
|
|
70
|
+
}
|
|
71
|
+
function parseJson(value, maxBytes = 8192) {
|
|
72
|
+
if (Buffer.byteLength(value) > maxBytes)
|
|
73
|
+
refuse("recovery record byte limit");
|
|
74
|
+
return JSON.parse(value);
|
|
75
|
+
}
|
|
76
|
+
function validRecoveryReceipt(value) {
|
|
77
|
+
return object(value) && Number.isSafeInteger(value.revision) && Number(value.revision) > 0 && identity(value.dispatchId)
|
|
78
|
+
&& ["acknowledged_delivery", "retired_instance"].includes(String(value.resolution))
|
|
79
|
+
&& ["claimed", "sent", "unknown"].includes(String(value.originalState)) && timestamp(value.at) && identity(value.actorId) && identity(value.requestId);
|
|
80
|
+
}
|
|
81
|
+
function auditRow(data) {
|
|
82
|
+
const parsed = parseJson(data);
|
|
83
|
+
if (!object(parsed) || !validRecoveryReceipt(parsed.receipt))
|
|
84
|
+
refuse("invalid recovery audit schema");
|
|
85
|
+
return { receipt: parsed.receipt, evidence: parsed.evidence };
|
|
86
|
+
}
|
|
87
|
+
function reconciliationRows(db, claim) {
|
|
88
|
+
const dispatchId = claim.id;
|
|
89
|
+
const count = db.prepare("SELECT COUNT(*) AS count FROM reconciliations WHERE dispatch_id=?").get(dispatchId);
|
|
90
|
+
const rows = db.prepare("SELECT data FROM reconciliations WHERE dispatch_id=? ORDER BY rowid DESC LIMIT 20").all(dispatchId);
|
|
91
|
+
let receipt = null;
|
|
92
|
+
const receipts = rows.map(row => {
|
|
93
|
+
const audit = auditRow(row.data);
|
|
94
|
+
if (audit.receipt.dispatchId !== dispatchId)
|
|
95
|
+
refuse("recovery dispatch identity mismatch");
|
|
96
|
+
if (audit.receipt.resolution === "acknowledged_delivery") {
|
|
97
|
+
if (!matchesReceipt(claim, audit.evidence, false))
|
|
98
|
+
refuse("invalid recovery host receipt schema");
|
|
99
|
+
receipt ??= (0, checkin_store_1.copyCustodyInputReceipt)(audit.evidence.receipt);
|
|
100
|
+
}
|
|
101
|
+
else if (!validRetirementShape(audit.evidence) || audit.evidence.host !== claim.host || audit.evidence.session !== claim.session
|
|
102
|
+
|| audit.evidence.instance !== claim.instance || audit.evidence.instance === null && !audit.evidence.wholeSessionRetired) {
|
|
103
|
+
refuse("invalid recovery retirement proof schema");
|
|
104
|
+
}
|
|
105
|
+
return audit.receipt;
|
|
106
|
+
});
|
|
107
|
+
return { receipts, count: count.count, receipt };
|
|
108
|
+
}
|
|
109
|
+
function readCheckInSnapshot(file, options) {
|
|
110
|
+
return readCustodySnapshot(file, options, "read");
|
|
111
|
+
}
|
|
112
|
+
/** Recovery may inspect a held marker only after trusted daemon quiescence; this never opens a writer. */
|
|
113
|
+
function readMaintenanceCheckInSnapshot(file, options, sense) {
|
|
114
|
+
requireStoppedSense({ sense });
|
|
115
|
+
return readCustodySnapshot(file, options, "maintenance-read");
|
|
116
|
+
}
|
|
117
|
+
function readCustodySnapshot(file, options, mode) {
|
|
118
|
+
if (!Number.isSafeInteger(options.limit) || options.limit < 1 || options.limit > 200)
|
|
119
|
+
refuse("inspection limit must be 1..200");
|
|
120
|
+
if (options.id !== undefined && (!/^[a-f0-9]{64}$/.test(options.id) || options.cursor !== undefined))
|
|
121
|
+
refuse("invalid inspection ID or conflicting cursor");
|
|
122
|
+
let cursor = null;
|
|
123
|
+
if (options.cursor !== undefined) {
|
|
124
|
+
let parsed;
|
|
125
|
+
try {
|
|
126
|
+
parsed = JSON.parse(Buffer.from(options.cursor, "base64url").toString("utf8"));
|
|
127
|
+
}
|
|
128
|
+
catch {
|
|
129
|
+
refuse("invalid inspection cursor");
|
|
130
|
+
}
|
|
131
|
+
if (options.cursor.length > 200 || !object(parsed) || typeof parsed.after !== "string" || !/^[a-f0-9]{64}$/.test(parsed.after)
|
|
132
|
+
|| typeof parsed.revision !== "number" || !Number.isSafeInteger(parsed.revision) || parsed.revision < 0)
|
|
133
|
+
refuse("invalid inspection cursor");
|
|
134
|
+
cursor = { after: parsed.after, revision: parsed.revision };
|
|
135
|
+
}
|
|
136
|
+
const db = (0, checkin_store_1.openCustodyDatabase)(file, mode);
|
|
137
|
+
if (!db)
|
|
138
|
+
return {
|
|
139
|
+
state: "missing", schemaVersion: null, revision: null, bytes: null, capacity: "unknown",
|
|
140
|
+
totalCount: null, noticeCount: null, counts: null, entries: [], nextCursor: null,
|
|
141
|
+
};
|
|
142
|
+
try {
|
|
143
|
+
return db.transaction(() => {
|
|
144
|
+
const schemaVersion = Number(db.pragma("user_version", { simple: true }));
|
|
145
|
+
const revision = schemaVersion === 2 ? (0, checkin_store_1.custodyRevision)(db) : 0;
|
|
146
|
+
if (cursor && cursor.revision !== revision)
|
|
147
|
+
refuse("inspection cursor revision conflict");
|
|
148
|
+
const capacity = (0, checkin_store_1.custodyCapacity)(db);
|
|
149
|
+
const total = db.prepare("SELECT COUNT(*) AS count FROM dispatches").get();
|
|
150
|
+
const notices = db.prepare("SELECT COUNT(*) AS count FROM notices").get();
|
|
151
|
+
const grouped = db.prepare("SELECT json_extract(data,'$.state') AS state,COUNT(*) AS count FROM dispatches GROUP BY json_extract(data,'$.state')").all();
|
|
152
|
+
const counts = { claimed: 0, sent: 0, not_sent: 0, unknown: 0 };
|
|
153
|
+
for (const group of grouped) {
|
|
154
|
+
if (!["claimed", "sent", "not_sent", "unknown"].includes(group.state))
|
|
155
|
+
refuse("invalid custody state schema");
|
|
156
|
+
counts[group.state] = group.count;
|
|
157
|
+
}
|
|
158
|
+
const rows = (options.id !== undefined
|
|
159
|
+
? db.prepare("SELECT id,host,session,instance,data FROM dispatches WHERE id=?").all(options.id)
|
|
160
|
+
: db.prepare("SELECT id,host,session,instance,data FROM dispatches WHERE id>? ORDER BY id LIMIT ?").all(cursor?.after ?? "", options.limit + 1));
|
|
161
|
+
const page = rows.slice(0, options.limit);
|
|
162
|
+
const entries = page.map(row => {
|
|
163
|
+
const claim = (0, checkin_store_1.parseCustodyDispatch)(row);
|
|
164
|
+
const audit = schemaVersion === 2 ? reconciliationRows(db, claim) : { receipts: [], count: 0, receipt: null };
|
|
165
|
+
const retired = schemaVersion === 2 && (0, checkin_store_1.hasRetiredCustodyInstance)(db, claim);
|
|
166
|
+
const entry = {
|
|
167
|
+
id: claim.id, host: claim.host, session: claim.session, instance: claim.instance, state: claim.state, trigger: claim.trigger,
|
|
168
|
+
claimedAt: claim.at, reason: claim.reason === undefined ? null : (0, redact_1.redactSecrets)(claim.reason), retired,
|
|
169
|
+
receipt: claim.receipt ? (0, checkin_store_1.copyCustodyInputReceipt)(claim.receipt) : audit.receipt, reconciliations: audit.receipts, reconciliationCount: audit.count,
|
|
170
|
+
};
|
|
171
|
+
return entry;
|
|
172
|
+
});
|
|
173
|
+
return {
|
|
174
|
+
state: "ready", schemaVersion, revision, ...capacity, totalCount: total.count, noticeCount: notices.count, counts, entries,
|
|
175
|
+
nextCursor: rows.length > options.limit ? Buffer.from(JSON.stringify({ after: page.at(-1).id, revision })).toString("base64url") : null,
|
|
176
|
+
};
|
|
177
|
+
})();
|
|
178
|
+
}
|
|
179
|
+
finally {
|
|
180
|
+
db.close();
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
function priorRequest(db, request, hash, validate) {
|
|
184
|
+
const row = db.prepare("SELECT hash,receipt FROM custody_requests WHERE actor_id=? AND request_id=?").get(request.actor.actorId, request.requestId);
|
|
185
|
+
if (!row)
|
|
186
|
+
return null;
|
|
187
|
+
if (row.hash !== hash)
|
|
188
|
+
refuse("owner request ID was already used with different arguments");
|
|
189
|
+
const receipt = parseJson(row.receipt);
|
|
190
|
+
if (!validate(receipt))
|
|
191
|
+
refuse("invalid owner request receipt schema");
|
|
192
|
+
return receipt;
|
|
193
|
+
}
|
|
194
|
+
function rememberRequest(db, request, hash, receipt) {
|
|
195
|
+
const serialized = JSON.stringify(receipt);
|
|
196
|
+
db.prepare("INSERT INTO custody_requests(actor_id,request_id,hash,receipt) VALUES(?,?,?,?)").run(request.actor.actorId, request.requestId, hash, serialized);
|
|
197
|
+
}
|
|
198
|
+
function retireInstance(db, proof, provenance, archive) {
|
|
199
|
+
if (proof.instance === null && !proof.wholeSessionRetired)
|
|
200
|
+
refuse("null instance requires whole session retirement proof");
|
|
201
|
+
const prior = (0, checkin_store_1.readRetiredCustodyRecord)(db, proof);
|
|
202
|
+
if (prior && !archive)
|
|
203
|
+
return;
|
|
204
|
+
if (prior?.archive !== undefined && canonical(prior.archive) !== canonical(archive))
|
|
205
|
+
refuse("retired instance archive reference already assigned; manual maintenance required");
|
|
206
|
+
if (Buffer.byteLength(JSON.stringify({ ...(prior ?? { proof, provenance }), ...(archive === undefined ? {} : { archive }) })) > 4096)
|
|
207
|
+
refuse("retired instance index capacity");
|
|
208
|
+
const data = JSON.stringify({ ...(prior ?? { proof: copyRetirement(proof), provenance }), ...(archive === undefined ? {} : { archive }) });
|
|
209
|
+
db.prepare(`INSERT INTO retired_instances(key,host,session,instance,data) VALUES(?,?,?,?,?)
|
|
210
|
+
ON CONFLICT(key) DO UPDATE SET data=excluded.data`).run((0, checkin_store_1.retiredCustodyKey)(proof), proof.host, proof.session, proof.instance, data);
|
|
211
|
+
}
|
|
212
|
+
function matchesReceipt(claim, evidence, requireFresh = true) {
|
|
213
|
+
return object(evidence) && evidence.kind === "guarded_host_receipt_v1" && timestamp(evidence.checkedAt) && (!requireFresh || fresh(evidence.checkedAt))
|
|
214
|
+
&& evidence.host === claim.host && (0, checkin_store_1.validCustodyInputReceipt)(evidence.receipt)
|
|
215
|
+
&& evidence.receipt.requestId === claim.id && evidence.receipt.surfaceId === claim.session
|
|
216
|
+
&& evidence.receipt.processInstance === claim.instance && evidence.receipt.pasteAdmitted && evidence.receipt.submitAdmitted;
|
|
217
|
+
}
|
|
218
|
+
async function reconcileShepherdDispatch(root, request) {
|
|
219
|
+
if (!object(request) || !validActor(request) || !identity(request.id))
|
|
220
|
+
refuse("invalid owner reconciliation request");
|
|
221
|
+
request = structuredClone(request);
|
|
222
|
+
const hash = digest(canonical({ operation: "reconcile", request: { ...request, evidence: { ...request.evidence, checkedAt: undefined } } }));
|
|
223
|
+
const file = (0, checkin_store_1.checkInStorePath)(root);
|
|
224
|
+
// Do not turn a missing ledger into a new source of authority.
|
|
225
|
+
const inspection = readCheckInSnapshot(file, { limit: 1 });
|
|
226
|
+
if (inspection.state === "missing")
|
|
227
|
+
refuse("custody store is missing");
|
|
228
|
+
return (0, file_lock_1.withFileLock)(path.dirname(file), "operations", () => {
|
|
229
|
+
const db = (0, checkin_store_1.openCustodyDatabase)(file, "write");
|
|
230
|
+
try {
|
|
231
|
+
let mutated = false;
|
|
232
|
+
const result = db.transaction(() => {
|
|
233
|
+
(0, checkin_store_1.custodyCapacity)(db, true);
|
|
234
|
+
const prior = priorRequest(db, request, hash, validRecoveryReceipt);
|
|
235
|
+
if (prior)
|
|
236
|
+
return prior;
|
|
237
|
+
if ((0, checkin_store_1.custodyRevision)(db) !== request.expectedRevision)
|
|
238
|
+
refuse("reconciliation revision conflict");
|
|
239
|
+
const row = db.prepare("SELECT id,host,session,instance,data FROM dispatches WHERE id=?").get(request.id);
|
|
240
|
+
if (!row)
|
|
241
|
+
refuse("dispatch not found");
|
|
242
|
+
const claim = (0, checkin_store_1.parseCustodyDispatch)(row);
|
|
243
|
+
if (claim.state === "not_sent")
|
|
244
|
+
refuse("definite not-sent dispatch requires no reconciliation");
|
|
245
|
+
let resolution;
|
|
246
|
+
let retirement = null;
|
|
247
|
+
let verifiedEvidence;
|
|
248
|
+
if (matchesReceipt(claim, request.evidence)) {
|
|
249
|
+
resolution = "acknowledged_delivery";
|
|
250
|
+
verifiedEvidence = {
|
|
251
|
+
kind: request.evidence.kind, host: request.evidence.host, checkedAt: request.evidence.checkedAt,
|
|
252
|
+
receipt: (0, checkin_store_1.copyCustodyInputReceipt)(request.evidence.receipt),
|
|
253
|
+
};
|
|
254
|
+
}
|
|
255
|
+
else if (validRetirement(request.evidence) && request.evidence.host === claim.host
|
|
256
|
+
&& request.evidence.session === claim.session && request.evidence.instance === claim.instance) {
|
|
257
|
+
resolution = "retired_instance";
|
|
258
|
+
retirement = request.evidence;
|
|
259
|
+
verifiedEvidence = copyRetirement(request.evidence);
|
|
260
|
+
}
|
|
261
|
+
else
|
|
262
|
+
refuse("matching confirmed host receipt or fresh exact gone-instance proof required");
|
|
263
|
+
const receipt = {
|
|
264
|
+
revision: (0, checkin_store_1.advanceCustodyRevision)(db), dispatchId: claim.id, resolution, originalState: claim.state,
|
|
265
|
+
at: new Date(Date.now()).toISOString(), actorId: request.actor.actorId, requestId: request.requestId,
|
|
266
|
+
};
|
|
267
|
+
if (retirement)
|
|
268
|
+
retireInstance(db, retirement, receipt);
|
|
269
|
+
if (Buffer.byteLength(JSON.stringify({ receipt, evidence: request.evidence })) > 8192)
|
|
270
|
+
refuse("recovery record byte limit");
|
|
271
|
+
const audit = JSON.stringify({ receipt, evidence: verifiedEvidence });
|
|
272
|
+
db.prepare("INSERT INTO reconciliations(id,dispatch_id,data) VALUES(?,?,?)").run(digest(JSON.stringify([request.actor.actorId, request.requestId])), claim.id, audit);
|
|
273
|
+
rememberRequest(db, request, hash, receipt);
|
|
274
|
+
mutated = true;
|
|
275
|
+
return receipt;
|
|
276
|
+
}).immediate();
|
|
277
|
+
if (mutated)
|
|
278
|
+
(0, runtime_1.emitNervesEvent)({ component: "senses", event: "senses.shepherd_dispatch_reconciled", message: "appended custody reconciliation without rewriting original outcome", meta: { resolution: result.resolution, revision: result.revision } });
|
|
279
|
+
return result;
|
|
280
|
+
}
|
|
281
|
+
finally {
|
|
282
|
+
db.close();
|
|
283
|
+
}
|
|
284
|
+
});
|
|
285
|
+
}
|
|
286
|
+
function syncFile(file) {
|
|
287
|
+
const handle = fs.openSync(file, fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW);
|
|
288
|
+
try {
|
|
289
|
+
fs.fsyncSync(handle);
|
|
290
|
+
}
|
|
291
|
+
finally {
|
|
292
|
+
fs.closeSync(handle);
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
function syncDirectory(dir) {
|
|
296
|
+
const handle = fs.openSync(dir, fs.constants.O_RDONLY | fs.constants.O_DIRECTORY | fs.constants.O_NOFOLLOW);
|
|
297
|
+
try {
|
|
298
|
+
fs.fsyncSync(handle);
|
|
299
|
+
}
|
|
300
|
+
finally {
|
|
301
|
+
fs.closeSync(handle);
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
function privateRegular(file, maxBytes) {
|
|
305
|
+
const stat = fs.lstatSync(file);
|
|
306
|
+
if (!stat.isFile() || stat.nlink !== 1 || stat.uid !== process.getuid() || (stat.mode & 0o7777) !== 0o600 || stat.size > maxBytes)
|
|
307
|
+
refuse("maintenance private file ownership, mode, link or byte limit");
|
|
308
|
+
}
|
|
309
|
+
function fileDigest(file) {
|
|
310
|
+
privateRegular(file, 64 * 1024 * 1024);
|
|
311
|
+
const handle = fs.openSync(file, fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW);
|
|
312
|
+
try {
|
|
313
|
+
const hash = (0, node_crypto_1.createHash)("sha256");
|
|
314
|
+
const buffer = Buffer.alloc(65536);
|
|
315
|
+
for (;;) {
|
|
316
|
+
const count = fs.readSync(handle, buffer, 0, buffer.length, null);
|
|
317
|
+
if (!count)
|
|
318
|
+
break;
|
|
319
|
+
hash.update(buffer.subarray(0, count));
|
|
320
|
+
}
|
|
321
|
+
return hash.digest("hex");
|
|
322
|
+
}
|
|
323
|
+
finally {
|
|
324
|
+
fs.closeSync(handle);
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
function writePrivateJson(file, value) {
|
|
328
|
+
const temp = `${file}.${(0, node_crypto_1.randomUUID)()}.tmp`;
|
|
329
|
+
fs.writeFileSync(temp, JSON.stringify(value), { flag: "wx", mode: 0o600 });
|
|
330
|
+
syncFile(temp);
|
|
331
|
+
fs.renameSync(temp, file);
|
|
332
|
+
syncDirectory(path.dirname(file));
|
|
333
|
+
}
|
|
334
|
+
function maintenanceDirectory(dir, create) {
|
|
335
|
+
if (!path.isAbsolute(dir) || path.normalize(dir) !== dir)
|
|
336
|
+
refuse("maintenance destination must be an exact absolute path");
|
|
337
|
+
(0, checkin_store_1.validateCustodyDirectory)(dir, create);
|
|
338
|
+
}
|
|
339
|
+
function validMaintenanceReceipt(value) {
|
|
340
|
+
return object(value) && Number.isSafeInteger(value.revision) && Number(value.revision) > 0 && identity(value.requestId) && identity(value.actorId)
|
|
341
|
+
&& Number.isSafeInteger(value.archivedCount) && Number(value.archivedCount) >= 0 && Number.isSafeInteger(value.retainedCount) && Number(value.retainedCount) >= 0
|
|
342
|
+
&& typeof value.archive === "string" && path.isAbsolute(value.archive) && typeof value.archiveDigest === "string" && /^[a-f0-9]{64}$/.test(value.archiveDigest) && timestamp(value.at);
|
|
343
|
+
}
|
|
344
|
+
function readIntent(file, hash, archive, replacementPrefix) {
|
|
345
|
+
if (!fs.lstatSync(file, { throwIfNoEntry: false }))
|
|
346
|
+
return null;
|
|
347
|
+
privateRegular(file, 8192);
|
|
348
|
+
const value = parseJson(fs.readFileSync(file, "utf8"));
|
|
349
|
+
if (!object(value) || value.version !== 1 || value.hash !== hash || typeof value.sourceRevision !== "number" || !Number.isSafeInteger(value.sourceRevision) || value.sourceRevision < 0
|
|
350
|
+
|| typeof value.sourceDigest !== "string" || !/^[a-f0-9]{64}$/.test(value.sourceDigest) || value.archive !== archive
|
|
351
|
+
|| typeof value.replacement !== "string" || !value.replacement.startsWith(replacementPrefix)
|
|
352
|
+
|| path.dirname(value.replacement) !== path.dirname(replacementPrefix)
|
|
353
|
+
|| !["copying", "prepared"].includes(String(value.stage)))
|
|
354
|
+
refuse("interrupted maintenance belongs to a different request or has invalid schema");
|
|
355
|
+
const base = { version: 1, hash, sourceDigest: value.sourceDigest, sourceRevision: value.sourceRevision, archive, replacement: value.replacement };
|
|
356
|
+
if (value.stage === "prepared") {
|
|
357
|
+
if (!validMaintenanceReceipt(value.receipt) || value.archiveDigest !== value.receipt.archiveDigest
|
|
358
|
+
|| typeof value.replacementDigest !== "string" || !/^[a-f0-9]{64}$/.test(value.replacementDigest))
|
|
359
|
+
refuse("invalid prepared maintenance schema");
|
|
360
|
+
return { ...base, stage: "prepared", archiveDigest: value.receipt.archiveDigest, replacementDigest: value.replacementDigest, receipt: value.receipt };
|
|
361
|
+
}
|
|
362
|
+
if (value.archiveDigest !== null || value.replacementDigest !== null || value.receipt !== null)
|
|
363
|
+
refuse("invalid copying maintenance schema");
|
|
364
|
+
return { ...base, stage: "copying", archiveDigest: null, replacementDigest: null, receipt: null };
|
|
365
|
+
}
|
|
366
|
+
function targetsClaim(proof, claim) {
|
|
367
|
+
return proof.host === claim.host && proof.session === claim.session && (proof.instance === null && proof.wholeSessionRetired || proof.instance === claim.instance);
|
|
368
|
+
}
|
|
369
|
+
function archiveCandidates(db, proofs) {
|
|
370
|
+
const ids = [];
|
|
371
|
+
for (const row of db.prepare("SELECT id,host,session,instance,data FROM dispatches").iterate()) {
|
|
372
|
+
const claim = (0, checkin_store_1.parseCustodyDispatch)(row);
|
|
373
|
+
if (!proofs.some(proof => targetsClaim(proof, claim)))
|
|
374
|
+
continue;
|
|
375
|
+
const retired = (0, checkin_store_1.hasRetiredCustodyInstance)(db, claim);
|
|
376
|
+
if ((claim.state === "claimed" || claim.state === "unknown") && !retired)
|
|
377
|
+
refuse("unresolved attempt must be reconciled before archive");
|
|
378
|
+
ids.push(claim.id);
|
|
379
|
+
}
|
|
380
|
+
return ids;
|
|
381
|
+
}
|
|
382
|
+
function validateArchive(file, expectedCount) {
|
|
383
|
+
const db = (0, checkin_store_1.openCustodyDatabase)(file, "read");
|
|
384
|
+
try {
|
|
385
|
+
if (db.pragma("integrity_check", { simple: true }) !== "ok")
|
|
386
|
+
refuse("archive integrity check failed");
|
|
387
|
+
const count = db.prepare("SELECT COUNT(*) AS count FROM dispatches").get();
|
|
388
|
+
if (count.count !== expectedCount)
|
|
389
|
+
refuse("archive dispatch count mismatch");
|
|
390
|
+
for (const row of db.prepare("SELECT id,host,session,instance,data FROM dispatches").iterate())
|
|
391
|
+
(0, checkin_store_1.parseCustodyDispatch)(row);
|
|
392
|
+
}
|
|
393
|
+
finally {
|
|
394
|
+
db.close();
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
function verifyPrepared(intent) {
|
|
398
|
+
if (intent.stage !== "prepared" || !intent.receipt || fileDigest(intent.archive) !== intent.archiveDigest
|
|
399
|
+
|| fileDigest(intent.replacement) !== intent.replacementDigest)
|
|
400
|
+
refuse("prepared maintenance payload digest mismatch");
|
|
401
|
+
const manifest = `${intent.archive}.json`;
|
|
402
|
+
privateRegular(manifest, 8192);
|
|
403
|
+
const value = parseJson(fs.readFileSync(manifest, "utf8"));
|
|
404
|
+
if (!object(value) || value.archiveDigest !== intent.archiveDigest || value.requestHash !== intent.hash
|
|
405
|
+
|| canonical(value.receipt) !== canonical(intent.receipt))
|
|
406
|
+
refuse("archive manifest mismatch");
|
|
407
|
+
}
|
|
408
|
+
function requireStoppedSense(request) {
|
|
409
|
+
if (!object(request.sense) || request.sense.kind !== "daemon_lifecycle" || request.sense.enabled !== false || request.sense.processState !== "stopped" || !fresh(request.sense.checkedAt)) {
|
|
410
|
+
refuse("maintenance requires a fresh disabled and stopped sense proof");
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
/** Fresh lifecycle observations are preconditions, not a change to the operator's archival intent. */
|
|
414
|
+
async function maintainShepherdCustody(root, request) {
|
|
415
|
+
if (!object(request) || !validActor(request) || typeof request.destination !== "string" || request.destination.length > 2000
|
|
416
|
+
|| !Array.isArray(request.retiredInstances) || request.retiredInstances.length > 200
|
|
417
|
+
|| request.requestFingerprint !== undefined && !/^[a-f0-9]{64}$/.test(request.requestFingerprint))
|
|
418
|
+
refuse("invalid owner maintenance request");
|
|
419
|
+
request = structuredClone(request);
|
|
420
|
+
const hash = request.requestFingerprint ?? digest(canonical({ operation: "maintenance", request: {
|
|
421
|
+
...request, sense: undefined, retiredInstances: request.retiredInstances.map(proof => ({ ...proof, checkedAt: undefined })),
|
|
422
|
+
} }));
|
|
423
|
+
const file = (0, checkin_store_1.checkInStorePath)(root);
|
|
424
|
+
const marker = `${file}.maintenance.json`;
|
|
425
|
+
const archive = path.join(request.destination, `custody-${hash}.sqlite`);
|
|
426
|
+
const replacementPrefix = `${file}.replacement-${hash}-`;
|
|
427
|
+
maintenanceDirectory(request.destination, false);
|
|
428
|
+
if (!fs.lstatSync(file, { throwIfNoEntry: false }))
|
|
429
|
+
refuse("custody store is missing");
|
|
430
|
+
return (0, file_lock_1.withFileLock)(path.dirname(file), "operations", async () => {
|
|
431
|
+
let intent = readIntent(marker, hash, archive, replacementPrefix);
|
|
432
|
+
const db = (0, checkin_store_1.openCustodyDatabase)(file, "maintenance");
|
|
433
|
+
try {
|
|
434
|
+
const prior = priorRequest(db, request, hash, validMaintenanceReceipt);
|
|
435
|
+
if (prior) {
|
|
436
|
+
if (intent) {
|
|
437
|
+
if (intent.stage !== "prepared" || fileDigest(file) !== intent.replacementDigest || fileDigest(archive) !== prior.archiveDigest)
|
|
438
|
+
refuse("completed maintenance does not match recovery marker");
|
|
439
|
+
fs.unlinkSync(marker);
|
|
440
|
+
syncDirectory(path.dirname(file));
|
|
441
|
+
}
|
|
442
|
+
return prior;
|
|
443
|
+
}
|
|
444
|
+
requireStoppedSense(request);
|
|
445
|
+
if (!request.retiredInstances.length || !request.retiredInstances.every(validRetirement)
|
|
446
|
+
|| request.retiredInstances.some(proof => proof.instance === null && !proof.wholeSessionRetired))
|
|
447
|
+
refuse("maintenance requires fresh exact retired-instance proofs");
|
|
448
|
+
if ((0, checkin_store_1.custodyRevision)(db) !== request.expectedRevision)
|
|
449
|
+
refuse("maintenance revision conflict");
|
|
450
|
+
if (intent) {
|
|
451
|
+
if (intent.sourceRevision !== (0, checkin_store_1.custodyRevision)(db) || fileDigest(file) !== intent.sourceDigest)
|
|
452
|
+
refuse("maintenance source changed; manual recovery required");
|
|
453
|
+
if (intent.stage === "prepared") {
|
|
454
|
+
verifyPrepared(intent);
|
|
455
|
+
requireStoppedSense(request);
|
|
456
|
+
db.close();
|
|
457
|
+
fs.renameSync(intent.replacement, file);
|
|
458
|
+
syncDirectory(path.dirname(file));
|
|
459
|
+
fs.unlinkSync(marker);
|
|
460
|
+
syncDirectory(path.dirname(file));
|
|
461
|
+
return intent.receipt;
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
const ids = archiveCandidates(db, request.retiredInstances);
|
|
465
|
+
if (!ids.length)
|
|
466
|
+
refuse("no settled retired-instance payloads are eligible for archive");
|
|
467
|
+
const total = db.prepare("SELECT COUNT(*) AS count FROM dispatches").get();
|
|
468
|
+
maintenanceDirectory(request.destination, true);
|
|
469
|
+
if (fs.lstatSync(archive, { throwIfNoEntry: false })) {
|
|
470
|
+
if (!intent || intent.stage !== "copying")
|
|
471
|
+
refuse("archive destination already exists; resume its exact prepared request");
|
|
472
|
+
privateRegular(archive, 64 * 1024 * 1024);
|
|
473
|
+
fs.renameSync(archive, `${archive}.interrupted-${(0, node_crypto_1.randomUUID)()}`);
|
|
474
|
+
if (fs.lstatSync(`${archive}.json`, { throwIfNoEntry: false })) {
|
|
475
|
+
privateRegular(`${archive}.json`, 8192);
|
|
476
|
+
fs.renameSync(`${archive}.json`, `${archive}.json.interrupted-${(0, node_crypto_1.randomUUID)()}`);
|
|
477
|
+
}
|
|
478
|
+
syncDirectory(request.destination);
|
|
479
|
+
(0, runtime_1.emitNervesEvent)({ level: "warn", component: "senses", event: "senses.shepherd_custody_archive_resumed", message: "preserved an interrupted archive publication before rebuilding from unchanged custody", meta: { stage: "copying" } });
|
|
480
|
+
}
|
|
481
|
+
intent = {
|
|
482
|
+
version: 1, hash, sourceDigest: fileDigest(file), sourceRevision: (0, checkin_store_1.custodyRevision)(db), stage: "copying", archive,
|
|
483
|
+
replacement: `${replacementPrefix}${(0, node_crypto_1.randomUUID)()}.sqlite`, archiveDigest: null, replacementDigest: null, receipt: null,
|
|
484
|
+
};
|
|
485
|
+
writePrivateJson(marker, intent);
|
|
486
|
+
const archiveTemp = `${archive}.${(0, node_crypto_1.randomUUID)()}.tmp`;
|
|
487
|
+
fs.writeFileSync(archiveTemp, "", { flag: "wx", mode: 0o600 });
|
|
488
|
+
await db.backup(archiveTemp);
|
|
489
|
+
syncFile(archiveTemp);
|
|
490
|
+
validateArchive(archiveTemp, total.count);
|
|
491
|
+
const archiveDigest = fileDigest(archiveTemp);
|
|
492
|
+
fs.writeFileSync(intent.replacement, "", { flag: "wx", mode: 0o600 });
|
|
493
|
+
await db.backup(intent.replacement);
|
|
494
|
+
const replacement = (0, checkin_store_1.openCustodyDatabase)(intent.replacement, "write");
|
|
495
|
+
let receipt;
|
|
496
|
+
try {
|
|
497
|
+
receipt = replacement.transaction(() => {
|
|
498
|
+
for (const id of ids) {
|
|
499
|
+
replacement.prepare("DELETE FROM reconciliations WHERE dispatch_id=?").run(id);
|
|
500
|
+
replacement.prepare("DELETE FROM dispatches WHERE id=?").run(id);
|
|
501
|
+
}
|
|
502
|
+
for (const proof of request.retiredInstances)
|
|
503
|
+
retireInstance(replacement, proof, { actorId: request.actor.actorId, requestId: request.requestId }, { path: archive, digest: archiveDigest });
|
|
504
|
+
const result = {
|
|
505
|
+
revision: (0, checkin_store_1.advanceCustodyRevision)(replacement), requestId: request.requestId, actorId: request.actor.actorId,
|
|
506
|
+
archivedCount: ids.length, retainedCount: total.count - ids.length, archive, archiveDigest, at: new Date(Date.now()).toISOString(),
|
|
507
|
+
};
|
|
508
|
+
rememberRequest(replacement, request, hash, result);
|
|
509
|
+
return result;
|
|
510
|
+
}).immediate();
|
|
511
|
+
replacement.exec("VACUUM");
|
|
512
|
+
(0, checkin_store_1.custodyCapacity)(replacement, true);
|
|
513
|
+
if (replacement.pragma("integrity_check", { simple: true }) !== "ok")
|
|
514
|
+
refuse("replacement custody integrity check failed");
|
|
515
|
+
}
|
|
516
|
+
finally {
|
|
517
|
+
replacement.close();
|
|
518
|
+
}
|
|
519
|
+
syncFile(intent.replacement);
|
|
520
|
+
validateArchive(intent.replacement, receipt.retainedCount);
|
|
521
|
+
fs.renameSync(archiveTemp, archive);
|
|
522
|
+
syncDirectory(request.destination);
|
|
523
|
+
writePrivateJson(`${archive}.json`, { version: 1, requestHash: hash, archiveDigest, receipt });
|
|
524
|
+
intent = { ...intent, stage: "prepared", archiveDigest, replacementDigest: fileDigest(intent.replacement), receipt };
|
|
525
|
+
writePrivateJson(marker, intent);
|
|
526
|
+
verifyPrepared(intent);
|
|
527
|
+
requireStoppedSense(request);
|
|
528
|
+
if (fileDigest(file) !== intent.sourceDigest)
|
|
529
|
+
refuse("maintenance source changed before swap");
|
|
530
|
+
db.close();
|
|
531
|
+
fs.renameSync(intent.replacement, file);
|
|
532
|
+
syncDirectory(path.dirname(file));
|
|
533
|
+
fs.unlinkSync(marker);
|
|
534
|
+
syncDirectory(path.dirname(file));
|
|
535
|
+
(0, runtime_1.emitNervesEvent)({ component: "senses", event: "senses.shepherd_custody_archived", message: "archived settled retired custody and retained replay tombstones", meta: { archivedCount: receipt.archivedCount, retainedCount: receipt.retainedCount, revision: receipt.revision } });
|
|
536
|
+
return receipt;
|
|
537
|
+
}
|
|
538
|
+
finally {
|
|
539
|
+
if (db.open)
|
|
540
|
+
db.close();
|
|
541
|
+
}
|
|
542
|
+
});
|
|
543
|
+
}
|
|
@@ -65,6 +65,7 @@ const agent_presentation_1 = require("./agent-presentation");
|
|
|
65
65
|
const feedback_worker_1 = require("./feedback-worker");
|
|
66
66
|
const checkin_config_1 = require("./checkin-config");
|
|
67
67
|
const checkin_store_1 = require("./checkin-store");
|
|
68
|
+
const observation_publisher_1 = require("./observation-publisher");
|
|
68
69
|
/**
|
|
69
70
|
* Ouro Shepherd: every time a coding agent in a terminal hands control back, a cheap judge decides
|
|
70
71
|
* whether it should have. A premature return is answered in the terminal, visibly, as
|
|
@@ -212,6 +213,8 @@ async function startShepherdSenseApp(options) {
|
|
|
212
213
|
const notifier = options.notifyOwner ? null : (0, owner_notice_1.createOwnerNotifier)({ agent, agentRoot, policy: noticePolicy(agent, agentRoot), schedule, listSessions: () => host.list() });
|
|
213
214
|
const notifyOwner = options.notifyOwner ?? ((record) => notifier.notify(record));
|
|
214
215
|
const custody = (options.openCheckInStore ?? checkin_store_1.openCheckInStore)((0, checkin_store_1.checkInStorePath)(agentRoot));
|
|
216
|
+
const observations = (0, observation_publisher_1.createShepherdObservationPublisher)({ agentRoot, agentName: agent, host: host.name, now });
|
|
217
|
+
observations.refresh();
|
|
215
218
|
const dispatchOwner = (0, node_crypto_1.randomUUID)();
|
|
216
219
|
const feedback = (options.feedbackWorker ?? feedback_worker_1.startFeedbackWorker)({ agent, agentRoot, now, schedule });
|
|
217
220
|
/**
|
|
@@ -684,9 +687,13 @@ async function startShepherdSenseApp(options) {
|
|
|
684
687
|
catch (error) {
|
|
685
688
|
(0, runtime_1.emitNervesEvent)({ level: "warn", component: "senses", event: "senses.shepherd_log_error", message: "could not log a Shepherd judgment", meta: { agent, error: error.message } });
|
|
686
689
|
}
|
|
690
|
+
if (!stopped)
|
|
691
|
+
observations.refresh();
|
|
687
692
|
(0, runtime_1.emitNervesEvent)({ component: "senses", event: "senses.shepherd_return", message: "handled a returned control", meta: { agent, host: host.name, kind: record.kind, action: record.action } });
|
|
688
693
|
}
|
|
689
694
|
const watcher = host.watch({
|
|
695
|
+
observed: observation => { if (!stopped)
|
|
696
|
+
observations.observe(observation); },
|
|
690
697
|
returned: (event) => {
|
|
691
698
|
if (stopped)
|
|
692
699
|
return;
|
|
@@ -712,6 +719,7 @@ async function startShepherdSenseApp(options) {
|
|
|
712
719
|
if (stopped)
|
|
713
720
|
return;
|
|
714
721
|
stopped = true;
|
|
722
|
+
observations.stop();
|
|
715
723
|
networkWaiting.clear();
|
|
716
724
|
watcher.close();
|
|
717
725
|
cancelWake?.();
|
package/npm-shrinkwrap.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ouro.bot/cli",
|
|
3
|
-
"version": "0.1.0-alpha.
|
|
3
|
+
"version": "0.1.0-alpha.936",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "@ouro.bot/cli",
|
|
9
|
-
"version": "0.1.0-alpha.
|
|
9
|
+
"version": "0.1.0-alpha.936",
|
|
10
10
|
"dependencies": {
|
|
11
11
|
"@anthropic-ai/sdk": "^0.78.0",
|
|
12
12
|
"@azure/identity": "^4.13.0",
|