@codyswann/lisa 4.33.0 → 4.33.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/lib/gate-failure-diagnosis.mjs +74 -0
- package/all/copy-overwrite/scripts/lisa-run-gates.mjs +16 -1
- package/dist/core/lisa-owned-hash-ledger.d.ts.map +1 -1
- package/dist/core/lisa-owned-hash-ledger.js +4 -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 +7 -4
- package/dist/core/upstream-evidence-manifest.js.map +1 -1
- package/package.json +4 -4
- 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/scripts/bootstrap-store.mjs +58 -5
- package/plugins/lisa/.codex-plugin/skills/lisa-secrets-access/scripts/providers.mjs +189 -5
- package/plugins/lisa/skills/lisa-secrets-access/scripts/bootstrap-store.mjs +58 -5
- package/plugins/lisa/skills/lisa-secrets-access/scripts/providers.mjs +189 -5
- package/plugins/lisa-agy/plugin.json +1 -1
- package/plugins/lisa-agy/skills/lisa-secrets-access/scripts/bootstrap-store.mjs +58 -5
- package/plugins/lisa-agy/skills/lisa-secrets-access/scripts/providers.mjs +189 -5
- 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/scripts/bootstrap-store.mjs +58 -5
- package/plugins/lisa-copilot/skills/lisa-secrets-access/scripts/providers.mjs +189 -5
- package/plugins/lisa-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-cursor/skills/lisa-secrets-access/scripts/bootstrap-store.mjs +58 -5
- package/plugins/lisa-cursor/skills/lisa-secrets-access/scripts/providers.mjs +189 -5
- 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/scripts/bootstrap-store.mjs +58 -5
- package/plugins/src/base/skills/lisa-secrets-access/scripts/providers.mjs +189 -5
package/package.json
CHANGED
|
@@ -165,7 +165,7 @@
|
|
|
165
165
|
"zod-validation-error": "^4.0.0"
|
|
166
166
|
},
|
|
167
167
|
"name": "@codyswann/lisa",
|
|
168
|
-
"version": "4.33.
|
|
168
|
+
"version": "4.33.2",
|
|
169
169
|
"description": "Claude Code governance framework that applies guardrails, guidance, and automated enforcement to projects",
|
|
170
170
|
"main": "dist/index.js",
|
|
171
171
|
"exports": {
|
|
@@ -313,7 +313,7 @@
|
|
|
313
313
|
"test": "tests"
|
|
314
314
|
},
|
|
315
315
|
"types": "./dist/index.d.ts",
|
|
316
|
-
"lisaReleaseCommit": "
|
|
317
|
-
"gitHead": "
|
|
318
|
-
"lisaReleaseTag": "v4.33.
|
|
316
|
+
"lisaReleaseCommit": "370d105806460cd105659ff5e60bcab56e020de1",
|
|
317
|
+
"gitHead": "370d105806460cd105659ff5e60bcab56e020de1",
|
|
318
|
+
"lisaReleaseTag": "v4.33.2"
|
|
319
319
|
}
|
|
@@ -22,6 +22,7 @@ import {
|
|
|
22
22
|
chmodSync,
|
|
23
23
|
existsSync,
|
|
24
24
|
mkdirSync,
|
|
25
|
+
readdirSync,
|
|
25
26
|
readFileSync,
|
|
26
27
|
renameSync,
|
|
27
28
|
rmSync,
|
|
@@ -30,6 +31,17 @@ import {
|
|
|
30
31
|
import { homedir } from "node:os";
|
|
31
32
|
import { dirname, join } from "node:path";
|
|
32
33
|
|
|
34
|
+
/**
|
|
35
|
+
* The one grammar a bootstrap key may take: a single safe path segment.
|
|
36
|
+
*
|
|
37
|
+
* Named once and shared by the guard that enforces it on the way in
|
|
38
|
+
* ({@link assertKey}) and the filter that applies it on the way out
|
|
39
|
+
* ({@link listBootstrapFiles}). Two copies of this expression would eventually
|
|
40
|
+
* disagree, and the direction that matters is the lax one: a name accepted by
|
|
41
|
+
* the reader but rejected by the writer is a name this module never wrote.
|
|
42
|
+
*/
|
|
43
|
+
const KEY_GRAMMAR = /^[A-Za-z0-9._-]+$/;
|
|
44
|
+
|
|
33
45
|
/**
|
|
34
46
|
* Where a file-backed bootstrap lives, for platforms with no keychain.
|
|
35
47
|
* @param {string} key Bootstrap variable name.
|
|
@@ -57,11 +69,7 @@ export function bootstrapFile(key, env = process.env) {
|
|
|
57
69
|
* @returns {string} The key, unchanged, when valid.
|
|
58
70
|
*/
|
|
59
71
|
export function assertKey(key) {
|
|
60
|
-
if (
|
|
61
|
-
typeof key !== "string" ||
|
|
62
|
-
!/^[A-Za-z0-9._-]+$/.test(key) ||
|
|
63
|
-
key === ".."
|
|
64
|
-
) {
|
|
72
|
+
if (typeof key !== "string" || !KEY_GRAMMAR.test(key) || key === "..") {
|
|
65
73
|
throw new Error(
|
|
66
74
|
`bootstrap key must be one safe path segment, got ${JSON.stringify(key)}`
|
|
67
75
|
);
|
|
@@ -230,3 +238,48 @@ export function readBootstrapFile(key, env = process.env) {
|
|
|
230
238
|
if (!existsSync(path)) return "";
|
|
231
239
|
return readFileSync(path, "utf8").trim();
|
|
232
240
|
}
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* Every file-backed bootstrap NAME this machine holds. Never a value.
|
|
244
|
+
*
|
|
245
|
+
* Exists so a failed lookup can say what is actually here instead of only what
|
|
246
|
+
* was missing. Those are different sentences: "no bootstrap credential is
|
|
247
|
+
* provisioned" and "one is provisioned under a different name" have different
|
|
248
|
+
* remedies, and a message that cannot tell them apart gets read as the first
|
|
249
|
+
* when it means the second (CodySwannGT/lisa#3555).
|
|
250
|
+
*
|
|
251
|
+
* Absence is empty, never an error. This runs on a path that is already
|
|
252
|
+
* failing, and a diagnostic that throws replaces the real message with its own.
|
|
253
|
+
*
|
|
254
|
+
* Two filters, and both are load-bearing because these names are rendered into
|
|
255
|
+
* an operator-facing message and into a `.lisa.config.json` snippet the
|
|
256
|
+
* operator is invited to paste:
|
|
257
|
+
*
|
|
258
|
+
* - **Grammar.** `readdirSync` returns whatever is in the directory. A name
|
|
259
|
+
* outside {@link KEY_GRAMMAR} is not a key this module would ever have
|
|
260
|
+
* written, and one carrying a quote or a newline would corrupt the suggested
|
|
261
|
+
* JSON rather than merely look odd. The same grammar `assertKey` enforces on
|
|
262
|
+
* the way in is applied on the way out.
|
|
263
|
+
* - **Dotfiles.** The reader is deliberately STRICTER than the writer here.
|
|
264
|
+
* `KEY_GRAMMAR` permits a leading dot, so `assertKey(".DS_Store")` passes —
|
|
265
|
+
* and macOS writes exactly that file into any directory it browses. Listing
|
|
266
|
+
* it would offer the operator a credential named `.DS_Store`. Being stricter
|
|
267
|
+
* on read is the safe direction: it can only omit a name, never invent one.
|
|
268
|
+
* - **Content.** An empty file is not a credential. Offering one sends the
|
|
269
|
+
* operator to a store that will fail the same way a second time — and the
|
|
270
|
+
* environment scan already refuses empty values, so listing empty FILES would
|
|
271
|
+
* have made the two stores disagree about what counts as provisioned.
|
|
272
|
+
* @param {Record<string, string|undefined>} [env] Environment to read.
|
|
273
|
+
* @returns {string[]} Bootstrap names that hold a credential, sorted.
|
|
274
|
+
*/
|
|
275
|
+
export function listBootstrapFiles(env = process.env) {
|
|
276
|
+
try {
|
|
277
|
+
const root = env.XDG_CONFIG_HOME || join(env.HOME || homedir(), ".config");
|
|
278
|
+
return readdirSync(join(root, "lisa", "bootstrap"))
|
|
279
|
+
.filter(name => KEY_GRAMMAR.test(name) && !name.startsWith("."))
|
|
280
|
+
.filter(name => readBootstrapFile(name, env) !== "")
|
|
281
|
+
.sort();
|
|
282
|
+
} catch {
|
|
283
|
+
return [];
|
|
284
|
+
}
|
|
285
|
+
}
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
* @module providers
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
|
-
import { readBootstrapFile } from "./bootstrap-store.mjs";
|
|
12
|
+
import { listBootstrapFiles, readBootstrapFile } from "./bootstrap-store.mjs";
|
|
13
13
|
|
|
14
14
|
import {
|
|
15
15
|
boundedChildOutput,
|
|
@@ -138,10 +138,194 @@ export function bootstrapToken(bootstrap) {
|
|
|
138
138
|
: "";
|
|
139
139
|
if (found) return found;
|
|
140
140
|
}
|
|
141
|
-
throw new Error(
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
141
|
+
throw new Error(describeMissingBootstrap(bootstrap));
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* The name Lisa looked up, reduced to the provider prefix it was built from.
|
|
146
|
+
*
|
|
147
|
+
* `BWS_ACCESS_TOKEN_lisa` → `BWS_ACCESS_TOKEN`. Used to find the SIBLINGS of a
|
|
148
|
+
* missing key: names that differ only in their tenant suffix are the ones an
|
|
149
|
+
* operator can actually point this project at.
|
|
150
|
+
* @param {string} key The bootstrap variable name that was looked up.
|
|
151
|
+
* @returns {string} The provider prefix, or the key when it carries no suffix.
|
|
152
|
+
*/
|
|
153
|
+
function bootstrapPrefix(key) {
|
|
154
|
+
for (const canonical of Object.values(PROVIDER_BOOTSTRAP_ENV)) {
|
|
155
|
+
if (key === canonical || key.startsWith(`${canonical}_`)) return canonical;
|
|
156
|
+
}
|
|
157
|
+
return key;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Keychain SERVICE NAMES on this machine. Never a password.
|
|
162
|
+
*
|
|
163
|
+
* `dump-keychain` without `-d` prints metadata only, so this cannot carry a
|
|
164
|
+
* credential into an error message. Bounded and wrapped: it runs on a path that
|
|
165
|
+
* is already failing, where a throw would replace the real message with its own
|
|
166
|
+
* and a hang would replace it with nothing.
|
|
167
|
+
* @returns {string[]} Service names, or empty when unavailable.
|
|
168
|
+
*/
|
|
169
|
+
function keychainServiceNames() {
|
|
170
|
+
if (process.platform !== "darwin") return [];
|
|
171
|
+
try {
|
|
172
|
+
const dump = boundedChildOutput("security", ["dump-keychain"], {
|
|
173
|
+
encoding: "utf8",
|
|
174
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
175
|
+
timeout: 5000,
|
|
176
|
+
});
|
|
177
|
+
return [...String(dump).matchAll(/"svce"<blob>="([^"]*)"/g)].map(
|
|
178
|
+
([, name]) => name
|
|
179
|
+
);
|
|
180
|
+
} catch {
|
|
181
|
+
// A locked, absent, or oversized keychain leaves the list empty.
|
|
182
|
+
return [];
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Bootstrap NAMES this machine holds under the same provider prefix.
|
|
188
|
+
*
|
|
189
|
+
* Names only — no value is read, and none is ever reported.
|
|
190
|
+
*
|
|
191
|
+
* Every source is injectable because the alternative is a test that asserts
|
|
192
|
+
* against whatever credentials the developer's own workstation happens to hold.
|
|
193
|
+
* Such a test passes or fails by accident, and it fails on the one machine that
|
|
194
|
+
* reproduces the bug (CodySwannGT/lisa#3555).
|
|
195
|
+
* @param {string} prefix Provider bootstrap prefix, e.g. `BWS_ACCESS_TOKEN`.
|
|
196
|
+
* @param {object} [deps] Injected sources for testing.
|
|
197
|
+
* @returns {Array<{name: string, where: string}>} What exists, sorted by name.
|
|
198
|
+
*/
|
|
199
|
+
export function discoverBootstrapNames(prefix, deps = {}) {
|
|
200
|
+
const env = deps.env ?? process.env;
|
|
201
|
+
const matches = name => name === prefix || name.startsWith(`${prefix}_`);
|
|
202
|
+
const found = new Map();
|
|
203
|
+
const note = (name, where) => {
|
|
204
|
+
if (matches(name) && !found.has(name)) found.set(name, where);
|
|
205
|
+
};
|
|
206
|
+
|
|
207
|
+
for (const name of Object.keys(env)) {
|
|
208
|
+
if ((env[name] ?? "").trim()) note(name, "env");
|
|
209
|
+
}
|
|
210
|
+
for (const name of (deps.listFiles ?? listBootstrapFiles)()) {
|
|
211
|
+
note(name, "bootstrap file");
|
|
212
|
+
}
|
|
213
|
+
for (const name of (deps.keychainNames ?? keychainServiceNames)()) {
|
|
214
|
+
note(name, "keychain");
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
return [...found.entries()]
|
|
218
|
+
.map(([name, where]) => ({ name, where }))
|
|
219
|
+
.sort((a, b) => a.name.localeCompare(b.name));
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* Whether the provider's own CLI is resolvable on PATH.
|
|
224
|
+
*
|
|
225
|
+
* Asked because the failure this message replaces was read as a MISSING BINARY
|
|
226
|
+
* by two separate sessions, one of which turned that reading into a standing
|
|
227
|
+
* instruction telling other agents not to bother installing it. The binary was
|
|
228
|
+
* on PATH the whole time. Stating which of the two is true costs one
|
|
229
|
+
* `command -v` and removes the wrong reading entirely.
|
|
230
|
+
*
|
|
231
|
+
* **Presence only, and the message says exactly that.** `command -v` proves the
|
|
232
|
+
* name resolves, never that the binary runs, is the right architecture, or is
|
|
233
|
+
* authorised. Reporting it as "installed and working" would be a second
|
|
234
|
+
* confidently-worded overclaim in the same message whose first overclaim is the
|
|
235
|
+
* defect being fixed. Actually proving "working" means executing the provider
|
|
236
|
+
* CLI on a path that is already failing, which is a cost and a side effect this
|
|
237
|
+
* diagnostic has no business incurring.
|
|
238
|
+
* @param {string} prefix Provider bootstrap prefix.
|
|
239
|
+
* @returns {boolean|null} PATH presence, or null when the provider has no CLI.
|
|
240
|
+
*/
|
|
241
|
+
export function providerCliPresent(prefix) {
|
|
242
|
+
const cli = { BWS_ACCESS_TOKEN: "bws", DOPPLER_TOKEN: "doppler" }[prefix];
|
|
243
|
+
if (!cli) return null;
|
|
244
|
+
try {
|
|
245
|
+
return Boolean(
|
|
246
|
+
String(
|
|
247
|
+
boundedChildOutput("command", ["-v", cli], {
|
|
248
|
+
encoding: "utf8",
|
|
249
|
+
shell: true,
|
|
250
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
251
|
+
timeout: 5000,
|
|
252
|
+
})
|
|
253
|
+
).trim()
|
|
254
|
+
);
|
|
255
|
+
} catch {
|
|
256
|
+
return false;
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* Explain a bootstrap that was not found, distinguishing the two causes.
|
|
262
|
+
*
|
|
263
|
+
* The message this replaces was accurate and still produced a wrong conclusion
|
|
264
|
+
* twice, because it answered only "what was missing" and never "what is here".
|
|
265
|
+
* Lisa looks up `<PREFIX>_<namespace>` where the namespace defaults to the
|
|
266
|
+
* project's own name, so a workstation holding one credential provisioned for
|
|
267
|
+
* one project fails closed in every OTHER project — and reads, from inside
|
|
268
|
+
* those projects, as though the provider were unavailable on the machine.
|
|
269
|
+
* @param {{sources: string[], key: string}} bootstrap Bootstrap config.
|
|
270
|
+
* @param {object} [deps] Injected sources for testing.
|
|
271
|
+
* @returns {string} The operator-facing explanation.
|
|
272
|
+
*/
|
|
273
|
+
export function describeMissingBootstrap(bootstrap, deps = {}) {
|
|
274
|
+
const prefix = bootstrapPrefix(bootstrap.key);
|
|
275
|
+
const siblings = (deps.discover ?? discoverBootstrapNames)(
|
|
276
|
+
prefix,
|
|
277
|
+
deps
|
|
278
|
+
).filter(entry => entry.name !== bootstrap.key);
|
|
279
|
+
const cli = (deps.cliPresent ?? providerCliPresent)(prefix);
|
|
280
|
+
|
|
281
|
+
const lines = [
|
|
282
|
+
`${bootstrap.key} not found in: ${bootstrap.sources.join(", ")}.`,
|
|
283
|
+
`It is the bootstrap credential — without it no other secret can be read.`,
|
|
284
|
+
``,
|
|
285
|
+
];
|
|
286
|
+
|
|
287
|
+
if (siblings.length === 0) {
|
|
288
|
+
lines.push(
|
|
289
|
+
`No bootstrap credential of any name was found on this machine, so none`,
|
|
290
|
+
`is provisioned here yet. Store one with:`,
|
|
291
|
+
``,
|
|
292
|
+
` lisa secrets bootstrap --key ${bootstrap.key}`
|
|
293
|
+
);
|
|
294
|
+
} else {
|
|
295
|
+
lines.push(
|
|
296
|
+
`This is a NAME MISMATCH, not a missing credential. ${siblings.length === 1 ? "A bootstrap credential exists" : "Bootstrap credentials exist"} here`,
|
|
297
|
+
`under ${siblings.length === 1 ? "a different name" : "different names"}:`,
|
|
298
|
+
``,
|
|
299
|
+
...siblings.map(entry => ` ${entry.name} (${entry.where})`),
|
|
300
|
+
``,
|
|
301
|
+
`Lisa looks up ${prefix}_<namespace>, where <namespace> defaults to this`,
|
|
302
|
+
`project's own name — so a credential provisioned for one project is not`,
|
|
303
|
+
`found from any other. Point this project at an existing one by setting`,
|
|
304
|
+
`its name in .lisa.config.json:`,
|
|
305
|
+
``,
|
|
306
|
+
` { "secrets": { "bootstrap": { "key": "${siblings[0].name}" } } }`,
|
|
307
|
+
``,
|
|
308
|
+
`or by exporting ${bootstrap.key} in the environment.`
|
|
309
|
+
);
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
if (cli !== null) {
|
|
313
|
+
lines.push(``);
|
|
314
|
+
lines.push(
|
|
315
|
+
...(cli
|
|
316
|
+
? [
|
|
317
|
+
`The provider CLI was found on PATH, so this is not a missing-binary`,
|
|
318
|
+
`problem and reinstalling it will not help. Only the credential NAME`,
|
|
319
|
+
`is wrong.`,
|
|
320
|
+
]
|
|
321
|
+
: [
|
|
322
|
+
`Separately, the provider CLI was NOT found on PATH. Both that and the`,
|
|
323
|
+
`credential name above need resolving.`,
|
|
324
|
+
])
|
|
325
|
+
);
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
return lines.join("\n");
|
|
145
329
|
}
|
|
146
330
|
|
|
147
331
|
/**
|
|
@@ -22,6 +22,7 @@ import {
|
|
|
22
22
|
chmodSync,
|
|
23
23
|
existsSync,
|
|
24
24
|
mkdirSync,
|
|
25
|
+
readdirSync,
|
|
25
26
|
readFileSync,
|
|
26
27
|
renameSync,
|
|
27
28
|
rmSync,
|
|
@@ -30,6 +31,17 @@ import {
|
|
|
30
31
|
import { homedir } from "node:os";
|
|
31
32
|
import { dirname, join } from "node:path";
|
|
32
33
|
|
|
34
|
+
/**
|
|
35
|
+
* The one grammar a bootstrap key may take: a single safe path segment.
|
|
36
|
+
*
|
|
37
|
+
* Named once and shared by the guard that enforces it on the way in
|
|
38
|
+
* ({@link assertKey}) and the filter that applies it on the way out
|
|
39
|
+
* ({@link listBootstrapFiles}). Two copies of this expression would eventually
|
|
40
|
+
* disagree, and the direction that matters is the lax one: a name accepted by
|
|
41
|
+
* the reader but rejected by the writer is a name this module never wrote.
|
|
42
|
+
*/
|
|
43
|
+
const KEY_GRAMMAR = /^[A-Za-z0-9._-]+$/;
|
|
44
|
+
|
|
33
45
|
/**
|
|
34
46
|
* Where a file-backed bootstrap lives, for platforms with no keychain.
|
|
35
47
|
* @param {string} key Bootstrap variable name.
|
|
@@ -57,11 +69,7 @@ export function bootstrapFile(key, env = process.env) {
|
|
|
57
69
|
* @returns {string} The key, unchanged, when valid.
|
|
58
70
|
*/
|
|
59
71
|
export function assertKey(key) {
|
|
60
|
-
if (
|
|
61
|
-
typeof key !== "string" ||
|
|
62
|
-
!/^[A-Za-z0-9._-]+$/.test(key) ||
|
|
63
|
-
key === ".."
|
|
64
|
-
) {
|
|
72
|
+
if (typeof key !== "string" || !KEY_GRAMMAR.test(key) || key === "..") {
|
|
65
73
|
throw new Error(
|
|
66
74
|
`bootstrap key must be one safe path segment, got ${JSON.stringify(key)}`
|
|
67
75
|
);
|
|
@@ -230,3 +238,48 @@ export function readBootstrapFile(key, env = process.env) {
|
|
|
230
238
|
if (!existsSync(path)) return "";
|
|
231
239
|
return readFileSync(path, "utf8").trim();
|
|
232
240
|
}
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* Every file-backed bootstrap NAME this machine holds. Never a value.
|
|
244
|
+
*
|
|
245
|
+
* Exists so a failed lookup can say what is actually here instead of only what
|
|
246
|
+
* was missing. Those are different sentences: "no bootstrap credential is
|
|
247
|
+
* provisioned" and "one is provisioned under a different name" have different
|
|
248
|
+
* remedies, and a message that cannot tell them apart gets read as the first
|
|
249
|
+
* when it means the second (CodySwannGT/lisa#3555).
|
|
250
|
+
*
|
|
251
|
+
* Absence is empty, never an error. This runs on a path that is already
|
|
252
|
+
* failing, and a diagnostic that throws replaces the real message with its own.
|
|
253
|
+
*
|
|
254
|
+
* Two filters, and both are load-bearing because these names are rendered into
|
|
255
|
+
* an operator-facing message and into a `.lisa.config.json` snippet the
|
|
256
|
+
* operator is invited to paste:
|
|
257
|
+
*
|
|
258
|
+
* - **Grammar.** `readdirSync` returns whatever is in the directory. A name
|
|
259
|
+
* outside {@link KEY_GRAMMAR} is not a key this module would ever have
|
|
260
|
+
* written, and one carrying a quote or a newline would corrupt the suggested
|
|
261
|
+
* JSON rather than merely look odd. The same grammar `assertKey` enforces on
|
|
262
|
+
* the way in is applied on the way out.
|
|
263
|
+
* - **Dotfiles.** The reader is deliberately STRICTER than the writer here.
|
|
264
|
+
* `KEY_GRAMMAR` permits a leading dot, so `assertKey(".DS_Store")` passes —
|
|
265
|
+
* and macOS writes exactly that file into any directory it browses. Listing
|
|
266
|
+
* it would offer the operator a credential named `.DS_Store`. Being stricter
|
|
267
|
+
* on read is the safe direction: it can only omit a name, never invent one.
|
|
268
|
+
* - **Content.** An empty file is not a credential. Offering one sends the
|
|
269
|
+
* operator to a store that will fail the same way a second time — and the
|
|
270
|
+
* environment scan already refuses empty values, so listing empty FILES would
|
|
271
|
+
* have made the two stores disagree about what counts as provisioned.
|
|
272
|
+
* @param {Record<string, string|undefined>} [env] Environment to read.
|
|
273
|
+
* @returns {string[]} Bootstrap names that hold a credential, sorted.
|
|
274
|
+
*/
|
|
275
|
+
export function listBootstrapFiles(env = process.env) {
|
|
276
|
+
try {
|
|
277
|
+
const root = env.XDG_CONFIG_HOME || join(env.HOME || homedir(), ".config");
|
|
278
|
+
return readdirSync(join(root, "lisa", "bootstrap"))
|
|
279
|
+
.filter(name => KEY_GRAMMAR.test(name) && !name.startsWith("."))
|
|
280
|
+
.filter(name => readBootstrapFile(name, env) !== "")
|
|
281
|
+
.sort();
|
|
282
|
+
} catch {
|
|
283
|
+
return [];
|
|
284
|
+
}
|
|
285
|
+
}
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
* @module providers
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
|
-
import { readBootstrapFile } from "./bootstrap-store.mjs";
|
|
12
|
+
import { listBootstrapFiles, readBootstrapFile } from "./bootstrap-store.mjs";
|
|
13
13
|
|
|
14
14
|
import {
|
|
15
15
|
boundedChildOutput,
|
|
@@ -138,10 +138,194 @@ export function bootstrapToken(bootstrap) {
|
|
|
138
138
|
: "";
|
|
139
139
|
if (found) return found;
|
|
140
140
|
}
|
|
141
|
-
throw new Error(
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
141
|
+
throw new Error(describeMissingBootstrap(bootstrap));
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* The name Lisa looked up, reduced to the provider prefix it was built from.
|
|
146
|
+
*
|
|
147
|
+
* `BWS_ACCESS_TOKEN_lisa` → `BWS_ACCESS_TOKEN`. Used to find the SIBLINGS of a
|
|
148
|
+
* missing key: names that differ only in their tenant suffix are the ones an
|
|
149
|
+
* operator can actually point this project at.
|
|
150
|
+
* @param {string} key The bootstrap variable name that was looked up.
|
|
151
|
+
* @returns {string} The provider prefix, or the key when it carries no suffix.
|
|
152
|
+
*/
|
|
153
|
+
function bootstrapPrefix(key) {
|
|
154
|
+
for (const canonical of Object.values(PROVIDER_BOOTSTRAP_ENV)) {
|
|
155
|
+
if (key === canonical || key.startsWith(`${canonical}_`)) return canonical;
|
|
156
|
+
}
|
|
157
|
+
return key;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Keychain SERVICE NAMES on this machine. Never a password.
|
|
162
|
+
*
|
|
163
|
+
* `dump-keychain` without `-d` prints metadata only, so this cannot carry a
|
|
164
|
+
* credential into an error message. Bounded and wrapped: it runs on a path that
|
|
165
|
+
* is already failing, where a throw would replace the real message with its own
|
|
166
|
+
* and a hang would replace it with nothing.
|
|
167
|
+
* @returns {string[]} Service names, or empty when unavailable.
|
|
168
|
+
*/
|
|
169
|
+
function keychainServiceNames() {
|
|
170
|
+
if (process.platform !== "darwin") return [];
|
|
171
|
+
try {
|
|
172
|
+
const dump = boundedChildOutput("security", ["dump-keychain"], {
|
|
173
|
+
encoding: "utf8",
|
|
174
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
175
|
+
timeout: 5000,
|
|
176
|
+
});
|
|
177
|
+
return [...String(dump).matchAll(/"svce"<blob>="([^"]*)"/g)].map(
|
|
178
|
+
([, name]) => name
|
|
179
|
+
);
|
|
180
|
+
} catch {
|
|
181
|
+
// A locked, absent, or oversized keychain leaves the list empty.
|
|
182
|
+
return [];
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Bootstrap NAMES this machine holds under the same provider prefix.
|
|
188
|
+
*
|
|
189
|
+
* Names only — no value is read, and none is ever reported.
|
|
190
|
+
*
|
|
191
|
+
* Every source is injectable because the alternative is a test that asserts
|
|
192
|
+
* against whatever credentials the developer's own workstation happens to hold.
|
|
193
|
+
* Such a test passes or fails by accident, and it fails on the one machine that
|
|
194
|
+
* reproduces the bug (CodySwannGT/lisa#3555).
|
|
195
|
+
* @param {string} prefix Provider bootstrap prefix, e.g. `BWS_ACCESS_TOKEN`.
|
|
196
|
+
* @param {object} [deps] Injected sources for testing.
|
|
197
|
+
* @returns {Array<{name: string, where: string}>} What exists, sorted by name.
|
|
198
|
+
*/
|
|
199
|
+
export function discoverBootstrapNames(prefix, deps = {}) {
|
|
200
|
+
const env = deps.env ?? process.env;
|
|
201
|
+
const matches = name => name === prefix || name.startsWith(`${prefix}_`);
|
|
202
|
+
const found = new Map();
|
|
203
|
+
const note = (name, where) => {
|
|
204
|
+
if (matches(name) && !found.has(name)) found.set(name, where);
|
|
205
|
+
};
|
|
206
|
+
|
|
207
|
+
for (const name of Object.keys(env)) {
|
|
208
|
+
if ((env[name] ?? "").trim()) note(name, "env");
|
|
209
|
+
}
|
|
210
|
+
for (const name of (deps.listFiles ?? listBootstrapFiles)()) {
|
|
211
|
+
note(name, "bootstrap file");
|
|
212
|
+
}
|
|
213
|
+
for (const name of (deps.keychainNames ?? keychainServiceNames)()) {
|
|
214
|
+
note(name, "keychain");
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
return [...found.entries()]
|
|
218
|
+
.map(([name, where]) => ({ name, where }))
|
|
219
|
+
.sort((a, b) => a.name.localeCompare(b.name));
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* Whether the provider's own CLI is resolvable on PATH.
|
|
224
|
+
*
|
|
225
|
+
* Asked because the failure this message replaces was read as a MISSING BINARY
|
|
226
|
+
* by two separate sessions, one of which turned that reading into a standing
|
|
227
|
+
* instruction telling other agents not to bother installing it. The binary was
|
|
228
|
+
* on PATH the whole time. Stating which of the two is true costs one
|
|
229
|
+
* `command -v` and removes the wrong reading entirely.
|
|
230
|
+
*
|
|
231
|
+
* **Presence only, and the message says exactly that.** `command -v` proves the
|
|
232
|
+
* name resolves, never that the binary runs, is the right architecture, or is
|
|
233
|
+
* authorised. Reporting it as "installed and working" would be a second
|
|
234
|
+
* confidently-worded overclaim in the same message whose first overclaim is the
|
|
235
|
+
* defect being fixed. Actually proving "working" means executing the provider
|
|
236
|
+
* CLI on a path that is already failing, which is a cost and a side effect this
|
|
237
|
+
* diagnostic has no business incurring.
|
|
238
|
+
* @param {string} prefix Provider bootstrap prefix.
|
|
239
|
+
* @returns {boolean|null} PATH presence, or null when the provider has no CLI.
|
|
240
|
+
*/
|
|
241
|
+
export function providerCliPresent(prefix) {
|
|
242
|
+
const cli = { BWS_ACCESS_TOKEN: "bws", DOPPLER_TOKEN: "doppler" }[prefix];
|
|
243
|
+
if (!cli) return null;
|
|
244
|
+
try {
|
|
245
|
+
return Boolean(
|
|
246
|
+
String(
|
|
247
|
+
boundedChildOutput("command", ["-v", cli], {
|
|
248
|
+
encoding: "utf8",
|
|
249
|
+
shell: true,
|
|
250
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
251
|
+
timeout: 5000,
|
|
252
|
+
})
|
|
253
|
+
).trim()
|
|
254
|
+
);
|
|
255
|
+
} catch {
|
|
256
|
+
return false;
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* Explain a bootstrap that was not found, distinguishing the two causes.
|
|
262
|
+
*
|
|
263
|
+
* The message this replaces was accurate and still produced a wrong conclusion
|
|
264
|
+
* twice, because it answered only "what was missing" and never "what is here".
|
|
265
|
+
* Lisa looks up `<PREFIX>_<namespace>` where the namespace defaults to the
|
|
266
|
+
* project's own name, so a workstation holding one credential provisioned for
|
|
267
|
+
* one project fails closed in every OTHER project — and reads, from inside
|
|
268
|
+
* those projects, as though the provider were unavailable on the machine.
|
|
269
|
+
* @param {{sources: string[], key: string}} bootstrap Bootstrap config.
|
|
270
|
+
* @param {object} [deps] Injected sources for testing.
|
|
271
|
+
* @returns {string} The operator-facing explanation.
|
|
272
|
+
*/
|
|
273
|
+
export function describeMissingBootstrap(bootstrap, deps = {}) {
|
|
274
|
+
const prefix = bootstrapPrefix(bootstrap.key);
|
|
275
|
+
const siblings = (deps.discover ?? discoverBootstrapNames)(
|
|
276
|
+
prefix,
|
|
277
|
+
deps
|
|
278
|
+
).filter(entry => entry.name !== bootstrap.key);
|
|
279
|
+
const cli = (deps.cliPresent ?? providerCliPresent)(prefix);
|
|
280
|
+
|
|
281
|
+
const lines = [
|
|
282
|
+
`${bootstrap.key} not found in: ${bootstrap.sources.join(", ")}.`,
|
|
283
|
+
`It is the bootstrap credential — without it no other secret can be read.`,
|
|
284
|
+
``,
|
|
285
|
+
];
|
|
286
|
+
|
|
287
|
+
if (siblings.length === 0) {
|
|
288
|
+
lines.push(
|
|
289
|
+
`No bootstrap credential of any name was found on this machine, so none`,
|
|
290
|
+
`is provisioned here yet. Store one with:`,
|
|
291
|
+
``,
|
|
292
|
+
` lisa secrets bootstrap --key ${bootstrap.key}`
|
|
293
|
+
);
|
|
294
|
+
} else {
|
|
295
|
+
lines.push(
|
|
296
|
+
`This is a NAME MISMATCH, not a missing credential. ${siblings.length === 1 ? "A bootstrap credential exists" : "Bootstrap credentials exist"} here`,
|
|
297
|
+
`under ${siblings.length === 1 ? "a different name" : "different names"}:`,
|
|
298
|
+
``,
|
|
299
|
+
...siblings.map(entry => ` ${entry.name} (${entry.where})`),
|
|
300
|
+
``,
|
|
301
|
+
`Lisa looks up ${prefix}_<namespace>, where <namespace> defaults to this`,
|
|
302
|
+
`project's own name — so a credential provisioned for one project is not`,
|
|
303
|
+
`found from any other. Point this project at an existing one by setting`,
|
|
304
|
+
`its name in .lisa.config.json:`,
|
|
305
|
+
``,
|
|
306
|
+
` { "secrets": { "bootstrap": { "key": "${siblings[0].name}" } } }`,
|
|
307
|
+
``,
|
|
308
|
+
`or by exporting ${bootstrap.key} in the environment.`
|
|
309
|
+
);
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
if (cli !== null) {
|
|
313
|
+
lines.push(``);
|
|
314
|
+
lines.push(
|
|
315
|
+
...(cli
|
|
316
|
+
? [
|
|
317
|
+
`The provider CLI was found on PATH, so this is not a missing-binary`,
|
|
318
|
+
`problem and reinstalling it will not help. Only the credential NAME`,
|
|
319
|
+
`is wrong.`,
|
|
320
|
+
]
|
|
321
|
+
: [
|
|
322
|
+
`Separately, the provider CLI was NOT found on PATH. Both that and the`,
|
|
323
|
+
`credential name above need resolving.`,
|
|
324
|
+
])
|
|
325
|
+
);
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
return lines.join("\n");
|
|
145
329
|
}
|
|
146
330
|
|
|
147
331
|
/**
|