rollbridge 0.1.35 → 0.1.37
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 +10 -3
- 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 +226 -60
- package/src/guardian-client.js +99 -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 +97 -0
- package/test/owner-replacement.test.js +604 -8
package/src/guardian-client.js
CHANGED
|
@@ -21,6 +21,8 @@ export default class GuardianClient {
|
|
|
21
21
|
this.idleWaiters = /** @type {(() => void)[]} */ ([])
|
|
22
22
|
this.guardianExitPromise = /** @type {Promise<void> | undefined} */ (undefined)
|
|
23
23
|
this.processes = /** @type {Map<string, GuardianProcess>} */ (new Map())
|
|
24
|
+
this.reservedProcessKey = /** @type {string | undefined} */ (undefined)
|
|
25
|
+
this.reservedProcessProvenance = /** @type {string | undefined} */ (undefined)
|
|
24
26
|
this.events = /** @type {Map<string, {reject: (error: Error) => void, resolve: (value: Record<string, import("./json.js").JsonValue>) => void}[]>} */ (new Map())
|
|
25
27
|
this.eventHandlers = /** @type {Map<string, ((event: Record<string, import("./json.js").JsonValue>) => void)[]>} */ (new Map())
|
|
26
28
|
}
|
|
@@ -52,7 +54,7 @@ export default class GuardianClient {
|
|
|
52
54
|
}
|
|
53
55
|
|
|
54
56
|
/**
|
|
55
|
-
* Starts a current transaction guardian in front of this authenticated
|
|
57
|
+
* Starts a current transaction guardian in front of this authenticated legacy guardian.
|
|
56
58
|
* @param {{ownerState: import("./json.js").JsonValue, socketPath: string, token: string}} options - Upgrade identity and exact committed state.
|
|
57
59
|
* @returns {Promise<GuardianClient>} Current guardian client backed by the legacy supervisor.
|
|
58
60
|
*/
|
|
@@ -111,6 +113,27 @@ export default class GuardianClient {
|
|
|
111
113
|
return processInstance
|
|
112
114
|
}
|
|
113
115
|
|
|
116
|
+
/**
|
|
117
|
+
* @param {string} key - Exact committed-owner registration reserved until replacement commit.
|
|
118
|
+
* @param {string} provenance - Guardian-inventoried definition fence.
|
|
119
|
+
*/
|
|
120
|
+
reserveProcessRecovery(key, provenance) {
|
|
121
|
+
if (this.reservedProcessKey) throw new Error(`Guardian process recovery ${this.reservedProcessKey} is already reserved`)
|
|
122
|
+
this.reservedProcessKey = key
|
|
123
|
+
this.reservedProcessProvenance = provenance
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** @param {string} key - Exact reserved registration to attach after authority commits. */
|
|
127
|
+
async recoverReservedProcess(key) {
|
|
128
|
+
if (this.reservedProcessKey !== key) throw new Error(`Guardian process recovery ${key} is not reserved`)
|
|
129
|
+
const processInstance = this.processes.get(key)
|
|
130
|
+
|
|
131
|
+
if (!processInstance) throw new Error(`Reserved guardian process ${key} was not reconstructed`)
|
|
132
|
+
await processInstance.attachReserved()
|
|
133
|
+
this.reservedProcessKey = undefined
|
|
134
|
+
this.reservedProcessProvenance = undefined
|
|
135
|
+
}
|
|
136
|
+
|
|
114
137
|
/**
|
|
115
138
|
* @param {Record<string, import("./json.js").JsonValue>} command - Command.
|
|
116
139
|
* @returns {Promise<import("./json.js").JsonValue>} Guardian response.
|
|
@@ -166,6 +189,32 @@ export default class GuardianClient {
|
|
|
166
189
|
await this.request({command: "publish-owner-state", ownerState})
|
|
167
190
|
}
|
|
168
191
|
|
|
192
|
+
/**
|
|
193
|
+
* Classifies the authenticated guardian's owner-replacement protocol without preparing a transaction.
|
|
194
|
+
* @returns {Promise<"atomic" | "legacy">} Compatible replacement route.
|
|
195
|
+
*/
|
|
196
|
+
async ownerReplacementProtocol() {
|
|
197
|
+
try {
|
|
198
|
+
const response = await this.request({command: "owner-replacement-capabilities"})
|
|
199
|
+
|
|
200
|
+
if (!isOwnerReplacementCapabilities(response)) throw new Error("Guardian returned an invalid owner-replacement capability response")
|
|
201
|
+
return "atomic"
|
|
202
|
+
} catch (error) {
|
|
203
|
+
if (!(error instanceof Error) || !isLegacyCapabilityDispatchDiagnostic(error.message)) throw error
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
try {
|
|
207
|
+
await this.request({command: "commit-retired-owner-replacement", replacementId: "owner-replacement-capability-probe"})
|
|
208
|
+
throw new Error("Guardian unexpectedly accepted the retired-owner capability probe")
|
|
209
|
+
} catch (error) {
|
|
210
|
+
if (!(error instanceof Error)) throw error
|
|
211
|
+
if (error.message === "Owner replacement transaction is not the prepared candidate") return "atomic"
|
|
212
|
+
if (error.message === "Guardian commit-retired-owner-replacement requires a process key" ||
|
|
213
|
+
error.message === "Unknown guardian command: commit-retired-owner-replacement") return "legacy"
|
|
214
|
+
throw new Error("Guardian returned an ambiguous retired-owner capability response", {cause: error})
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
169
218
|
/**
|
|
170
219
|
* @param {import("./json.js").JsonValue} authority - Persisted current authority.
|
|
171
220
|
* @param {import("./json.js").JsonValue} nextAuthority - Requested authority.
|
|
@@ -202,6 +251,23 @@ export default class GuardianClient {
|
|
|
202
251
|
await this.request({command: "commit-retired-owner-replacement", key, replacementId})
|
|
203
252
|
}
|
|
204
253
|
|
|
254
|
+
/**
|
|
255
|
+
* Begins acquiring the authenticated legacy backend owner channel at the disruptive boundary.
|
|
256
|
+
* @param {string} replacementId - Exact prepared candidate transaction.
|
|
257
|
+
* @param {number} graceMs - Event-driven incumbent disconnect grace.
|
|
258
|
+
*/
|
|
259
|
+
async beginLegacyOwnerClaim(replacementId, graceMs) {
|
|
260
|
+
await this.request({command: "begin-legacy-owner-claim", graceMs, replacementId})
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* Waits until the upgraded guardian owns its authenticated legacy backend.
|
|
265
|
+
* @param {string} replacementId - Exact prepared candidate transaction.
|
|
266
|
+
*/
|
|
267
|
+
async completeLegacyOwnerClaim(replacementId) {
|
|
268
|
+
await this.request({command: "complete-legacy-owner-claim", replacementId})
|
|
269
|
+
}
|
|
270
|
+
|
|
205
271
|
/** @param {string} replacementId - Committed transaction awaiting incumbent retirement. */
|
|
206
272
|
async finalizeOwnerReplacement(replacementId) {
|
|
207
273
|
await this.request({command: "finalize-owner-replacement", replacementId})
|
|
@@ -354,6 +420,16 @@ class GuardianProcess extends ManagedProcess {
|
|
|
354
420
|
|
|
355
421
|
/** Reconnects to an already registered guardian process without changing its desired state. */
|
|
356
422
|
async recover() {
|
|
423
|
+
if (this.client.reservedProcessKey === this.key) {
|
|
424
|
+
if (this.client.reservedProcessProvenance !== this.provenance) throw new Error(`Guardian provenance mismatch for reserved process ${this.key}`)
|
|
425
|
+
this.cachedStatus = asProcessStatus(await this.client.request({command: "status", key: this.key}))
|
|
426
|
+
return
|
|
427
|
+
}
|
|
428
|
+
await this.ensureRegistered()
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
/** Attaches a reconstructed process whose incumbent-owned registration was reserved through commit. */
|
|
432
|
+
async attachReserved() {
|
|
357
433
|
await this.ensureRegistered()
|
|
358
434
|
}
|
|
359
435
|
|
|
@@ -463,6 +539,28 @@ function asProcessStatus(value) {
|
|
|
463
539
|
return JSON.parse(JSON.stringify(value))
|
|
464
540
|
}
|
|
465
541
|
|
|
542
|
+
/**
|
|
543
|
+
* @param {import("./json.js").JsonValue} value - Capability response.
|
|
544
|
+
* @returns {boolean} Whether the response explicitly guarantees retired-owner commit support.
|
|
545
|
+
*/
|
|
546
|
+
function isOwnerReplacementCapabilities(value) {
|
|
547
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return false
|
|
548
|
+
const response = /** @type {Record<string, import("./json.js").JsonValue>} */ (value)
|
|
549
|
+
|
|
550
|
+
return response.protocol === "owner-replacement" && Number.isInteger(response.version) && Number(response.version) >= 1 &&
|
|
551
|
+
Array.isArray(response.commands) && response.commands.every((command) => typeof command === "string") &&
|
|
552
|
+
response.commands.includes("commit-retired-owner-replacement")
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
/**
|
|
556
|
+
* @param {string} diagnostic - Exact old generic-dispatch response.
|
|
557
|
+
* @returns {boolean} Whether this is the narrow old unknown-command dispatch signature.
|
|
558
|
+
*/
|
|
559
|
+
function isLegacyCapabilityDispatchDiagnostic(diagnostic) {
|
|
560
|
+
return diagnostic === "Guardian owner-replacement-capabilities requires a process key" ||
|
|
561
|
+
diagnostic === "Unknown guardian command: owner-replacement-capabilities"
|
|
562
|
+
}
|
|
563
|
+
|
|
466
564
|
/**
|
|
467
565
|
* @param {import("./json.js").JsonValue} value - Protocol value.
|
|
468
566
|
* @returns {import("./managed-process.js").ManagedProcessLog} Process output entry.
|
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,77 @@ 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
|
+
|
|
301
|
+
test("reserved process recovery rejects a reconstructed definition with different provenance", async () => {
|
|
302
|
+
const fixture = await createGuardian()
|
|
303
|
+
const candidate = new GuardianClient({socketPath: fixture.client.socketPath, token: fixture.token})
|
|
304
|
+
const processKey = "release:v1:worker"
|
|
305
|
+
|
|
306
|
+
try {
|
|
307
|
+
await fixture.client.process(processKey, definition("worker")).recover()
|
|
308
|
+
const [registration] = await fixture.client.inventory()
|
|
309
|
+
|
|
310
|
+
assert.ok(registration)
|
|
311
|
+
await candidate.connect()
|
|
312
|
+
candidate.reserveProcessRecovery(processKey, registration.provenance)
|
|
313
|
+
await assert.rejects(
|
|
314
|
+
() => candidate.process(processKey, definition("different-worker")).recover(),
|
|
315
|
+
/provenance mismatch for reserved process/
|
|
316
|
+
)
|
|
317
|
+
assert.deepEqual((await fixture.client.inventory()).map(({key}) => key), [processKey])
|
|
318
|
+
} finally {
|
|
319
|
+
candidate.disconnect()
|
|
320
|
+
await cleanupGuardian(fixture)
|
|
321
|
+
}
|
|
322
|
+
})
|
|
323
|
+
|
|
227
324
|
test("retired owner replacement rejects a registered process absent from committed owner state", async () => {
|
|
228
325
|
const fixture = await createGuardian()
|
|
229
326
|
const candidate = new GuardianClient({socketPath: fixture.client.socketPath, token: fixture.token})
|