@codyswann/lisa 4.21.6 → 4.22.2
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/all/copy-overwrite/scripts/lisa-environment-prepare.mjs +28 -12
- package/dist/core/lisa-owned-hash-ledger.d.ts.map +1 -1
- package/dist/core/lisa-owned-hash-ledger.js +2 -0
- package/dist/core/lisa-owned-hash-ledger.js.map +1 -1
- package/dist/core/nightly-e2e-guard-behavior-certificate.js +2 -2
- package/dist/core/upstream-evidence-manifest.d.ts.map +1 -1
- package/dist/core/upstream-evidence-manifest.js +9 -7
- package/dist/core/upstream-evidence-manifest.js.map +1 -1
- package/dist/migrations/ensure-playwright-dedicated-caller.d.ts +0 -2
- package/dist/migrations/ensure-playwright-dedicated-caller.d.ts.map +1 -1
- package/dist/migrations/ensure-playwright-dedicated-caller.js +12 -40
- package/dist/migrations/ensure-playwright-dedicated-caller.js.map +1 -1
- package/expo/create-only/.github/workflows/maestro-e2e.yml +6 -0
- package/expo/create-only/.github/workflows/playwright-e2e.yml +5 -5
- package/package.json +2 -2
- package/plugins/lisa/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa/.codex-plugin/skills/lisa-secrets-access/SKILL.md +26 -1
- package/plugins/lisa/.codex-plugin/skills/lisa-secrets-access/scripts/providers.mjs +90 -2
- package/plugins/lisa/.codex-plugin/skills/lisa-secrets-access/scripts/publish-aws-bootstrap.mjs +307 -53
- package/plugins/lisa/.codex-plugin/skills/lisa-setup-remote-aws/SKILL.md +20 -3
- package/plugins/lisa/skills/lisa-secrets-access/SKILL.md +26 -1
- package/plugins/lisa/skills/lisa-secrets-access/scripts/providers.mjs +90 -2
- package/plugins/lisa/skills/lisa-secrets-access/scripts/publish-aws-bootstrap.mjs +307 -53
- package/plugins/lisa/skills/lisa-setup-remote-aws/SKILL.md +20 -3
- package/plugins/lisa-agy/plugin.json +1 -1
- package/plugins/lisa-agy/skills/lisa-secrets-access/SKILL.md +26 -1
- package/plugins/lisa-agy/skills/lisa-secrets-access/scripts/providers.mjs +90 -2
- package/plugins/lisa-agy/skills/lisa-secrets-access/scripts/publish-aws-bootstrap.mjs +307 -53
- package/plugins/lisa-agy/skills/lisa-setup-remote-aws/SKILL.md +20 -3
- package/plugins/lisa-cdk/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-cdk/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-cdk-agy/plugin.json +1 -1
- package/plugins/lisa-cdk-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-cdk-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-copilot/skills/lisa-secrets-access/SKILL.md +26 -1
- package/plugins/lisa-copilot/skills/lisa-secrets-access/scripts/providers.mjs +90 -2
- package/plugins/lisa-copilot/skills/lisa-secrets-access/scripts/publish-aws-bootstrap.mjs +307 -53
- package/plugins/lisa-copilot/skills/lisa-setup-remote-aws/SKILL.md +20 -3
- package/plugins/lisa-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-cursor/skills/lisa-secrets-access/SKILL.md +26 -1
- package/plugins/lisa-cursor/skills/lisa-secrets-access/scripts/providers.mjs +90 -2
- package/plugins/lisa-cursor/skills/lisa-secrets-access/scripts/publish-aws-bootstrap.mjs +307 -53
- package/plugins/lisa-cursor/skills/lisa-setup-remote-aws/SKILL.md +20 -3
- package/plugins/lisa-expo/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-expo/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-expo-agy/plugin.json +1 -1
- package/plugins/lisa-expo-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-expo-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-harper-fabric/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-harper-fabric/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-harper-fabric-agy/plugin.json +1 -1
- package/plugins/lisa-harper-fabric-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-harper-fabric-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-nestjs/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-nestjs/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-nestjs-agy/plugin.json +1 -1
- package/plugins/lisa-nestjs-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-nestjs-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-openclaw/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-openclaw/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-openclaw-agy/plugin.json +1 -1
- package/plugins/lisa-openclaw-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-openclaw-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-phaser/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-phaser/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-phaser-agy/plugin.json +1 -1
- package/plugins/lisa-phaser-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-phaser-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-rails/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-rails/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-rails-agy/plugin.json +1 -1
- package/plugins/lisa-rails-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-rails-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-typescript/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-typescript/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-typescript-agy/plugin.json +1 -1
- package/plugins/lisa-typescript-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-typescript-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-wiki/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-wiki/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-wiki-agy/plugin.json +1 -1
- package/plugins/lisa-wiki-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-wiki-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/src/base/skills/lisa-secrets-access/SKILL.md +26 -1
- package/plugins/src/base/skills/lisa-secrets-access/scripts/providers.mjs +90 -2
- package/plugins/src/base/skills/lisa-secrets-access/scripts/publish-aws-bootstrap.mjs +307 -53
- package/plugins/src/base/skills/lisa-setup-remote-aws/SKILL.md +20 -3
|
@@ -11,11 +11,13 @@
|
|
|
11
11
|
*
|
|
12
12
|
* This command makes that handoff one fail-closed operation:
|
|
13
13
|
*
|
|
14
|
-
* 1.
|
|
15
|
-
* 2.
|
|
16
|
-
* 3.
|
|
17
|
-
* 4.
|
|
18
|
-
* 5.
|
|
14
|
+
* 1. acquire a provider-backed lock for the exact bundle record;
|
|
15
|
+
* 2. re-read and prove the record is writable with a no-op write;
|
|
16
|
+
* 3. validate the candidate and assume every declared role with it;
|
|
17
|
+
* 4. replace the provider value;
|
|
18
|
+
* 5. read the value back exactly and assume every role again;
|
|
19
|
+
* 6. restore the previous value if a post-write check fails; and
|
|
20
|
+
* 7. release the lock only after publication or rollback is complete.
|
|
19
21
|
*
|
|
20
22
|
* The candidate arrives on stdin so it never appears in a shell command or a
|
|
21
23
|
* temporary file. No credential value is written to stdout or stderr.
|
|
@@ -28,6 +30,7 @@
|
|
|
28
30
|
* @module publish-aws-bootstrap
|
|
29
31
|
*/
|
|
30
32
|
|
|
33
|
+
import { randomUUID } from "node:crypto";
|
|
31
34
|
import { readFileSync, realpathSync } from "node:fs";
|
|
32
35
|
import { fileURLToPath } from "node:url";
|
|
33
36
|
|
|
@@ -36,7 +39,14 @@ import {
|
|
|
36
39
|
parseBootstrap,
|
|
37
40
|
readProfiles,
|
|
38
41
|
} from "./aws-bootstrap.mjs";
|
|
39
|
-
import {
|
|
42
|
+
import {
|
|
43
|
+
COORDINATION_KEY_PREFIX,
|
|
44
|
+
createCoordinationRecord,
|
|
45
|
+
fetchAll,
|
|
46
|
+
fetchRaw as providerFetchRaw,
|
|
47
|
+
removeCoordinationRecord,
|
|
48
|
+
writeSecret,
|
|
49
|
+
} from "./providers.mjs";
|
|
40
50
|
import { readConfig } from "./surfaces.mjs";
|
|
41
51
|
|
|
42
52
|
import { boundedChildOutput } from "../../lisa-setup-workstation/scripts/bounded-child.mjs";
|
|
@@ -44,6 +54,8 @@ import { boundedChildOutput } from "../../lisa-setup-workstation/scripts/bounded
|
|
|
44
54
|
const PROFILE_NAME = /^[\w.@-]+$/;
|
|
45
55
|
const ROLE_ARN =
|
|
46
56
|
/^arn:(?:aws|aws-us-gov|aws-cn):iam::\d{12}:role\/[\w+=,.@/-]+$/;
|
|
57
|
+
const PUBLICATION_COORDINATION_SCOPE = "AWS_BOOTSTRAP_";
|
|
58
|
+
const PUBLICATION_LOCK_TTL_MS = 30 * 60 * 1000;
|
|
47
59
|
|
|
48
60
|
/**
|
|
49
61
|
* Validate and normalize one complete bundle without changing its serialized
|
|
@@ -188,7 +200,7 @@ export function verifyAwsBootstrap(candidate, run = boundedChildOutput) {
|
|
|
188
200
|
* Locate the provider record that owns the bundle.
|
|
189
201
|
* @param {object} cfg Resolved lisa-secrets-access configuration.
|
|
190
202
|
* @param {(cfg: object) => Map<string, object>} fetch Provider reader.
|
|
191
|
-
* @returns {{id: string, value: string}} Writable provider entry.
|
|
203
|
+
* @returns {{id: string, value: string, projectId: string|null}} Writable provider entry.
|
|
192
204
|
*/
|
|
193
205
|
function currentEntry(cfg, fetch) {
|
|
194
206
|
const entry = fetch(cfg).get(BOOTSTRAP_KEY);
|
|
@@ -202,32 +214,272 @@ function currentEntry(cfg, fetch) {
|
|
|
202
214
|
`${BOOTSTRAP_KEY} has no provider identifier, so it cannot be published`
|
|
203
215
|
);
|
|
204
216
|
}
|
|
205
|
-
return {
|
|
217
|
+
return {
|
|
218
|
+
id: entry.id,
|
|
219
|
+
value: entry.value,
|
|
220
|
+
projectId: entry.projectId ?? null,
|
|
221
|
+
};
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function firstLine(error) {
|
|
225
|
+
return (error instanceof Error ? error.message : String(error)).split(
|
|
226
|
+
"\n"
|
|
227
|
+
)[0];
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
function lockPrefix(targetId) {
|
|
231
|
+
return `${COORDINATION_KEY_PREFIX}${PUBLICATION_COORDINATION_SCOPE}${targetId}_`;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function deleteCoordination(cfg, row, fetchRaw, remove) {
|
|
235
|
+
remove(cfg, row.id);
|
|
236
|
+
if (fetchRaw(cfg).some(candidate => candidate.id === row.id)) {
|
|
237
|
+
throw new Error(
|
|
238
|
+
`publication lock ${row.id} is still present after deletion`
|
|
239
|
+
);
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
function writeProviderValue(cfg, id, value, write) {
|
|
244
|
+
try {
|
|
245
|
+
write(cfg, id, value);
|
|
246
|
+
} catch (error) {
|
|
247
|
+
const status =
|
|
248
|
+
typeof error === "object" &&
|
|
249
|
+
error !== null &&
|
|
250
|
+
Number.isInteger(error.status)
|
|
251
|
+
? ` (exit ${error.status})`
|
|
252
|
+
: "";
|
|
253
|
+
// `bws secret edit` has no stdin form, so the value is present in argv and
|
|
254
|
+
// therefore in Node's child-process error message. Never propagate that
|
|
255
|
+
// message: doing so would print the credential this command protects.
|
|
256
|
+
throw new Error(`provider write command failed${status}`);
|
|
257
|
+
}
|
|
206
258
|
}
|
|
207
259
|
|
|
208
260
|
/**
|
|
209
|
-
*
|
|
261
|
+
* Enter the provider election for one AWS bootstrap record.
|
|
262
|
+
*
|
|
263
|
+
* Every publisher creates a unique contender. The provider's creation time,
|
|
264
|
+
* with its immutable id as a tie-breaker, elects exactly one oldest active
|
|
265
|
+
* contender. Unlike a read-then-create mutex, no participant can overwrite or
|
|
266
|
+
* mistake another participant's record for its own.
|
|
210
267
|
* @param {object} cfg Resolved configuration.
|
|
268
|
+
* @param {{id: string, projectId: string|null}} target Target provider entry.
|
|
211
269
|
* @param {object} [operations] Injectable provider operations for tests.
|
|
212
|
-
* @returns {
|
|
270
|
+
* @returns {object} The winning coordination row.
|
|
213
271
|
*/
|
|
214
|
-
export function
|
|
272
|
+
export function acquirePublicationLock(cfg, target, operations = {}) {
|
|
273
|
+
if (!target.projectId) {
|
|
274
|
+
throw new Error(
|
|
275
|
+
`${BOOTSTRAP_KEY} has no provider project identifier, so publication cannot be serialized`
|
|
276
|
+
);
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
const create = operations.createCoordination ?? createCoordinationRecord;
|
|
280
|
+
const fetchRaw = operations.fetchRaw ?? providerFetchRaw;
|
|
281
|
+
const remove = operations.removeCoordination ?? removeCoordinationRecord;
|
|
282
|
+
const holderId = (operations.holderId ?? randomUUID)();
|
|
283
|
+
const prefix = lockPrefix(target.id);
|
|
284
|
+
const key = `${prefix}${holderId}`;
|
|
285
|
+
let contender;
|
|
286
|
+
|
|
287
|
+
try {
|
|
288
|
+
contender = create(
|
|
289
|
+
cfg,
|
|
290
|
+
key,
|
|
291
|
+
target.projectId,
|
|
292
|
+
"Lisa AWS bootstrap publication contender; expires after 30 minutes"
|
|
293
|
+
);
|
|
294
|
+
} catch (error) {
|
|
295
|
+
throw new Error(`publication lock creation failed: ${firstLine(error)}`);
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
if (!contender?.id || contender.key !== key) {
|
|
299
|
+
if (contender?.id) {
|
|
300
|
+
try {
|
|
301
|
+
deleteCoordination(cfg, contender, fetchRaw, remove);
|
|
302
|
+
} catch {
|
|
303
|
+
// The validation failure below remains the actionable root cause.
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
throw new Error("provider returned an invalid publication lock contender");
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
let rows;
|
|
310
|
+
try {
|
|
311
|
+
rows = fetchRaw(cfg).filter(
|
|
312
|
+
row =>
|
|
313
|
+
row.projectId === target.projectId &&
|
|
314
|
+
typeof row.key === "string" &&
|
|
315
|
+
row.key.startsWith(prefix)
|
|
316
|
+
);
|
|
317
|
+
} catch (error) {
|
|
318
|
+
try {
|
|
319
|
+
deleteCoordination(cfg, contender, fetchRaw, remove);
|
|
320
|
+
} catch (cleanupError) {
|
|
321
|
+
throw new Error(
|
|
322
|
+
`publication lock read failed: ${firstLine(error)}; contender cleanup failed: ${firstLine(cleanupError)}`
|
|
323
|
+
);
|
|
324
|
+
}
|
|
325
|
+
throw new Error(`publication lock read failed: ${firstLine(error)}`);
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
const visible = rows.find(row => row.id === contender.id);
|
|
329
|
+
if (!visible) {
|
|
330
|
+
try {
|
|
331
|
+
deleteCoordination(cfg, contender, fetchRaw, remove);
|
|
332
|
+
} catch (cleanupError) {
|
|
333
|
+
throw new Error(
|
|
334
|
+
`the publication lock contender could not be observed after creation; contender cleanup failed: ${firstLine(cleanupError)}`
|
|
335
|
+
);
|
|
336
|
+
}
|
|
337
|
+
throw new Error(
|
|
338
|
+
"the publication lock contender could not be observed after creation"
|
|
339
|
+
);
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
const providerNow = Date.parse(visible.creationDate ?? "");
|
|
343
|
+
if (!Number.isFinite(providerNow)) {
|
|
344
|
+
try {
|
|
345
|
+
deleteCoordination(cfg, contender, fetchRaw, remove);
|
|
346
|
+
} catch (cleanupError) {
|
|
347
|
+
throw new Error(
|
|
348
|
+
`the publication lock contender has no valid provider creation time; contender cleanup failed: ${firstLine(cleanupError)}`
|
|
349
|
+
);
|
|
350
|
+
}
|
|
351
|
+
throw new Error(
|
|
352
|
+
"the publication lock contender has no valid provider creation time"
|
|
353
|
+
);
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
const active = [];
|
|
357
|
+
for (const row of rows) {
|
|
358
|
+
const createdAt = Date.parse(row.creationDate ?? "");
|
|
359
|
+
if (!Number.isFinite(createdAt)) {
|
|
360
|
+
try {
|
|
361
|
+
deleteCoordination(cfg, contender, fetchRaw, remove);
|
|
362
|
+
} catch (cleanupError) {
|
|
363
|
+
throw new Error(
|
|
364
|
+
`publication lock ${row.id ?? "without an id"} has no valid provider creation time; contender cleanup failed: ${firstLine(cleanupError)}`
|
|
365
|
+
);
|
|
366
|
+
}
|
|
367
|
+
throw new Error(
|
|
368
|
+
`publication lock ${row.id ?? "without an id"} has no valid provider creation time`
|
|
369
|
+
);
|
|
370
|
+
}
|
|
371
|
+
// Compare provider time with provider time. A remote host whose wall clock
|
|
372
|
+
// is skewed must not be able to expire another host's active publication.
|
|
373
|
+
if (createdAt <= providerNow - PUBLICATION_LOCK_TTL_MS) {
|
|
374
|
+
try {
|
|
375
|
+
deleteCoordination(cfg, row, fetchRaw, remove);
|
|
376
|
+
} catch (error) {
|
|
377
|
+
throw new Error(
|
|
378
|
+
`expired publication lock cleanup failed: ${firstLine(error)}`
|
|
379
|
+
);
|
|
380
|
+
}
|
|
381
|
+
continue;
|
|
382
|
+
}
|
|
383
|
+
active.push({ row, createdAt });
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
active.sort(
|
|
387
|
+
(left, right) =>
|
|
388
|
+
left.createdAt - right.createdAt ||
|
|
389
|
+
String(left.row.id).localeCompare(String(right.row.id))
|
|
390
|
+
);
|
|
391
|
+
if (active[0]?.row.id !== contender.id) {
|
|
392
|
+
try {
|
|
393
|
+
deleteCoordination(cfg, contender, fetchRaw, remove);
|
|
394
|
+
} catch (error) {
|
|
395
|
+
throw new Error(
|
|
396
|
+
`another publisher holds the provider lock; contender cleanup failed: ${firstLine(error)}`
|
|
397
|
+
);
|
|
398
|
+
}
|
|
399
|
+
throw new Error("another publisher holds the provider lock");
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
return { ...visible, targetId: target.id };
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
/**
|
|
406
|
+
* Delete a publication contender and prove the provider no longer lists it.
|
|
407
|
+
* @param {object} cfg Resolved configuration.
|
|
408
|
+
* @param {{id: string}} lock Acquired coordination row.
|
|
409
|
+
* @param {object} [operations] Injectable provider operations for tests.
|
|
410
|
+
*/
|
|
411
|
+
export function releasePublicationLock(cfg, lock, operations = {}) {
|
|
412
|
+
const fetchRaw = operations.fetchRaw ?? providerFetchRaw;
|
|
413
|
+
const remove = operations.removeCoordination ?? removeCoordinationRecord;
|
|
414
|
+
deleteCoordination(cfg, lock, fetchRaw, remove);
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
/**
|
|
418
|
+
* Hold the provider lock around an entire publication or preflight operation.
|
|
419
|
+
* The target is deliberately refreshed after acquisition so a pre-lock
|
|
420
|
+
* snapshot can never overwrite a publication that finished while this process
|
|
421
|
+
* was entering the election.
|
|
422
|
+
*/
|
|
423
|
+
function withPublicationLock(cfg, operations, task) {
|
|
215
424
|
const fetch = operations.fetch ?? fetchAll;
|
|
216
|
-
const
|
|
217
|
-
const
|
|
425
|
+
const acquire = operations.acquireLock ?? acquirePublicationLock;
|
|
426
|
+
const release = operations.releaseLock ?? releasePublicationLock;
|
|
427
|
+
const target = currentEntry(cfg, fetch);
|
|
428
|
+
const lock = acquire(cfg, target, operations);
|
|
429
|
+
let result;
|
|
430
|
+
let taskError;
|
|
431
|
+
|
|
218
432
|
try {
|
|
219
|
-
|
|
433
|
+
const current = currentEntry(cfg, fetch);
|
|
434
|
+
if (current.id !== target.id || current.projectId !== target.projectId) {
|
|
435
|
+
throw new Error(
|
|
436
|
+
`${BOOTSTRAP_KEY} moved after publication lock acquisition; retry from the new provider record`
|
|
437
|
+
);
|
|
438
|
+
}
|
|
439
|
+
result = task(current);
|
|
440
|
+
} catch (error) {
|
|
441
|
+
taskError = error;
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
try {
|
|
445
|
+
release(cfg, lock, operations);
|
|
446
|
+
} catch (releaseError) {
|
|
447
|
+
const releaseReason = `publication lock release failed: ${firstLine(releaseError)}`;
|
|
448
|
+
if (taskError) {
|
|
449
|
+
throw new Error(`${firstLine(taskError)}; ${releaseReason}`);
|
|
450
|
+
}
|
|
451
|
+
throw new Error(releaseReason);
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
if (taskError) throw taskError;
|
|
455
|
+
return result;
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
function preflightUnderLock(cfg, entry, write) {
|
|
459
|
+
try {
|
|
460
|
+
writeProviderValue(cfg, entry.id, entry.value, write);
|
|
220
461
|
} catch (error) {
|
|
221
|
-
const reason =
|
|
222
|
-
error instanceof Error ? error.message.split("\n")[0] : String(error);
|
|
223
462
|
throw new Error(
|
|
224
|
-
`no write access to ${BOOTSTRAP_KEY}: ${
|
|
463
|
+
`no write access to ${BOOTSTRAP_KEY}: ${firstLine(error)}\n` +
|
|
225
464
|
`Refusing to continue because a rotated key that cannot be published would strand every remote session.`
|
|
226
465
|
);
|
|
227
466
|
}
|
|
228
467
|
return entry;
|
|
229
468
|
}
|
|
230
469
|
|
|
470
|
+
/**
|
|
471
|
+
* Exercise the exact provider write permission without changing its value.
|
|
472
|
+
* @param {object} cfg Resolved configuration.
|
|
473
|
+
* @param {object} [operations] Injectable provider operations for tests.
|
|
474
|
+
* @returns {{id: string, value: string}} Current provider entry.
|
|
475
|
+
*/
|
|
476
|
+
export function preflightAwsBootstrap(cfg, operations = {}) {
|
|
477
|
+
const write = operations.write ?? writeSecret;
|
|
478
|
+
return withPublicationLock(cfg, operations, entry =>
|
|
479
|
+
preflightUnderLock(cfg, entry, write)
|
|
480
|
+
);
|
|
481
|
+
}
|
|
482
|
+
|
|
231
483
|
/**
|
|
232
484
|
* Restore the previous provider value and prove the restoration.
|
|
233
485
|
* @param {object} cfg Resolved configuration.
|
|
@@ -243,7 +495,7 @@ function rollback(cfg, previous, candidateRaw, fetch, write) {
|
|
|
243
495
|
if (current.value !== candidateRaw) {
|
|
244
496
|
return "rollback skipped because the provider changed after this publication";
|
|
245
497
|
}
|
|
246
|
-
|
|
498
|
+
writeProviderValue(cfg, previous.id, previous.value, write);
|
|
247
499
|
const restored = currentEntry(cfg, fetch);
|
|
248
500
|
if (restored.value !== previous.value) {
|
|
249
501
|
return "the previous provider value could not be verified after rollback";
|
|
@@ -268,46 +520,48 @@ export function publishAwsBootstrap(raw, cfg, operations = {}) {
|
|
|
268
520
|
const fetch = operations.fetch ?? fetchAll;
|
|
269
521
|
const write = operations.write ?? writeSecret;
|
|
270
522
|
const verify = operations.verify ?? verifyAwsBootstrap;
|
|
271
|
-
const previous = preflightAwsBootstrap(cfg, { fetch, write });
|
|
272
523
|
const candidate = validateAwsBootstrap(raw);
|
|
524
|
+
return withPublicationLock(cfg, operations, previous => {
|
|
525
|
+
preflightUnderLock(cfg, previous, write);
|
|
273
526
|
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
527
|
+
// Verify before the write. A candidate that cannot assume one of its roles
|
|
528
|
+
// never becomes the value every new remote session receives.
|
|
529
|
+
verify(candidate);
|
|
277
530
|
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
531
|
+
if (candidate.raw === previous.value) {
|
|
532
|
+
return {
|
|
533
|
+
changed: false,
|
|
534
|
+
profiles: candidate.profiles.map(profile => profile.name),
|
|
535
|
+
};
|
|
536
|
+
}
|
|
284
537
|
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
538
|
+
try {
|
|
539
|
+
writeProviderValue(cfg, previous.id, candidate.raw, write);
|
|
540
|
+
const stored = currentEntry(cfg, fetch);
|
|
541
|
+
if (stored.value !== candidate.raw) {
|
|
542
|
+
throw new Error(
|
|
543
|
+
"provider read-back did not match the candidate exactly"
|
|
544
|
+
);
|
|
545
|
+
}
|
|
546
|
+
const storedCandidate = validateAwsBootstrap(stored.value);
|
|
547
|
+
verify(storedCandidate);
|
|
548
|
+
return {
|
|
549
|
+
changed: true,
|
|
550
|
+
profiles: storedCandidate.profiles.map(profile => profile.name),
|
|
551
|
+
};
|
|
552
|
+
} catch (error) {
|
|
553
|
+
const rollbackOutcome = rollback(
|
|
554
|
+
cfg,
|
|
555
|
+
previous,
|
|
556
|
+
candidate.raw,
|
|
557
|
+
fetch,
|
|
558
|
+
write
|
|
559
|
+
);
|
|
560
|
+
throw new Error(
|
|
561
|
+
`${BOOTSTRAP_KEY} publication failed: ${firstLine(error)}; ${rollbackOutcome}`
|
|
562
|
+
);
|
|
290
563
|
}
|
|
291
|
-
|
|
292
|
-
verify(storedCandidate);
|
|
293
|
-
return {
|
|
294
|
-
changed: true,
|
|
295
|
-
profiles: storedCandidate.profiles.map(profile => profile.name),
|
|
296
|
-
};
|
|
297
|
-
} catch (error) {
|
|
298
|
-
const reason =
|
|
299
|
-
error instanceof Error ? error.message.split("\n")[0] : String(error);
|
|
300
|
-
const rollbackOutcome = rollback(
|
|
301
|
-
cfg,
|
|
302
|
-
previous,
|
|
303
|
-
candidate.raw,
|
|
304
|
-
fetch,
|
|
305
|
-
write
|
|
306
|
-
);
|
|
307
|
-
throw new Error(
|
|
308
|
-
`${BOOTSTRAP_KEY} publication failed: ${reason}; ${rollbackOutcome}`
|
|
309
|
-
);
|
|
310
|
-
}
|
|
564
|
+
});
|
|
311
565
|
}
|
|
312
566
|
|
|
313
567
|
/** Read a candidate from stdin without ever placing it in argv. */
|
|
@@ -56,9 +56,12 @@ which is current. Treat the infrastructure secret as a short-lived deployment
|
|
|
56
56
|
emission and the configured provider as the consumer-facing source of truth.
|
|
57
57
|
|
|
58
58
|
Publish the emission as part of the same rotation automation. The publisher
|
|
59
|
-
reads the candidate on stdin,
|
|
60
|
-
|
|
61
|
-
|
|
59
|
+
reads the candidate on stdin, acquires a provider-backed lock for the exact
|
|
60
|
+
bundle record, re-reads the current value after winning, proves every role
|
|
61
|
+
before writing, reads the provider value back exactly, proves every stored role
|
|
62
|
+
again, and restores the previous provider value if a post-write check fails.
|
|
63
|
+
The lock remains held through rollback and its deletion is verified before the
|
|
64
|
+
command reports success:
|
|
62
65
|
|
|
63
66
|
```bash
|
|
64
67
|
aws --profile shared secretsmanager get-secret-value \
|
|
@@ -73,10 +76,24 @@ Resolve the script from the installed runtime or `node_modules` when the
|
|
|
73
76
|
argument or temporary file. A CDK deployment followed later by a human copy is
|
|
74
77
|
not a completed rotation.
|
|
75
78
|
|
|
79
|
+
The current publisher supports Bitwarden writes. It creates a temporary,
|
|
80
|
+
non-secret contender in the bundle's Bitwarden project, elects the oldest
|
|
81
|
+
active contender using provider-issued creation metadata, and removes the
|
|
82
|
+
contender when the transaction finishes. A contender expires after 30 minutes
|
|
83
|
+
so a killed publisher cannot block future rotations forever. The service
|
|
84
|
+
account therefore needs create and delete access as well as read and edit
|
|
85
|
+
access; `preflight` proves that complete lifecycle without changing the bundle.
|
|
86
|
+
|
|
87
|
+
Normal remote-session setup is read-only. It refreshes the already-published
|
|
88
|
+
bundle but does not enter the publisher election or mutate Bitwarden, so two
|
|
89
|
+
containers starting together cannot cause this publication race.
|
|
90
|
+
|
|
76
91
|
**One provider cannot serve this particular secret.** If `secrets.provider` is
|
|
77
92
|
`aws`, reading the bundle requires AWS credentials — and the bundle *is* the AWS
|
|
78
93
|
credential. Anything else works: Bitwarden, 1Password, Doppler, Vault, or a
|
|
79
94
|
platform secret store. Pick the one that is not the thing being bootstrapped.
|
|
95
|
+
Automated publication additionally requires a provider with an implemented
|
|
96
|
+
write and coordination path; that is currently Bitwarden.
|
|
80
97
|
|
|
81
98
|
On a surface that materializes (see `lisa-secrets-access`), the bundle arrives
|
|
82
99
|
in `secrets.env` with everything else, so the project hook can run this setup
|