@codyswann/lisa 2.319.0 → 2.321.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 +16 -4
- 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/surfaces.mjs +27 -10
- package/plugins/lisa/.codex-plugin/skills/lisa-setup-remote-env/SKILL.md +51 -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 +376 -25
- 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/surfaces.mjs +27 -10
- package/plugins/lisa/skills/lisa-setup-remote-env/SKILL.md +51 -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 +376 -25
- 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/surfaces.mjs +27 -10
- package/plugins/lisa-agy/skills/lisa-setup-remote-env/SKILL.md +51 -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 +376 -25
- 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/surfaces.mjs +27 -10
- package/plugins/lisa-copilot/skills/lisa-setup-remote-env/SKILL.md +51 -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 +376 -25
- 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/surfaces.mjs +27 -10
- package/plugins/lisa-cursor/skills/lisa-setup-remote-env/SKILL.md +51 -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 +376 -25
- 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/surfaces.mjs +27 -10
- package/plugins/src/base/skills/lisa-setup-remote-env/SKILL.md +51 -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 +376 -25
- package/plugins/src/base/skills/lisa-setup-remote-env/scripts/verify-remote-env.mjs +56 -1
- package/scripts/lisa-remote-env/session-start.sh +30 -0
- package/scripts/lisa-remote-env/setup.sh +69 -0
|
@@ -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
|
|
|
@@ -24,9 +35,10 @@ import {
|
|
|
24
35
|
mkdirSync,
|
|
25
36
|
readFileSync,
|
|
26
37
|
rmSync,
|
|
38
|
+
writeFileSync,
|
|
27
39
|
} from "node:fs";
|
|
28
40
|
import { dirname, join, resolve } from "node:path";
|
|
29
|
-
import { fileURLToPath } from "node:url";
|
|
41
|
+
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
30
42
|
|
|
31
43
|
import { assertPinned, extractVersion, planToolchain } from "./toolchain.mjs";
|
|
32
44
|
|
|
@@ -78,18 +90,35 @@ export function readRemoteEnvConfig(cwd = process.cwd()) {
|
|
|
78
90
|
|
|
79
91
|
/**
|
|
80
92
|
* Probe what a tool reports for its version, treating absence as not present.
|
|
93
|
+
*
|
|
94
|
+
* "Not installed" and "dislikes `--version`" are different answers, and only a
|
|
95
|
+
* failure to *spawn* means the first. Info-ZIP's `unzip` — the build shipped by
|
|
96
|
+
* both macOS and Ubuntu — parses `--version` one letter at a time, warns that
|
|
97
|
+
* `-n` and `-o` conflict, and exits 10. Reading that as absence made `require`
|
|
98
|
+
* fail on a machine that had the tool, which is precisely the false alarm the
|
|
99
|
+
* check exists to avoid raising.
|
|
100
|
+
*
|
|
101
|
+
* Version text is still recovered from a failed invocation where there is any,
|
|
102
|
+
* because a tool that refuses the flag often prints its banner anyway — which
|
|
103
|
+
* is how `unzip` still reports 6.00 despite exiting non-zero.
|
|
104
|
+
*
|
|
105
|
+
* The runner is injectable so both branches can be exercised without depending
|
|
106
|
+
* on which quirky binaries happen to exist on the machine running the tests.
|
|
81
107
|
* @param {string} name Executable name.
|
|
108
|
+
* @param {Function} [exec] Command runner, for tests.
|
|
82
109
|
* @returns {{version: string|null, present: boolean}} Probe result.
|
|
83
110
|
*/
|
|
84
|
-
function probe(name) {
|
|
111
|
+
export function probe(name, exec = execFileSync) {
|
|
85
112
|
try {
|
|
86
|
-
const out =
|
|
113
|
+
const out = exec(name, ["--version"], {
|
|
87
114
|
encoding: "utf8",
|
|
88
|
-
stdio: ["ignore", "pipe", "
|
|
115
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
89
116
|
});
|
|
90
117
|
return { present: true, version: extractVersion(out) };
|
|
91
|
-
} catch {
|
|
92
|
-
return { present: false, version: null };
|
|
118
|
+
} catch (err) {
|
|
119
|
+
if (err.code === "ENOENT") return { present: false, version: null };
|
|
120
|
+
const output = `${err.stdout ?? ""}${err.stderr ?? ""}`;
|
|
121
|
+
return { present: true, version: extractVersion(output) };
|
|
93
122
|
}
|
|
94
123
|
}
|
|
95
124
|
|
|
@@ -197,31 +226,353 @@ function runHook(hook, dryRun) {
|
|
|
197
226
|
execFileSync("bash", [path], { stdio: "inherit" });
|
|
198
227
|
}
|
|
199
228
|
|
|
200
|
-
|
|
229
|
+
/** Phases this runner can execute, in the order they must happen. */
|
|
230
|
+
const PHASES = ["toolchain", "secrets", "hook"];
|
|
231
|
+
|
|
232
|
+
/** Where a host project keeps the scripts its remote environment invokes. */
|
|
233
|
+
export const INSTALL_DIR = join("scripts", "lisa-remote-env");
|
|
234
|
+
|
|
235
|
+
/** Assets a host project needs on disk before any remote session can start. */
|
|
236
|
+
const INSTALLABLE = ["setup.sh", "session-start.sh"];
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* Copy this skill's assets into the host project that will run them.
|
|
240
|
+
*
|
|
241
|
+
* These live in the skill, but the paths that reference them are *repository*
|
|
242
|
+
* paths: a vendor's setup field says `bash scripts/lisa-remote-env/setup.sh`,
|
|
243
|
+
* and a session-start hook is only committed if it is a real file in the repo.
|
|
244
|
+
* Nothing put them there, so every reference resolved to a path that did not
|
|
245
|
+
* exist — the environment came up, ran a missing script, exited non-zero, and
|
|
246
|
+
* the session failed to start with no indication that a setup step was skipped.
|
|
247
|
+
*
|
|
248
|
+
* Copied rather than symlinked or generated: the file must survive in a fresh
|
|
249
|
+
* clone on a container that has never seen the plugin, which is the whole
|
|
250
|
+
* reason the entrypoint is thin enough to copy in the first place.
|
|
251
|
+
* @param {string} [cwd] Repository root.
|
|
252
|
+
* @returns {Array<{name: string, action: string}>} What was written.
|
|
253
|
+
*/
|
|
254
|
+
export function installAssets(cwd = process.cwd()) {
|
|
255
|
+
const destination = join(cwd, INSTALL_DIR);
|
|
256
|
+
mkdirSync(destination, { recursive: true, mode: 0o755 });
|
|
257
|
+
return INSTALLABLE.map(name => {
|
|
258
|
+
const source = resolve(HERE, "..", "assets", name);
|
|
259
|
+
if (!existsSync(source)) {
|
|
260
|
+
throw new Error(`asset ${name} is missing from this skill install`);
|
|
261
|
+
}
|
|
262
|
+
const target = join(destination, name);
|
|
263
|
+
const desired = readFileSync(source, "utf8");
|
|
264
|
+
const unchanged =
|
|
265
|
+
existsSync(target) && readFileSync(target, "utf8") === desired;
|
|
266
|
+
if (!unchanged) writeFileSync(target, desired, { mode: 0o755 });
|
|
267
|
+
chmodSync(target, 0o755);
|
|
268
|
+
return { name, action: unchanged ? "current" : "written" };
|
|
269
|
+
});
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/**
|
|
273
|
+
* The settings block that wires the session-start hook into a repository.
|
|
274
|
+
*
|
|
275
|
+
* Emitted rather than written, because `.claude/settings.json` belongs to the
|
|
276
|
+
* project: it may already carry hooks, and merging someone else's file from
|
|
277
|
+
* here is how a careless tool destroys a configuration it did not understand.
|
|
278
|
+
*/
|
|
279
|
+
const SESSION_START_BLOCK = `{
|
|
280
|
+
"hooks": {
|
|
281
|
+
"SessionStart": [
|
|
282
|
+
{
|
|
283
|
+
"matcher": "startup|resume",
|
|
284
|
+
"hooks": [
|
|
285
|
+
{
|
|
286
|
+
"type": "command",
|
|
287
|
+
"command": "bash \\"$CLAUDE_PROJECT_DIR\\"/scripts/lisa-remote-env/session-start.sh"
|
|
288
|
+
}
|
|
289
|
+
]
|
|
290
|
+
}
|
|
291
|
+
]
|
|
292
|
+
}
|
|
293
|
+
}`;
|
|
294
|
+
|
|
295
|
+
/**
|
|
296
|
+
* Pin which cloud environment this project's sessions use.
|
|
297
|
+
*
|
|
298
|
+
* `/remote-env` writes `remote.defaultEnvironmentId` into *user* settings, which
|
|
299
|
+
* is one value for the whole machine. That is wrong as soon as a developer has
|
|
300
|
+
* more than one project, because an environment's contents are project-shaped:
|
|
301
|
+
* its setup script is a repository-relative path and its bootstrap is scoped to
|
|
302
|
+
* that project's secrets. Pointing one project's session at another's
|
|
303
|
+
* environment runs a setup script that may not exist there, and a non-zero setup
|
|
304
|
+
* script means the session never starts.
|
|
305
|
+
*
|
|
306
|
+
* Written to `.claude/settings.local.json` for two reasons. It outranks user
|
|
307
|
+
* settings, so the per-project choice wins over the machine-wide default; and it
|
|
308
|
+
* is gitignored, which is correct because an environment belongs to one
|
|
309
|
+
* developer's account and is meaningless in someone else's checkout.
|
|
310
|
+
*
|
|
311
|
+
* Merged rather than replaced — that file commonly holds permission grants a
|
|
312
|
+
* developer has accumulated, and clobbering them to write one key would be a
|
|
313
|
+
* poor trade.
|
|
314
|
+
* @param {string} environmentId Cloud environment identifier.
|
|
315
|
+
* @param {string} [cwd] Repository root.
|
|
316
|
+
* @returns {string} The settings path written.
|
|
317
|
+
*/
|
|
318
|
+
export function pinEnvironment(environmentId, cwd = process.cwd()) {
|
|
319
|
+
const path = join(cwd, ".claude", "settings.local.json");
|
|
320
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
321
|
+
const existing = existsSync(path)
|
|
322
|
+
? JSON.parse(readFileSync(path, "utf8"))
|
|
323
|
+
: {};
|
|
324
|
+
const merged = {
|
|
325
|
+
...existing,
|
|
326
|
+
remote: { ...(existing.remote ?? {}), defaultEnvironmentId: environmentId },
|
|
327
|
+
};
|
|
328
|
+
writeFileSync(path, `${JSON.stringify(merged, null, 2)}\n`);
|
|
329
|
+
return path;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
/**
|
|
333
|
+
* Produce the configuration a human pastes to provision a Claude cloud surface.
|
|
334
|
+
*
|
|
335
|
+
* This surface has no API tier and no console tier to fall back to: a Claude
|
|
336
|
+
* cloud environment is configured only in the environment dialog, which has no
|
|
337
|
+
* settings page, no direct URL, and no endpoint. Emit is not a degraded option
|
|
338
|
+
* here, it is the only one — so the read-back in `verify-remote-env.mjs` is
|
|
339
|
+
* what makes the result trustworthy, exactly as it would be at any other tier.
|
|
340
|
+
* @param {{bootstrapKey: string|null, install: string}} options Project details.
|
|
341
|
+
* @returns {string} Text to show the operator.
|
|
342
|
+
*/
|
|
343
|
+
export function emitClaudeWeb({ bootstrapKey, install }) {
|
|
344
|
+
const key = bootstrapKey ?? "<secrets.bootstrap.key is not configured>";
|
|
345
|
+
return [
|
|
346
|
+
"Provisioning tier: EMIT — and for this surface that is the only tier.",
|
|
347
|
+
" A Claude cloud environment is account-scoped configuration edited in the",
|
|
348
|
+
" environment selector at claude.ai/code. There is no settings page, no",
|
|
349
|
+
" direct URL and no API, so nothing here can provision it for you.",
|
|
350
|
+
"",
|
|
351
|
+
"Paste into the environment dialog",
|
|
352
|
+
"---------------------------------",
|
|
353
|
+
" Network access: Trusted, or Custom plus any host your project needs",
|
|
354
|
+
"",
|
|
355
|
+
" Environment variables:",
|
|
356
|
+
` ${key}=<read this from your credential manager>`,
|
|
357
|
+
"",
|
|
358
|
+
" Only the bootstrap belongs in this box. Values here are stored as",
|
|
359
|
+
" plain text and are readable by anyone who uses the environment — on an",
|
|
360
|
+
" organization-shared environment that is every member of the org. Every",
|
|
361
|
+
" other credential is materialized by the session-start hook below, which",
|
|
362
|
+
" is why exactly one value needs to live here.",
|
|
363
|
+
"",
|
|
364
|
+
" Setup script:",
|
|
365
|
+
` ${install} && bash scripts/lisa-remote-env/setup.sh`,
|
|
366
|
+
"",
|
|
367
|
+
" The install must come first. On a fresh container node_modules is the",
|
|
368
|
+
" only copy of the Lisa skills present, because Claude receives them as",
|
|
369
|
+
" an installed plugin rather than as part of the clone.",
|
|
370
|
+
"",
|
|
371
|
+
"Commit to the repository",
|
|
372
|
+
"------------------------",
|
|
373
|
+
" scripts/lisa-remote-env/session-start.sh (from this skill's assets)",
|
|
374
|
+
"",
|
|
375
|
+
" .claude/settings.json — merge this into any hooks already there:",
|
|
376
|
+
SESSION_START_BLOCK.split("\n")
|
|
377
|
+
.map(line => ` ${line}`)
|
|
378
|
+
.join("\n"),
|
|
379
|
+
"",
|
|
380
|
+
" The setup script is skipped whenever a cached environment exists, so",
|
|
381
|
+
" secrets are materialized from this hook instead. It runs every session,",
|
|
382
|
+
" including a resumed one, which is what keeps a rotated value current.",
|
|
383
|
+
"",
|
|
384
|
+
"Worth knowing about this base image",
|
|
385
|
+
"-----------------------------------",
|
|
386
|
+
" The GitHub CLI is not pre-installed. If this project's flows shell out to",
|
|
387
|
+
" `gh`, add it to remoteEnv.tools.install with a pinned version and",
|
|
388
|
+
" checksum, the same as any other tool.",
|
|
389
|
+
"",
|
|
390
|
+
" A credential handled by the GitHub proxy reads as the literal string",
|
|
391
|
+
' "proxy-injected" inside the session. Tools that authenticate through the',
|
|
392
|
+
" proxy work; a script that reads the variable itself gets the placeholder.",
|
|
393
|
+
"",
|
|
394
|
+
"Then prove it",
|
|
395
|
+
"-------------",
|
|
396
|
+
" Start a session and run the read-back. Whatever provisioned an",
|
|
397
|
+
" environment, the same verify is what makes it trustworthy:",
|
|
398
|
+
" node node_modules/@codyswann/lisa/plugins/lisa/skills/lisa-setup-remote-env/scripts/verify-remote-env.mjs",
|
|
399
|
+
].join("\n");
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
/**
|
|
403
|
+
* Decide which phases this invocation runs.
|
|
404
|
+
*
|
|
405
|
+
* An explicit `--phase` always wins, because that is how a session-start hook
|
|
406
|
+
* asks for the one phase it owns. Otherwise every phase runs except a secrets
|
|
407
|
+
* step that belongs to a different moment on this surface — running it here
|
|
408
|
+
* would produce a copy that the surface never refreshes.
|
|
409
|
+
* @param {string|undefined} requested Value of `--phase`, when given.
|
|
410
|
+
* @param {string|null} materializeAt When this surface materializes.
|
|
411
|
+
* @returns {string[]} Phases to run, in order.
|
|
412
|
+
*/
|
|
413
|
+
export function selectPhases(requested, materializeAt) {
|
|
414
|
+
if (requested) {
|
|
415
|
+
if (!PHASES.includes(requested)) {
|
|
416
|
+
throw new Error(
|
|
417
|
+
`unknown --phase "${requested}". Known: ${PHASES.join(", ")}.`
|
|
418
|
+
);
|
|
419
|
+
}
|
|
420
|
+
if (requested === "secrets" && materializeAt !== "session-start") {
|
|
421
|
+
// Not an error: the hook is committed to the repository and runs on every
|
|
422
|
+
// surface the project is ever checked out on. Refusing loudly would make
|
|
423
|
+
// a correct local session look broken every time it started.
|
|
424
|
+
return [];
|
|
425
|
+
}
|
|
426
|
+
return [requested];
|
|
427
|
+
}
|
|
428
|
+
return PHASES.filter(
|
|
429
|
+
phase => phase !== "secrets" || materializeAt === "setup"
|
|
430
|
+
);
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
/**
|
|
434
|
+
* Read the surface the resolver detects, without duplicating its rules.
|
|
435
|
+
*
|
|
436
|
+
* Imported rather than shelled out to, because the answer is a pure function of
|
|
437
|
+
* the environment and a subprocess would buy nothing. The path is converted to
|
|
438
|
+
* a file URL first: a bare absolute path is not a portable module specifier.
|
|
439
|
+
* @returns {Promise<{surface: string, materializeAt: string|null}>} Detected surface.
|
|
440
|
+
*/
|
|
441
|
+
async function detectSurface() {
|
|
442
|
+
const script = siblingScript("lisa-secrets-access", "surfaces.mjs");
|
|
443
|
+
const mod = await import(pathToFileURL(script).href);
|
|
444
|
+
const surface = mod.detectSurface();
|
|
445
|
+
return { surface, materializeAt: mod.SURFACES[surface].materializeAt };
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
/**
|
|
449
|
+
* Name the project's own install command rather than inventing one.
|
|
450
|
+
*
|
|
451
|
+
* The emitted setup line must begin with whatever this project already uses; a
|
|
452
|
+
* guessed package manager produces a container that fails on its first command.
|
|
453
|
+
* @param {string} [cwd] Repository root.
|
|
454
|
+
* @returns {string} The install command to place before the setup script.
|
|
455
|
+
*/
|
|
456
|
+
export function detectInstallCommand(cwd = process.cwd()) {
|
|
457
|
+
const lockfiles = [
|
|
458
|
+
["bun.lockb", "bun install"],
|
|
459
|
+
["bun.lock", "bun install"],
|
|
460
|
+
["pnpm-lock.yaml", "pnpm install --frozen-lockfile"],
|
|
461
|
+
["yarn.lock", "yarn install --immutable"],
|
|
462
|
+
["package-lock.json", "npm ci"],
|
|
463
|
+
];
|
|
464
|
+
for (const [file, command] of lockfiles) {
|
|
465
|
+
if (existsSync(join(cwd, file))) return command;
|
|
466
|
+
}
|
|
467
|
+
return "<your install command>";
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
/**
|
|
471
|
+
* Read the bootstrap key name, which is the one value the operator must paste.
|
|
472
|
+
* @param {string} [cwd] Repository root.
|
|
473
|
+
* @returns {string|null} The configured key name, when there is one.
|
|
474
|
+
*/
|
|
475
|
+
function readBootstrapKey(cwd = process.cwd()) {
|
|
476
|
+
const path = join(cwd, ".lisa.config.json");
|
|
477
|
+
if (!existsSync(path)) return null;
|
|
478
|
+
return JSON.parse(readFileSync(path, "utf8")).secrets?.bootstrap?.key ?? null;
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
async function main() {
|
|
201
482
|
const dryRun = process.argv.includes("--dry-run");
|
|
483
|
+
const emit = process.argv
|
|
484
|
+
.find(arg => arg.startsWith("--emit="))
|
|
485
|
+
?.slice("--emit=".length);
|
|
486
|
+
|
|
487
|
+
if (process.argv.includes("--install")) {
|
|
488
|
+
console.log(`Installing remote-environment scripts into ${INSTALL_DIR}/`);
|
|
489
|
+
for (const { name, action } of installAssets()) {
|
|
490
|
+
console.log(` ${action.padEnd(8)} ${join(INSTALL_DIR, name)}`);
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
const pin = process.argv
|
|
494
|
+
.find(arg => arg.startsWith("--pin-env="))
|
|
495
|
+
?.slice("--pin-env=".length);
|
|
496
|
+
if (pin) {
|
|
497
|
+
console.log(`\nPinned this project to environment ${pin}`);
|
|
498
|
+
console.log(` written ${pinEnvironment(pin)}`);
|
|
499
|
+
console.log(
|
|
500
|
+
" That file is gitignored and outranks the machine-wide default, so\n" +
|
|
501
|
+
" every project can name its own environment."
|
|
502
|
+
);
|
|
503
|
+
}
|
|
504
|
+
console.log(
|
|
505
|
+
"\nCommit these. They are repository files by design — a container that " +
|
|
506
|
+
"has\njust cloned the repo has never seen the plugin they came from."
|
|
507
|
+
);
|
|
508
|
+
return;
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
if (emit) {
|
|
512
|
+
if (emit !== "claude-web") {
|
|
513
|
+
throw new Error(
|
|
514
|
+
`no emit template for surface "${emit}".\n` +
|
|
515
|
+
`Emitting is implemented for claude-web, which has no other tier.`
|
|
516
|
+
);
|
|
517
|
+
}
|
|
518
|
+
console.log(
|
|
519
|
+
emitClaudeWeb({
|
|
520
|
+
bootstrapKey: readBootstrapKey(),
|
|
521
|
+
install: detectInstallCommand(),
|
|
522
|
+
})
|
|
523
|
+
);
|
|
524
|
+
return;
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
const requested = process.argv
|
|
528
|
+
.find(arg => arg.startsWith("--phase="))
|
|
529
|
+
?.slice("--phase=".length);
|
|
202
530
|
const cfg = readRemoteEnvConfig();
|
|
531
|
+
const { surface, materializeAt } = await detectSurface();
|
|
532
|
+
const phases = selectPhases(requested, materializeAt);
|
|
203
533
|
|
|
204
|
-
|
|
205
|
-
|
|
534
|
+
if (!phases.length) {
|
|
535
|
+
console.log(
|
|
536
|
+
`Nothing to do: surface "${surface}" does not materialize from a ` +
|
|
537
|
+
`session-start hook.`
|
|
538
|
+
);
|
|
539
|
+
return;
|
|
540
|
+
}
|
|
206
541
|
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
);
|
|
212
|
-
execFileSync("node", dryRun ? [materialize, "--dry-run"] : [materialize], {
|
|
213
|
-
stdio: "inherit",
|
|
214
|
-
});
|
|
542
|
+
if (phases.includes("toolchain")) {
|
|
543
|
+
console.log("Toolchain:");
|
|
544
|
+
applyToolchain(cfg.tools, dryRun);
|
|
545
|
+
}
|
|
215
546
|
|
|
216
|
-
|
|
547
|
+
if (phases.includes("secrets")) {
|
|
548
|
+
console.log("\nSecrets:");
|
|
549
|
+
const materialize = siblingScript(
|
|
550
|
+
"lisa-secrets-access",
|
|
551
|
+
"materialize-secrets.mjs"
|
|
552
|
+
);
|
|
553
|
+
execFileSync("node", dryRun ? [materialize, "--dry-run"] : [materialize], {
|
|
554
|
+
stdio: "inherit",
|
|
555
|
+
});
|
|
556
|
+
} else if (!requested) {
|
|
557
|
+
console.log(
|
|
558
|
+
`\nSecrets: materialized from a session-start hook on "${surface}", ` +
|
|
559
|
+
`not here.\n` +
|
|
560
|
+
` This script is skipped whenever a cached environment exists, so a ` +
|
|
561
|
+
`value written\n here would go stale the moment it was rotated. The ` +
|
|
562
|
+
`hook runs every session.`
|
|
563
|
+
);
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
if (phases.includes("hook")) runHook(cfg.hook, dryRun);
|
|
217
567
|
console.log(`\nRemote environment ${dryRun ? "plan complete" : "ready"}.`);
|
|
218
568
|
}
|
|
219
569
|
|
|
220
570
|
if (import.meta.url === `file://${process.argv[1]}`) {
|
|
221
|
-
try
|
|
222
|
-
|
|
223
|
-
|
|
571
|
+
// Awaited rather than called bare: main is async, so a synchronous try/catch
|
|
572
|
+
// would let a rejected promise escape as an unhandled rejection and exit 0 —
|
|
573
|
+
// reporting a prepared environment that was never prepared.
|
|
574
|
+
main().catch(err => {
|
|
224
575
|
console.error(err.message);
|
|
225
576
|
process.exit(1);
|
|
226
|
-
}
|
|
577
|
+
});
|
|
227
578
|
}
|
|
@@ -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(
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
#
|
|
3
|
+
# Session-start entrypoint for surfaces that materialize secrets per session
|
|
4
|
+
# rather than during environment setup.
|
|
5
|
+
#
|
|
6
|
+
# Wired into the repository's `.claude/settings.json` as a SessionStart hook,
|
|
7
|
+
# so it is part of the clone and runs on every session — including a session
|
|
8
|
+
# resumed onto a cached environment, which is the case that matters.
|
|
9
|
+
#
|
|
10
|
+
# Why this exists at all: a cloud environment's setup script is skipped whenever
|
|
11
|
+
# a filesystem cache exists. Materializing there would write the values once and
|
|
12
|
+
# never refresh them, so a rotated credential would stay stale until the cache
|
|
13
|
+
# expired days later. This hook runs every session, so the copy on disk is
|
|
14
|
+
# always the provider's current view.
|
|
15
|
+
#
|
|
16
|
+
# It is deliberately a guard and a delegation, not a second implementation. The
|
|
17
|
+
# skill-resolution ladder is subtle enough that two copies would drift, so the
|
|
18
|
+
# real work stays in setup.sh and this file only decides whether to call it.
|
|
19
|
+
set -euo pipefail
|
|
20
|
+
|
|
21
|
+
# Exit before doing anything on a machine that is not a remote session. The hook
|
|
22
|
+
# is committed to the repository, so it also fires on every local session; the
|
|
23
|
+
# materialize step would correctly refuse there, but failing on a developer's
|
|
24
|
+
# laptop every time they start a session is noise, not a signal.
|
|
25
|
+
if [ "${CLAUDE_CODE_REMOTE:-}" != "true" ]; then
|
|
26
|
+
exit 0
|
|
27
|
+
fi
|
|
28
|
+
|
|
29
|
+
here="$(CDPATH='' cd -- "$(dirname -- "$0")" && pwd)"
|
|
30
|
+
exec bash "${here}/setup.sh" --phase=secrets "$@"
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
#
|
|
3
|
+
# Remote environment entrypoint. Installed by /lisa:setup:remote-env; the remote
|
|
4
|
+
# environment's setup AND maintenance fields both call this exact path.
|
|
5
|
+
#
|
|
6
|
+
# They are the same script on purpose. A container may be built fresh or resumed
|
|
7
|
+
# from cache, and everything below is idempotent and version-aware — so running
|
|
8
|
+
# it twice is correct, and running it on resume is what picks up a rotated
|
|
9
|
+
# value, an edited note, or a changed version pin.
|
|
10
|
+
#
|
|
11
|
+
# This file is deliberately thin. It resolves an interpreter and hands off; the
|
|
12
|
+
# reviewed, tested, versioned logic lives in the Lisa skill rather than here,
|
|
13
|
+
# and emphatically not in a vendor settings field.
|
|
14
|
+
set -euo pipefail
|
|
15
|
+
|
|
16
|
+
# Node is the one thing that cannot be installed by the installer, since the
|
|
17
|
+
# installer is written in it. Fail with an actionable message rather than a
|
|
18
|
+
# "command not found" forty lines deep.
|
|
19
|
+
if ! command -v node >/dev/null 2>&1; then
|
|
20
|
+
echo "node is required to prepare this environment but is not present." >&2
|
|
21
|
+
echo "It cannot be installed by the toolchain step, because that step runs" >&2
|
|
22
|
+
echo "on node. Pin a base image that provides it." >&2
|
|
23
|
+
exit 1
|
|
24
|
+
fi
|
|
25
|
+
|
|
26
|
+
# Where the skill lives depends on how this project's harness receives it, and
|
|
27
|
+
# the two delivery models differ in a way that matters here.
|
|
28
|
+
#
|
|
29
|
+
# OpenCode and Antigravity get skills written INTO the checkout by `lisa apply`,
|
|
30
|
+
# so a clone already carries them. Claude and Codex receive them as an installed
|
|
31
|
+
# plugin, which lives in the user's home directory and is emphatically NOT part
|
|
32
|
+
# of a clone. A fresh remote container is the second case every time: it clones
|
|
33
|
+
# the repository and has never run a plugin install.
|
|
34
|
+
#
|
|
35
|
+
# So the agent directories are searched first — they are the cheapest hit and
|
|
36
|
+
# need nothing installed — and the npm package is the fallback that makes the
|
|
37
|
+
# plugin-delivered harnesses work at all. Lisa is a dependency of every project
|
|
38
|
+
# it is applied to, so `node_modules` carries the same skill at the version that
|
|
39
|
+
# project pins, which is the version its setup should run.
|
|
40
|
+
runner=""
|
|
41
|
+
for candidate in \
|
|
42
|
+
".claude/skills/lisa-setup-remote-env/scripts/setup-remote-env.mjs" \
|
|
43
|
+
".agents/skills/lisa-setup-remote-env/scripts/setup-remote-env.mjs" \
|
|
44
|
+
".codex/skills/lisa-setup-remote-env/scripts/setup-remote-env.mjs" \
|
|
45
|
+
"node_modules/@codyswann/lisa/plugins/lisa/skills/lisa-setup-remote-env/scripts/setup-remote-env.mjs"; do
|
|
46
|
+
if [ -f "$candidate" ]; then
|
|
47
|
+
runner="$candidate"
|
|
48
|
+
break
|
|
49
|
+
fi
|
|
50
|
+
done
|
|
51
|
+
|
|
52
|
+
if [ -z "$runner" ]; then
|
|
53
|
+
echo "Cannot find the lisa-setup-remote-env skill." >&2
|
|
54
|
+
echo >&2
|
|
55
|
+
echo "Searched the agent skill directories and node_modules. On a remote" >&2
|
|
56
|
+
echo "container the usual cause is that dependencies have not been installed" >&2
|
|
57
|
+
echo "yet: Claude and Codex receive Lisa skills as an installed plugin, which" >&2
|
|
58
|
+
echo "is not part of a clone, so node_modules is the only copy present." >&2
|
|
59
|
+
echo >&2
|
|
60
|
+
echo "Install dependencies before this script runs. The environment's setup" >&2
|
|
61
|
+
echo "command should be the install and this script together, for example:" >&2
|
|
62
|
+
echo " bun install && bash scripts/lisa-remote-env/setup.sh" >&2
|
|
63
|
+
echo >&2
|
|
64
|
+
echo "If dependencies are installed, run 'lisa apply' so the skills are" >&2
|
|
65
|
+
echo "present, then re-run setup." >&2
|
|
66
|
+
exit 1
|
|
67
|
+
fi
|
|
68
|
+
|
|
69
|
+
exec node "$runner" "$@"
|