@codyswann/lisa 2.318.0 → 2.320.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/dist/core/upstream-evidence-manifest.d.ts.map +1 -1
- package/dist/core/upstream-evidence-manifest.js +15 -6
- package/dist/core/upstream-evidence-manifest.js.map +1 -1
- package/package.json +1 -1
- 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/doctor-secrets.mjs +34 -6
- package/plugins/lisa/.codex-plugin/skills/lisa-secrets-access/scripts/surfaces.mjs +32 -3
- package/plugins/lisa/.codex-plugin/skills/lisa-secrets-access/scripts/validate-config.mjs +65 -5
- package/plugins/lisa/.codex-plugin/skills/lisa-setup-remote-env/SKILL.md +39 -2
- package/plugins/lisa/.codex-plugin/skills/lisa-setup-remote-env/assets/session-start.sh +30 -0
- package/plugins/lisa/.codex-plugin/skills/lisa-setup-remote-env/scripts/setup-remote-env.mjs +252 -20
- package/plugins/lisa/.codex-plugin/skills/lisa-setup-remote-env/scripts/verify-remote-env.mjs +56 -1
- package/plugins/lisa/skills/lisa-secrets-access/scripts/doctor-secrets.mjs +34 -6
- package/plugins/lisa/skills/lisa-secrets-access/scripts/surfaces.mjs +32 -3
- package/plugins/lisa/skills/lisa-secrets-access/scripts/validate-config.mjs +65 -5
- package/plugins/lisa/skills/lisa-setup-remote-env/SKILL.md +39 -2
- package/plugins/lisa/skills/lisa-setup-remote-env/assets/session-start.sh +30 -0
- package/plugins/lisa/skills/lisa-setup-remote-env/scripts/setup-remote-env.mjs +252 -20
- package/plugins/lisa/skills/lisa-setup-remote-env/scripts/verify-remote-env.mjs +56 -1
- package/plugins/lisa-agy/plugin.json +1 -1
- package/plugins/lisa-agy/skills/lisa-secrets-access/scripts/doctor-secrets.mjs +34 -6
- package/plugins/lisa-agy/skills/lisa-secrets-access/scripts/surfaces.mjs +32 -3
- package/plugins/lisa-agy/skills/lisa-secrets-access/scripts/validate-config.mjs +65 -5
- package/plugins/lisa-agy/skills/lisa-setup-remote-env/SKILL.md +39 -2
- package/plugins/lisa-agy/skills/lisa-setup-remote-env/assets/session-start.sh +30 -0
- package/plugins/lisa-agy/skills/lisa-setup-remote-env/scripts/setup-remote-env.mjs +252 -20
- package/plugins/lisa-agy/skills/lisa-setup-remote-env/scripts/verify-remote-env.mjs +56 -1
- 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/doctor-secrets.mjs +34 -6
- package/plugins/lisa-copilot/skills/lisa-secrets-access/scripts/surfaces.mjs +32 -3
- package/plugins/lisa-copilot/skills/lisa-secrets-access/scripts/validate-config.mjs +65 -5
- package/plugins/lisa-copilot/skills/lisa-setup-remote-env/SKILL.md +39 -2
- package/plugins/lisa-copilot/skills/lisa-setup-remote-env/assets/session-start.sh +30 -0
- package/plugins/lisa-copilot/skills/lisa-setup-remote-env/scripts/setup-remote-env.mjs +252 -20
- package/plugins/lisa-copilot/skills/lisa-setup-remote-env/scripts/verify-remote-env.mjs +56 -1
- package/plugins/lisa-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-cursor/skills/lisa-secrets-access/scripts/doctor-secrets.mjs +34 -6
- package/plugins/lisa-cursor/skills/lisa-secrets-access/scripts/surfaces.mjs +32 -3
- package/plugins/lisa-cursor/skills/lisa-secrets-access/scripts/validate-config.mjs +65 -5
- package/plugins/lisa-cursor/skills/lisa-setup-remote-env/SKILL.md +39 -2
- package/plugins/lisa-cursor/skills/lisa-setup-remote-env/assets/session-start.sh +30 -0
- package/plugins/lisa-cursor/skills/lisa-setup-remote-env/scripts/setup-remote-env.mjs +252 -20
- package/plugins/lisa-cursor/skills/lisa-setup-remote-env/scripts/verify-remote-env.mjs +56 -1
- 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/doctor-secrets.mjs +34 -6
- package/plugins/src/base/skills/lisa-secrets-access/scripts/surfaces.mjs +32 -3
- package/plugins/src/base/skills/lisa-secrets-access/scripts/validate-config.mjs +65 -5
- package/plugins/src/base/skills/lisa-setup-remote-env/SKILL.md +39 -2
- package/plugins/src/base/skills/lisa-setup-remote-env/assets/session-start.sh +30 -0
- package/plugins/src/base/skills/lisa-setup-remote-env/scripts/setup-remote-env.mjs +252 -20
- package/plugins/src/base/skills/lisa-setup-remote-env/scripts/verify-remote-env.mjs +56 -1
|
@@ -5,15 +5,26 @@
|
|
|
5
5
|
* This is the script a remote environment's setup **and** maintenance fields
|
|
6
6
|
* call. They are the same script on purpose. A container may be built fresh or
|
|
7
7
|
* resumed from cache, and every step here is idempotent and version-aware, so
|
|
8
|
-
* running it twice is correct
|
|
9
|
-
* rotated value, an edited note, or a changed version pin.
|
|
8
|
+
* running it twice is correct.
|
|
10
9
|
*
|
|
11
10
|
* Order matters. The toolchain comes first because the secrets step needs the
|
|
12
11
|
* provider CLI that the toolchain installs. The project hook comes last because
|
|
13
12
|
* it is the only part that may assume everything else is ready.
|
|
14
13
|
*
|
|
14
|
+
* **Which phases run depends on the surface, and the reason is not cosmetic.**
|
|
15
|
+
* On a surface that re-runs this script when a container resumes, materializing
|
|
16
|
+
* here is what picks up a rotated value. On a surface that *skips* it whenever a
|
|
17
|
+
* filesystem cache exists, materializing here would write the value once and
|
|
18
|
+
* then never refresh it — a rotated credential would stay stale until the cache
|
|
19
|
+
* expired. Those surfaces materialize from a session-start hook instead, which
|
|
20
|
+
* runs every session including resumed ones, and `--phase=secrets` is how that
|
|
21
|
+
* hook re-enters this file.
|
|
22
|
+
*
|
|
23
|
+
* The selection comes from the surface's `materializeAt` capability rather than
|
|
24
|
+
* from its name, so adding a surface does not mean editing a branch here.
|
|
25
|
+
*
|
|
15
26
|
* Usage:
|
|
16
|
-
* setup-remote-env.mjs [--dry-run]
|
|
27
|
+
* setup-remote-env.mjs [--dry-run] [--phase=toolchain|secrets|hook]
|
|
17
28
|
* @module setup-remote-env
|
|
18
29
|
*/
|
|
19
30
|
|
|
@@ -26,7 +37,7 @@ import {
|
|
|
26
37
|
rmSync,
|
|
27
38
|
} from "node:fs";
|
|
28
39
|
import { dirname, join, resolve } from "node:path";
|
|
29
|
-
import { fileURLToPath } from "node:url";
|
|
40
|
+
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
30
41
|
|
|
31
42
|
import { assertPinned, extractVersion, planToolchain } from "./toolchain.mjs";
|
|
32
43
|
|
|
@@ -197,31 +208,252 @@ function runHook(hook, dryRun) {
|
|
|
197
208
|
execFileSync("bash", [path], { stdio: "inherit" });
|
|
198
209
|
}
|
|
199
210
|
|
|
200
|
-
|
|
211
|
+
/** Phases this runner can execute, in the order they must happen. */
|
|
212
|
+
const PHASES = ["toolchain", "secrets", "hook"];
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* The settings block that wires the session-start hook into a repository.
|
|
216
|
+
*
|
|
217
|
+
* Emitted rather than written, because `.claude/settings.json` belongs to the
|
|
218
|
+
* project: it may already carry hooks, and merging someone else's file from
|
|
219
|
+
* here is how a careless tool destroys a configuration it did not understand.
|
|
220
|
+
*/
|
|
221
|
+
const SESSION_START_BLOCK = `{
|
|
222
|
+
"hooks": {
|
|
223
|
+
"SessionStart": [
|
|
224
|
+
{
|
|
225
|
+
"matcher": "startup|resume",
|
|
226
|
+
"hooks": [
|
|
227
|
+
{
|
|
228
|
+
"type": "command",
|
|
229
|
+
"command": "bash \\"$CLAUDE_PROJECT_DIR\\"/scripts/lisa-remote-env/session-start.sh"
|
|
230
|
+
}
|
|
231
|
+
]
|
|
232
|
+
}
|
|
233
|
+
]
|
|
234
|
+
}
|
|
235
|
+
}`;
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* Produce the configuration a human pastes to provision a Claude cloud surface.
|
|
239
|
+
*
|
|
240
|
+
* This surface has no API tier and no console tier to fall back to: a Claude
|
|
241
|
+
* cloud environment is configured only in the environment dialog, which has no
|
|
242
|
+
* settings page, no direct URL, and no endpoint. Emit is not a degraded option
|
|
243
|
+
* here, it is the only one — so the read-back in `verify-remote-env.mjs` is
|
|
244
|
+
* what makes the result trustworthy, exactly as it would be at any other tier.
|
|
245
|
+
* @param {{bootstrapKey: string|null, install: string}} options Project details.
|
|
246
|
+
* @returns {string} Text to show the operator.
|
|
247
|
+
*/
|
|
248
|
+
export function emitClaudeWeb({ bootstrapKey, install }) {
|
|
249
|
+
const key = bootstrapKey ?? "<secrets.bootstrap.key is not configured>";
|
|
250
|
+
return [
|
|
251
|
+
"Provisioning tier: EMIT — and for this surface that is the only tier.",
|
|
252
|
+
" A Claude cloud environment is account-scoped configuration edited in the",
|
|
253
|
+
" environment selector at claude.ai/code. There is no settings page, no",
|
|
254
|
+
" direct URL and no API, so nothing here can provision it for you.",
|
|
255
|
+
"",
|
|
256
|
+
"Paste into the environment dialog",
|
|
257
|
+
"---------------------------------",
|
|
258
|
+
" Network access: Trusted, or Custom plus any host your project needs",
|
|
259
|
+
"",
|
|
260
|
+
" Environment variables:",
|
|
261
|
+
` ${key}=<read this from your credential manager>`,
|
|
262
|
+
"",
|
|
263
|
+
" Only the bootstrap belongs in this box. Values here are stored as",
|
|
264
|
+
" plain text and are readable by anyone who uses the environment — on an",
|
|
265
|
+
" organization-shared environment that is every member of the org. Every",
|
|
266
|
+
" other credential is materialized by the session-start hook below, which",
|
|
267
|
+
" is why exactly one value needs to live here.",
|
|
268
|
+
"",
|
|
269
|
+
" Setup script:",
|
|
270
|
+
` ${install} && bash scripts/lisa-remote-env/setup.sh`,
|
|
271
|
+
"",
|
|
272
|
+
" The install must come first. On a fresh container node_modules is the",
|
|
273
|
+
" only copy of the Lisa skills present, because Claude receives them as",
|
|
274
|
+
" an installed plugin rather than as part of the clone.",
|
|
275
|
+
"",
|
|
276
|
+
"Commit to the repository",
|
|
277
|
+
"------------------------",
|
|
278
|
+
" scripts/lisa-remote-env/session-start.sh (from this skill's assets)",
|
|
279
|
+
"",
|
|
280
|
+
" .claude/settings.json — merge this into any hooks already there:",
|
|
281
|
+
SESSION_START_BLOCK.split("\n")
|
|
282
|
+
.map(line => ` ${line}`)
|
|
283
|
+
.join("\n"),
|
|
284
|
+
"",
|
|
285
|
+
" The setup script is skipped whenever a cached environment exists, so",
|
|
286
|
+
" secrets are materialized from this hook instead. It runs every session,",
|
|
287
|
+
" including a resumed one, which is what keeps a rotated value current.",
|
|
288
|
+
"",
|
|
289
|
+
"Worth knowing about this base image",
|
|
290
|
+
"-----------------------------------",
|
|
291
|
+
" The GitHub CLI is not pre-installed. If this project's flows shell out to",
|
|
292
|
+
" `gh`, add it to remoteEnv.tools.install with a pinned version and",
|
|
293
|
+
" checksum, the same as any other tool.",
|
|
294
|
+
"",
|
|
295
|
+
" A credential handled by the GitHub proxy reads as the literal string",
|
|
296
|
+
' "proxy-injected" inside the session. Tools that authenticate through the',
|
|
297
|
+
" proxy work; a script that reads the variable itself gets the placeholder.",
|
|
298
|
+
"",
|
|
299
|
+
"Then prove it",
|
|
300
|
+
"-------------",
|
|
301
|
+
" Start a session and run the read-back. Whatever provisioned an",
|
|
302
|
+
" environment, the same verify is what makes it trustworthy:",
|
|
303
|
+
" node node_modules/@codyswann/lisa/plugins/lisa/skills/lisa-setup-remote-env/scripts/verify-remote-env.mjs",
|
|
304
|
+
].join("\n");
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/**
|
|
308
|
+
* Decide which phases this invocation runs.
|
|
309
|
+
*
|
|
310
|
+
* An explicit `--phase` always wins, because that is how a session-start hook
|
|
311
|
+
* asks for the one phase it owns. Otherwise every phase runs except a secrets
|
|
312
|
+
* step that belongs to a different moment on this surface — running it here
|
|
313
|
+
* would produce a copy that the surface never refreshes.
|
|
314
|
+
* @param {string|undefined} requested Value of `--phase`, when given.
|
|
315
|
+
* @param {string|null} materializeAt When this surface materializes.
|
|
316
|
+
* @returns {string[]} Phases to run, in order.
|
|
317
|
+
*/
|
|
318
|
+
export function selectPhases(requested, materializeAt) {
|
|
319
|
+
if (requested) {
|
|
320
|
+
if (!PHASES.includes(requested)) {
|
|
321
|
+
throw new Error(
|
|
322
|
+
`unknown --phase "${requested}". Known: ${PHASES.join(", ")}.`
|
|
323
|
+
);
|
|
324
|
+
}
|
|
325
|
+
if (requested === "secrets" && materializeAt !== "session-start") {
|
|
326
|
+
// Not an error: the hook is committed to the repository and runs on every
|
|
327
|
+
// surface the project is ever checked out on. Refusing loudly would make
|
|
328
|
+
// a correct local session look broken every time it started.
|
|
329
|
+
return [];
|
|
330
|
+
}
|
|
331
|
+
return [requested];
|
|
332
|
+
}
|
|
333
|
+
return PHASES.filter(
|
|
334
|
+
phase => phase !== "secrets" || materializeAt === "setup"
|
|
335
|
+
);
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
/**
|
|
339
|
+
* Read the surface the resolver detects, without duplicating its rules.
|
|
340
|
+
*
|
|
341
|
+
* Imported rather than shelled out to, because the answer is a pure function of
|
|
342
|
+
* the environment and a subprocess would buy nothing. The path is converted to
|
|
343
|
+
* a file URL first: a bare absolute path is not a portable module specifier.
|
|
344
|
+
* @returns {Promise<{surface: string, materializeAt: string|null}>} Detected surface.
|
|
345
|
+
*/
|
|
346
|
+
async function detectSurface() {
|
|
347
|
+
const script = siblingScript("lisa-secrets-access", "surfaces.mjs");
|
|
348
|
+
const mod = await import(pathToFileURL(script).href);
|
|
349
|
+
const surface = mod.detectSurface();
|
|
350
|
+
return { surface, materializeAt: mod.SURFACES[surface].materializeAt };
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
/**
|
|
354
|
+
* Name the project's own install command rather than inventing one.
|
|
355
|
+
*
|
|
356
|
+
* The emitted setup line must begin with whatever this project already uses; a
|
|
357
|
+
* guessed package manager produces a container that fails on its first command.
|
|
358
|
+
* @param {string} [cwd] Repository root.
|
|
359
|
+
* @returns {string} The install command to place before the setup script.
|
|
360
|
+
*/
|
|
361
|
+
export function detectInstallCommand(cwd = process.cwd()) {
|
|
362
|
+
const lockfiles = [
|
|
363
|
+
["bun.lockb", "bun install"],
|
|
364
|
+
["bun.lock", "bun install"],
|
|
365
|
+
["pnpm-lock.yaml", "pnpm install --frozen-lockfile"],
|
|
366
|
+
["yarn.lock", "yarn install --immutable"],
|
|
367
|
+
["package-lock.json", "npm ci"],
|
|
368
|
+
];
|
|
369
|
+
for (const [file, command] of lockfiles) {
|
|
370
|
+
if (existsSync(join(cwd, file))) return command;
|
|
371
|
+
}
|
|
372
|
+
return "<your install command>";
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
/**
|
|
376
|
+
* Read the bootstrap key name, which is the one value the operator must paste.
|
|
377
|
+
* @param {string} [cwd] Repository root.
|
|
378
|
+
* @returns {string|null} The configured key name, when there is one.
|
|
379
|
+
*/
|
|
380
|
+
function readBootstrapKey(cwd = process.cwd()) {
|
|
381
|
+
const path = join(cwd, ".lisa.config.json");
|
|
382
|
+
if (!existsSync(path)) return null;
|
|
383
|
+
return JSON.parse(readFileSync(path, "utf8")).secrets?.bootstrap?.key ?? null;
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
async function main() {
|
|
201
387
|
const dryRun = process.argv.includes("--dry-run");
|
|
388
|
+
const emit = process.argv
|
|
389
|
+
.find(arg => arg.startsWith("--emit="))
|
|
390
|
+
?.slice("--emit=".length);
|
|
391
|
+
|
|
392
|
+
if (emit) {
|
|
393
|
+
if (emit !== "claude-web") {
|
|
394
|
+
throw new Error(
|
|
395
|
+
`no emit template for surface "${emit}".\n` +
|
|
396
|
+
`Emitting is implemented for claude-web, which has no other tier.`
|
|
397
|
+
);
|
|
398
|
+
}
|
|
399
|
+
console.log(
|
|
400
|
+
emitClaudeWeb({
|
|
401
|
+
bootstrapKey: readBootstrapKey(),
|
|
402
|
+
install: detectInstallCommand(),
|
|
403
|
+
})
|
|
404
|
+
);
|
|
405
|
+
return;
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
const requested = process.argv
|
|
409
|
+
.find(arg => arg.startsWith("--phase="))
|
|
410
|
+
?.slice("--phase=".length);
|
|
202
411
|
const cfg = readRemoteEnvConfig();
|
|
412
|
+
const { surface, materializeAt } = await detectSurface();
|
|
413
|
+
const phases = selectPhases(requested, materializeAt);
|
|
203
414
|
|
|
204
|
-
|
|
205
|
-
|
|
415
|
+
if (!phases.length) {
|
|
416
|
+
console.log(
|
|
417
|
+
`Nothing to do: surface "${surface}" does not materialize from a ` +
|
|
418
|
+
`session-start hook.`
|
|
419
|
+
);
|
|
420
|
+
return;
|
|
421
|
+
}
|
|
206
422
|
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
423
|
+
if (phases.includes("toolchain")) {
|
|
424
|
+
console.log("Toolchain:");
|
|
425
|
+
applyToolchain(cfg.tools, dryRun);
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
if (phases.includes("secrets")) {
|
|
429
|
+
console.log("\nSecrets:");
|
|
430
|
+
const materialize = siblingScript(
|
|
431
|
+
"lisa-secrets-access",
|
|
432
|
+
"materialize-secrets.mjs"
|
|
433
|
+
);
|
|
434
|
+
execFileSync("node", dryRun ? [materialize, "--dry-run"] : [materialize], {
|
|
435
|
+
stdio: "inherit",
|
|
436
|
+
});
|
|
437
|
+
} else if (!requested) {
|
|
438
|
+
console.log(
|
|
439
|
+
`\nSecrets: materialized from a session-start hook on "${surface}", ` +
|
|
440
|
+
`not here.\n` +
|
|
441
|
+
` This script is skipped whenever a cached environment exists, so a ` +
|
|
442
|
+
`value written\n here would go stale the moment it was rotated. The ` +
|
|
443
|
+
`hook runs every session.`
|
|
444
|
+
);
|
|
445
|
+
}
|
|
215
446
|
|
|
216
|
-
runHook(cfg.hook, dryRun);
|
|
447
|
+
if (phases.includes("hook")) runHook(cfg.hook, dryRun);
|
|
217
448
|
console.log(`\nRemote environment ${dryRun ? "plan complete" : "ready"}.`);
|
|
218
449
|
}
|
|
219
450
|
|
|
220
451
|
if (import.meta.url === `file://${process.argv[1]}`) {
|
|
221
|
-
try
|
|
222
|
-
|
|
223
|
-
|
|
452
|
+
// Awaited rather than called bare: main is async, so a synchronous try/catch
|
|
453
|
+
// would let a rejected promise escape as an unhandled rejection and exit 0 —
|
|
454
|
+
// reporting a prepared environment that was never prepared.
|
|
455
|
+
main().catch(err => {
|
|
224
456
|
console.error(err.message);
|
|
225
457
|
process.exit(1);
|
|
226
|
-
}
|
|
458
|
+
});
|
|
227
459
|
}
|
|
@@ -22,7 +22,8 @@
|
|
|
22
22
|
*/
|
|
23
23
|
|
|
24
24
|
import { execFileSync } from "node:child_process";
|
|
25
|
-
import { existsSync, statSync } from "node:fs";
|
|
25
|
+
import { existsSync, readFileSync, statSync } from "node:fs";
|
|
26
|
+
import { join } from "node:path";
|
|
26
27
|
|
|
27
28
|
import { readRemoteEnvConfig } from "./setup-remote-env.mjs";
|
|
28
29
|
import { extractVersion } from "./toolchain.mjs";
|
|
@@ -119,6 +120,43 @@ function verifyToolchain(tools) {
|
|
|
119
120
|
}
|
|
120
121
|
}
|
|
121
122
|
|
|
123
|
+
/**
|
|
124
|
+
* Assert that a declared credential is genuinely readable, not a proxy stand-in.
|
|
125
|
+
*
|
|
126
|
+
* A surface may keep a credential outside the sandbox entirely and substitute
|
|
127
|
+
* the real value at egress. The variable is then present and non-empty, so a
|
|
128
|
+
* presence check passes — and a script that reads the variable and puts it in a
|
|
129
|
+
* header sends the placeholder and fails somewhere far from here, with an error
|
|
130
|
+
* that points at the service rather than at the environment.
|
|
131
|
+
*
|
|
132
|
+
* Reported without printing any value: the placeholder is compared, and a real
|
|
133
|
+
* credential is only ever reported as present.
|
|
134
|
+
*
|
|
135
|
+
* Takes its reporter as an argument rather than writing to this module's
|
|
136
|
+
* results array, so the rule can be exercised against a synthetic environment
|
|
137
|
+
* without a container and without leaking findings between runs.
|
|
138
|
+
* @param {string[]} required Declared credential names.
|
|
139
|
+
* @param {Record<string, string|undefined>} env Environment to inspect.
|
|
140
|
+
* @param {(ok: boolean, label: string, detail: string) => void} report Collector.
|
|
141
|
+
*/
|
|
142
|
+
export function verifyNotProxied(required, env, report) {
|
|
143
|
+
for (const name of required) {
|
|
144
|
+
const value = (env[name] ?? "").trim();
|
|
145
|
+
if (!value) {
|
|
146
|
+
report(false, `credential ${name}`, "declared but not present");
|
|
147
|
+
continue;
|
|
148
|
+
}
|
|
149
|
+
report(
|
|
150
|
+
value !== "proxy-injected",
|
|
151
|
+
`credential ${name}`,
|
|
152
|
+
value === "proxy-injected"
|
|
153
|
+
? 'reads as "proxy-injected" — substituted at egress, so anything ' +
|
|
154
|
+
"reading this variable directly receives the placeholder"
|
|
155
|
+
: "present"
|
|
156
|
+
);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
122
160
|
/**
|
|
123
161
|
* Assert the working tree is clean.
|
|
124
162
|
*
|
|
@@ -142,11 +180,28 @@ function verifyCleanCheckout() {
|
|
|
142
180
|
}
|
|
143
181
|
}
|
|
144
182
|
|
|
183
|
+
/**
|
|
184
|
+
* Read the credential names the project declares it needs.
|
|
185
|
+
* @param {string} [cwd] Repository root.
|
|
186
|
+
* @returns {string[]} Declared names, or none when unconfigured.
|
|
187
|
+
*/
|
|
188
|
+
function readRequired(cwd = process.cwd()) {
|
|
189
|
+
const path = join(cwd, ".lisa.config.json");
|
|
190
|
+
if (!existsSync(path)) return [];
|
|
191
|
+
const required = JSON.parse(readFileSync(path, "utf8")).secrets?.require;
|
|
192
|
+
if (required == null) return [];
|
|
193
|
+
if (!Array.isArray(required)) {
|
|
194
|
+
throw new Error("secrets.require must be an array when present");
|
|
195
|
+
}
|
|
196
|
+
return required;
|
|
197
|
+
}
|
|
198
|
+
|
|
145
199
|
function main() {
|
|
146
200
|
const cfg = readRemoteEnvConfig();
|
|
147
201
|
const secretsDir = process.argv[2];
|
|
148
202
|
|
|
149
203
|
verifyToolchain(cfg.tools);
|
|
204
|
+
verifyNotProxied(readRequired(), process.env, check);
|
|
150
205
|
|
|
151
206
|
const surface = node([
|
|
152
207
|
new URL(
|
|
@@ -47,6 +47,16 @@ function fingerprint(value) {
|
|
|
47
47
|
return createHash("sha256").update(value).digest("hex").slice(0, 12);
|
|
48
48
|
}
|
|
49
49
|
|
|
50
|
+
/**
|
|
51
|
+
* The value a proxied credential reads as instead of a usable token.
|
|
52
|
+
*
|
|
53
|
+
* Some surfaces keep a credential outside the sandbox entirely and substitute
|
|
54
|
+
* the real value at egress. The variable is then present and non-empty, which
|
|
55
|
+
* is exactly what makes it dangerous: a presence check passes, and only a
|
|
56
|
+
* consumer that reads the variable *itself* discovers it holds a placeholder.
|
|
57
|
+
*/
|
|
58
|
+
const PROXY_PLACEHOLDER = "proxy-injected";
|
|
59
|
+
|
|
50
60
|
/**
|
|
51
61
|
* Assert every declared name actually resolves.
|
|
52
62
|
* @param {object} cfg Resolved configuration.
|
|
@@ -56,18 +66,36 @@ function fingerprint(value) {
|
|
|
56
66
|
*/
|
|
57
67
|
export function checkRequired(cfg, provider, file, report) {
|
|
58
68
|
for (const name of cfg.require ?? []) {
|
|
59
|
-
const
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
if (resolves) report("ok", name, "resolves");
|
|
64
|
-
else
|
|
69
|
+
const fromEnv = (process.env[name] ?? "").trim();
|
|
70
|
+
const resolves = fromEnv || file.get(name) || provider.get(name)?.value;
|
|
71
|
+
|
|
72
|
+
if (!resolves) {
|
|
65
73
|
report(
|
|
66
74
|
"error",
|
|
67
75
|
name,
|
|
68
76
|
"declared in secrets.require but resolves nowhere — a startup error, " +
|
|
69
77
|
"not a late surprise"
|
|
70
78
|
);
|
|
79
|
+
continue;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Reported separately from "resolves" because the two are not the same
|
|
83
|
+
// claim. A tool that authenticates through the proxy works; a script that
|
|
84
|
+
// reads this variable and puts it in an Authorization header sends the
|
|
85
|
+
// literal placeholder and gets a confusing auth failure far from here.
|
|
86
|
+
if (fromEnv === PROXY_PLACEHOLDER) {
|
|
87
|
+
report(
|
|
88
|
+
"warn",
|
|
89
|
+
name,
|
|
90
|
+
`reads as the literal string "${PROXY_PLACEHOLDER}" — the value is ` +
|
|
91
|
+
`substituted at egress and never enters this environment. Tools that ` +
|
|
92
|
+
`authenticate through the proxy work; anything reading this variable ` +
|
|
93
|
+
`directly gets the placeholder, not a credential`
|
|
94
|
+
);
|
|
95
|
+
continue;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
report("ok", name, "resolves");
|
|
71
99
|
}
|
|
72
100
|
}
|
|
73
101
|
|
|
@@ -28,11 +28,36 @@ import { join } from "node:path";
|
|
|
28
28
|
* drift and leak. It is *required* on surfaces whose bootstrap runs before the
|
|
29
29
|
* consuming process exists and which therefore have no other channel; a remote
|
|
30
30
|
* agent container prepares itself during setup, long before any task starts.
|
|
31
|
+
*
|
|
32
|
+
* `materializeAt` — *when* the write happens, for surfaces that do one. The two
|
|
33
|
+
* remote surfaces share the capability but not the timing, and a reader adding a
|
|
34
|
+
* third should not assume otherwise. `codex-cloud` re-runs its setup script when
|
|
35
|
+
* a container resumes, so materializing during setup picks up a rotated value.
|
|
36
|
+
* `claude-web` *skips* its setup script whenever a filesystem cache exists, so
|
|
37
|
+
* materializing there would strand a rotated value until the cache expired —
|
|
38
|
+
* it materializes from a session-start hook instead, which runs every session.
|
|
39
|
+
*
|
|
40
|
+
* Stated as a capability rather than inferred from the surface name, so the
|
|
41
|
+
* setup runner selects its phases from the table instead of branching on which
|
|
42
|
+
* vendor it happens to be talking to.
|
|
31
43
|
*/
|
|
32
44
|
export const SURFACES = {
|
|
33
|
-
local: { materialized: false, mayWriteValues: false },
|
|
34
|
-
"github-actions": {
|
|
35
|
-
|
|
45
|
+
local: { materialized: false, mayWriteValues: false, materializeAt: null },
|
|
46
|
+
"github-actions": {
|
|
47
|
+
materialized: false,
|
|
48
|
+
mayWriteValues: false,
|
|
49
|
+
materializeAt: null,
|
|
50
|
+
},
|
|
51
|
+
"codex-cloud": {
|
|
52
|
+
materialized: true,
|
|
53
|
+
mayWriteValues: true,
|
|
54
|
+
materializeAt: "setup",
|
|
55
|
+
},
|
|
56
|
+
"claude-web": {
|
|
57
|
+
materialized: true,
|
|
58
|
+
mayWriteValues: true,
|
|
59
|
+
materializeAt: "session-start",
|
|
60
|
+
},
|
|
36
61
|
};
|
|
37
62
|
|
|
38
63
|
/** Config defaults when `.lisa.config.json` carries no `secrets` block. */
|
|
@@ -68,6 +93,10 @@ export function detectSurface(configured = null, env = process.env) {
|
|
|
68
93
|
return explicit;
|
|
69
94
|
}
|
|
70
95
|
if ((env.GITHUB_ACTIONS ?? "") === "true") return "github-actions";
|
|
96
|
+
// Compared to the exact string rather than tested for presence: the variable
|
|
97
|
+
// is documented as carrying "true" in a cloud session and never being true
|
|
98
|
+
// locally, so a presence test would misread a shell that exports it empty.
|
|
99
|
+
if ((env.CLAUDE_CODE_REMOTE ?? "") === "true") return "claude-web";
|
|
71
100
|
if ((env.CODEX_SANDBOX ?? env.CODEX_HOME ?? "") !== "") return "codex-cloud";
|
|
72
101
|
return "local";
|
|
73
102
|
}
|
|
@@ -19,14 +19,59 @@
|
|
|
19
19
|
import { existsSync, readFileSync } from "node:fs";
|
|
20
20
|
import { join } from "node:path";
|
|
21
21
|
|
|
22
|
+
import { SURFACES as SURFACE_CAPABILITIES } from "./surfaces.mjs";
|
|
23
|
+
|
|
22
24
|
/** Providers with a read implementation today. */
|
|
23
25
|
const IMPLEMENTED_PROVIDERS = new Set(["bitwarden", "doppler", "env"]);
|
|
24
26
|
|
|
25
27
|
/** Providers named in the dispatch table but not yet implemented. */
|
|
26
28
|
const DECLARED_PROVIDERS = new Set(["1password", "aws", "vault"]);
|
|
27
29
|
|
|
28
|
-
/**
|
|
29
|
-
|
|
30
|
+
/**
|
|
31
|
+
* Surfaces the resolver knows.
|
|
32
|
+
*
|
|
33
|
+
* Derived from the resolver's own table rather than restated here. The two
|
|
34
|
+
* lists previously drifted apart by construction: adding a surface meant
|
|
35
|
+
* remembering to edit a second file, and forgetting produced a config that
|
|
36
|
+
* resolved correctly at runtime while `doctor` called it unknown.
|
|
37
|
+
*/
|
|
38
|
+
const SURFACES = new Set(Object.keys(SURFACE_CAPABILITIES));
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* What a provisioned surface must record before anything dispatches to it.
|
|
42
|
+
*
|
|
43
|
+
* Deliberately not uniform, because these surfaces do not bind the same way. A
|
|
44
|
+
* Codex Cloud environment is bound to one repository, so naming the repository
|
|
45
|
+
* is part of proving the environment is the right one. A Claude cloud
|
|
46
|
+
* environment has no repository at all — it is account-scoped configuration
|
|
47
|
+
* (network policy, variables, setup script) and the repository arrives per
|
|
48
|
+
* session — so its durable handle is the routine that dispatch fires.
|
|
49
|
+
*
|
|
50
|
+
* Requiring `repository` of every surface, as this file used to, would demand
|
|
51
|
+
* a field that cannot be true of `claude-web` in any meaningful sense.
|
|
52
|
+
*
|
|
53
|
+
* `repository` stays the default so every existing surface keeps its current
|
|
54
|
+
* contract. This file checks structure only — whether a declaration *could* be
|
|
55
|
+
* correct — so it deliberately does not restate the fuller preconditions that
|
|
56
|
+
* `lisa-remote-dispatch` enforces at the moment it actually dispatches.
|
|
57
|
+
*/
|
|
58
|
+
const SURFACE_BINDINGS = {
|
|
59
|
+
"claude-web": ["routineId", "fireUrl"],
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* The fields a surface must record, with the default applied.
|
|
64
|
+
*
|
|
65
|
+
* Read through one helper rather than at each call site, because the two
|
|
66
|
+
* callers answer different questions — "could this declaration be correct" and
|
|
67
|
+
* "may an automation dispatch to it" — and a fallback that drifted between them
|
|
68
|
+
* would let those answers disagree about the very same config.
|
|
69
|
+
* @param {string} surface Surface name.
|
|
70
|
+
* @returns {string[]} Field names that must be present.
|
|
71
|
+
*/
|
|
72
|
+
function bindingsFor(surface) {
|
|
73
|
+
return SURFACE_BINDINGS[surface] ?? ["repository"];
|
|
74
|
+
}
|
|
30
75
|
|
|
31
76
|
/** Install methods the toolchain runner supports. */
|
|
32
77
|
const INSTALL_METHODS = new Set(["release-zip", "npm-global"]);
|
|
@@ -139,15 +184,30 @@ export function validateRemoteEnv(remoteEnv) {
|
|
|
139
184
|
for (const [surface, block] of Object.entries(remoteEnv.surfaces ?? {})) {
|
|
140
185
|
if (!SURFACES.has(surface)) {
|
|
141
186
|
problems.push(`remoteEnv.surfaces has unknown surface "${surface}"`);
|
|
187
|
+
continue;
|
|
142
188
|
}
|
|
143
|
-
|
|
144
|
-
|
|
189
|
+
for (const field of bindingsFor(surface)) {
|
|
190
|
+
if (!block[field]) {
|
|
191
|
+
problems.push(`remoteEnv.surfaces["${surface}"] has no ${field}`);
|
|
192
|
+
}
|
|
145
193
|
}
|
|
146
194
|
}
|
|
147
195
|
|
|
148
196
|
return problems;
|
|
149
197
|
}
|
|
150
198
|
|
|
199
|
+
/**
|
|
200
|
+
* Report whether a surface has been provisioned far enough to dispatch to.
|
|
201
|
+
* @param {object|undefined} remoteEnv The remote-environment block.
|
|
202
|
+
* @param {string} surface Surface name.
|
|
203
|
+
* @returns {boolean} Whether every binding field is recorded.
|
|
204
|
+
*/
|
|
205
|
+
export function isProvisioned(remoteEnv, surface) {
|
|
206
|
+
const block = remoteEnv?.surfaces?.[surface];
|
|
207
|
+
if (!block) return false;
|
|
208
|
+
return bindingsFor(surface).every(field => Boolean(block[field]));
|
|
209
|
+
}
|
|
210
|
+
|
|
151
211
|
/**
|
|
152
212
|
* Validate the `automations` block against declared surfaces.
|
|
153
213
|
* @param {object|undefined} automations The block, if present.
|
|
@@ -173,7 +233,7 @@ export function validateAutomations(automations, remoteEnv) {
|
|
|
173
233
|
);
|
|
174
234
|
continue;
|
|
175
235
|
}
|
|
176
|
-
if (!remoteEnv
|
|
236
|
+
if (!isProvisioned(remoteEnv, loop.executionEnv)) {
|
|
177
237
|
problems.push(
|
|
178
238
|
`automations["${name}"] dispatches to "${loop.executionEnv}", which is ` +
|
|
179
239
|
`not provisioned. Run /lisa:setup:remote-env ${loop.executionEnv} first.`
|