@ouro.bot/cli 0.1.0-alpha.771 → 0.1.0-alpha.773
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/changelog.json +17 -0
- package/deploy/unraid/README.txt +37 -9
- package/deploy/unraid/sanctuary-acceptance-contract.json +1 -1
- package/deploy/unraid/sanctuary-unit16-host-broker.mjs +242 -31
- package/deploy/unraid/sanctuary-unit16-run.sh +19 -2
- package/deploy/unraid/sanctuary.ouro/bundle-meta.json +1 -1
- package/deploy/unraid/sanctuary.xml +1 -1
- package/dist/heart/daemon/cli-defaults.js +4 -0
- package/dist/heart/daemon/cli-exec.js +39 -1
- package/dist/heart/daemon/daemon-bootstrap-startup.js +40 -4
- package/dist/heart/daemon/daemon-entry.js +34 -71
- package/dist/heart/daemon/daemon.js +71 -25
- package/dist/heart/daemon/sanctuary-acceptance-adapter.js +111 -1
- package/dist/heart/daemon/sanctuary-acceptance-harness.js +245 -49
- package/dist/heart/daemon/startup-tui.js +1 -0
- package/dist/heart/external-events/router.js +24 -14
- package/npm-shrinkwrap.json +2 -2
- package/package.json +1 -1
package/changelog.json
CHANGED
|
@@ -1,6 +1,23 @@
|
|
|
1
1
|
{
|
|
2
2
|
"_note": "This changelog is maintained as part of the PR/version-bump workflow. Agent-curated, not auto-generated. Agents read this file directly via read_file to understand what changed between versions.",
|
|
3
3
|
"versions": [
|
|
4
|
+
{
|
|
5
|
+
"version": "0.1.0-alpha.773",
|
|
6
|
+
"changes": [
|
|
7
|
+
"Fail daemon startup closed until configured providers are live and their credentials are cached.",
|
|
8
|
+
"Surface provider startup failures immediately through a fresh redacted tombstone instead of waiting for a dead daemon or echoing raw logs.",
|
|
9
|
+
"Recover legacy provider dead letters from current-boot readiness once per generation without per-record alert noise.",
|
|
10
|
+
"Recover expired managed-runtime leases only after newer runtime liveness, with inspect-before-action turns and bounded batch wakeups."
|
|
11
|
+
]
|
|
12
|
+
},
|
|
13
|
+
{
|
|
14
|
+
"version": "0.1.0-alpha.772",
|
|
15
|
+
"changes": [
|
|
16
|
+
"Make Sanctuary Unraid key rotation crash-resumable with exact transaction and rejection proofs.",
|
|
17
|
+
"Require durable zero-playback journal evidence before live callback injection.",
|
|
18
|
+
"Reject malformed Unraid rotation checkpoints before mutation with exact slot, attestation, revocation-order, and phase-state validation."
|
|
19
|
+
]
|
|
20
|
+
},
|
|
4
21
|
{
|
|
5
22
|
"version": "0.1.0-alpha.771",
|
|
6
23
|
"changes": [
|
package/deploy/unraid/README.txt
CHANGED
|
@@ -2440,6 +2440,9 @@ Packaged Unit 16 acceptance execution:
|
|
|
2440
2440
|
failure. It never reads or changes Unraid autostart configuration. Every command
|
|
2441
2441
|
also receives a freshly generated, redacted typed container-inspect snapshot;
|
|
2442
2442
|
raw container environment or credential values are never captured.
|
|
2443
|
+
Callback injection requires two stable zero observations from a durable callback playback journal under
|
|
2444
|
+
`state/approvals`; the journal is keyed only by the full callback-coordinate
|
|
2445
|
+
digest and stores no raw update, callback data, user, chat, message, or query ID.
|
|
2443
2446
|
Evidence-snapshot keeps the bundle mount read-only and overlays only the exact
|
|
2444
2447
|
canonical `state/acceptance` directory as writable. It first stops the exact
|
|
2445
2448
|
healthy staging container under recovery responsibility, then opens the
|
|
@@ -2459,17 +2462,40 @@ Audit and safety verification:
|
|
|
2459
2462
|
stop and restart mutations; only the separate write key may perform the one
|
|
2460
2463
|
typed approved restart action.
|
|
2461
2464
|
The packaged `unraid-key-rotate` command is the only canonical key-rotation
|
|
2462
|
-
authority.
|
|
2463
|
-
|
|
2465
|
+
authority. Its host launcher stops the exact production container and assumes
|
|
2466
|
+
trap-backed recovery responsibility before key mutation. It inventories the
|
|
2467
|
+
two occupied canonical names by exact immutable ID, creates collision-safe
|
|
2468
|
+
`Butler RO Rotation <suffix>` and
|
|
2464
2469
|
`Butler RW Rotation <suffix>` keys, stores and capability-probes both through
|
|
2465
|
-
the Sanctuary machine vault, and
|
|
2470
|
+
the Sanctuary machine vault, and on the uninterrupted path requires an exact
|
|
2471
|
+
four-key inventory. Because
|
|
2472
|
+
Unraid 7.2.3 cannot create a role-free key through its CLI, the root broker
|
|
2473
|
+
creates each key as provisional `GUEST` with the intended scope plus only
|
|
2474
|
+
`API_KEY:UPDATE_ANY`, then immediately uses that in-memory credential to
|
|
2475
|
+
self-downgrade the same immutable ID to no roles and the exact intended scope.
|
|
2476
|
+
Disk and live-auth proofs must pass before the credential may reach the vault.
|
|
2477
|
+
The provisional credential never reaches argv, logs, evidence, the bundle, or
|
|
2478
|
+
the model. It then
|
|
2466
2479
|
revokes both old IDs and proves each old credential receives a 401 or 403.
|
|
2467
2480
|
Only after an exact inventory contains the temporary pair alone does it create,
|
|
2468
2481
|
store, and probe the canonical `Butler RO` and `Butler RW` pair. It requires an
|
|
2469
|
-
exact four-key temporary-plus-canonical inventory
|
|
2482
|
+
exact four-key temporary-plus-canonical inventory on the uninterrupted path,
|
|
2483
|
+
revokes and rejection-probes
|
|
2470
2484
|
both temporary IDs, and finally requires exactly the canonical pair. Every
|
|
2471
2485
|
unexpected key, duplicate, malformed permission set, role, name, inventory
|
|
2472
|
-
delta, or probe response fails closed.
|
|
2486
|
+
delta, or probe response fails closed. A resumed path may observe fewer keys
|
|
2487
|
+
only when the transaction checkpoint plus a durable rejection receipt proves
|
|
2488
|
+
that the missing immutable ID was already revoked; it never treats absence
|
|
2489
|
+
alone as success.
|
|
2490
|
+
|
|
2491
|
+
A failed invocation resumes from the durable redacted transaction checkpoint
|
|
2492
|
+
instead of reminting known keys. The checkpoint binds every created immutable
|
|
2493
|
+
ID to its name, scope, vault field, temporary/canonical class, and attestation
|
|
2494
|
+
state. Recovery reconciles exact live inventory and rejection proofs, skips
|
|
2495
|
+
already-attested vault writes after canonical fields advance, and never
|
|
2496
|
+
recreates a temporary key already known revoked. Root-owned recovery ownership
|
|
2497
|
+
survives broker response loss and is cleared only after the harness has
|
|
2498
|
+
checkpointed the immutable ID and the vault has read back the exact binding.
|
|
2473
2499
|
|
|
2474
2500
|
Record only IDs, names, and permission sets in evidence; never raw key values.
|
|
2475
2501
|
Raw credentials must not appear in argv, shell history, stdout, logs, or
|
|
@@ -2477,7 +2503,9 @@ Audit and safety verification:
|
|
|
2477
2503
|
socket in process memory. The narrow revocation-retry recovery records under
|
|
2478
2504
|
`/mnt/user/appdata/ouro-butler/acceptance/revoked-key-proof` are the sole file
|
|
2479
2505
|
exception: they are root-owned mode 0600 beneath a root-owned mode 0700
|
|
2480
|
-
directory, excluded from backup/evidence,
|
|
2481
|
-
|
|
2482
|
-
|
|
2483
|
-
|
|
2506
|
+
directory, excluded from backup/evidence, and readable only by the root broker.
|
|
2507
|
+
Recovery state is retained across failed retries.
|
|
2508
|
+
Creation intent is fsynced before the CLI side effect so an exact provisional
|
|
2509
|
+
key can be adopted after process death. Raw recovery proof is durably deleted
|
|
2510
|
+
only after the exact authentication rejection and its redacted replayable
|
|
2511
|
+
receipt are fsynced. Never substitute names for IDs when revoking keys.
|
|
@@ -31,7 +31,7 @@
|
|
|
31
31
|
"telegram-poller-quiescence": { "operation": "quiesce_telegram_poller", "authority": "fixed-managed-telegram-poller-stop-and-count", "modelReachable": false, "timeoutMs": 15000 },
|
|
32
32
|
"telegram-vault-store": { "operation": "store_telegram_bootstrap", "authority": "fixed-runtime-vault-merge", "modelReachable": false, "timeoutMs": 15000 },
|
|
33
33
|
"cursor-snapshot": { "operation": "snapshot", "authority": "fixed-telegram-state-read", "modelReachable": false, "timeoutMs": 15000 },
|
|
34
|
-
"callback-live": { "operation": "inject_callbacks_concurrently|inject_callback_replay", "authority": "fixed-live-telegram-callback-probe", "modelReachable": false, "timeoutMs": 15000 },
|
|
34
|
+
"callback-live": { "operation": "callback_playback_preflight|inject_callbacks_concurrently|inject_callback_replay", "authority": "fixed-live-telegram-callback-probe", "modelReachable": false, "timeoutMs": 15000 },
|
|
35
35
|
"key-inventory": { "operation": "inventory_keys", "authority": "fixed-unraid-key-directory-read", "modelReachable": false, "timeoutMs": 15000 },
|
|
36
36
|
"key-create": { "operation": "create_key", "authority": "fixed-unraid-cli-create-and-vault-store", "modelReachable": false, "timeoutMs": 15000 },
|
|
37
37
|
"key-store": { "operation": "store_key", "authority": "fixed-machine-vault-readback", "modelReachable": false, "timeoutMs": 15000 },
|
|
@@ -149,27 +149,113 @@ function runUnraid(args) {
|
|
|
149
149
|
return object(JSON.parse(result.stdout), "Unraid API response")
|
|
150
150
|
}
|
|
151
151
|
|
|
152
|
-
function
|
|
152
|
+
function permissionObjects(values) {
|
|
153
|
+
const grouped = new Map()
|
|
154
|
+
for (const value of values) {
|
|
155
|
+
const [resource, action] = value.split(":")
|
|
156
|
+
const actions = grouped.get(resource) ?? []
|
|
157
|
+
actions.push(action)
|
|
158
|
+
grouped.set(resource, actions)
|
|
159
|
+
}
|
|
160
|
+
return [...grouped.entries()].sort(([left], [right]) => left.localeCompare(right))
|
|
161
|
+
.map(([resource, actions]) => ({ resource, actions: actions.sort() }))
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
async function createUnraidKey(name, requested, dependencies = {
|
|
165
|
+
runUnraid, inventoryRecords, persistRecovery, readRecovery: optionalRecoveryRecord,
|
|
166
|
+
persistIntent: persistCreateIntent, readIntent: optionalCreateIntent, removeIntent: removeCreateIntent,
|
|
167
|
+
fetchImpl: fetch,
|
|
168
|
+
}) {
|
|
153
169
|
const before = dependencies.inventoryRecords()
|
|
154
170
|
const beforeIds = new Set(before.map((record) => record.id))
|
|
171
|
+
const provisionalPermissions = [...requested, "API_KEY:UPDATE_ANY"].sort()
|
|
172
|
+
let recoveryId = null
|
|
173
|
+
let cliIdentity = null
|
|
155
174
|
try {
|
|
156
|
-
const
|
|
157
|
-
|
|
175
|
+
const occupied = before.filter((record) => record.name === name)
|
|
176
|
+
const existingIntent = dependencies.readIntent?.(name) ?? null
|
|
177
|
+
if (existingIntent === null) {
|
|
178
|
+
if (occupied.length !== 0) throw new Error("existing provisional key has no durable creation intent")
|
|
179
|
+
dependencies.persistIntent(name, provisionalPermissions)
|
|
180
|
+
} else if (existingIntent.name !== name || JSON.stringify(existingIntent.permissions) !== JSON.stringify(provisionalPermissions)) {
|
|
181
|
+
throw new Error("Unraid key creation intent is invalid")
|
|
182
|
+
}
|
|
183
|
+
let created
|
|
184
|
+
let provisional
|
|
185
|
+
let resumed = false
|
|
186
|
+
let resumedFinal = false
|
|
187
|
+
if (occupied.length === 1) {
|
|
188
|
+
provisional = occupied[0]
|
|
189
|
+
let recovery = dependencies.readRecovery?.(provisional.id) ?? null
|
|
190
|
+
const provisionalExact = JSON.stringify(flattened(provisional)) === JSON.stringify(provisionalPermissions)
|
|
191
|
+
&& JSON.stringify(provisional.roles) === JSON.stringify(["GUEST"])
|
|
192
|
+
if (recovery === null && provisionalExact) {
|
|
193
|
+
dependencies.persistRecovery(provisional)
|
|
194
|
+
recovery = provisional
|
|
195
|
+
}
|
|
196
|
+
if (!recovery || recovery.id !== provisional.id || recovery.name !== provisional.name || recovery.key !== provisional.key
|
|
197
|
+
|| JSON.stringify(flattened(recovery)) !== JSON.stringify(provisionalPermissions)
|
|
198
|
+
|| JSON.stringify(recovery.roles) !== JSON.stringify(["GUEST"])
|
|
199
|
+
|| !(provisionalExact
|
|
200
|
+
|| (JSON.stringify(flattened(provisional)) === JSON.stringify(requested) && provisional.roles.length === 0))) {
|
|
201
|
+
throw new Error("existing provisional key is not exact owned recovery")
|
|
202
|
+
}
|
|
203
|
+
resumedFinal = provisional.roles.length === 0
|
|
204
|
+
created = { id: provisional.id, key: provisional.key, name: provisional.name }
|
|
205
|
+
resumed = true
|
|
206
|
+
} else if (occupied.length === 0) {
|
|
207
|
+
created = object(dependencies.runUnraid(["apikey", "--name", name, "--create", "--roles", "GUEST", "--permissions", provisionalPermissions.join(","), "--json"]), "created Unraid key")
|
|
208
|
+
exactKeys(created, ["id", "key", "name"], "created Unraid key")
|
|
209
|
+
cliIdentity = { id: text(created.id, "created key id", KEY_ID), key: text(created.key, "created key descriptor"), name: created.name }
|
|
210
|
+
} else {
|
|
211
|
+
throw new Error("created provisional key name is ambiguous")
|
|
212
|
+
}
|
|
158
213
|
const id = text(created.id, "created key id", KEY_ID)
|
|
159
214
|
const key = text(created.key, "created key descriptor")
|
|
160
|
-
if (created.name !== name || beforeIds.has(id)) throw new Error("created key identity is invalid")
|
|
161
|
-
const matches = dependencies.inventoryRecords().filter((record) => record.id === id)
|
|
215
|
+
if (created.name !== name || (!resumed && beforeIds.has(id))) throw new Error("created key identity is invalid")
|
|
216
|
+
const matches = resumed ? [provisional] : dependencies.inventoryRecords().filter((record) => record.id === id)
|
|
162
217
|
if (matches.length !== 1 || matches[0].name !== name || matches[0].key !== key
|
|
163
|
-
|| JSON.stringify(flattened(matches[0])) !== JSON.stringify(
|
|
164
|
-
|
|
218
|
+
|| (!resumedFinal && (JSON.stringify(flattened(matches[0])) !== JSON.stringify(provisionalPermissions)
|
|
219
|
+
|| JSON.stringify(matches[0].roles) !== JSON.stringify(["GUEST"])))) {
|
|
220
|
+
throw new Error("created provisional key record does not match the exact CLI result")
|
|
165
221
|
}
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
const
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
222
|
+
if (!resumed) dependencies.persistRecovery(matches[0])
|
|
223
|
+
recoveryId = id
|
|
224
|
+
const headers = { "content-type": "application/json", "x-api-key": key }
|
|
225
|
+
if (!resumedFinal) try {
|
|
226
|
+
const updateResponse = await dependencies.fetchImpl(GRAPHQL_ENDPOINT, {
|
|
227
|
+
method: "POST", headers,
|
|
228
|
+
body: JSON.stringify({
|
|
229
|
+
query: "mutation AcceptanceApiKeyDowngrade($input: UpdateApiKeyInput!) { apiKey { update(input: $input) { id name roles permissions { resource actions } } } }",
|
|
230
|
+
variables: { input: { id, roles: [], permissions: permissionObjects(requested) } },
|
|
231
|
+
}),
|
|
232
|
+
signal: AbortSignal.timeout(10_000),
|
|
233
|
+
})
|
|
234
|
+
const updateEnvelope = object(await updateResponse.json(), "Unraid key downgrade response")
|
|
235
|
+
if (!updateResponse.ok || updateEnvelope.errors || !updateEnvelope.data) throw new Error("Unraid key downgrade was rejected")
|
|
236
|
+
} catch {
|
|
237
|
+
// A lost response is accepted only when disk and live-auth attestation below prove convergence.
|
|
172
238
|
}
|
|
239
|
+
const finalized = dependencies.inventoryRecords().filter((record) => record.id === id)
|
|
240
|
+
if (finalized.length !== 1 || finalized[0].name !== name || finalized[0].key !== key
|
|
241
|
+
|| JSON.stringify(flattened(finalized[0])) !== JSON.stringify(requested) || finalized[0].roles.length !== 0) {
|
|
242
|
+
throw new Error("created key did not converge to its exact final scope")
|
|
243
|
+
}
|
|
244
|
+
const probeResponse = await dependencies.fetchImpl(GRAPHQL_ENDPOINT, {
|
|
245
|
+
method: "POST", headers,
|
|
246
|
+
body: JSON.stringify({ query: "query AcceptanceAuthProbe { info { os { hostname } } }", variables: {} }),
|
|
247
|
+
signal: AbortSignal.timeout(10_000),
|
|
248
|
+
})
|
|
249
|
+
const probeEnvelope = object(await probeResponse.json(), "Unraid key readiness response")
|
|
250
|
+
if (!probeResponse.ok || probeEnvelope.errors || !probeEnvelope.data) throw new Error("created key readiness probe failed")
|
|
251
|
+
return finalized[0]
|
|
252
|
+
} catch (error) {
|
|
253
|
+
const candidates = dependencies.inventoryRecords().filter((record) => record.name === name
|
|
254
|
+
&& !beforeIds.has(record.id)
|
|
255
|
+
&& JSON.stringify(flattened(record)) === JSON.stringify(provisionalPermissions)
|
|
256
|
+
&& JSON.stringify(record.roles) === JSON.stringify(["GUEST"])
|
|
257
|
+
&& (cliIdentity === null || (record.id === cliIdentity.id && record.key === cliIdentity.key && record.name === cliIdentity.name)))
|
|
258
|
+
if (candidates.length === 1 && candidates[0].id !== recoveryId) dependencies.persistRecovery(candidates[0])
|
|
173
259
|
throw error
|
|
174
260
|
}
|
|
175
261
|
}
|
|
@@ -570,27 +656,149 @@ function containerRestartSnapshot(expectedImage) {
|
|
|
570
656
|
}
|
|
571
657
|
|
|
572
658
|
function recoveryPath(id) { return `${RECOVERY_ROOT}/${id}.json` }
|
|
659
|
+
function createIntentPath(name) { return `${RECOVERY_ROOT}/create-${createHash("sha256").update(name).digest("hex")}.json` }
|
|
660
|
+
function rejectionReceiptPath(id) { return `${RECOVERY_ROOT}/${id}.rejected.json` }
|
|
573
661
|
|
|
574
|
-
function
|
|
662
|
+
function persistPrivateJson(target, value) {
|
|
575
663
|
mkdirSync(RECOVERY_ROOT, { recursive: true, mode: 0o700 })
|
|
576
664
|
chmodSync(RECOVERY_ROOT, 0o700)
|
|
577
665
|
chownSync(RECOVERY_ROOT, 0, 0)
|
|
578
|
-
const target = recoveryPath(record.id)
|
|
579
666
|
const temporary = `${target}.tmp-${process.pid}`
|
|
580
667
|
const fd = openSync(temporary, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL, 0o600)
|
|
581
|
-
try { writeFileSync(fd, `${JSON.stringify(
|
|
582
|
-
chownSync(temporary, 0, 0)
|
|
668
|
+
try { chownSync(temporary, 0, 0); writeFileSync(fd, `${JSON.stringify(value)}\n`); fsyncSync(fd) } finally { closeSync(fd) }
|
|
583
669
|
renameSync(temporary, target)
|
|
584
670
|
const directory = openSync(RECOVERY_ROOT, constants.O_RDONLY)
|
|
585
671
|
try { fsyncSync(directory) } finally { closeSync(directory) }
|
|
586
672
|
}
|
|
587
673
|
|
|
674
|
+
function optionalPrivateJson(target) {
|
|
675
|
+
try { return object(readPrivateJson(target), "private acceptance record") }
|
|
676
|
+
catch (error) { if (error?.code === "ENOENT") return null; throw error }
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
function persistCreateIntent(name, requested) {
|
|
680
|
+
mkdirSync(RECOVERY_ROOT, { recursive: true, mode: 0o700 })
|
|
681
|
+
chmodSync(RECOVERY_ROOT, 0o700)
|
|
682
|
+
chownSync(RECOVERY_ROOT, 0, 0)
|
|
683
|
+
const target = createIntentPath(name)
|
|
684
|
+
const fd = openSync(target, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL, 0o600)
|
|
685
|
+
try {
|
|
686
|
+
chownSync(target, 0, 0)
|
|
687
|
+
writeFileSync(fd, `${JSON.stringify({ schemaVersion: 1, name, permissions: requested })}\n`)
|
|
688
|
+
fsyncSync(fd)
|
|
689
|
+
} finally { closeSync(fd) }
|
|
690
|
+
const directory = openSync(RECOVERY_ROOT, constants.O_RDONLY)
|
|
691
|
+
try { fsyncSync(directory) } finally { closeSync(directory) }
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
function optionalCreateIntent(name) {
|
|
695
|
+
const value = optionalPrivateJson(createIntentPath(name))
|
|
696
|
+
if (value === null) return null
|
|
697
|
+
exactKeys(value, ["schemaVersion", "name", "permissions"], "Unraid key creation intent")
|
|
698
|
+
if (value.schemaVersion !== 1 || value.name !== name) throw new Error("Unraid key creation intent is invalid")
|
|
699
|
+
return { name, permissions: permissions(value.permissions) }
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
function removePrivateFile(target) {
|
|
703
|
+
try { unlinkSync(target) } catch (error) { if (error?.code !== "ENOENT") throw error }
|
|
704
|
+
const directory = openSync(RECOVERY_ROOT, constants.O_RDONLY)
|
|
705
|
+
try { fsyncSync(directory) } finally { closeSync(directory) }
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
function removeCreateIntent(name) { removePrivateFile(createIntentPath(name)) }
|
|
709
|
+
|
|
710
|
+
function optionalRejectionReceipt(id) {
|
|
711
|
+
const value = optionalPrivateJson(rejectionReceiptPath(id))
|
|
712
|
+
if (value === null) return null
|
|
713
|
+
exactKeys(value, ["schemaVersion", "id", "status", "proofDigest"], "revoked key rejection receipt")
|
|
714
|
+
if (value.schemaVersion !== 1 || value.id !== id || (value.status !== 401 && value.status !== 403)
|
|
715
|
+
|| value.proofDigest !== createHash("sha256").update(`revoked-key-rejected\0${id}\0${value.status}`).digest("hex")) {
|
|
716
|
+
throw new Error("revoked key rejection receipt is invalid")
|
|
717
|
+
}
|
|
718
|
+
return { valid: false, status: value.status, id }
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
function persistRejectionReceipt(id, status) {
|
|
722
|
+
persistPrivateJson(rejectionReceiptPath(id), {
|
|
723
|
+
schemaVersion: 1, id, status,
|
|
724
|
+
proofDigest: createHash("sha256").update(`revoked-key-rejected\0${id}\0${status}`).digest("hex"),
|
|
725
|
+
})
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
async function probeRevokedUnraidKey(id, dependencies = {
|
|
729
|
+
readReceipt: optionalRejectionReceipt,
|
|
730
|
+
readRecovery: optionalRecoveryRecord,
|
|
731
|
+
persistReceipt: persistRejectionReceipt,
|
|
732
|
+
removeRecovery,
|
|
733
|
+
fetchImpl: fetch,
|
|
734
|
+
}) {
|
|
735
|
+
text(id, "key id", KEY_ID)
|
|
736
|
+
const receipt = dependencies.readReceipt(id)
|
|
737
|
+
if (receipt !== null) {
|
|
738
|
+
const staleRecovery = dependencies.readRecovery(id)
|
|
739
|
+
if (staleRecovery !== null) {
|
|
740
|
+
if (staleRecovery.id !== id) throw new Error("recovery key id is invalid")
|
|
741
|
+
dependencies.removeRecovery(id)
|
|
742
|
+
}
|
|
743
|
+
return receipt
|
|
744
|
+
}
|
|
745
|
+
const record = dependencies.readRecovery(id)
|
|
746
|
+
if (record === null || record.id !== id) throw new Error("recovery key id is invalid")
|
|
747
|
+
const response = await dependencies.fetchImpl(GRAPHQL_ENDPOINT, {
|
|
748
|
+
method: "POST", headers: { "content-type": "application/json", "x-api-key": record.key },
|
|
749
|
+
body: JSON.stringify({ query: "query AcceptanceAuthProbe { info { os { hostname } } }", variables: {} }),
|
|
750
|
+
signal: AbortSignal.timeout(10_000),
|
|
751
|
+
})
|
|
752
|
+
if (response.status !== 401 && response.status !== 403) throw new Error("revoked key still authenticates")
|
|
753
|
+
dependencies.persistReceipt(id, response.status)
|
|
754
|
+
dependencies.removeRecovery(id)
|
|
755
|
+
return { valid: false, status: response.status, id }
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
function optionalRecoveryRecord(id) {
|
|
759
|
+
try { return normalizeRecord(readPrivateJson(recoveryPath(id))) }
|
|
760
|
+
catch (error) { if (error?.code === "ENOENT") return null; throw error }
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
function persistRecovery(record) {
|
|
764
|
+
persistPrivateJson(recoveryPath(record.id), record)
|
|
765
|
+
}
|
|
766
|
+
|
|
588
767
|
function removeRecovery(id) {
|
|
589
768
|
unlinkSync(recoveryPath(id))
|
|
590
769
|
const directory = openSync(RECOVERY_ROOT, constants.O_RDONLY)
|
|
591
770
|
try { fsyncSync(directory) } finally { closeSync(directory) }
|
|
592
771
|
}
|
|
593
772
|
|
|
773
|
+
function acknowledgeStoredUnraidKey(id, dependencies = {
|
|
774
|
+
readRecovery: optionalRecoveryRecord,
|
|
775
|
+
inventoryRecords,
|
|
776
|
+
removeRecovery,
|
|
777
|
+
removeIntent: removeCreateIntent,
|
|
778
|
+
}) {
|
|
779
|
+
text(id, "key id", KEY_ID)
|
|
780
|
+
const matches = dependencies.inventoryRecords().filter((record) => record.id === id)
|
|
781
|
+
if (matches.length !== 1 || !KEY_NAME.test(matches[0].name)) throw new Error("Unraid key final storage acknowledgement is invalid")
|
|
782
|
+
const expected = matches[0].name.startsWith("Butler RO") ? RO_PERMISSIONS : [...RO_PERMISSIONS, "DOCKER:UPDATE_ANY"].sort()
|
|
783
|
+
if (matches[0].roles.length !== 0 || JSON.stringify(flattened(matches[0])) !== JSON.stringify(expected)) {
|
|
784
|
+
throw new Error("Unraid key final storage acknowledgement is invalid")
|
|
785
|
+
}
|
|
786
|
+
const recovery = dependencies.readRecovery(id)
|
|
787
|
+
if (recovery === null) {
|
|
788
|
+
dependencies.removeIntent?.(matches[0].name)
|
|
789
|
+
return { acknowledged: true, id }
|
|
790
|
+
}
|
|
791
|
+
const provisional = [...expected, "API_KEY:UPDATE_ANY"].sort()
|
|
792
|
+
if (recovery.id !== id || recovery.name !== matches[0].name || recovery.key !== matches[0].key
|
|
793
|
+
|| JSON.stringify(recovery.roles) !== JSON.stringify(["GUEST"])
|
|
794
|
+
|| JSON.stringify(flattened(recovery)) !== JSON.stringify(provisional)) {
|
|
795
|
+
throw new Error("Unraid key recovery ownership is invalid")
|
|
796
|
+
}
|
|
797
|
+
dependencies.removeRecovery(id)
|
|
798
|
+
dependencies.removeIntent?.(matches[0].name)
|
|
799
|
+
return { acknowledged: true, id }
|
|
800
|
+
}
|
|
801
|
+
|
|
594
802
|
function permissions(value) {
|
|
595
803
|
if (!Array.isArray(value) || value.length === 0) throw new Error("permissions are invalid")
|
|
596
804
|
const result = value.map((item) => text(item, "permission", PERMISSION)).sort()
|
|
@@ -1556,7 +1764,12 @@ async function dispatch(request, dependencies = {
|
|
|
1556
1764
|
if (payload.targetServerId !== TARGET_SERVER) throw new Error("target server is invalid")
|
|
1557
1765
|
const name = text(payload.name, "key name", KEY_NAME)
|
|
1558
1766
|
const requested = permissions(payload.permissions)
|
|
1559
|
-
return createUnraidKey(name, requested)
|
|
1767
|
+
return await createUnraidKey(name, requested)
|
|
1768
|
+
}
|
|
1769
|
+
if (operation === "acknowledge_key_storage") {
|
|
1770
|
+
exactKeys(payload, ["operation", "targetServerId", "keyId"], operation)
|
|
1771
|
+
if (payload.targetServerId !== TARGET_SERVER) throw new Error("target server is invalid")
|
|
1772
|
+
return acknowledgeStoredUnraidKey(text(payload.keyId, "key id", KEY_ID))
|
|
1560
1773
|
}
|
|
1561
1774
|
if (operation === "revoke_key") {
|
|
1562
1775
|
exactKeys(payload, ["operation", "targetServerId", "keyId"], operation)
|
|
@@ -1574,17 +1787,7 @@ async function dispatch(request, dependencies = {
|
|
|
1574
1787
|
if (operation === "probe_revoked_key") {
|
|
1575
1788
|
exactKeys(payload, ["operation", "targetServerId", "keyId"], operation)
|
|
1576
1789
|
if (payload.targetServerId !== TARGET_SERVER) throw new Error("target server is invalid")
|
|
1577
|
-
|
|
1578
|
-
const record = normalizeRecord(readPrivateJson(recoveryPath(id)))
|
|
1579
|
-
if (record.id !== id) throw new Error("recovery key id is invalid")
|
|
1580
|
-
const response = await fetch(GRAPHQL_ENDPOINT, {
|
|
1581
|
-
method: "POST", headers: { "content-type": "application/json", "x-api-key": record.key },
|
|
1582
|
-
body: JSON.stringify({ query: "query AcceptanceAuthProbe { info { os { hostname } } }", variables: {} }),
|
|
1583
|
-
signal: AbortSignal.timeout(10_000),
|
|
1584
|
-
})
|
|
1585
|
-
if (response.status !== 401 && response.status !== 403) throw new Error("revoked key still authenticates")
|
|
1586
|
-
removeRecovery(id)
|
|
1587
|
-
return { valid: false, status: response.status, id }
|
|
1790
|
+
return await probeRevokedUnraidKey(text(payload.keyId, "key id", KEY_ID))
|
|
1588
1791
|
}
|
|
1589
1792
|
if (operation === "request_reboot") {
|
|
1590
1793
|
exactKeys(payload, ["operation", "targetId", "idempotencyKey", "preflightDigest", "processBindingDigest"], operation)
|
|
@@ -1721,7 +1924,11 @@ function writeClosedInventory(file) {
|
|
|
1721
1924
|
|
|
1722
1925
|
function createBrokerServer(dispatchRequest = dispatch) {
|
|
1723
1926
|
const dispatchDrain = createDispatchDrain()
|
|
1927
|
+
const connections = new Set()
|
|
1724
1928
|
const server = createServer({ allowHalfOpen: true }, (connection) => {
|
|
1929
|
+
connections.add(connection)
|
|
1930
|
+
connection.setTimeout(30_000, () => connection.destroy())
|
|
1931
|
+
connection.once("close", () => connections.delete(connection))
|
|
1725
1932
|
let input = ""
|
|
1726
1933
|
connection.setEncoding("utf8")
|
|
1727
1934
|
connection.on("data", (chunk) => {
|
|
@@ -1737,7 +1944,8 @@ function createBrokerServer(dispatchRequest = dispatch) {
|
|
|
1737
1944
|
} catch { connection.end(`${JSON.stringify({ ok: false, error: "host operation failed" })}\n`) }
|
|
1738
1945
|
})
|
|
1739
1946
|
})
|
|
1740
|
-
|
|
1947
|
+
const destroyConnections = () => { for (const connection of connections) connection.destroy() }
|
|
1948
|
+
return { server, dispatchDrain, destroyConnections }
|
|
1741
1949
|
}
|
|
1742
1950
|
|
|
1743
1951
|
async function main() {
|
|
@@ -1754,7 +1962,7 @@ async function main() {
|
|
|
1754
1962
|
const snapshotFd = openSync(initialSnapshot, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL, 0o600)
|
|
1755
1963
|
try { writeFileSync(snapshotFd, `${JSON.stringify(snapshot)}\n`); fsyncSync(snapshotFd) } finally { closeSync(snapshotFd) }
|
|
1756
1964
|
chownSync(initialSnapshot, 0, 0)
|
|
1757
|
-
const { server, dispatchDrain } = createBrokerServer()
|
|
1965
|
+
const { server, dispatchDrain, destroyConnections } = createBrokerServer()
|
|
1758
1966
|
server.listen(socket, () => {
|
|
1759
1967
|
chownSync(socket, 0, 10001)
|
|
1760
1968
|
chmodSync(socket, 0o660)
|
|
@@ -1764,6 +1972,7 @@ async function main() {
|
|
|
1764
1972
|
if (shuttingDown) return
|
|
1765
1973
|
shuttingDown = true
|
|
1766
1974
|
server.close()
|
|
1975
|
+
destroyConnections()
|
|
1767
1976
|
await dispatchDrain.stopAndDrain()
|
|
1768
1977
|
await interactiveRestartDriver.stopAndDrain()
|
|
1769
1978
|
let failed = false
|
|
@@ -1781,6 +1990,7 @@ if (process.argv[1]?.endsWith("sanctuary-unit16-host-broker.mjs")) {
|
|
|
1781
1990
|
}
|
|
1782
1991
|
|
|
1783
1992
|
export {
|
|
1993
|
+
acknowledgeStoredUnraidKey,
|
|
1784
1994
|
assertStableContainerProcess,
|
|
1785
1995
|
attestHealthProbeProcessAbsent,
|
|
1786
1996
|
armRestartAfterResponseClosed,
|
|
@@ -1805,6 +2015,7 @@ export {
|
|
|
1805
2015
|
observeRebootPreflight,
|
|
1806
2016
|
parseVaultStatus,
|
|
1807
2017
|
optionalStoppedContainerExact,
|
|
2018
|
+
probeRevokedUnraidKey,
|
|
1808
2019
|
queryGraphqlAutostart,
|
|
1809
2020
|
readBoundedProcStatus,
|
|
1810
2021
|
driveDuplicateCallbacks,
|
|
@@ -50,9 +50,22 @@ assert_health_probe_cleanup() {
|
|
|
50
50
|
' "$ACCEPTANCE_STATE_ROOT" "$BUNDLE_ROOT/state/health/sanctuary-health.json.lease"
|
|
51
51
|
}
|
|
52
52
|
|
|
53
|
+
terminate_broker() {
|
|
54
|
+
test -n "$BROKER_PID" || return 0
|
|
55
|
+
kill "$BROKER_PID" 2>/dev/null || true
|
|
56
|
+
BROKER_WAIT=0
|
|
57
|
+
while kill -0 "$BROKER_PID" 2>/dev/null && test "$BROKER_WAIT" -lt 10; do
|
|
58
|
+
sleep 1
|
|
59
|
+
BROKER_WAIT=$(( BROKER_WAIT + 1 ))
|
|
60
|
+
done
|
|
61
|
+
if kill -0 "$BROKER_PID" 2>/dev/null; then kill -KILL "$BROKER_PID" 2>/dev/null || true; fi
|
|
62
|
+
wait "$BROKER_PID" 2>/dev/null || true
|
|
63
|
+
BROKER_PID=
|
|
64
|
+
}
|
|
65
|
+
|
|
53
66
|
cleanup_unit16() {
|
|
54
67
|
CLEANUP_STATUS=$?
|
|
55
|
-
|
|
68
|
+
terminate_broker
|
|
56
69
|
if test "$ACCEPTANCE_CANONICAL_PINNED" = yes && ! assert_health_probe_cleanup; then CLEANUP_STATUS=1; fi
|
|
57
70
|
if test "$PRODUCTION_STOPPED" = yes && test "$HOST_REBOOT_COMMIT_STATE" = not_sent; then
|
|
58
71
|
if ! restore_production_container; then CLEANUP_STATUS=1; fi
|
|
@@ -73,7 +86,10 @@ cleanup_unit16() {
|
|
|
73
86
|
if test "$ACCEPTANCE_ALIAS_MOUNTED" = no && test -n "$PRIVATE_ROOT" && test -d "$PRIVATE_ROOT"; then rm -rf -- "$PRIVATE_ROOT"; fi
|
|
74
87
|
return "$CLEANUP_STATUS"
|
|
75
88
|
}
|
|
76
|
-
trap cleanup_unit16 EXIT
|
|
89
|
+
trap cleanup_unit16 EXIT
|
|
90
|
+
trap 'exit 129' HUP
|
|
91
|
+
trap 'exit 130' INT
|
|
92
|
+
trap 'exit 143' TERM
|
|
77
93
|
|
|
78
94
|
case "$MODE" in
|
|
79
95
|
materialize)
|
|
@@ -347,6 +363,7 @@ cmp -s "$EXPECTED_CONFIG" "$CONFIG_PATH" || exit 1
|
|
|
347
363
|
case "$COMMAND" in telegram-bootstrap|callback-inject) test -r /proc/self/fd/3 || exit 2 ;; esac
|
|
348
364
|
prepare_live_facts
|
|
349
365
|
if test "$COMMAND" = telegram-bootstrap; then quiesce_production_telegram_poller; fi
|
|
366
|
+
if test "$COMMAND" = unraid-key-rotate; then stop_exact_production_container; fi
|
|
350
367
|
|
|
351
368
|
case "$COMMAND" in
|
|
352
369
|
telegram-bootstrap) TIME_LIMIT=900; NETWORK=host; INPUT=yes; BUNDLE_MODE=readonly; BROKER=no ;;
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
<?xml version="1.0"?>
|
|
2
2
|
<Container version="2">
|
|
3
3
|
<Name>Mendelow Cloud Butler</Name>
|
|
4
|
-
<Repository>ghcr.io/ourostack/ouroboros-butler:0.1.0-alpha.
|
|
4
|
+
<Repository>ghcr.io/ourostack/ouroboros-butler:0.1.0-alpha.773</Repository>
|
|
5
5
|
<Registry>https://github.com/ourostack/ouroboros/pkgs/container/ouroboros-butler</Registry>
|
|
6
6
|
<Network>host</Network>
|
|
7
7
|
<Shell>sh</Shell>
|
|
@@ -77,6 +77,8 @@ const provider_models_1 = require("../provider-models");
|
|
|
77
77
|
const cli_parse_1 = require("./cli-parse");
|
|
78
78
|
const provider_discovery_1 = require("./provider-discovery");
|
|
79
79
|
const provider_credentials_1 = require("../provider-credentials");
|
|
80
|
+
const startup_tui_1 = require("./startup-tui");
|
|
81
|
+
const daemon_tombstone_1 = require("./daemon-tombstone");
|
|
80
82
|
// ── Default implementations ──
|
|
81
83
|
async function defaultStartDaemonProcess(socketPath) {
|
|
82
84
|
const launchdStarted = await startDaemonProcessViaLaunchd(socketPath);
|
|
@@ -724,6 +726,7 @@ function createDefaultOuroCliDeps(socketPath = socket_client_1.DEFAULT_DAEMON_SO
|
|
|
724
726
|
readHealthState: daemon_health_1.readHealth,
|
|
725
727
|
readHealthUpdatedAt: defaultReadHealthUpdatedAt,
|
|
726
728
|
readRecentDaemonLogLines: defaultReadRecentDaemonLogLines,
|
|
729
|
+
readDaemonStartupTombstone: daemon_tombstone_1.readDaemonTombstone,
|
|
727
730
|
sleep: defaultSleep,
|
|
728
731
|
spawnBackgroundCli: defaultSpawnBackgroundCli,
|
|
729
732
|
now: () => Date.now(),
|
|
@@ -732,6 +735,7 @@ function createDefaultOuroCliDeps(socketPath = socket_client_1.DEFAULT_DAEMON_SO
|
|
|
732
735
|
startupStabilityWindowMs: 1_500,
|
|
733
736
|
startupTimeoutMs: 60_000,
|
|
734
737
|
startupRetryLimit: 1,
|
|
738
|
+
isProcessAlive: startup_tui_1.defaultIsProcessAlive,
|
|
735
739
|
listDiscoveredAgents: defaultListDiscoveredAgents,
|
|
736
740
|
runHatchFlow: hatch_flow_1.runHatchFlow,
|
|
737
741
|
promptInput: defaultPromptInput,
|
|
@@ -113,6 +113,7 @@ const readiness_repair_1 = require("./readiness-repair");
|
|
|
113
113
|
const human_readiness_1 = require("./human-readiness");
|
|
114
114
|
const human_command_screens_1 = require("./human-command-screens");
|
|
115
115
|
const startup_tui_1 = require("./startup-tui");
|
|
116
|
+
const daemon_bootstrap_startup_1 = require("./daemon-bootstrap-startup");
|
|
116
117
|
const stale_bundle_prune_1 = require("./stale-bundle-prune");
|
|
117
118
|
const up_progress_1 = require("./up-progress");
|
|
118
119
|
const provider_ping_progress_1 = require("./provider-ping-progress");
|
|
@@ -1192,6 +1193,7 @@ async function ensureDaemonRunning(deps, options = {}) {
|
|
|
1192
1193
|
writeRaw: deps.writeRaw ?? ((text) => process.stdout.write(text)),
|
|
1193
1194
|
/* v8 ignore next -- thin wrapper: real stdout TTY detection injected by default deps @preserve */
|
|
1194
1195
|
isTTY: deps.isTTY ?? process.stdout.isTTY === true,
|
|
1196
|
+
isProcessAlive: deps.isProcessAlive,
|
|
1195
1197
|
/* v8 ignore next -- thin wrapper: real Date.now fallback injected by default deps @preserve */
|
|
1196
1198
|
now: deps.now ?? (() => Date.now()),
|
|
1197
1199
|
/* v8 ignore next -- thin wrapper: real setTimeout fallback injected by default deps @preserve */
|
|
@@ -1240,6 +1242,7 @@ async function ensureDaemonRunning(deps, options = {}) {
|
|
|
1240
1242
|
writeRaw: deps.writeRaw ?? ((text) => process.stdout.write(text)),
|
|
1241
1243
|
/* v8 ignore next -- thin wrapper: real stdout TTY detection injected by default deps @preserve */
|
|
1242
1244
|
isTTY: deps.isTTY ?? process.stdout.isTTY === true,
|
|
1245
|
+
isProcessAlive: deps.isProcessAlive,
|
|
1243
1246
|
/* v8 ignore next -- thin wrapper: real Date.now fallback injected by default deps @preserve */
|
|
1244
1247
|
now: deps.now ?? (() => Date.now()),
|
|
1245
1248
|
/* v8 ignore next -- thin wrapper: real setTimeout fallback injected by default deps @preserve */
|
|
@@ -1295,7 +1298,9 @@ function formatDaemonStartupFailureMessage(pid, failure, deps) {
|
|
|
1295
1298
|
const lines = [
|
|
1296
1299
|
`background service started (pid ${pid ?? "unknown"}) but did not finish booting: ${failure.reason}`,
|
|
1297
1300
|
];
|
|
1298
|
-
const recentLogLines =
|
|
1301
|
+
const recentLogLines = failure.includeRecentLogs === false
|
|
1302
|
+
? []
|
|
1303
|
+
: deps.readRecentDaemonLogLines?.(DEFAULT_DAEMON_STARTUP_LOG_LINES) ?? [];
|
|
1299
1304
|
if (recentLogLines.length > 0) {
|
|
1300
1305
|
lines.push("recent daemon logs:");
|
|
1301
1306
|
lines.push(...recentLogLines.map((line) => ` ${line}`));
|
|
@@ -1303,6 +1308,19 @@ function formatDaemonStartupFailureMessage(pid, failure, deps) {
|
|
|
1303
1308
|
lines.push("Run `ouro logs` to watch live startup logs or `ouro doctor` for a deeper diagnosis.");
|
|
1304
1309
|
return lines.join("\n");
|
|
1305
1310
|
}
|
|
1311
|
+
function readFreshDaemonStartupFailure(deps, options) {
|
|
1312
|
+
const tombstone = deps.readDaemonStartupTombstone?.() ?? null;
|
|
1313
|
+
if (!tombstone || (tombstone.reason !== daemon_bootstrap_startup_1.PUBLIC_DAEMON_STARTUP_FAILURE_REASON && tombstone.reason !== "startupFailure"))
|
|
1314
|
+
return null;
|
|
1315
|
+
const timestampMs = Date.parse(tombstone.timestamp);
|
|
1316
|
+
if (!Number.isFinite(timestampMs) || timestampMs < options.bootStartedAtMs)
|
|
1317
|
+
return null;
|
|
1318
|
+
if (options.pid !== null && tombstone.pid !== options.pid)
|
|
1319
|
+
return null;
|
|
1320
|
+
return tombstone.reason === daemon_bootstrap_startup_1.PUBLIC_DAEMON_STARTUP_FAILURE_REASON
|
|
1321
|
+
? tombstone.message
|
|
1322
|
+
: "background service failed during startup; run `ouro doctor` for diagnosis";
|
|
1323
|
+
}
|
|
1306
1324
|
async function waitForDaemonStartup(deps, options) {
|
|
1307
1325
|
const now = deps.now ?? Date.now;
|
|
1308
1326
|
const sleep = deps.sleep ?? defaultSleep;
|
|
@@ -1316,6 +1334,16 @@ async function waitForDaemonStartup(deps, options) {
|
|
|
1316
1334
|
if (!useHealthMonitor) {
|
|
1317
1335
|
while (now() < deadline) {
|
|
1318
1336
|
await sleep(pollIntervalMs);
|
|
1337
|
+
const startupFailure = readFreshDaemonStartupFailure(deps, options);
|
|
1338
|
+
if (startupFailure)
|
|
1339
|
+
return { reason: startupFailure, retryable: false, includeRecentLogs: false };
|
|
1340
|
+
if (options.pid !== null && deps.isProcessAlive && !deps.isProcessAlive(options.pid)) {
|
|
1341
|
+
return {
|
|
1342
|
+
reason: `${options.serviceLabel} exited before answering`,
|
|
1343
|
+
retryable: false,
|
|
1344
|
+
includeRecentLogs: false,
|
|
1345
|
+
};
|
|
1346
|
+
}
|
|
1319
1347
|
const latestEvent = options.readLatestDaemonEvent?.() ?? null;
|
|
1320
1348
|
deps.reportDaemonStartupPhase?.(formatDaemonStartupProgressLine(options.serviceLabel, "waiting", latestEvent));
|
|
1321
1349
|
if (await deps.checkSocketAlive(deps.socketPath))
|
|
@@ -1328,8 +1356,18 @@ async function waitForDaemonStartup(deps, options) {
|
|
|
1328
1356
|
}
|
|
1329
1357
|
while (now() < deadline) {
|
|
1330
1358
|
await sleep(pollIntervalMs);
|
|
1359
|
+
const startupFailure = readFreshDaemonStartupFailure(deps, options);
|
|
1360
|
+
if (startupFailure)
|
|
1361
|
+
return { reason: startupFailure, retryable: false, includeRecentLogs: false };
|
|
1331
1362
|
const aliveNow = await deps.checkSocketAlive(deps.socketPath);
|
|
1332
1363
|
if (!aliveNow) {
|
|
1364
|
+
if (options.pid !== null && deps.isProcessAlive && !deps.isProcessAlive(options.pid)) {
|
|
1365
|
+
return {
|
|
1366
|
+
reason: `${options.serviceLabel} exited before answering`,
|
|
1367
|
+
retryable: false,
|
|
1368
|
+
includeRecentLogs: false,
|
|
1369
|
+
};
|
|
1370
|
+
}
|
|
1333
1371
|
if (sawSocket) {
|
|
1334
1372
|
return {
|
|
1335
1373
|
reason: `${options.serviceLabel} answered once and then disappeared during startup`,
|