@codyswann/lisa 3.16.0 → 3.17.0
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-gates.mjs +1165 -0
- package/all/copy-overwrite/scripts/lisa-reconcile-policy.mjs +1188 -0
- package/all/copy-overwrite/scripts/lisa-run-gates.mjs +597 -0
- package/dist/core/lisa-owned-hash-ledger.d.ts.map +1 -1
- package/dist/core/lisa-owned-hash-ledger.js +24 -0
- package/dist/core/lisa-owned-hash-ledger.js.map +1 -1
- package/dist/core/upstream-evidence-manifest.d.ts.map +1 -1
- package/dist/core/upstream-evidence-manifest.js +72 -8
- package/dist/core/upstream-evidence-manifest.js.map +1 -1
- package/package.json +6 -2
- package/plugins/lisa/.claude-plugin/plugin.json +10 -1
- package/plugins/lisa/.codex-plugin/hooks.json +9 -0
- package/plugins/lisa/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa/.codex-plugin/skills/lisa-doctor/SKILL.md +108 -2
- package/plugins/lisa/.codex-plugin/skills/lisa-secrets-access/scripts/preflight-secrets.mjs +324 -0
- package/plugins/lisa/.codex-plugin/skills/lisa-secrets-access/scripts/routing-floor.mjs +153 -0
- package/plugins/lisa/.codex-plugin/skills/lisa-secrets-access/scripts/surfaces.mjs +55 -7
- package/plugins/lisa/.codex-plugin/skills/lisa-secrets-access/scripts/validate-config.mjs +89 -17
- package/plugins/lisa/.codex-plugin/skills/lisa-setup-remote-env/scripts/preflight-tools.mjs +242 -0
- package/plugins/lisa/.codex-plugin/skills/lisa-setup-remote-env/scripts/tool-floor.mjs +94 -0
- package/plugins/lisa/.codex-plugin/skills/lisa-setup-remote-env/scripts/verify-remote-env.mjs +14 -32
- package/plugins/lisa/hooks/secrets-preflight.sh +72 -0
- package/plugins/lisa/skills/lisa-doctor/SKILL.md +108 -2
- package/plugins/lisa/skills/lisa-secrets-access/scripts/preflight-secrets.mjs +324 -0
- package/plugins/lisa/skills/lisa-secrets-access/scripts/routing-floor.mjs +153 -0
- package/plugins/lisa/skills/lisa-secrets-access/scripts/surfaces.mjs +55 -7
- package/plugins/lisa/skills/lisa-secrets-access/scripts/validate-config.mjs +89 -17
- package/plugins/lisa/skills/lisa-setup-remote-env/scripts/preflight-tools.mjs +242 -0
- package/plugins/lisa/skills/lisa-setup-remote-env/scripts/tool-floor.mjs +94 -0
- package/plugins/lisa/skills/lisa-setup-remote-env/scripts/verify-remote-env.mjs +14 -32
- package/plugins/lisa-agy/plugin.json +1 -1
- package/plugins/lisa-agy/skills/lisa-doctor/SKILL.md +108 -2
- package/plugins/lisa-agy/skills/lisa-secrets-access/scripts/preflight-secrets.mjs +324 -0
- package/plugins/lisa-agy/skills/lisa-secrets-access/scripts/routing-floor.mjs +153 -0
- package/plugins/lisa-agy/skills/lisa-secrets-access/scripts/surfaces.mjs +55 -7
- package/plugins/lisa-agy/skills/lisa-secrets-access/scripts/validate-config.mjs +89 -17
- package/plugins/lisa-agy/skills/lisa-setup-remote-env/scripts/preflight-tools.mjs +242 -0
- package/plugins/lisa-agy/skills/lisa-setup-remote-env/scripts/tool-floor.mjs +94 -0
- package/plugins/lisa-agy/skills/lisa-setup-remote-env/scripts/verify-remote-env.mjs +14 -32
- 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 +10 -1
- package/plugins/lisa-copilot/hooks/secrets-preflight.sh +72 -0
- package/plugins/lisa-copilot/skills/lisa-doctor/SKILL.md +108 -2
- package/plugins/lisa-copilot/skills/lisa-secrets-access/scripts/preflight-secrets.mjs +324 -0
- package/plugins/lisa-copilot/skills/lisa-secrets-access/scripts/routing-floor.mjs +153 -0
- package/plugins/lisa-copilot/skills/lisa-secrets-access/scripts/surfaces.mjs +55 -7
- package/plugins/lisa-copilot/skills/lisa-secrets-access/scripts/validate-config.mjs +89 -17
- package/plugins/lisa-copilot/skills/lisa-setup-remote-env/scripts/preflight-tools.mjs +242 -0
- package/plugins/lisa-copilot/skills/lisa-setup-remote-env/scripts/tool-floor.mjs +94 -0
- package/plugins/lisa-copilot/skills/lisa-setup-remote-env/scripts/verify-remote-env.mjs +14 -32
- package/plugins/lisa-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-cursor/hooks/hooks.json +3 -0
- package/plugins/lisa-cursor/hooks/secrets-preflight.sh +72 -0
- package/plugins/lisa-cursor/skills/lisa-doctor/SKILL.md +108 -2
- package/plugins/lisa-cursor/skills/lisa-secrets-access/scripts/preflight-secrets.mjs +324 -0
- package/plugins/lisa-cursor/skills/lisa-secrets-access/scripts/routing-floor.mjs +153 -0
- package/plugins/lisa-cursor/skills/lisa-secrets-access/scripts/surfaces.mjs +55 -7
- package/plugins/lisa-cursor/skills/lisa-secrets-access/scripts/validate-config.mjs +89 -17
- package/plugins/lisa-cursor/skills/lisa-setup-remote-env/scripts/preflight-tools.mjs +242 -0
- package/plugins/lisa-cursor/skills/lisa-setup-remote-env/scripts/tool-floor.mjs +94 -0
- package/plugins/lisa-cursor/skills/lisa-setup-remote-env/scripts/verify-remote-env.mjs +14 -32
- 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/.claude-plugin/plugin.json +9 -0
- package/plugins/src/base/hooks/secrets-preflight.sh +72 -0
- package/plugins/src/base/skills/lisa-doctor/SKILL.md +108 -2
- package/plugins/src/base/skills/lisa-secrets-access/scripts/preflight-secrets.mjs +324 -0
- package/plugins/src/base/skills/lisa-secrets-access/scripts/routing-floor.mjs +153 -0
- package/plugins/src/base/skills/lisa-secrets-access/scripts/surfaces.mjs +55 -7
- package/plugins/src/base/skills/lisa-secrets-access/scripts/validate-config.mjs +89 -17
- package/plugins/src/base/skills/lisa-setup-remote-env/scripts/preflight-tools.mjs +242 -0
- package/plugins/src/base/skills/lisa-setup-remote-env/scripts/tool-floor.mjs +94 -0
- package/plugins/src/base/skills/lisa-setup-remote-env/scripts/verify-remote-env.mjs +14 -32
- package/scripts/generate-lisa-owned-hash-ledger.mjs +10 -1
- package/scripts/lib/per-agent-hook-filter.mjs +18 -0
- package/typescript/copy-contents/.husky/pre-commit +130 -21
- package/typescript/copy-contents/.husky/pre-push +202 -22
|
@@ -0,0 +1,324 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Prove, before an agent starts work, that the credentials it needs resolve.
|
|
3
|
+
*
|
|
4
|
+
* The contract has always claimed this. `secrets.require` is documented as an
|
|
5
|
+
* assertion — "a listed name that does not resolve is a startup error, not a
|
|
6
|
+
* late surprise" — and that sentence is the literal text of an error message in
|
|
7
|
+
* `doctor-secrets.mjs`. But nothing ran it at startup. The only caller of
|
|
8
|
+
* either presence check was a bash line inside `lisa-doctor`'s SKILL.md, which
|
|
9
|
+
* an agent had to read and choose to execute. The guarantee was prose; the
|
|
10
|
+
* plumbing to deliver it did not exist.
|
|
11
|
+
*
|
|
12
|
+
* This module is the missing caller, and it answers the question the gate
|
|
13
|
+
* actually asks. `AGENTS.md` requires intake to establish that the factory has
|
|
14
|
+
* the tooling "*and provable access to that tooling*" before accepting work. A
|
|
15
|
+
* credential that resolves nowhere is precisely a failure of provable access,
|
|
16
|
+
* and discovering it forty minutes into a build — after a ticket is claimed and
|
|
17
|
+
* a branch is cut — converts a clean refusal into abandoned half-work.
|
|
18
|
+
*
|
|
19
|
+
* **Three verdicts, and the third is the point.** `ok` and `missing` are
|
|
20
|
+
* obvious. `unreachable` is what happens when the provider itself cannot be
|
|
21
|
+
* asked: no CLI installed, no bootstrap token, an API that errored. That state
|
|
22
|
+
* must never collapse into either neighbour. Folded into `ok` it is a vacuous
|
|
23
|
+
* green — the check reports clean precisely when it learned nothing, which is
|
|
24
|
+
* the failure mode that lets a suppressed error read as a measured zero. Folded
|
|
25
|
+
* into `missing` it blames the vault for a fault in the caller's access, and
|
|
26
|
+
* sends whoever reads it to grant a credential that was never absent.
|
|
27
|
+
*
|
|
28
|
+
* Both non-ok verdicts fail. They are distinguished in the *message*, not the
|
|
29
|
+
* exit code, because the remediation differs completely: `missing` means extend
|
|
30
|
+
* the grant in the vault, `unreachable` means fix this machine's access to it.
|
|
31
|
+
* @module preflight-secrets
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
import { execFileSync } from "node:child_process";
|
|
35
|
+
import { realpathSync } from "node:fs";
|
|
36
|
+
import { fileURLToPath } from "node:url";
|
|
37
|
+
|
|
38
|
+
import { fetchAll } from "./providers.mjs";
|
|
39
|
+
import { readConfig } from "./surfaces.mjs";
|
|
40
|
+
import { readMaterialized } from "./resolve-secret.mjs";
|
|
41
|
+
import {
|
|
42
|
+
routingFloorReasons,
|
|
43
|
+
SUBSTITUTE_SUBSTRATES,
|
|
44
|
+
} from "./routing-floor.mjs";
|
|
45
|
+
|
|
46
|
+
/** Verdicts this check can reach, worst last. */
|
|
47
|
+
export const VERDICTS = ["ok", "missing", "unreachable"];
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* The names that must resolve on this surface, and why each is required.
|
|
51
|
+
*
|
|
52
|
+
* The floor is *unioned* with `require` rather than checked against it. A
|
|
53
|
+
* project should not have to restate `GH_TOKEN` to be protected by a rule its
|
|
54
|
+
* own `tracker: "github"` already implies, and a required set that depends on
|
|
55
|
+
* someone having typed it is exactly the hand-maintained list this replaces.
|
|
56
|
+
*
|
|
57
|
+
* `require` keeps its second job untouched. It still narrows resolution — a
|
|
58
|
+
* name outside it cannot be fetched — and the floor deliberately does not feed
|
|
59
|
+
* that. Unioning the floor into `cfg.require` would switch narrowing *on* for
|
|
60
|
+
* every project that never opted into it, and every credential they resolve
|
|
61
|
+
* today that is not in the floor would start throwing.
|
|
62
|
+
* @param {object} cfg Resolved configuration.
|
|
63
|
+
* @returns {Array<{name: string, reasons: string[]}>} Required names, sorted.
|
|
64
|
+
*/
|
|
65
|
+
export function requiredNames(cfg) {
|
|
66
|
+
const reasons = routingFloorReasons(cfg.routing ?? {});
|
|
67
|
+
const declared = cfg.require ?? [];
|
|
68
|
+
const names = [...new Set([...(cfg.requiredFloor ?? []), ...declared])];
|
|
69
|
+
return names
|
|
70
|
+
.sort((left, right) => left.localeCompare(right))
|
|
71
|
+
.map(name => ({ name, reasons: whyRequired(name, reasons, declared) }));
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Why one name is required, never empty.
|
|
76
|
+
*
|
|
77
|
+
* `report` renders this as "NAME — required because <reasons>", so an empty
|
|
78
|
+
* list prints a sentence that stops mid-clause and tells the operator nothing
|
|
79
|
+
* about which line of config to change. A name can reach the required set
|
|
80
|
+
* through the floor without a routing reason — anything that hands `preflight`
|
|
81
|
+
* a `requiredFloor` it did not derive from `routing` does exactly that — so the
|
|
82
|
+
* renderer needs a cause it can always print.
|
|
83
|
+
* @param {string} name The credential name.
|
|
84
|
+
* @param {Record<string, string[]>} reasons Routing-derived causes.
|
|
85
|
+
* @param {string[]} declared Names listed in `secrets.require`.
|
|
86
|
+
* @returns {string[]} At least one reason.
|
|
87
|
+
*/
|
|
88
|
+
function whyRequired(name, reasons, declared) {
|
|
89
|
+
const routingReasons = Object.hasOwn(reasons, name) ? reasons[name] : [];
|
|
90
|
+
const causes = [
|
|
91
|
+
...(routingReasons ?? []),
|
|
92
|
+
...(declared.includes(name) ? ["declared in secrets.require"] : []),
|
|
93
|
+
];
|
|
94
|
+
return causes.length
|
|
95
|
+
? causes
|
|
96
|
+
: ["it is in this project's resolved secrets floor"];
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Check every required credential against the resolution ladder.
|
|
101
|
+
*
|
|
102
|
+
* Values are never returned, printed, or written anywhere by this function. It
|
|
103
|
+
* reports names and whether each resolved, which is all a readiness check needs
|
|
104
|
+
* and the only shape safe to surface into an agent's context.
|
|
105
|
+
* @param {object} [cfg] Resolved configuration.
|
|
106
|
+
* @param {Function} [fetch] Provider view factory, injected for tests.
|
|
107
|
+
* @param {Function} [materialized] Materialized view factory, for tests.
|
|
108
|
+
* @param {Record<string, string|undefined>} [env] Environment to inspect.
|
|
109
|
+
* @param {Function} [probe] Substrate probe runner, injected for tests.
|
|
110
|
+
* @returns {{verdict: string, required: Array<{name: string, reasons: string[]}>, missing: Array<{name: string, reasons: string[]}>, reason: string|null}}
|
|
111
|
+
*/
|
|
112
|
+
export function preflight(
|
|
113
|
+
cfg = readConfig(),
|
|
114
|
+
fetch = fetchAll,
|
|
115
|
+
materialized = readMaterialized,
|
|
116
|
+
env = process.env,
|
|
117
|
+
probe = runProbe
|
|
118
|
+
) {
|
|
119
|
+
const required = requiredNames(cfg);
|
|
120
|
+
if (!required.length) {
|
|
121
|
+
return { verdict: "ok", required, missing: [], reason: null };
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const file = safely(() => materialized(cfg)) ?? new Map();
|
|
125
|
+
|
|
126
|
+
// Env and the materialized file are consulted before the provider, and a
|
|
127
|
+
// required name satisfied by either needs no provider call at all. A session
|
|
128
|
+
// whose credentials are already exported is the common case on CI, and paying
|
|
129
|
+
// a network round-trip to confirm what is already in the environment would
|
|
130
|
+
// make the cheapest surface the slowest.
|
|
131
|
+
const unresolvedLocally = required.filter(
|
|
132
|
+
({ name }) =>
|
|
133
|
+
!present(env[name]) && !file.get(name) && !substrateSatisfies(name, probe)
|
|
134
|
+
);
|
|
135
|
+
if (!unresolvedLocally.length) {
|
|
136
|
+
return { verdict: "ok", required, missing: [], reason: null };
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
let provider;
|
|
140
|
+
try {
|
|
141
|
+
provider = fetch(cfg);
|
|
142
|
+
} catch (err) {
|
|
143
|
+
return {
|
|
144
|
+
verdict: "unreachable",
|
|
145
|
+
required,
|
|
146
|
+
missing: unresolvedLocally,
|
|
147
|
+
reason: err.message,
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const missing = unresolvedLocally.filter(
|
|
152
|
+
({ name }) => !present(provider.get(name)?.value)
|
|
153
|
+
);
|
|
154
|
+
return {
|
|
155
|
+
verdict: missing.length ? "missing" : "ok",
|
|
156
|
+
required,
|
|
157
|
+
missing,
|
|
158
|
+
reason: null,
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Render a verdict for whoever has to act on it.
|
|
164
|
+
*
|
|
165
|
+
* Written for a non-technical operator standing at the gate, per `AGENTS.md`:
|
|
166
|
+
* everything crossing a gate outward must be readable by the person being asked
|
|
167
|
+
* to decide. So each line names the credential, why it is required, and which
|
|
168
|
+
* of the two remedies applies.
|
|
169
|
+
* @param {object} result A {@link preflight} result.
|
|
170
|
+
* @param {object} cfg Resolved configuration.
|
|
171
|
+
* @returns {string} Operator-readable report, empty when nothing needs saying.
|
|
172
|
+
*/
|
|
173
|
+
export function report(result, cfg) {
|
|
174
|
+
if (result.verdict === "ok") return "";
|
|
175
|
+
|
|
176
|
+
const lines = [];
|
|
177
|
+
if (result.verdict === "unreachable") {
|
|
178
|
+
lines.push(
|
|
179
|
+
`Secrets preflight could NOT be completed on surface "${cfg.surface}".`,
|
|
180
|
+
`The "${cfg.provider}" provider could not be reached, so nothing is`,
|
|
181
|
+
`known about any credential — this is not a report that they are fine.`,
|
|
182
|
+
``,
|
|
183
|
+
` reason: ${result.reason}`,
|
|
184
|
+
``,
|
|
185
|
+
`Fix this machine's access to the provider (bootstrap token, CLI`,
|
|
186
|
+
`install, network), then start a new session. Do not treat the`,
|
|
187
|
+
`credentials below as verified:`
|
|
188
|
+
);
|
|
189
|
+
} else {
|
|
190
|
+
lines.push(
|
|
191
|
+
`Secrets preflight FAILED on surface "${cfg.surface}".`,
|
|
192
|
+
`These credentials are required and resolve nowhere — not in the`,
|
|
193
|
+
`environment, not materialized, not in the "${cfg.provider}" grant.`,
|
|
194
|
+
`Extend the grant in the vault (or correct the routing that requires`,
|
|
195
|
+
`them), then start a new session:`
|
|
196
|
+
);
|
|
197
|
+
}
|
|
198
|
+
lines.push(``);
|
|
199
|
+
for (const { name, reasons } of result.missing) {
|
|
200
|
+
lines.push(` ${name} — required because ${reasons.join("; ")}`);
|
|
201
|
+
}
|
|
202
|
+
lines.push(
|
|
203
|
+
``,
|
|
204
|
+
`Work needing one of these cannot be completed. Route the item to`,
|
|
205
|
+
`blocked with this reason rather than claiming it and stopping partway.`
|
|
206
|
+
);
|
|
207
|
+
return lines.join("\n");
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* Whether an alternative substrate already provides this capability.
|
|
212
|
+
* @param {string} name Required credential name.
|
|
213
|
+
* @param {Function} probe Probe runner, injected for tests.
|
|
214
|
+
* @returns {boolean} True when the substrate answered successfully.
|
|
215
|
+
*/
|
|
216
|
+
function substrateSatisfies(name, probe) {
|
|
217
|
+
const substrate = SUBSTITUTE_SUBSTRATES[name];
|
|
218
|
+
return substrate ? probe(substrate) : false;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* How long a substrate probe may take before it counts as no answer.
|
|
223
|
+
*
|
|
224
|
+
* `gh auth status` contacts GitHub, and this runs on the session-start path.
|
|
225
|
+
* Unbounded, a hung connection holds the session open for as long as the child
|
|
226
|
+
* lives — the `catch` cannot help, because it is only reached once the call
|
|
227
|
+
* returns. Five seconds is far beyond a healthy round trip and far below the
|
|
228
|
+
* point where a person concludes the agent is broken.
|
|
229
|
+
*/
|
|
230
|
+
const PROBE_TIMEOUT_MS = 5000;
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* Run a substrate probe, treating any failure as "did not satisfy".
|
|
234
|
+
*
|
|
235
|
+
* Output is discarded rather than captured. The probe's job is to answer a
|
|
236
|
+
* yes/no question with its exit status, and a command that can print a
|
|
237
|
+
* credential is one whose stdout should never enter this process.
|
|
238
|
+
*
|
|
239
|
+
* A timeout throws, which the catch maps to false — the fail-closed answer. The
|
|
240
|
+
* credential is then checked against the environment, the materialized file and
|
|
241
|
+
* the provider as if no substrate existed, so a slow network costs a stricter
|
|
242
|
+
* check rather than a wrong verdict.
|
|
243
|
+
*
|
|
244
|
+
* Exported so the bound itself can be proved against a real slow command. A
|
|
245
|
+
* timeout that only exists as an option object is a claim; one that has been
|
|
246
|
+
* watched cut a hanging child off is a control.
|
|
247
|
+
* @param {{command: string, args: string[]}} substrate Probe definition.
|
|
248
|
+
* @returns {boolean} True when the command exited zero.
|
|
249
|
+
*/
|
|
250
|
+
export function runProbe(substrate) {
|
|
251
|
+
try {
|
|
252
|
+
execFileSync(substrate.command, substrate.args, {
|
|
253
|
+
stdio: "ignore",
|
|
254
|
+
timeout: PROBE_TIMEOUT_MS,
|
|
255
|
+
});
|
|
256
|
+
return true;
|
|
257
|
+
} catch {
|
|
258
|
+
return false;
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/**
|
|
263
|
+
* Whether a resolved value counts as present.
|
|
264
|
+
* @param {unknown} value Candidate value.
|
|
265
|
+
* @returns {boolean} True when it is a non-blank string.
|
|
266
|
+
*/
|
|
267
|
+
function present(value) {
|
|
268
|
+
return typeof value === "string" && value.trim().length > 0;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* Run a thunk, treating any throw as "no view available".
|
|
273
|
+
*
|
|
274
|
+
* The materialized file is optional on every surface and absent on most, so a
|
|
275
|
+
* failure to read it is not a preflight failure — the provider is still
|
|
276
|
+
* authoritative. Only the provider's own unavailability is a verdict.
|
|
277
|
+
* @param {Function} thunk Work to attempt.
|
|
278
|
+
* @returns {*} The result, or undefined when it threw.
|
|
279
|
+
*/
|
|
280
|
+
function safely(thunk) {
|
|
281
|
+
try {
|
|
282
|
+
return thunk();
|
|
283
|
+
} catch {
|
|
284
|
+
return undefined;
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/**
|
|
289
|
+
* CLI entry point. Prints the report and exits non-zero on any failure.
|
|
290
|
+
*/
|
|
291
|
+
function main() {
|
|
292
|
+
const cfg = readConfig();
|
|
293
|
+
const result = preflight(cfg);
|
|
294
|
+
const text = report(result, cfg);
|
|
295
|
+
if (text) console.error(text);
|
|
296
|
+
if (result.verdict !== "ok") process.exit(1);
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/**
|
|
300
|
+
* Whether this module is the entry point node was asked to run.
|
|
301
|
+
*
|
|
302
|
+
* Both sides are realpath'd rather than compared as text: `import.meta.url` is
|
|
303
|
+
* the resolved path while `process.argv[1]` is whatever the caller typed, so a
|
|
304
|
+
* symlinked path — every git worktree, and every `/tmp` path on macOS — makes a
|
|
305
|
+
* raw comparison false. The module then loads, runs nothing, and exits 0, which
|
|
306
|
+
* is a readiness check reporting clean because it never ran. Same rule and same
|
|
307
|
+
* reasoning as `scripts/lib/invoked-as-script.mjs`, written out here because a
|
|
308
|
+
* plugin payload has no `./lib/` to import from.
|
|
309
|
+
* @param {string} moduleUrl This module's own `import.meta.url`.
|
|
310
|
+
* @param {string} [argv1] Entry path; defaults to `process.argv[1]`.
|
|
311
|
+
* @returns {boolean} Whether the CLI body should run.
|
|
312
|
+
*/
|
|
313
|
+
function invokedAsScript(moduleUrl, argv1 = process.argv[1]) {
|
|
314
|
+
if (!argv1) return false;
|
|
315
|
+
try {
|
|
316
|
+
return realpathSync(argv1) === realpathSync(fileURLToPath(moduleUrl));
|
|
317
|
+
} catch {
|
|
318
|
+
return false;
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
if (invokedAsScript(import.meta.url)) {
|
|
323
|
+
main();
|
|
324
|
+
}
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Derive the credentials a project's vendor routing already implies.
|
|
3
|
+
*
|
|
4
|
+
* `.lisa.config.json` declares `tracker` and `source` — which tracker receives
|
|
5
|
+
* ticket writes, which system hosts PRDs. Those two lines already determine a
|
|
6
|
+
* set of credentials without which nothing works: `tracker: "github"` cannot
|
|
7
|
+
* write an issue without `GH_TOKEN`, `source: "notion"` cannot read a PRD
|
|
8
|
+
* without `NOTION_API_TOKEN`.
|
|
9
|
+
*
|
|
10
|
+
* Until now that mapping lived only in prose, in `config-resolution.md`'s
|
|
11
|
+
* invariants, with nothing connecting it to `secrets.require`. So the two could
|
|
12
|
+
* drift silently and adding a vendor never added its credential to the asserted
|
|
13
|
+
* set — the list was hand-maintained when most of it was deducible.
|
|
14
|
+
*
|
|
15
|
+
* This module is that deduction, as code. The floor is *unioned* into the
|
|
16
|
+
* required set at resolution time rather than checked against a hand-written
|
|
17
|
+
* list, because correctness should not depend on someone having remembered to
|
|
18
|
+
* type `GH_TOKEN` into a file. A project still declares its extras — an
|
|
19
|
+
* `ATTIO_API_KEY` no routing can imply — in `secrets.require`.
|
|
20
|
+
*
|
|
21
|
+
* Deliberately not exhaustive about *optional* integrations. A credential
|
|
22
|
+
* appears here only when the routing that implies it makes the project
|
|
23
|
+
* non-functional without it. Sonar, Sentry and PostHog are configured
|
|
24
|
+
* separately and a project runs fine with none of them, so they are extras a
|
|
25
|
+
* project declares, not a floor anything derives.
|
|
26
|
+
* @module routing-floor
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* The credential each destination tracker cannot operate without.
|
|
31
|
+
*
|
|
32
|
+
* Confluence and JIRA share `ATLASSIAN_API_TOKEN` because they are one vendor
|
|
33
|
+
* behind one token — the same reason `lisa-atlassian-access` serves both.
|
|
34
|
+
*/
|
|
35
|
+
const TRACKER_CREDENTIALS = {
|
|
36
|
+
jira: ["ATLASSIAN_API_TOKEN"],
|
|
37
|
+
github: ["GH_TOKEN"],
|
|
38
|
+
linear: ["LINEAR_API_KEY"],
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
/** The credential each PRD source cannot be read without. */
|
|
42
|
+
const SOURCE_CREDENTIALS = {
|
|
43
|
+
notion: ["NOTION_API_TOKEN"],
|
|
44
|
+
confluence: ["ATLASSIAN_API_TOKEN"],
|
|
45
|
+
jira: ["ATLASSIAN_API_TOKEN"],
|
|
46
|
+
github: ["GH_TOKEN"],
|
|
47
|
+
linear: ["LINEAR_API_KEY"],
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* The credentials a project's routing makes mandatory.
|
|
52
|
+
*
|
|
53
|
+
* An unknown or absent vendor contributes nothing rather than throwing. This
|
|
54
|
+
* function answers "what does routing imply", and a `tracker` Lisa does not
|
|
55
|
+
* recognise is a *routing* error that `config-resolution`'s dispatch already
|
|
56
|
+
* reports with a better message than a credential check could. Failing here
|
|
57
|
+
* too would report the same defect twice, in the wrong vocabulary, and would
|
|
58
|
+
* make a preflight the place a typo in `tracker` first surfaces.
|
|
59
|
+
* @param {{tracker?: string, source?: string}} [routing] Vendor routing.
|
|
60
|
+
* @returns {readonly string[]} Sorted, de-duplicated credential names.
|
|
61
|
+
*/
|
|
62
|
+
export function routingFloor(routing = {}) {
|
|
63
|
+
const tracker = normalize(routing.tracker);
|
|
64
|
+
const source = normalize(routing.source);
|
|
65
|
+
const names = [
|
|
66
|
+
...credentialsFor(TRACKER_CREDENTIALS, tracker),
|
|
67
|
+
...credentialsFor(SOURCE_CREDENTIALS, source),
|
|
68
|
+
];
|
|
69
|
+
return [...new Set(names)].sort((left, right) => left.localeCompare(right));
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Explain which routing key implied each credential.
|
|
74
|
+
*
|
|
75
|
+
* The preflight reports a missing credential to whoever has to fix it, and
|
|
76
|
+
* "GH_TOKEN is required" is a much weaker sentence than "GH_TOKEN is required
|
|
77
|
+
* because tracker is github". The second names the line of config to change if
|
|
78
|
+
* the requirement itself is wrong, which is the other valid remedy.
|
|
79
|
+
* @param {{tracker?: string, source?: string}} [routing] Vendor routing.
|
|
80
|
+
* @returns {Record<string, string[]>} Credential name to the reasons for it.
|
|
81
|
+
*/
|
|
82
|
+
export function routingFloorReasons(routing = {}) {
|
|
83
|
+
const tracker = normalize(routing.tracker);
|
|
84
|
+
const source = normalize(routing.source);
|
|
85
|
+
const reasons = {};
|
|
86
|
+
for (const name of credentialsFor(TRACKER_CREDENTIALS, tracker)) {
|
|
87
|
+
reasons[name] = [`tracker is "${tracker}"`];
|
|
88
|
+
}
|
|
89
|
+
for (const name of credentialsFor(SOURCE_CREDENTIALS, source)) {
|
|
90
|
+
reasons[name] = [...(reasons[name] ?? []), `source is "${source}"`];
|
|
91
|
+
}
|
|
92
|
+
return reasons;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Look a vendor up without consulting anything it inherited.
|
|
97
|
+
*
|
|
98
|
+
* A routing value reaches these maps as an arbitrary string from a config file,
|
|
99
|
+
* and plain indexing answers `constructor` and `__proto__` with inherited
|
|
100
|
+
* members rather than with undefined — so `?? []` never fires and spreading the
|
|
101
|
+
* result throws `not iterable`. `routingFloor` runs inside `readConfig`, which
|
|
102
|
+
* means a typo in `tracker` would abort configuration loading with an
|
|
103
|
+
* iterability error, in place of the documented behavior: an unrecognised
|
|
104
|
+
* vendor contributes nothing, and routing dispatch reports the typo in its own
|
|
105
|
+
* vocabulary.
|
|
106
|
+
* @param {Record<string, string[]>} map Credential map to read.
|
|
107
|
+
* @param {string} key Normalized vendor name.
|
|
108
|
+
* @returns {string[]} Declared credentials, empty for anything not declared.
|
|
109
|
+
*/
|
|
110
|
+
function credentialsFor(map, key) {
|
|
111
|
+
return Object.hasOwn(map, key) ? map[key] : [];
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Substrates that satisfy a credential without the variable being set.
|
|
116
|
+
*
|
|
117
|
+
* A required name is a proxy for a capability, not an end in itself, and some
|
|
118
|
+
* capabilities have a second legitimate route. `gh` authenticates from its own
|
|
119
|
+
* keyring after `gh auth login`, so a laptop can drive every GitHub operation
|
|
120
|
+
* Lisa performs with `GH_TOKEN` unset — which is the normal state of a
|
|
121
|
+
* developer machine, not a misconfiguration.
|
|
122
|
+
*
|
|
123
|
+
* Without this the floor fails a local session over a credential that surface
|
|
124
|
+
* demonstrably does not need. That is worse than not checking: a control that
|
|
125
|
+
* fires when nothing is wrong is one people learn to skip, and it would have
|
|
126
|
+
* fired on every session in Lisa's own repository.
|
|
127
|
+
*
|
|
128
|
+
* This mirrors the credential-substrate-precedence contract the access skills
|
|
129
|
+
* already follow — a capability is proven by whichever substrate can serve it,
|
|
130
|
+
* not by one hardcoded variable.
|
|
131
|
+
*
|
|
132
|
+
* Kept deliberately small. An entry belongs here only when the alternative is
|
|
133
|
+
* genuinely equivalent for everything Lisa does with that credential and can be
|
|
134
|
+
* proven by a cheap local command. `ATLASSIAN_API_TOKEN` is not listed even
|
|
135
|
+
* though `acli` exists, because the contract ranks the token first and acli
|
|
136
|
+
* covers only part of the surface.
|
|
137
|
+
*/
|
|
138
|
+
export const SUBSTITUTE_SUBSTRATES = {
|
|
139
|
+
GH_TOKEN: {
|
|
140
|
+
command: "gh",
|
|
141
|
+
args: ["auth", "status"],
|
|
142
|
+
describes: "the gh CLI is authenticated (gh auth login)",
|
|
143
|
+
},
|
|
144
|
+
};
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Lower-case a routing value, treating blank and absent as the same.
|
|
148
|
+
* @param {unknown} value Raw routing value.
|
|
149
|
+
* @returns {string} A comparable key, empty when nothing was declared.
|
|
150
|
+
*/
|
|
151
|
+
function normalize(value) {
|
|
152
|
+
return typeof value === "string" ? value.trim().toLowerCase() : "";
|
|
153
|
+
}
|
|
@@ -18,6 +18,7 @@ import { homedir } from "node:os";
|
|
|
18
18
|
import { join } from "node:path";
|
|
19
19
|
|
|
20
20
|
import { bootstrapKeyFor } from "./providers.mjs";
|
|
21
|
+
import { routingFloor } from "./routing-floor.mjs";
|
|
21
22
|
|
|
22
23
|
/**
|
|
23
24
|
* Capabilities, per surface.
|
|
@@ -188,7 +189,7 @@ export function materializedPaths(namespace, env = process.env) {
|
|
|
188
189
|
* provider means the environment *is* the provider. A credentials manager is
|
|
189
190
|
* the preferred path, never a required one.
|
|
190
191
|
* @param {string} [cwd] Directory to look in.
|
|
191
|
-
* @returns {object} Resolved configuration with defaults applied.
|
|
192
|
+
* @returns {{provider: string, bootstrap: {sources: string[], key: string|null}, require: string[]|null, requiredFloor: readonly string[], rotating: string[], namespace: string, narrow: object, surface: string, routing: {tracker?: string, source?: string}, capabilities: object}} Resolved configuration with defaults applied.
|
|
192
193
|
*/
|
|
193
194
|
export function readConfig(cwd = process.cwd(), env = process.env) {
|
|
194
195
|
const path = join(cwd, ".lisa.config.json");
|
|
@@ -203,13 +204,19 @@ export function readConfig(cwd = process.cwd(), env = process.env) {
|
|
|
203
204
|
// so they can come from the environment instead. Everything else keeps its
|
|
204
205
|
// default, and an environment that sets neither still gets the old behaviour.
|
|
205
206
|
if (!existsSync(path)) return withSurface(fromEnvironment(env));
|
|
206
|
-
let
|
|
207
|
+
let root;
|
|
207
208
|
try {
|
|
208
|
-
|
|
209
|
+
root = JSON.parse(readFileSync(path, "utf8"));
|
|
209
210
|
} catch (err) {
|
|
210
211
|
throw new Error(`.lisa.config.json is not readable: ${err.message}`);
|
|
211
212
|
}
|
|
212
|
-
|
|
213
|
+
const cfg = root.secrets;
|
|
214
|
+
// Read outside the `secrets` block on purpose. The routing that implies a
|
|
215
|
+
// credential is declared once, at the top level, and restating it under
|
|
216
|
+
// `secrets` would be a second copy free to disagree with the one every other
|
|
217
|
+
// skill dispatches on.
|
|
218
|
+
const routing = { tracker: root.tracker, source: root.source };
|
|
219
|
+
if (!cfg) return withSurface({ ...DEFAULTS, routing });
|
|
213
220
|
const provider = cfg.provider ?? DEFAULTS.provider;
|
|
214
221
|
const namespace = assertNamespace(cfg.namespace ?? DEFAULTS.namespace);
|
|
215
222
|
return withSurface({
|
|
@@ -221,6 +228,7 @@ export function readConfig(cwd = process.cwd(), env = process.env) {
|
|
|
221
228
|
namespace,
|
|
222
229
|
narrow: { ...DEFAULTS.narrow, ...(cfg.narrow ?? {}) },
|
|
223
230
|
surface: cfg.surface ?? null,
|
|
231
|
+
routing,
|
|
224
232
|
});
|
|
225
233
|
}
|
|
226
234
|
|
|
@@ -339,11 +347,51 @@ function fromEnvironment(env) {
|
|
|
339
347
|
}
|
|
340
348
|
|
|
341
349
|
/**
|
|
342
|
-
* Attach the resolved surface
|
|
350
|
+
* Attach the resolved surface, its capabilities, and the required sets.
|
|
351
|
+
*
|
|
352
|
+
* `require` is flattened here rather than at each caller because this is the
|
|
353
|
+
* one place that knows the surface, and a surface-scoped list cannot be read
|
|
354
|
+
* without it.
|
|
343
355
|
* @param {object} cfg Configuration without surface resolution.
|
|
344
|
-
* @returns {object} The same configuration plus
|
|
356
|
+
* @returns {object} The same configuration plus surface-derived fields.
|
|
345
357
|
*/
|
|
346
358
|
function withSurface(cfg) {
|
|
347
359
|
const surface = detectSurface(cfg.surface);
|
|
348
|
-
return {
|
|
360
|
+
return {
|
|
361
|
+
...cfg,
|
|
362
|
+
surface,
|
|
363
|
+
capabilities: SURFACES[surface],
|
|
364
|
+
require: resolveRequire(cfg.require, surface),
|
|
365
|
+
requiredFloor: routingFloor(cfg.routing ?? {}),
|
|
366
|
+
};
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
/**
|
|
370
|
+
* Flatten a `require` declaration for one surface.
|
|
371
|
+
*
|
|
372
|
+
* Two shapes are accepted. An array is every surface — the original meaning,
|
|
373
|
+
* and still correct for the many projects whose credentials do not vary. An
|
|
374
|
+
* object opts into scoping: `all` applies everywhere and a key matching the
|
|
375
|
+
* surface adds to it.
|
|
376
|
+
*
|
|
377
|
+
* Scoping exists because the required set genuinely differs by where the agent
|
|
378
|
+
* runs. `CLAUDE_ROUTINE_TOKEN` is mandatory on `claude-web` and meaningless on
|
|
379
|
+
* a laptop; asserting one flat list everywhere would fail a developer's session
|
|
380
|
+
* over a credential only a cloud surface uses, and the reliable response to a
|
|
381
|
+
* check that fails for the wrong reason is to stop believing it.
|
|
382
|
+
*
|
|
383
|
+
* Returns `null` — not `[]` — when nothing is declared, because the two mean
|
|
384
|
+
* different things downstream: `null` leaves resolution un-narrowed, while an
|
|
385
|
+
* empty array would declare that the project needs no secrets and make
|
|
386
|
+
* `assertDeclared` reject every name.
|
|
387
|
+
* @param {string[]|Record<string, string[]>|null|undefined} raw Declaration.
|
|
388
|
+
* @param {string} surface Resolved surface key.
|
|
389
|
+
* @returns {string[]|null} Names required on this surface, or null.
|
|
390
|
+
*/
|
|
391
|
+
function resolveRequire(raw, surface) {
|
|
392
|
+
if (!raw) return null;
|
|
393
|
+
if (Array.isArray(raw)) return raw;
|
|
394
|
+
if (typeof raw !== "object") return null;
|
|
395
|
+
const names = [...(raw.all ?? []), ...(raw[surface] ?? [])];
|
|
396
|
+
return names.length ? [...new Set(names)] : null;
|
|
349
397
|
}
|