@ouro.bot/cli 0.1.0-alpha.990 → 0.1.0-alpha.991
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 +6 -0
- package/deploy/unraid/Dockerfile +15 -2
- package/deploy/unraid/README.txt +44 -9
- package/deploy/unraid/github_known_hosts +3 -0
- package/deploy/unraid/ouro-sync-ssh +98 -0
- package/deploy/unraid/ouro-sync.sudoers +3 -0
- package/deploy/unraid/sanctuary-butler-upgrade.mjs +26 -2
- package/deploy/unraid/sanctuary-replay-gate.mjs +361 -8
- package/deploy/unraid/sanctuary-sync-cutover.sh +1039 -0
- package/deploy/unraid/sanctuary.ouro/agent.json +2 -1
- package/deploy/unraid/sanctuary.ouro/bundle-meta.json +1 -1
- package/deploy/unraid/sanctuary.ouro/tool-profiles.json +3 -1
- package/deploy/unraid/sanctuary.xml +2 -1
- package/dist/heart/config.js +2 -1
- package/dist/heart/daemon/agent-discovery.js +5 -1
- package/dist/heart/daemon/agent-home.js +67 -31
- package/dist/heart/daemon/boot-sync-probe.js +36 -10
- package/dist/heart/daemon/cli-exec.js +79 -20
- package/dist/heart/daemon/cli-help.js +12 -0
- package/dist/heart/daemon/cli-parse.js +14 -0
- package/dist/heart/daemon/container-spec-auditor-main.js +22 -6
- package/dist/heart/daemon/container-spec-auditor.js +18 -2
- package/dist/heart/daemon/daemon-entry.js +22 -0
- package/dist/heart/daemon/sanctuary-acceptance-harness.js +1 -1
- package/dist/heart/daemon/sanctuary-bundle-migration.js +97 -1
- package/dist/heart/daemon/sanctuary-package-management.js +10 -0
- package/dist/heart/sync-lock.js +431 -0
- package/dist/heart/sync.js +1252 -399
- package/dist/mind/prompt.js +92 -19
- package/dist/repertoire/guardrails.js +285 -3
- package/dist/repertoire/tools-bundle.js +107 -185
- package/dist/repertoire/tools.js +5 -1
- package/dist/senses/pipeline.js +37 -3
- package/dist/senses/private-runtime.js +1 -6
- package/dist/senses/voice/twilio-phone.js +16 -16
- package/npm-shrinkwrap.json +2 -2
- package/package.json +1 -1
|
@@ -9,7 +9,10 @@
|
|
|
9
9
|
// run [--cases a,b] [--plant <id>] [--baseline <file>]
|
|
10
10
|
// open the replay window, run the cases, close the window, print one JSON line per case.
|
|
11
11
|
// --baseline (or OURO_REPLAY_BASELINE) names the live version's run of this gate: a case
|
|
12
|
-
// that failed there too is reported as failing-on-live-too and does not block
|
|
12
|
+
// that failed there too is reported as failing-on-live-too and does not block.
|
|
13
|
+
// Once the bundle syncs (bundle-backup/cutover-done), the run pauses sync for its length,
|
|
14
|
+
// syncs once after the restores, checks the remote gained none of its residue, checks git
|
|
15
|
+
// tracks the psyche and nothing excluded, and times sync on one read-only turn
|
|
13
16
|
// self-test run every case's readback against built-in pass and fail fixtures (used by CI)
|
|
14
17
|
//
|
|
15
18
|
// The replay window routes owner notices and A2A await deliveries for the replay peers to
|
|
@@ -38,6 +41,26 @@ const [BUTLER_UID, BUTLER_GID] = BUTLER_USER.split(":").map(Number)
|
|
|
38
41
|
/** The psyche files that say who the Butler is. TACIT, what he learns, is the one he writes himself. */
|
|
39
42
|
export const IDENTITY_FILES = ["SOUL.md", "IDENTITY.md", "LORE.md", "ASPIRATIONS.md"]
|
|
40
43
|
export const CLI_ENTRY = "/opt/ouro/dist/heart/daemon/ouro-entry.js"
|
|
44
|
+
// Bundle sync (M1c). The cutover marker is root's, beside the old backup repository; the pause marker is sync's own (src/heart/sync.ts).
|
|
45
|
+
export const DEFAULT_BACKUP_DIR = "/mnt/user/appdata/ouro-butler/bundle-backup"
|
|
46
|
+
export const SYNC_PAUSE_MS = 2 * 60 * 60 * 1000
|
|
47
|
+
export const SYNC_LOCK_WAIT_MS = 120 * 1000
|
|
48
|
+
export const SYNC_LOCK_POLL_MS = 1000
|
|
49
|
+
// sync-lock.ts's RECLAIM_STALE_MS: a lock that does not parse counts as held for this long.
|
|
50
|
+
export const SYNC_LOCK_MALFORMED_MS = 30 * 1000
|
|
51
|
+
// The sync path inside the container, as src/heart/sync.ts's SYNC_SSH_COMMAND.
|
|
52
|
+
export const SYNC_SSH_COMMAND = "sudo -n -u ouro-sync /usr/local/libexec/ouro-sync-ssh"
|
|
53
|
+
export const SYNC_LATENCY_LIMIT_MS = 2000
|
|
54
|
+
export const SYNC_EVENTS_WAIT_MS = 60 * 1000
|
|
55
|
+
export const SYNC_EVENTS_POLL_MS = 2000
|
|
56
|
+
export const LATENCY_WORDS = "(replay) what time is it?"
|
|
57
|
+
/** The psyche files git must track once the bundle syncs. */
|
|
58
|
+
export const PSYCHE_FILES = ["ASPIRATIONS.md", "IDENTITY.md", "LORE.md", "SOUL.md", "TACIT.md"]
|
|
59
|
+
/** Files each release installs, which sync must never track (SANCTUARY_PACKAGE_MANAGED_FILES, pinned by a test), and the release's bookkeeping. */
|
|
60
|
+
export const NEVER_TRACKED = ["provider-readiness.json", "tool-profiles.json", "mcp/media-mcp.mjs", "habits/sanctuary-health.md", "awaiting/house-care-sweep.md", "product/PRODUCT.md", "bundle-meta.json", "psyche/.release-marker"]
|
|
61
|
+
export const SECRET_PATH = /credential|secret|token|\.key$|\.pem$|(^|\/)\.env($|\.)/i
|
|
62
|
+
/** Text the gate's cases plant (the learns-into-tacit rule, the outbox canary, the replay prefix). None may reach the remote. */
|
|
63
|
+
export const GATE_PLANTED_STRINGS = ["english subtitles", "ISOLATION-CANARY", "(replay)"]
|
|
41
64
|
// Long enough for every case at its slowest (nine chat messages at up to 5.5 minutes each, plus the await case's 8 minute wait),
|
|
42
65
|
// and well inside the 2 hour cap: an await that has not resolved when the window closes is cancelled by the Butler.
|
|
43
66
|
// Waits for cut turns come out of the same budget and stop five minutes before the window closes (runSuite).
|
|
@@ -873,7 +896,7 @@ export function withTurnNote(checks, turn) {
|
|
|
873
896
|
}
|
|
874
897
|
|
|
875
898
|
// Safety checks a live failure never excuses: each guards the owner and the house, not the Butler's answers.
|
|
876
|
-
export const NEVER_EXCUSED = ["window-trusted", "no-telegram", "owner-policy-untouched", "tacit-restored", "replay-friend-restored"]
|
|
899
|
+
export const NEVER_EXCUSED = ["window-trusted", "no-telegram", "owner-policy-untouched", "tacit-restored", "replay-friend-restored", "no-sync-residue", "psyche-git-tracked"]
|
|
877
900
|
export const FAILING_ON_LIVE_TOO = "failing-on-live-too"
|
|
878
901
|
|
|
879
902
|
/**
|
|
@@ -895,7 +918,8 @@ export function summarize(all) {
|
|
|
895
918
|
const ids = (status) => all.filter((entry) => entry.status === status).map((entry) => entry.id)
|
|
896
919
|
const failed = ids("fail")
|
|
897
920
|
const failingOnLiveToo = ids(FAILING_ON_LIVE_TOO)
|
|
898
|
-
|
|
921
|
+
const pending = ids("pending")
|
|
922
|
+
return { ok: failed.length === 0, passed: ids("pass").length, skipped: ids("skipped").length, failed, warned: ids("warn"), ...(failingOnLiveToo.length > 0 ? { failingOnLiveToo } : {}), ...(pending.length > 0 ? { pending } : {}) }
|
|
899
923
|
}
|
|
900
924
|
|
|
901
925
|
/** Case lines of an earlier `run` (its stdout, or a log holding it), for --baseline. A missing, stale or empty baseline excuses nothing. */
|
|
@@ -963,11 +987,202 @@ function replayFriendRestoredResult(host, learned) {
|
|
|
963
987
|
return { id: "replay-friend-restored", status: detail ? "fail" : "pass", checks: [check("the replay friend's notes and preferences hold what they had before the run", !detail, detail)] }
|
|
964
988
|
}
|
|
965
989
|
|
|
966
|
-
|
|
990
|
+
// ---- bundle sync around the run (M1c) -------------------------------------------------------------------------------
|
|
991
|
+
|
|
992
|
+
const pendingSync = (id, name) => ({ id, status: "pending", checks: [check(name, true, "the bundle has not moved to bundle sync yet (bundle-backup/cutover-done is absent)")] })
|
|
993
|
+
|
|
994
|
+
/**
|
|
995
|
+
* Pauses sync for the run: a leftover marker from a crashed run goes first, then a fresh one that expires on its own in two hours,
|
|
996
|
+
* then the gate waits (at most two minutes) for a sync already running to let go of the lock, and records HEAD.
|
|
997
|
+
*/
|
|
998
|
+
export async function openSyncBracket(sync, { waitMs = SYNC_LOCK_WAIT_MS } = {}) {
|
|
999
|
+
sync.resume()
|
|
1000
|
+
sync.pause(SYNC_PAUSE_MS)
|
|
1001
|
+
if (!(await sync.waitLockFree(waitMs))) return { ok: false, detail: `sync lock busy: .git/ouro-sync.lock was still held after ${Math.round(waitMs / 1000)} s` }
|
|
1002
|
+
return { ok: true, head: sync.head(), untracked: untrackedSnapshot(sync) }
|
|
1003
|
+
}
|
|
1004
|
+
|
|
1005
|
+
/** The bundle's untracked files and their hashes, or why they could not be read. */
|
|
1006
|
+
export function untrackedSnapshot(sync) {
|
|
1007
|
+
try { return { ok: true, files: sync.untracked() } } catch (error) { return { ok: false, error: String(error?.message ?? error) } }
|
|
1008
|
+
}
|
|
1009
|
+
|
|
1010
|
+
/**
|
|
1011
|
+
* The untracked paths the run created or changed: in `after` and not in `before`, or with another hash. A tracked file the run
|
|
1012
|
+
* changed cannot be told apart from a household turn's edit this way, so those stay on the hand-kept list (TACIT).
|
|
1013
|
+
*/
|
|
1014
|
+
export function createdDuringRun(before, after) {
|
|
1015
|
+
if (!before.ok) return { paths: [], error: before.error }
|
|
1016
|
+
if (!after.ok) return { paths: [], error: after.error }
|
|
1017
|
+
return { paths: Object.keys(after.files).filter((file) => before.files[file] !== after.files[file]).sort() }
|
|
1018
|
+
}
|
|
1019
|
+
|
|
1020
|
+
// An entry ending in "/" covers a folder, one ending in "*" covers names that start with it, and any other names one path.
|
|
1021
|
+
const touches = (paths, prefixes) => paths.filter((file) => prefixes.some((prefix) => (prefix.endsWith("/") ? file.startsWith(prefix) : prefix.endsWith("*") ? file.startsWith(prefix.slice(0, -1)) : file === prefix)))
|
|
1022
|
+
|
|
1023
|
+
/**
|
|
1024
|
+
* Ends the pause, runs one sync, and checks what the Butler pushed since the recorded HEAD: none of its commits touches a path the
|
|
1025
|
+
* gate touched or adds a line holding one of the gate's planted strings. Owner pushes, and household turns made during the run,
|
|
1026
|
+
* are allowed: only the Butler's own non-merge commits are read, and a restored file commits as an unchanged tree.
|
|
1027
|
+
*/
|
|
1028
|
+
export async function closeSyncBracket(sync, head, { touched, planted, snapshotError }) {
|
|
1029
|
+
sync.resume()
|
|
1030
|
+
const synced = await sync.runSync()
|
|
1031
|
+
const ran = check("ouro sync ran once after the restores", synced.ok, synced.ok ? "" : synced.error)
|
|
1032
|
+
if (!synced.ok) return { id: "no-sync-residue", status: "fail", checks: [ran] }
|
|
1033
|
+
let changes
|
|
1034
|
+
try { changes = sync.agentChanges(head) } catch (error) {
|
|
1035
|
+
return { id: "no-sync-residue", status: "fail", checks: [ran, check("the Butler's commits since the run began could be read", false, String(error?.message ?? error))] }
|
|
1036
|
+
}
|
|
1037
|
+
const paths = touches(changes.paths, touched)
|
|
1038
|
+
const strings = planted.filter((text) => changes.added.some((line) => line.toLowerCase().includes(text.toLowerCase())))
|
|
1039
|
+
const checks = [
|
|
1040
|
+
ran,
|
|
1041
|
+
check("the Butler's commits pushed since the run began touch no path the gate touched", paths.length === 0, paths.length ? `${paths.join(", ")} (${changes.commits} commits up to ${changes.remoteHead})` : ""),
|
|
1042
|
+
check("those commits add none of the gate's planted strings", strings.length === 0, strings.join(", ")),
|
|
1043
|
+
...(snapshotError === undefined ? [] : [check("the files the run created in the bundle could be read", false, snapshotError)]),
|
|
1044
|
+
]
|
|
1045
|
+
return { id: "no-sync-residue", status: checks.every((entry) => entry.ok) ? "pass" : "fail", checks }
|
|
1046
|
+
}
|
|
1047
|
+
|
|
1048
|
+
/** The path of each `git status --porcelain` line (the new name of a rename), unquoted. */
|
|
1049
|
+
export function porcelainPaths(status) {
|
|
1050
|
+
return String(status ?? "").split("\n").filter((line) => line.length > 3).map((line) => {
|
|
1051
|
+
const file = line.slice(3).split(" -> ").at(-1)
|
|
1052
|
+
return file.startsWith('"') && file.endsWith('"') ? file.slice(1, -1) : file
|
|
1053
|
+
})
|
|
1054
|
+
}
|
|
1055
|
+
|
|
1056
|
+
/**
|
|
1057
|
+
* The paths in `git status --porcelain --untracked-files=all` output that sync must never commit: a package-managed file or a
|
|
1058
|
+
* secret-named path. The cutover's step 10 runs this same function (`status-scan`), so the two checks cannot drift.
|
|
1059
|
+
*/
|
|
1060
|
+
export function statusProblems(status) {
|
|
1061
|
+
return porcelainPaths(status).filter((file) => NEVER_TRACKED.includes(file) || SECRET_PATH.test(file))
|
|
1062
|
+
}
|
|
1063
|
+
|
|
1064
|
+
/** The check `psyche is git-tracked`: pending until the cutover, then git tracks the psyche and nothing the release or a secret owns. */
|
|
1065
|
+
export function trackingResult(sync) {
|
|
1066
|
+
if (!sync.cutoverDone()) return pendingSync("psyche-git-tracked", "git tracks the psyche and nothing excluded")
|
|
1067
|
+
let seen
|
|
1068
|
+
try { seen = sync.tracking() } catch (error) {
|
|
1069
|
+
return { id: "psyche-git-tracked", status: "fail", checks: [check("the bundle's git state could be read", false, String(error?.message ?? error))] }
|
|
1070
|
+
}
|
|
1071
|
+
const missing = PSYCHE_FILES.filter((name) => !seen.psyche.includes(`psyche/${name}`))
|
|
1072
|
+
const dirty = statusProblems(seen.status)
|
|
1073
|
+
const checks = [
|
|
1074
|
+
check("git tracks all five psyche files", missing.length === 0, missing.length ? `not tracked: ${missing.join(", ")}` : ""),
|
|
1075
|
+
check("git tracks nothing the exclude list covers", seen.excludedTracked.length === 0, seen.excludedTracked.join(", ")),
|
|
1076
|
+
check("git status shows no package-managed or secret path", dirty.length === 0, dirty.join(", ")),
|
|
1077
|
+
]
|
|
1078
|
+
return { id: "psyche-git-tracked", status: checks.every((entry) => entry.ok) ? "pass" : "fail", checks }
|
|
1079
|
+
}
|
|
1080
|
+
|
|
1081
|
+
const PULL_ENDS = ["heart.sync_pull_end", "heart.sync_pull_error", "heart.sync_pull_aborted", "heart.sync_paused", "heart.sync_not_a_repo"]
|
|
1082
|
+
// The synchronous post-turn commit ends at heart.sync_commit_end; the network push after it is not timed. A push_end before it
|
|
1083
|
+
// means the commit was skipped (sync busy), so nothing was timed.
|
|
1084
|
+
const COMMIT_ENDS = ["heart.sync_commit_end", "heart.sync_push_end", "heart.sync_push_error", "heart.sync_paused", "heart.sync_not_a_repo"]
|
|
1085
|
+
|
|
1086
|
+
/** The pre-turn pull (start to end) and the post-turn commit (start to its end) in a turn's sync events, in order. */
|
|
1087
|
+
export function syncIntervals(events) {
|
|
1088
|
+
const sorted = [...events].sort((a, b) => a.at - b.at)
|
|
1089
|
+
const span = (startName, ends, from) => {
|
|
1090
|
+
const start = sorted.findIndex((event, index) => index >= from && event.event === startName)
|
|
1091
|
+
if (start < 0) return { ms: null, problem: `no ${startName} was logged`, next: from }
|
|
1092
|
+
const end = sorted.findIndex((event, index) => index > start && ends.includes(event.event))
|
|
1093
|
+
if (end < 0) return { ms: null, problem: `no end was logged after ${startName}`, next: start + 1 }
|
|
1094
|
+
const ended = sorted[end].event
|
|
1095
|
+
return ended === ends[0] ? { ms: sorted[end].at - sorted[start].at, next: end + 1 } : { ms: null, problem: `it ended with ${ended}`, next: end + 1 }
|
|
1096
|
+
}
|
|
1097
|
+
const pull = span("heart.sync_pull_start", PULL_ENDS, 0)
|
|
1098
|
+
const commit = span("heart.sync_push_start", COMMIT_ENDS, pull.next)
|
|
1099
|
+
return { pull, commit, complete: pull.ms !== null && commit.ms !== null }
|
|
1100
|
+
}
|
|
1101
|
+
|
|
1102
|
+
/**
|
|
1103
|
+
* The check `sync latency`: one read-only turn after the cases, inside the bracket (the residue check reads its commits too). Its
|
|
1104
|
+
* pre-turn pull and its synchronous post-turn commit each stay under two seconds; the network push is not timed.
|
|
1105
|
+
*/
|
|
1106
|
+
export async function syncLatencyResult(host) {
|
|
1107
|
+
const sync = host.sync
|
|
1108
|
+
const since = host.now()
|
|
1109
|
+
host.openWindow(10)
|
|
1110
|
+
let sent
|
|
1111
|
+
try { sent = await host.send({ who: "principal", text: LATENCY_WORDS, delegated: false, context: randomUUID() }) } finally { host.closeWindow() }
|
|
1112
|
+
if (sent.error) return { id: "sync-latency", status: "fail", checks: [check("the read-only turn answered", false, sent.error)] }
|
|
1113
|
+
const deadline = host.now() + SYNC_EVENTS_WAIT_MS
|
|
1114
|
+
let intervals = syncIntervals(sync.events(since))
|
|
1115
|
+
while (!intervals.complete && host.now() < deadline) {
|
|
1116
|
+
await host.sleep(SYNC_EVENTS_POLL_MS)
|
|
1117
|
+
intervals = syncIntervals(sync.events(since))
|
|
1118
|
+
}
|
|
1119
|
+
const judge = (name, span) => check(`${name} took under ${SYNC_LATENCY_LIMIT_MS} ms`, span.ms !== null && span.ms < SYNC_LATENCY_LIMIT_MS, span.ms === null ? span.problem : `${span.ms} ms`)
|
|
1120
|
+
const checks = [judge("the pre-turn pull (heart.sync_pull_start to heart.sync_pull_end)", intervals.pull), judge("the post-turn commit (heart.sync_push_start to heart.sync_commit_end)", intervals.commit)]
|
|
1121
|
+
return { id: "sync-latency", status: checks.every((entry) => entry.ok) ? "pass" : "fail", checks }
|
|
1122
|
+
}
|
|
1123
|
+
|
|
1124
|
+
/**
|
|
1125
|
+
* The replay run. On a bundle that syncs, the cases run inside the sync bracket: sync is paused from before the window opens until
|
|
1126
|
+
* the restores are done (the pause is written inside the `try` whose `finally` removes it). Then one read-only turn times sync,
|
|
1127
|
+
* one sync runs, and the gate checks that the remote gained none of the residue of the cases or of that turn. The tracking check
|
|
1128
|
+
* comes last.
|
|
1129
|
+
*/
|
|
1130
|
+
export async function runSuite(host, options = {}) {
|
|
1131
|
+
const sync = host.sync
|
|
1132
|
+
if (!sync) return runCases(host, options)
|
|
1133
|
+
validateCases(options)
|
|
1134
|
+
await host.waitReady?.()
|
|
1135
|
+
if (!sync.cutoverDone()) {
|
|
1136
|
+
const suite = await runCases(host, { ...options, ready: true })
|
|
1137
|
+
return withSyncResults(suite, [pendingSync("no-sync-residue", "the remote gains none of the gate's residue"), trackingResult(sync), pendingSync("sync-latency", "sync's pull and commit each stay under two seconds")], options)
|
|
1138
|
+
}
|
|
1139
|
+
let bracket
|
|
1140
|
+
let suite
|
|
1141
|
+
let untrackedAfter
|
|
1142
|
+
try {
|
|
1143
|
+
bracket = await openSyncBracket(sync, options.syncLockWaitMs === undefined ? {} : { waitMs: options.syncLockWaitMs })
|
|
1144
|
+
if (bracket.ok) {
|
|
1145
|
+
suite = await runCases(host, { ...options, ready: true })
|
|
1146
|
+
// Read while sync is still paused, so nothing the run created has been committed yet.
|
|
1147
|
+
untrackedAfter = untrackedSnapshot(sync)
|
|
1148
|
+
}
|
|
1149
|
+
} finally {
|
|
1150
|
+
// The pause never outlives the run. A crash that skips this leaves a marker that expires on its own.
|
|
1151
|
+
sync.resume()
|
|
1152
|
+
}
|
|
1153
|
+
if (!bracket.ok) {
|
|
1154
|
+
const busy = { id: "no-sync-residue", status: "fail", checks: [check("sync let go of the bundle before the run", false, bracket.detail)] }
|
|
1155
|
+
return { results: [busy], summary: summarize([busy]) }
|
|
1156
|
+
}
|
|
1157
|
+
const windowRefused = suite.results.some((entry) => entry.id === "window-trusted")
|
|
1158
|
+
const latency = windowRefused ? { id: "sync-latency", status: "skipped", reason: "the Butler did not trust the replay window", checks: [] } : await syncLatencyResult(host)
|
|
1159
|
+
// A replay friend's record changes on every turn (its token count), so its path is not residue; what a case could teach the
|
|
1160
|
+
// Butler is, and the planted strings and the friend ids catch that in any file. Every other untracked file the run created or
|
|
1161
|
+
// changed is the gate's, and so is the release marker the upgrade plants before the gate runs.
|
|
1162
|
+
const replayFriends = [host.friends.principal, host.friends.stranger, host.friends.escalation].filter(Boolean)
|
|
1163
|
+
const created = createdDuringRun(bracket.untracked, untrackedAfter)
|
|
1164
|
+
const touched = ["psyche/TACIT.md", "state/replay/", "psyche/.release-marker*", ...created.paths.filter((file) => !replayFriends.some((id) => file === `friends/${id}.json`))]
|
|
1165
|
+
const planted = [...GATE_PLANTED_STRINGS, ...replayFriends]
|
|
1166
|
+
const residue = await closeSyncBracket(sync, bracket.head, { touched, planted, snapshotError: created.error })
|
|
1167
|
+
const tracking = trackingResult(sync)
|
|
1168
|
+
return withSyncResults(suite, [residue, tracking, latency], options)
|
|
1169
|
+
}
|
|
1170
|
+
|
|
1171
|
+
function withSyncResults(suite, extra, { liveResults, plant } = {}) {
|
|
1172
|
+
const judged = liveResults ? applyBaseline(extra, liveResults, { plant }) : extra
|
|
1173
|
+
const results = [...suite.results, ...judged]
|
|
1174
|
+
return { results, summary: summarize(results) }
|
|
1175
|
+
}
|
|
1176
|
+
|
|
1177
|
+
function validateCases({ cases = CASES.map((entry) => entry.id), plant } = {}) {
|
|
967
1178
|
const unknown = [...cases, ...(plant ? [plant] : [])].filter((id) => !CASES.some((entry) => entry.id === id))
|
|
968
1179
|
if (unknown.length) throw new Error(`unknown case(s): ${unknown.join(", ")}. Known: ${CASES.map((entry) => entry.id).join(", ")}`)
|
|
1180
|
+
}
|
|
1181
|
+
|
|
1182
|
+
async function runCases(host, { cases = CASES.map((entry) => entry.id), plant, windowMinutes = DEFAULT_WINDOW_MINUTES, liveResults, log = console.error, ready = false } = {}) {
|
|
1183
|
+
validateCases({ cases, plant })
|
|
969
1184
|
const selected = CASES.filter((entry) => cases.includes(entry.id))
|
|
970
|
-
await host.waitReady?.()
|
|
1185
|
+
if (!ready) await host.waitReady?.()
|
|
971
1186
|
const runStart = host.now()
|
|
972
1187
|
const baseline = await host.observe()
|
|
973
1188
|
const results = []
|
|
@@ -1338,7 +1553,138 @@ function retireRecordInContainer(friendId, did) {
|
|
|
1338
1553
|
sh("docker", ["exec", "-u", BUTLER_USER, CONTAINER, "node", "-e", RETIRE_RECORD_SCRIPT, CONTAINER_BUNDLE, friendId, did ?? ""])
|
|
1339
1554
|
}
|
|
1340
1555
|
|
|
1341
|
-
|
|
1556
|
+
/**
|
|
1557
|
+
* Git on the bundle's own repository, run inside the container as the Butler, never as root on the host: after the cutover the
|
|
1558
|
+
* Butler owns `.git`, so its config, hooks and attributes may name any command (a clean filter, a pushurl through `ext::`), and
|
|
1559
|
+
* that command must run with no more than the Butler's own rights. It runs no hooks and no fsmonitor, and takes no optional lock.
|
|
1560
|
+
* `env` adds `-e NAME=value` pairs (ls-remote's ssh). This is the gate's only git call.
|
|
1561
|
+
*/
|
|
1562
|
+
export function bundleGit(args, run = sh, { env = [], timeout = 120_000 } = {}) {
|
|
1563
|
+
const envArgs = ["GIT_OPTIONAL_LOCKS=0", ...env].flatMap((pair) => ["-e", pair])
|
|
1564
|
+
return run("docker", ["exec", "-u", BUTLER_USER, ...envArgs, CONTAINER, "git", "-c", "core.hooksPath=/dev/null", "-c", "core.fsmonitor=false", "-C", CONTAINER_BUNDLE, ...args], { timeout, maxBuffer: 256 * 1024 * 1024 })
|
|
1565
|
+
}
|
|
1566
|
+
|
|
1567
|
+
/** The untracked, unignored paths in the bundle and a hash of each (git's, with no filter run), read in the container. */
|
|
1568
|
+
export function untrackedFiles(git) {
|
|
1569
|
+
const listed = String(git(["status", "--porcelain=v1", "-z", "--untracked-files=all"])).split("\0").filter((entry) => entry.startsWith("?? ")).map((entry) => entry.slice(3))
|
|
1570
|
+
const files = {}
|
|
1571
|
+
for (let i = 0; i < listed.length; i += 200) {
|
|
1572
|
+
const chunk = listed.slice(i, i + 200)
|
|
1573
|
+
const hashes = String(git(["hash-object", "--no-filters", "--", ...chunk])).split("\n").filter(Boolean)
|
|
1574
|
+
chunk.forEach((file, j) => { files[file] = hashes[j] })
|
|
1575
|
+
}
|
|
1576
|
+
return files
|
|
1577
|
+
}
|
|
1578
|
+
|
|
1579
|
+
/**
|
|
1580
|
+
* The host side of bundle sync for the gate: the cutover marker, sync's own pause marker, the lock, one `ouro sync` in the container,
|
|
1581
|
+
* and read-only git. `sleep` and `now` come from the host, so tests drive the clock.
|
|
1582
|
+
*/
|
|
1583
|
+
export function makeSyncHost({ bundle, backupDir = DEFAULT_BACKUP_DIR, exec = sh, git = (args) => bundleGit(args, exec), sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)), now = () => Date.now(), pollMs = SYNC_LOCK_POLL_MS }) {
|
|
1584
|
+
const state = path.join(bundle, "state")
|
|
1585
|
+
const marker = path.join(state, "sync-paused")
|
|
1586
|
+
const agentName = path.basename(bundle, ".ouro")
|
|
1587
|
+
const branch = () => {
|
|
1588
|
+
try { return readJsonNoFollow(path.join(bundle, "agent.json"))?.sync?.branch ?? "main" } catch { return "main" }
|
|
1589
|
+
}
|
|
1590
|
+
const lines = (text) => text.split("\n").filter(Boolean)
|
|
1591
|
+
// sync-lock.ts: a lock that does not parse is busy for 30 s (a writer may be between create and write), then stale. A parsed
|
|
1592
|
+
// lock is stale when its pid is gone, or when the pid now runs a process that started at another time. The pid is the
|
|
1593
|
+
// container's, so it is checked there; a container that is not running holds nothing.
|
|
1594
|
+
const lockHeld = (lock) => {
|
|
1595
|
+
const stat = lstatOrNull(lock)
|
|
1596
|
+
if (!stat) return false
|
|
1597
|
+
let content
|
|
1598
|
+
try { content = JSON.parse(readTextNoFollow(lock)) } catch { content = null }
|
|
1599
|
+
if (!content || typeof content.pid !== "number" || typeof content.token !== "string") return now() - stat.mtimeMs <= SYNC_LOCK_MALFORMED_MS
|
|
1600
|
+
let procStat
|
|
1601
|
+
try {
|
|
1602
|
+
procStat = exec("docker", ["exec", CONTAINER, "cat", `/proc/${content.pid}/stat`])
|
|
1603
|
+
} catch (error) {
|
|
1604
|
+
const text = `${error?.stderr ?? ""} ${error?.message ?? ""}`
|
|
1605
|
+
if (error?.status === 1 || /no such container|is not running/iu.test(text)) return false
|
|
1606
|
+
return true
|
|
1607
|
+
}
|
|
1608
|
+
const started = String(procStat).slice(String(procStat).lastIndexOf(")") + 2).split(" ")[22 - 3]
|
|
1609
|
+
if (typeof content.procStart === "string" && /^\d+$/u.test(content.procStart) && started !== undefined && started !== content.procStart) return false
|
|
1610
|
+
return true
|
|
1611
|
+
}
|
|
1612
|
+
return {
|
|
1613
|
+
cutoverDone: () => lstatOrNull(path.join(backupDir, "cutover-done")) !== null,
|
|
1614
|
+
/** Sync's pause marker, written as root for the owner of state/ with no link followed. */
|
|
1615
|
+
pause(ms) {
|
|
1616
|
+
const owner = refuseLinkOrWrongType(state, "directory")
|
|
1617
|
+
if (!owner) throw new Error(`${state} does not exist`)
|
|
1618
|
+
const startedAt = new Date(now())
|
|
1619
|
+
writeFileAsRoot(state, "sync-paused", `${JSON.stringify({ startedAt: startedAt.toISOString(), expiresAt: new Date(startedAt.getTime() + ms).toISOString() })}\n`, 0o644, { uid: owner.uid, gid: owner.gid }, "state")
|
|
1620
|
+
},
|
|
1621
|
+
resume() { rmSync(marker, { force: true }) },
|
|
1622
|
+
/** Waits until no live process holds sync's lock. A lock whose holder is dead is stale, as `src/heart/sync-lock.ts` judges it. */
|
|
1623
|
+
async waitLockFree(waitMs) {
|
|
1624
|
+
const lock = path.join(bundle, ".git", "ouro-sync.lock")
|
|
1625
|
+
const deadline = now() + waitMs
|
|
1626
|
+
while (lockHeld(lock)) {
|
|
1627
|
+
if (now() >= deadline) return false
|
|
1628
|
+
await sleep(pollMs)
|
|
1629
|
+
}
|
|
1630
|
+
return true
|
|
1631
|
+
},
|
|
1632
|
+
head: () => git(["rev-parse", "HEAD"]).trim(),
|
|
1633
|
+
async runSync() {
|
|
1634
|
+
try {
|
|
1635
|
+
exec("docker", ["exec", "-u", BUTLER_USER, CONTAINER, "node", CLI_ENTRY, "sync", "--agent", agentName], { timeout: 120_000 })
|
|
1636
|
+
return { ok: true }
|
|
1637
|
+
} catch (error) {
|
|
1638
|
+
return { ok: false, error: `${error.stderr ?? ""} ${error.stdout ?? ""} ${error.message ?? ""}`.replace(/\s+/g, " ").trim() }
|
|
1639
|
+
}
|
|
1640
|
+
},
|
|
1641
|
+
/**
|
|
1642
|
+
* The Butler's own non-merge commits between `head` and the remote branch: their paths and their added lines. The remote head
|
|
1643
|
+
* comes from GitHub itself (`git ls-remote`, inside the container through the sync path), never from a tracking ref a push may
|
|
1644
|
+
* not move.
|
|
1645
|
+
*/
|
|
1646
|
+
agentChanges(head) {
|
|
1647
|
+
const listed = bundleGit(["ls-remote", "origin", `refs/heads/${branch()}`], exec, { env: [`GIT_SSH_COMMAND=${SYNC_SSH_COMMAND}`, "GIT_SSH_VARIANT=ssh"], timeout: 60_000 })
|
|
1648
|
+
const remoteHead = /^([0-9a-f]{40})\s/u.exec(listed)?.[1]
|
|
1649
|
+
if (!remoteHead) throw new Error(`git ls-remote origin refs/heads/${branch()} listed no head`)
|
|
1650
|
+
const commits = lines(git(["log", "--no-merges", `--author=<${agentName}@ouro.local>`, "--format=%H", `${head}..${remoteHead}`]))
|
|
1651
|
+
if (commits.length === 0) return { remoteHead, commits: 0, paths: [], added: [] }
|
|
1652
|
+
const paths = [...new Set(lines(git(["show", "--format=", "--name-only", ...commits])))]
|
|
1653
|
+
const added = git(["show", "--format=", "--unified=0", "--no-color", "--no-ext-diff", "--no-textconv", ...commits]).split("\n").filter((line) => line.startsWith("+") && !line.startsWith("+++")).map((line) => line.slice(1))
|
|
1654
|
+
return { remoteHead, commits: commits.length, paths, added }
|
|
1655
|
+
},
|
|
1656
|
+
untracked: () => untrackedFiles(git),
|
|
1657
|
+
tracking: () => ({
|
|
1658
|
+
psyche: lines(git(["ls-files", "--", "psyche/"])),
|
|
1659
|
+
excludedTracked: lines(git(["ls-files", "-ci", "--exclude-standard"])),
|
|
1660
|
+
status: git(["status", "--porcelain", "--untracked-files=all"]),
|
|
1661
|
+
}),
|
|
1662
|
+
/** Sync's events since `sinceMs` from the bundle's daemon logs, read through no link. */
|
|
1663
|
+
events(sinceMs) {
|
|
1664
|
+
const dir = path.join(state, "daemon", "logs")
|
|
1665
|
+
const stat = lstatOrNull(dir)
|
|
1666
|
+
if (!stat || stat.isSymbolicLink() || !stat.isDirectory()) return []
|
|
1667
|
+
const out = []
|
|
1668
|
+
for (const name of readdirSync(dir).filter((entry) => entry.endsWith(".ndjson")).sort()) {
|
|
1669
|
+
let text
|
|
1670
|
+
try { text = readTextNoFollow(path.join(dir, name)) } catch { continue }
|
|
1671
|
+
for (const line of text.split("\n")) {
|
|
1672
|
+
if (!line.includes('"heart.sync_')) continue
|
|
1673
|
+
try {
|
|
1674
|
+
const entry = JSON.parse(line)
|
|
1675
|
+
const at = Date.parse(entry.ts)
|
|
1676
|
+
if (typeof entry.event !== "string" || !(at >= sinceMs)) continue
|
|
1677
|
+
if (entry.meta?.agentRoot !== undefined && entry.meta.agentRoot !== CONTAINER_BUNDLE) continue
|
|
1678
|
+
out.push({ event: entry.event, at })
|
|
1679
|
+
} catch { /* a torn line */ }
|
|
1680
|
+
}
|
|
1681
|
+
}
|
|
1682
|
+
return out
|
|
1683
|
+
},
|
|
1684
|
+
}
|
|
1685
|
+
}
|
|
1686
|
+
|
|
1687
|
+
export function makeHost({ bundle = DEFAULT_BUNDLE, trustDir = DEFAULT_TRUST_DIR, clientDir = DEFAULT_CLIENT_DIR, backupDir = DEFAULT_BACKUP_DIR, chainRoot, cardUrl, log = console.error, exec = sh, stage = dockerStage(exec), rootUid = 0, rootGid = 0 } = {}) {
|
|
1342
1688
|
const state = path.join(bundle, "state")
|
|
1343
1689
|
const replayDir = path.join(state, "replay")
|
|
1344
1690
|
const clientIssue = rootOwnedDirectoryIssue(clientDir, rootUid)
|
|
@@ -1373,6 +1719,7 @@ export function makeHost({ bundle = DEFAULT_BUNDLE, trustDir = DEFAULT_TRUST_DIR
|
|
|
1373
1719
|
}
|
|
1374
1720
|
return {
|
|
1375
1721
|
friends: { principal: provisioned.principal.friendId, stranger: provisioned.stranger.friendId, escalation: provisioned.escalation.friendId },
|
|
1722
|
+
sync: makeSyncHost({ bundle, backupDir, exec }),
|
|
1376
1723
|
now: () => Date.now(),
|
|
1377
1724
|
sleep: (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
|
|
1378
1725
|
/** The Butler's A2A card must answer before the window opens: a fresh container needs a moment after it turns healthy. */
|
|
@@ -2064,6 +2411,7 @@ export function parseArgs(argv) {
|
|
|
2064
2411
|
else if (flag === "--trust-dir" && rest[i + 1]) options.trustDir = rest[++i]
|
|
2065
2412
|
else if (flag === "--card-url" && rest[i + 1]) options.cardUrl = rest[++i]
|
|
2066
2413
|
else if (flag === "--client-dir" && rest[i + 1]) options.clientDir = rest[++i]
|
|
2414
|
+
else if (flag === "--backup-dir" && rest[i + 1]) options.backupDir = rest[++i]
|
|
2067
2415
|
else if (flag === "--retire-friend" && rest[i + 1]) options.retire = [...(options.retire ?? []), rest[++i]]
|
|
2068
2416
|
else if (flag === "--rotate-replay-identities") options.rotate = true
|
|
2069
2417
|
else if (flag === "--window-minutes" && rest[i + 1]) options.windowMinutes = Number(rest[++i])
|
|
@@ -2082,12 +2430,17 @@ export async function main(argv, io = { out: (text) => console.log(text), err: (
|
|
|
2082
2430
|
io.out(problems.length === 0 ? `self-test ok (${CASES.length} cases)` : `self-test FAILED (${problems.length})`)
|
|
2083
2431
|
return problems.length === 0 ? 0 : 1
|
|
2084
2432
|
}
|
|
2433
|
+
if (options.command === "status-scan") {
|
|
2434
|
+
// The cutover's step 10: `git status --porcelain --untracked-files=all` on stdin; prints each path sync must never commit.
|
|
2435
|
+
for (const file of statusProblems((deps.readStdin ?? (() => readFileSync(0, "utf8")))())) io.out(file)
|
|
2436
|
+
return 0
|
|
2437
|
+
}
|
|
2085
2438
|
if (options.command === "provision") {
|
|
2086
2439
|
try { deps.provision({ ...(options.bundle ? { bundle: options.bundle } : {}), ...(options.trustDir ? { trustDir: options.trustDir } : {}), ...(options.cardUrl ? { cardUrl: options.cardUrl } : {}), ...(options.clientDir ? { clientDir: options.clientDir } : {}), ...(options.retire ? { retire: options.retire } : {}), ...(options.rotate ? { rotate: true } : {}), log: io.out }); return 0 } catch (error) { io.err(`provision failed: ${error.message}`); return 1 }
|
|
2087
2440
|
}
|
|
2088
2441
|
if (options.command === "run") {
|
|
2089
2442
|
let host
|
|
2090
|
-
try { host = deps.makeHost({ ...(options.bundle ? { bundle: options.bundle } : {}), ...(options.trustDir ? { trustDir: options.trustDir } : {}), ...(options.clientDir ? { clientDir: options.clientDir } : {}), ...(options.cardUrl ? { cardUrl: options.cardUrl } : {}), log: io.err }) } catch (error) { io.err(error.message); return 1 }
|
|
2443
|
+
try { host = deps.makeHost({ ...(options.bundle ? { bundle: options.bundle } : {}), ...(options.backupDir ? { backupDir: options.backupDir } : {}), ...(options.trustDir ? { trustDir: options.trustDir } : {}), ...(options.clientDir ? { clientDir: options.clientDir } : {}), ...(options.cardUrl ? { cardUrl: options.cardUrl } : {}), log: io.err }) } catch (error) { io.err(error.message); return 1 }
|
|
2091
2444
|
// butler-ship runs this gate against the live version first and hands the upgrade that log through the environment.
|
|
2092
2445
|
const baselineFile = options.baseline ?? env.OURO_REPLAY_BASELINE
|
|
2093
2446
|
let baseline
|
|
@@ -2105,7 +2458,7 @@ export async function main(argv, io = { out: (text) => console.log(text), err: (
|
|
|
2105
2458
|
io.out(JSON.stringify({ summary: suite.summary }))
|
|
2106
2459
|
return suite.summary.ok ? 0 : 1
|
|
2107
2460
|
}
|
|
2108
|
-
io.err("Usage: sanctuary-replay-gate.mjs <provision [--rotate-replay-identities]|run [--cases a,b] [--plant <case>] [--window-minutes n] [--baseline <file>]|self-test> [--bundle <dir>] [--trust-dir <dir>] [--client-dir <dir>] [--card-url <url>]")
|
|
2461
|
+
io.err("Usage: sanctuary-replay-gate.mjs <provision [--rotate-replay-identities]|run [--cases a,b] [--plant <case>] [--window-minutes n] [--baseline <file>]|self-test|status-scan> [--bundle <dir>] [--trust-dir <dir>] [--client-dir <dir>] [--backup-dir <dir>] [--card-url <url>]")
|
|
2109
2462
|
return 2
|
|
2110
2463
|
}
|
|
2111
2464
|
|