@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
|
|
@@ -281,7 +281,7 @@ Values never enter that process, so its output cannot leak one even if logged.
|
|
|
281
281
|
|
|
282
282
|
| Provider | Read | Write (rotation only) |
|
|
283
283
|
| --- | --- | --- |
|
|
284
|
-
| `bitwarden` | `bws secret list --output json` → index by `key` | `bws secret edit` |
|
|
284
|
+
| `bitwarden` | `bws secret list --output json` → index by `key` | `bws secret edit`; temporary `create`/`delete` for AWS publication coordination |
|
|
285
285
|
| `doppler` | `doppler secrets download --no-file --format json` | not implemented |
|
|
286
286
|
| `env` | environment only; the provider is the environment | n/a |
|
|
287
287
|
| `1password` | `op read "op://<vault>/<name>/credential"` | not implemented |
|
|
@@ -297,6 +297,31 @@ secret you would otherwise store inside it.
|
|
|
297
297
|
|
|
298
298
|
Unimplemented providers fail with a message naming where to add them, rather than failing obscurely. Do not claim support that does not exist.
|
|
299
299
|
|
|
300
|
+
### AWS bootstrap publication coordination
|
|
301
|
+
|
|
302
|
+
`publish-aws-bootstrap.mjs publish` and `preflight` serialize mutations of
|
|
303
|
+
`LISA_AWS_BOOTSTRAP_JSON` in the provider. Each contender creates a unique,
|
|
304
|
+
non-secret coordination record in the target record's Bitwarden project. The
|
|
305
|
+
oldest active contender wins by provider-issued `creationDate`, with the
|
|
306
|
+
provider id as a deterministic tie-breaker. Later contenders remove themselves
|
|
307
|
+
and stop before the bundle is written.
|
|
308
|
+
|
|
309
|
+
The winner re-reads the target after acquisition, then holds the coordination
|
|
310
|
+
record across the no-op write check, candidate verification, replacement,
|
|
311
|
+
read-back, post-write verification, and any rollback. Cleanup deletes the
|
|
312
|
+
coordination record and proves it no longer appears in the provider. Records
|
|
313
|
+
older than 30 minutes are expired and removed on the next attempt, covering a
|
|
314
|
+
killed host without permitting a permanent lock.
|
|
315
|
+
|
|
316
|
+
Coordination keys start with `LISA_COORDINATION_` and are excluded before
|
|
317
|
+
normal secret selection, so they can never resolve or materialize as
|
|
318
|
+
credentials. Their value is a fixed public sentinel; no credential is copied
|
|
319
|
+
into them. This publication lock is separate from the advisory rotation lease
|
|
320
|
+
record used by `rotate-secret.mjs`.
|
|
321
|
+
|
|
322
|
+
Ordinary session refresh only reads and materializes existing provider values.
|
|
323
|
+
It neither creates coordination records nor writes the AWS bundle.
|
|
324
|
+
|
|
300
325
|
Cache **in-process only**. Never write a resolved value to disk except through the materialization contract above.
|
|
301
326
|
|
|
302
327
|
## Doctor checks worth wiring
|
|
@@ -33,6 +33,17 @@ export const ENV_KEY = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
|
33
33
|
*/
|
|
34
34
|
export const LEASE_KEY = "LISA_ROTATION_LEASES";
|
|
35
35
|
|
|
36
|
+
/**
|
|
37
|
+
* Prefix reserved for provider-side coordination records.
|
|
38
|
+
*
|
|
39
|
+
* These records contain no credential material. They exist only long enough
|
|
40
|
+
* to serialize a provider mutation and must never cross the exposure boundary
|
|
41
|
+
* into a shell, file, hook, or CI environment.
|
|
42
|
+
*/
|
|
43
|
+
export const COORDINATION_KEY_PREFIX = "LISA_COORDINATION_";
|
|
44
|
+
|
|
45
|
+
const COORDINATION_VALUE = "coordination-only";
|
|
46
|
+
|
|
36
47
|
/**
|
|
37
48
|
* The environment variable each provider's CLI reads its own bootstrap from.
|
|
38
49
|
*
|
|
@@ -177,7 +188,7 @@ function fromKeychain(key) {
|
|
|
177
188
|
* caller may see, so the set it returns *is* the permitted set. Restating that
|
|
178
189
|
* as a list in config would duplicate a boundary the provider already enforces.
|
|
179
190
|
* @param {object} cfg Resolved configuration.
|
|
180
|
-
* @returns {Array<{key: string, value: string, note: string, projectId: string|null}>} Rows.
|
|
191
|
+
* @returns {Array<{key: string, value: string, note: string, projectId: string|null, id?: string|null, creationDate?: string|null, revisionDate?: string|null}>} Rows.
|
|
181
192
|
*/
|
|
182
193
|
export function fetchRaw(cfg) {
|
|
183
194
|
const env = providerEnv(cfg);
|
|
@@ -201,6 +212,8 @@ export function fetchRaw(cfg) {
|
|
|
201
212
|
note: s.note ?? "",
|
|
202
213
|
projectId: s.projectId ?? null,
|
|
203
214
|
id: s.id ?? null,
|
|
215
|
+
creationDate: s.creationDate ?? null,
|
|
216
|
+
revisionDate: s.revisionDate ?? null,
|
|
204
217
|
}));
|
|
205
218
|
}
|
|
206
219
|
|
|
@@ -248,7 +261,7 @@ function run(bin, args, env) {
|
|
|
248
261
|
* which is neither stable nor visible at the call site.
|
|
249
262
|
* @param {Array<object>} rows Raw provider rows.
|
|
250
263
|
* @param {{projectIds: string[], excludeKeys: string[]}} narrow Narrowing controls.
|
|
251
|
-
* @returns {Map<string, {value: string, note: string, id: string|null}>} Selected secrets by name.
|
|
264
|
+
* @returns {Map<string, {value: string, note: string, id: string|null, projectId: string|null, creationDate: string|null, revisionDate: string|null}>} Selected secrets by name.
|
|
252
265
|
*/
|
|
253
266
|
export function normalizeRows(
|
|
254
267
|
rows,
|
|
@@ -260,6 +273,12 @@ export function normalizeRows(
|
|
|
260
273
|
|
|
261
274
|
for (const row of rows) {
|
|
262
275
|
if (projects.size && !projects.has(row.projectId)) continue;
|
|
276
|
+
if (
|
|
277
|
+
typeof row.key === "string" &&
|
|
278
|
+
row.key.startsWith(COORDINATION_KEY_PREFIX)
|
|
279
|
+
) {
|
|
280
|
+
continue;
|
|
281
|
+
}
|
|
263
282
|
if (typeof row.key !== "string" || !ENV_KEY.test(row.key)) continue;
|
|
264
283
|
if (excluded.has(row.key)) continue;
|
|
265
284
|
if (out.has(row.key)) {
|
|
@@ -276,6 +295,9 @@ export function normalizeRows(
|
|
|
276
295
|
value: row.value,
|
|
277
296
|
note: row.note ?? "",
|
|
278
297
|
id: row.id ?? null,
|
|
298
|
+
projectId: row.projectId ?? null,
|
|
299
|
+
creationDate: row.creationDate ?? null,
|
|
300
|
+
revisionDate: row.revisionDate ?? null,
|
|
279
301
|
});
|
|
280
302
|
}
|
|
281
303
|
return out;
|
|
@@ -370,3 +392,69 @@ export function writeSecret(cfg, id, value) {
|
|
|
370
392
|
`Rotation requires a proven write path; add one in providers.mjs.`
|
|
371
393
|
);
|
|
372
394
|
}
|
|
395
|
+
|
|
396
|
+
/**
|
|
397
|
+
* Create one provider-issued publication contender in the target project.
|
|
398
|
+
*
|
|
399
|
+
* The deliberately constrained interface accepts no value: coordination
|
|
400
|
+
* records always contain the same public sentinel, which prevents this helper
|
|
401
|
+
* from becoming an alternate credential-write path.
|
|
402
|
+
* @param {object} cfg Resolved configuration.
|
|
403
|
+
* @param {string} key Unique coordination key.
|
|
404
|
+
* @param {string} projectId Project containing the target provider record.
|
|
405
|
+
* @param {string} note Non-sensitive lifecycle metadata.
|
|
406
|
+
* @returns {object} The provider-issued coordination row.
|
|
407
|
+
*/
|
|
408
|
+
export function createCoordinationRecord(cfg, key, projectId, note) {
|
|
409
|
+
const env = providerEnv(cfg);
|
|
410
|
+
|
|
411
|
+
if (cfg.provider === "bitwarden") {
|
|
412
|
+
const raw = run(
|
|
413
|
+
"bws",
|
|
414
|
+
[
|
|
415
|
+
"secret",
|
|
416
|
+
"create",
|
|
417
|
+
key,
|
|
418
|
+
COORDINATION_VALUE,
|
|
419
|
+
projectId,
|
|
420
|
+
"--note",
|
|
421
|
+
note,
|
|
422
|
+
"--output",
|
|
423
|
+
"json",
|
|
424
|
+
],
|
|
425
|
+
env
|
|
426
|
+
);
|
|
427
|
+
const created = JSON.parse(raw);
|
|
428
|
+
return {
|
|
429
|
+
key: created.key,
|
|
430
|
+
value: created.value,
|
|
431
|
+
note: created.note ?? "",
|
|
432
|
+
projectId: created.projectId ?? null,
|
|
433
|
+
id: created.id ?? null,
|
|
434
|
+
creationDate: created.creationDate ?? null,
|
|
435
|
+
revisionDate: created.revisionDate ?? null,
|
|
436
|
+
};
|
|
437
|
+
}
|
|
438
|
+
throw new Error(
|
|
439
|
+
`provider "${cfg.provider}" has no coordination-record creation implemented.\n` +
|
|
440
|
+
`AWS bootstrap publication requires a provider-backed single-writer lock.`
|
|
441
|
+
);
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
/**
|
|
445
|
+
* Remove one provider-side coordination record by its provider identifier.
|
|
446
|
+
* @param {object} cfg Resolved configuration.
|
|
447
|
+
* @param {string} id Provider-side identifier for the coordination record.
|
|
448
|
+
*/
|
|
449
|
+
export function removeCoordinationRecord(cfg, id) {
|
|
450
|
+
const env = providerEnv(cfg);
|
|
451
|
+
|
|
452
|
+
if (cfg.provider === "bitwarden") {
|
|
453
|
+
run("bws", ["secret", "delete", id, "--output", "none"], env);
|
|
454
|
+
return;
|
|
455
|
+
}
|
|
456
|
+
throw new Error(
|
|
457
|
+
`provider "${cfg.provider}" has no coordination-record deletion implemented.\n` +
|
|
458
|
+
`AWS bootstrap publication requires verified lock cleanup.`
|
|
459
|
+
);
|
|
460
|
+
}
|