rollbridge 0.1.36 → 0.1.38
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/README.md +5 -3
- package/changelog.d/20260830-guardian-retired-replacement-process-key.md +7 -0
- package/docs/cli.md +2 -1
- package/docs/config.md +4 -2
- package/docs/troubleshooting.md +3 -0
- package/package.json +1 -1
- package/src/daemon.js +243 -66
- package/src/guardian-client.js +66 -1
- package/src/process-guardian.js +31 -6
- package/test/fixtures/partial-owner-replacement-process-guardian.js +106 -0
- package/test/guardian-client.test.js +74 -0
- package/test/owner-replacement.test.js +602 -3
package/src/process-guardian.js
CHANGED
|
@@ -77,6 +77,8 @@ let ownerMutationId
|
|
|
77
77
|
let retiringClient
|
|
78
78
|
/** @type {string | undefined} */
|
|
79
79
|
let retiringReplacementId
|
|
80
|
+
/** @type {Promise<Error | undefined> | undefined} */
|
|
81
|
+
let legacyOwnerClaim
|
|
80
82
|
let shuttingDown = false
|
|
81
83
|
/** @type {net.Socket | undefined} */
|
|
82
84
|
let shutdownClient
|
|
@@ -117,11 +119,7 @@ const server = net.createServer((socket) => {
|
|
|
117
119
|
}
|
|
118
120
|
if (retiringClient === socket) finalizeReplacementRetirement()
|
|
119
121
|
if (replacementClient === socket) {
|
|
120
|
-
|
|
121
|
-
replacementId = undefined
|
|
122
|
-
replacementAuthority = undefined
|
|
123
|
-
replacementOwnerState = undefined
|
|
124
|
-
if (ownerClient && !ownerClient.destroyed) ownerClient.write(`${JSON.stringify({event: "replacement-aborted"})}\n`)
|
|
122
|
+
abortReplacement("Replacement candidate disconnected before commit")
|
|
125
123
|
}
|
|
126
124
|
if (shutdownClient === socket) void finishShutdown()
|
|
127
125
|
})
|
|
@@ -180,6 +178,10 @@ async function handleLine(socket, line) {
|
|
|
180
178
|
async function execute(request, socket) {
|
|
181
179
|
if (shuttingDown) throw new Error("Process guardian is shutting down")
|
|
182
180
|
|
|
181
|
+
if (request.command === "owner-replacement-capabilities") {
|
|
182
|
+
return {commands: ["commit-retired-owner-replacement"], protocol: "owner-replacement", version: 1}
|
|
183
|
+
}
|
|
184
|
+
|
|
183
185
|
if (request.command === "claim-owner") {
|
|
184
186
|
if (!ownerClient) {
|
|
185
187
|
if (ownerState !== undefined && !isDeepStrictEqual(request.authority, ownerAuthority(ownerState))) {
|
|
@@ -225,6 +227,26 @@ async function execute(request, socket) {
|
|
|
225
227
|
return {abandoned: true}
|
|
226
228
|
}
|
|
227
229
|
|
|
230
|
+
if (request.command === "begin-legacy-owner-claim") {
|
|
231
|
+
if (!legacyGuardian) throw new Error("Guardian is not a legacy upgrade coordinator")
|
|
232
|
+
requireReplacement(socket, request)
|
|
233
|
+
if (legacyOwnerClaim) throw new Error("Legacy guardian owner claim is already pending")
|
|
234
|
+
legacyOwnerClaim = legacyGuardian.claimOwner(request.graceMs ?? 30000, ownerAuthority(ownerState)).then(
|
|
235
|
+
() => undefined,
|
|
236
|
+
(error) => error instanceof Error ? error : new Error(String(error))
|
|
237
|
+
)
|
|
238
|
+
return {prepared: true}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
if (request.command === "complete-legacy-owner-claim") {
|
|
242
|
+
if (!legacyGuardian || !legacyOwnerClaim) throw new Error("Legacy guardian owner claim was not prepared")
|
|
243
|
+
requireReplacement(socket, request)
|
|
244
|
+
const claimError = await legacyOwnerClaim
|
|
245
|
+
|
|
246
|
+
if (claimError) throw claimError
|
|
247
|
+
return {claimed: true}
|
|
248
|
+
}
|
|
249
|
+
|
|
228
250
|
if (request.command === "publish-owner-state") {
|
|
229
251
|
requireOwner(socket, request.command)
|
|
230
252
|
if (request.ownerState === undefined) throw new Error("Guardian owner publication requires ownerState")
|
|
@@ -470,7 +492,10 @@ function requireOwner(socket, command) {
|
|
|
470
492
|
|
|
471
493
|
/** @param {string} reason - Abort diagnostic. */
|
|
472
494
|
function abortReplacement(reason) {
|
|
473
|
-
|
|
495
|
+
const event = `${JSON.stringify({event: "replacement-aborted", reason})}\n`
|
|
496
|
+
|
|
497
|
+
if (replacementClient && !replacementClient.destroyed) replacementClient.write(event)
|
|
498
|
+
if (ownerClient && !ownerClient.destroyed) ownerClient.write(event)
|
|
474
499
|
replacementClient = undefined
|
|
475
500
|
replacementId = undefined
|
|
476
501
|
replacementAuthority = undefined
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
|
|
3
|
+
import fs from "node:fs/promises"
|
|
4
|
+
import net from "node:net"
|
|
5
|
+
|
|
6
|
+
const [socketPath, backendPath, mode = "partial"] = process.argv.slice(2)
|
|
7
|
+
|
|
8
|
+
if (!socketPath || !backendPath || !process.send) throw new Error("partial process-guardian requires frontend and backend sockets plus a private bootstrap channel")
|
|
9
|
+
|
|
10
|
+
const token = await new Promise((resolve, reject) => {
|
|
11
|
+
process.once("disconnect", () => reject(new Error("Partial guardian bootstrap channel closed before authentication capability arrived")))
|
|
12
|
+
process.once("message", (message) => {
|
|
13
|
+
if (!message || typeof message !== "object" || !("token" in message) || typeof message.token !== "string" || !message.token) {
|
|
14
|
+
reject(new Error("Partial guardian bootstrap authentication capability is invalid"))
|
|
15
|
+
return
|
|
16
|
+
}
|
|
17
|
+
resolve(message.token)
|
|
18
|
+
})
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
const clients = new Set()
|
|
22
|
+
const server = net.createServer((frontend) => {
|
|
23
|
+
const backend = net.createConnection(backendPath)
|
|
24
|
+
let frontendBuffer = ""
|
|
25
|
+
let backendBuffer = ""
|
|
26
|
+
|
|
27
|
+
clients.add(frontend)
|
|
28
|
+
frontend.setEncoding("utf8")
|
|
29
|
+
backend.setEncoding("utf8")
|
|
30
|
+
frontend.on("error", () => frontend.destroy())
|
|
31
|
+
backend.on("error", () => backend.destroy())
|
|
32
|
+
frontend.once("close", () => {
|
|
33
|
+
clients.delete(frontend)
|
|
34
|
+
backend.destroy()
|
|
35
|
+
})
|
|
36
|
+
backend.once("close", () => frontend.destroy())
|
|
37
|
+
frontend.on("data", (chunk) => {
|
|
38
|
+
frontendBuffer += chunk
|
|
39
|
+
let newline = frontendBuffer.indexOf("\n")
|
|
40
|
+
|
|
41
|
+
while (newline >= 0) {
|
|
42
|
+
const line = frontendBuffer.slice(0, newline)
|
|
43
|
+
const request = JSON.parse(line)
|
|
44
|
+
|
|
45
|
+
frontendBuffer = frontendBuffer.slice(newline + 1)
|
|
46
|
+
if (request.token !== token) {
|
|
47
|
+
frontend.write(`${JSON.stringify({error: "Guardian authentication failed", id: request.id})}\n`)
|
|
48
|
+
} else if (request.command === "owner-replacement-capabilities") {
|
|
49
|
+
const result = mode === "malformed-capability"
|
|
50
|
+
? {protocol: "owner-replacement", version: 1}
|
|
51
|
+
: undefined
|
|
52
|
+
|
|
53
|
+
frontend.write(`${JSON.stringify(result
|
|
54
|
+
? {id: request.id, result}
|
|
55
|
+
: {error: "Guardian owner-replacement-capabilities requires a process key", id: request.id})}\n`)
|
|
56
|
+
} else if (request.command === "commit-retired-owner-replacement") {
|
|
57
|
+
frontend.write(`${JSON.stringify({
|
|
58
|
+
error: request.key
|
|
59
|
+
? "Guardian commit-retired-owner-replacement requires the committed owner"
|
|
60
|
+
: "Guardian commit-retired-owner-replacement requires a process key",
|
|
61
|
+
id: request.id
|
|
62
|
+
})}\n`)
|
|
63
|
+
} else {
|
|
64
|
+
if (mode === "wrong-provenance" && request.command === "register") {
|
|
65
|
+
request.provenance = `${request.provenance}-tampered`
|
|
66
|
+
backend.write(`${JSON.stringify(request)}\n`)
|
|
67
|
+
} else {
|
|
68
|
+
backend.write(`${line}\n`)
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
newline = frontendBuffer.indexOf("\n")
|
|
72
|
+
}
|
|
73
|
+
})
|
|
74
|
+
backend.on("data", (chunk) => {
|
|
75
|
+
backendBuffer += chunk
|
|
76
|
+
let newline = backendBuffer.indexOf("\n")
|
|
77
|
+
|
|
78
|
+
while (newline >= 0) {
|
|
79
|
+
const line = backendBuffer.slice(0, newline)
|
|
80
|
+
|
|
81
|
+
backendBuffer = backendBuffer.slice(newline + 1)
|
|
82
|
+
frontend.write(`${line}\n`)
|
|
83
|
+
newline = backendBuffer.indexOf("\n")
|
|
84
|
+
}
|
|
85
|
+
})
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
server.on("error", (error) => {
|
|
89
|
+
if (process.send) process.send({error: error.message})
|
|
90
|
+
else throw error
|
|
91
|
+
})
|
|
92
|
+
server.listen(socketPath, async () => {
|
|
93
|
+
await fs.chmod(socketPath, 0o600)
|
|
94
|
+
process.send?.({ready: true})
|
|
95
|
+
process.disconnect?.()
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
const shutdown = () => {
|
|
99
|
+
for (const client of clients) client.destroy()
|
|
100
|
+
server.close(() => {
|
|
101
|
+
void fs.rm(socketPath, {force: true}).finally(() => process.exit(0))
|
|
102
|
+
})
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
process.once("SIGINT", shutdown)
|
|
106
|
+
process.once("SIGTERM", shutdown)
|
|
@@ -210,6 +210,32 @@ test("replacement staging rejects owner state published after prepare", async ()
|
|
|
210
210
|
}
|
|
211
211
|
})
|
|
212
212
|
|
|
213
|
+
test("replacement abort notifies both the candidate and committed owner", async () => {
|
|
214
|
+
const fixture = await createGuardian()
|
|
215
|
+
const candidate = new GuardianClient({socketPath: fixture.client.socketPath, token: fixture.token})
|
|
216
|
+
const authority = {configDigest: "incumbent", runtime: null}
|
|
217
|
+
const nextAuthority = {configDigest: "candidate", runtime: null}
|
|
218
|
+
|
|
219
|
+
try {
|
|
220
|
+
await fixture.client.publishOwnerState({authority, snapshot: {activeReleaseId: "v1"}})
|
|
221
|
+
await candidate.connect()
|
|
222
|
+
const prepared = await candidate.prepareOwnerReplacement(authority, nextAuthority)
|
|
223
|
+
const incumbentAborted = fixture.client.waitForEvent("replacement-aborted")
|
|
224
|
+
const candidateAborted = candidate.waitForEvent("replacement-aborted")
|
|
225
|
+
|
|
226
|
+
await candidate.abortOwnerReplacement(prepared.replacementId)
|
|
227
|
+
assert.deepEqual(await Promise.all([incumbentAborted, candidateAborted]), [
|
|
228
|
+
{event: "replacement-aborted", reason: "Replacement candidate aborted the prepared transaction"},
|
|
229
|
+
{event: "replacement-aborted", reason: "Replacement candidate aborted the prepared transaction"}
|
|
230
|
+
])
|
|
231
|
+
await fixture.client.shutdown()
|
|
232
|
+
await fixture.client.guardianExit()
|
|
233
|
+
} finally {
|
|
234
|
+
candidate.disconnect()
|
|
235
|
+
await cleanupGuardian(fixture)
|
|
236
|
+
}
|
|
237
|
+
})
|
|
238
|
+
|
|
213
239
|
test("retired owner replacement commit carries its exact recovered process key", async () => {
|
|
214
240
|
const client = new GuardianClient({socketPath: "/unused", token: "authenticated-capability"})
|
|
215
241
|
const replacementId = "prepared-replacement"
|
|
@@ -224,6 +250,54 @@ test("retired owner replacement commit carries its exact recovered process key",
|
|
|
224
250
|
await client.commitRetiredOwnerReplacement(replacementId, processKey)
|
|
225
251
|
})
|
|
226
252
|
|
|
253
|
+
test("guardian owner-replacement capability classification is explicit and fail closed", async () => {
|
|
254
|
+
const current = new GuardianClient({socketPath: "/unused", token: "authenticated-capability"})
|
|
255
|
+
|
|
256
|
+
current.request = async (request) => {
|
|
257
|
+
assert.deepEqual(request, {command: "owner-replacement-capabilities"})
|
|
258
|
+
return {
|
|
259
|
+
commands: ["commit-retired-owner-replacement", "future-command"],
|
|
260
|
+
futureField: {supported: true},
|
|
261
|
+
protocol: "owner-replacement",
|
|
262
|
+
version: 2
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
assert.equal(await current.ownerReplacementProtocol(), "atomic")
|
|
266
|
+
|
|
267
|
+
for (const [commitDiagnostic, expected] of [
|
|
268
|
+
["Owner replacement transaction is not the prepared candidate", "atomic"],
|
|
269
|
+
["Guardian commit-retired-owner-replacement requires a process key", "legacy"],
|
|
270
|
+
["Unknown guardian command: commit-retired-owner-replacement", "legacy"]
|
|
271
|
+
]) {
|
|
272
|
+
const older = new GuardianClient({socketPath: "/unused", token: "authenticated-capability"})
|
|
273
|
+
const requests = /** @type {Record<string, import("../src/json.js").JsonValue>[]} */ ([])
|
|
274
|
+
|
|
275
|
+
older.request = async (request) => {
|
|
276
|
+
requests.push(request)
|
|
277
|
+
if (request.command === "owner-replacement-capabilities") throw new Error("Guardian owner-replacement-capabilities requires a process key")
|
|
278
|
+
throw new Error(commitDiagnostic)
|
|
279
|
+
}
|
|
280
|
+
assert.equal(await older.ownerReplacementProtocol(), expected)
|
|
281
|
+
assert.deepEqual(requests, [
|
|
282
|
+
{command: "owner-replacement-capabilities"},
|
|
283
|
+
{command: "commit-retired-owner-replacement", replacementId: "owner-replacement-capability-probe"}
|
|
284
|
+
])
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
for (const fixture of [
|
|
288
|
+
async () => ({commands: [], protocol: "owner-replacement", version: 1}),
|
|
289
|
+
async (/** @type {Record<string, import("../src/json.js").JsonValue>} */ request) => {
|
|
290
|
+
if (request.command === "owner-replacement-capabilities") throw new Error("Guardian owner-replacement-capabilities requires a process key")
|
|
291
|
+
throw new Error("Guardian commit-retired-owner-replacement requires the committed owner")
|
|
292
|
+
}
|
|
293
|
+
]) {
|
|
294
|
+
const ambiguous = new GuardianClient({socketPath: "/unused", token: "authenticated-capability"})
|
|
295
|
+
|
|
296
|
+
ambiguous.request = fixture
|
|
297
|
+
await assert.rejects(() => ambiguous.ownerReplacementProtocol(), /invalid owner-replacement capability response|ambiguous retired-owner capability response/)
|
|
298
|
+
}
|
|
299
|
+
})
|
|
300
|
+
|
|
227
301
|
test("reserved process recovery rejects a reconstructed definition with different provenance", async () => {
|
|
228
302
|
const fixture = await createGuardian()
|
|
229
303
|
const candidate = new GuardianClient({socketPath: fixture.client.socketPath, token: fixture.token})
|