@codyswann/lisa 2.319.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.
Files changed (84) hide show
  1. package/dist/core/upstream-evidence-manifest.d.ts.map +1 -1
  2. package/dist/core/upstream-evidence-manifest.js +12 -4
  3. package/dist/core/upstream-evidence-manifest.js.map +1 -1
  4. package/package.json +1 -1
  5. package/plugins/lisa/.claude-plugin/plugin.json +1 -1
  6. package/plugins/lisa/.codex-plugin/plugin.json +1 -1
  7. package/plugins/lisa/.codex-plugin/skills/lisa-secrets-access/scripts/surfaces.mjs +27 -10
  8. package/plugins/lisa/.codex-plugin/skills/lisa-setup-remote-env/SKILL.md +39 -2
  9. package/plugins/lisa/.codex-plugin/skills/lisa-setup-remote-env/assets/session-start.sh +30 -0
  10. package/plugins/lisa/.codex-plugin/skills/lisa-setup-remote-env/scripts/setup-remote-env.mjs +252 -20
  11. package/plugins/lisa/.codex-plugin/skills/lisa-setup-remote-env/scripts/verify-remote-env.mjs +56 -1
  12. package/plugins/lisa/skills/lisa-secrets-access/scripts/surfaces.mjs +27 -10
  13. package/plugins/lisa/skills/lisa-setup-remote-env/SKILL.md +39 -2
  14. package/plugins/lisa/skills/lisa-setup-remote-env/assets/session-start.sh +30 -0
  15. package/plugins/lisa/skills/lisa-setup-remote-env/scripts/setup-remote-env.mjs +252 -20
  16. package/plugins/lisa/skills/lisa-setup-remote-env/scripts/verify-remote-env.mjs +56 -1
  17. package/plugins/lisa-agy/plugin.json +1 -1
  18. package/plugins/lisa-agy/skills/lisa-secrets-access/scripts/surfaces.mjs +27 -10
  19. package/plugins/lisa-agy/skills/lisa-setup-remote-env/SKILL.md +39 -2
  20. package/plugins/lisa-agy/skills/lisa-setup-remote-env/assets/session-start.sh +30 -0
  21. package/plugins/lisa-agy/skills/lisa-setup-remote-env/scripts/setup-remote-env.mjs +252 -20
  22. package/plugins/lisa-agy/skills/lisa-setup-remote-env/scripts/verify-remote-env.mjs +56 -1
  23. package/plugins/lisa-cdk/.claude-plugin/plugin.json +1 -1
  24. package/plugins/lisa-cdk/.codex-plugin/plugin.json +1 -1
  25. package/plugins/lisa-cdk-agy/plugin.json +1 -1
  26. package/plugins/lisa-cdk-copilot/.claude-plugin/plugin.json +1 -1
  27. package/plugins/lisa-cdk-cursor/.claude-plugin/plugin.json +1 -1
  28. package/plugins/lisa-copilot/.claude-plugin/plugin.json +1 -1
  29. package/plugins/lisa-copilot/skills/lisa-secrets-access/scripts/surfaces.mjs +27 -10
  30. package/plugins/lisa-copilot/skills/lisa-setup-remote-env/SKILL.md +39 -2
  31. package/plugins/lisa-copilot/skills/lisa-setup-remote-env/assets/session-start.sh +30 -0
  32. package/plugins/lisa-copilot/skills/lisa-setup-remote-env/scripts/setup-remote-env.mjs +252 -20
  33. package/plugins/lisa-copilot/skills/lisa-setup-remote-env/scripts/verify-remote-env.mjs +56 -1
  34. package/plugins/lisa-cursor/.claude-plugin/plugin.json +1 -1
  35. package/plugins/lisa-cursor/skills/lisa-secrets-access/scripts/surfaces.mjs +27 -10
  36. package/plugins/lisa-cursor/skills/lisa-setup-remote-env/SKILL.md +39 -2
  37. package/plugins/lisa-cursor/skills/lisa-setup-remote-env/assets/session-start.sh +30 -0
  38. package/plugins/lisa-cursor/skills/lisa-setup-remote-env/scripts/setup-remote-env.mjs +252 -20
  39. package/plugins/lisa-cursor/skills/lisa-setup-remote-env/scripts/verify-remote-env.mjs +56 -1
  40. package/plugins/lisa-expo/.claude-plugin/plugin.json +1 -1
  41. package/plugins/lisa-expo/.codex-plugin/plugin.json +1 -1
  42. package/plugins/lisa-expo-agy/plugin.json +1 -1
  43. package/plugins/lisa-expo-copilot/.claude-plugin/plugin.json +1 -1
  44. package/plugins/lisa-expo-cursor/.claude-plugin/plugin.json +1 -1
  45. package/plugins/lisa-harper-fabric/.claude-plugin/plugin.json +1 -1
  46. package/plugins/lisa-harper-fabric/.codex-plugin/plugin.json +1 -1
  47. package/plugins/lisa-harper-fabric-agy/plugin.json +1 -1
  48. package/plugins/lisa-harper-fabric-copilot/.claude-plugin/plugin.json +1 -1
  49. package/plugins/lisa-harper-fabric-cursor/.claude-plugin/plugin.json +1 -1
  50. package/plugins/lisa-nestjs/.claude-plugin/plugin.json +1 -1
  51. package/plugins/lisa-nestjs/.codex-plugin/plugin.json +1 -1
  52. package/plugins/lisa-nestjs-agy/plugin.json +1 -1
  53. package/plugins/lisa-nestjs-copilot/.claude-plugin/plugin.json +1 -1
  54. package/plugins/lisa-nestjs-cursor/.claude-plugin/plugin.json +1 -1
  55. package/plugins/lisa-openclaw/.claude-plugin/plugin.json +1 -1
  56. package/plugins/lisa-openclaw/.codex-plugin/plugin.json +1 -1
  57. package/plugins/lisa-openclaw-agy/plugin.json +1 -1
  58. package/plugins/lisa-openclaw-copilot/.claude-plugin/plugin.json +1 -1
  59. package/plugins/lisa-openclaw-cursor/.claude-plugin/plugin.json +1 -1
  60. package/plugins/lisa-phaser/.claude-plugin/plugin.json +1 -1
  61. package/plugins/lisa-phaser/.codex-plugin/plugin.json +1 -1
  62. package/plugins/lisa-phaser-agy/plugin.json +1 -1
  63. package/plugins/lisa-phaser-copilot/.claude-plugin/plugin.json +1 -1
  64. package/plugins/lisa-phaser-cursor/.claude-plugin/plugin.json +1 -1
  65. package/plugins/lisa-rails/.claude-plugin/plugin.json +1 -1
  66. package/plugins/lisa-rails/.codex-plugin/plugin.json +1 -1
  67. package/plugins/lisa-rails-agy/plugin.json +1 -1
  68. package/plugins/lisa-rails-copilot/.claude-plugin/plugin.json +1 -1
  69. package/plugins/lisa-rails-cursor/.claude-plugin/plugin.json +1 -1
  70. package/plugins/lisa-typescript/.claude-plugin/plugin.json +1 -1
  71. package/plugins/lisa-typescript/.codex-plugin/plugin.json +1 -1
  72. package/plugins/lisa-typescript-agy/plugin.json +1 -1
  73. package/plugins/lisa-typescript-copilot/.claude-plugin/plugin.json +1 -1
  74. package/plugins/lisa-typescript-cursor/.claude-plugin/plugin.json +1 -1
  75. package/plugins/lisa-wiki/.claude-plugin/plugin.json +1 -1
  76. package/plugins/lisa-wiki/.codex-plugin/plugin.json +1 -1
  77. package/plugins/lisa-wiki-agy/plugin.json +1 -1
  78. package/plugins/lisa-wiki-copilot/.claude-plugin/plugin.json +1 -1
  79. package/plugins/lisa-wiki-cursor/.claude-plugin/plugin.json +1 -1
  80. package/plugins/src/base/skills/lisa-secrets-access/scripts/surfaces.mjs +27 -10
  81. package/plugins/src/base/skills/lisa-setup-remote-env/SKILL.md +39 -2
  82. package/plugins/src/base/skills/lisa-setup-remote-env/assets/session-start.sh +30 -0
  83. package/plugins/src/base/skills/lisa-setup-remote-env/scripts/setup-remote-env.mjs +252 -20
  84. package/plugins/src/base/skills/lisa-setup-remote-env/scripts/verify-remote-env.mjs +56 -1
@@ -39,6 +39,20 @@ Omitting the install does not degrade gracefully. The entrypoint exits before th
39
39
 
40
40
  Order matters. The toolchain comes first because the secrets step needs the provider CLI it installs. The hook comes last because it is the only part that may assume everything else is ready.
41
41
 
42
+ ### Which phases run depends on the surface
43
+
44
+ Not every surface runs all three here, and the reason is worth understanding before changing it.
45
+
46
+ A surface that **re-runs this script when a container resumes** — Codex Cloud — should materialize during setup. Re-running is exactly what picks up a rotated value, an edited note, or a changed version pin.
47
+
48
+ A surface that **skips this script whenever a filesystem cache exists** — Claude Code web — must not. Materializing here would write the values once and then never refresh them, so a credential rotated on Tuesday would still be serving Monday's value until the cache expired days later. Those surfaces materialize from a session-start hook instead, which runs every session including a resumed one:
49
+
50
+ ```sh
51
+ bash scripts/lisa-remote-env/session-start.sh # guard; delegates to --phase=secrets
52
+ ```
53
+
54
+ The selection comes from the surface's `materializeAt` capability in `lisa-secrets-access`, not from its name, so adding a surface does not mean editing a branch. The hook is committed to the repository, so it also fires on a developer's machine — it exits `0` immediately there rather than failing, because a correct local session must not look broken.
55
+
42
56
  ## Toolchain manifest — two entry kinds
43
57
 
44
58
  ```json
@@ -131,6 +145,25 @@ Environment vars: LISA_SECRETS_SURFACE=codex-cloud
131
145
  maintenance both run before task secrets exist>
132
146
  ```
133
147
 
148
+ ### Claude Code web is emit-only, and that is not a fallback
149
+
150
+ **For Claude Code web there is no tier 1 or tier 2 to fall back from.** A cloud environment is account-scoped configuration — network access level, environment variables, setup script — edited only in the environment selector at claude.ai/code, which has no settings page, no direct URL, and no API. `/remote-env` selects an environment; it cannot create or edit one.
151
+
152
+ Note what this surface's environment is *not*: it carries no repository. The repository arrives per session, so an environment is reusable across every project, and there is nothing to bind. Its durable handle is the routine that dispatch fires, which is why `remoteEnv.surfaces["claude-web"]` records `routineId` and `fireUrl` rather than a repository.
153
+
154
+ Generate the exact text to paste:
155
+
156
+ ```sh
157
+ node scripts/setup-remote-env.mjs --emit=claude-web
158
+ ```
159
+
160
+ It reads the project's own install command from its lockfile and the bootstrap name from `secrets.bootstrap.key`, then emits the environment fields, the `.claude/settings.json` hook block, and the two base-image surprises worth knowing before they cost an afternoon:
161
+
162
+ - **`gh` is not pre-installed.** If the project's flows shell out to it, add it to `remoteEnv.tools.install`, pinned and checksummed like anything else.
163
+ - **A proxied credential reads as the literal string `proxy-injected`.** Tools that authenticate through the GitHub proxy work; a script that reads the variable directly gets the placeholder. The read-back asserts this rather than leaving it to be discovered against a live service.
164
+
165
+ **Only the bootstrap belongs in the environment-variable box.** Values there are stored as plain text and are readable by anyone who uses the environment — on an organization-shared environment, that is every member of the organization. Everything else is materialized by the session-start hook. There is no dedicated secrets store on this surface, and personal versus shared environments cannot be told apart programmatically, so this one is a rule the operator upholds rather than something the tooling can enforce.
166
+
134
167
  ## Verification is tier-independent
135
168
 
136
169
  **Whatever tier provisioned it, the same read-back proves it.** Trust comes from the verify, not the mechanism — which is what makes emit-tier as trustworthy as API-tier.
@@ -139,7 +172,9 @@ Environment vars: LISA_SECRETS_SURFACE=codex-cloud
139
172
  scripts/verify-remote-env.mjs [SECRETS_DIR]
140
173
  ```
141
174
 
142
- Asserts, without printing a value: each declared tool at its pinned or minimum version, the detected surface, the secrets directory at mode `0700`, both files at mode `0600`, and a clean checkout.
175
+ Asserts, without printing a value: each declared tool at its pinned or minimum version, the detected surface, the secrets directory at mode `0700`, both files at mode `0600`, a clean checkout, and that every name in `secrets.require` resolves to a real credential rather than a proxy placeholder.
176
+
177
+ That last one exists because presence is a weaker claim than usability. A credential the surface keeps outside the sandbox and substitutes at egress is present and non-empty, so a presence check passes — and a script that reads the variable and puts it in a header sends the placeholder and fails against the service, with an error pointing anywhere but at the environment.
143
178
 
144
179
  **Never verify against vendor UI state.** On 2026-08-01 a Codex environments table reported zero tasks for an environment that had demonstrably completed one, because the task records carried a null environment identifier and the `--env` filter was correspondingly unreliable. Reconcile through durable identifiers only.
145
180
 
@@ -147,7 +182,7 @@ Asserts, without printing a value: each declared tool at its pinned or minimum v
147
182
 
148
183
  Checked when setup runs, not at 3am:
149
184
 
150
- - the environment exists and is bound to **this** repository as its default checkout;
185
+ - the environment exists, and on a surface that binds one, is bound to **this** repository as its default checkout — Claude cloud environments bind no repository at all, so there is nothing to check there;
151
186
  - the bootstrap credential resolves;
152
187
  - either a checkout-local Lisa skill is present, or the project dependency install has made the pinned `@codyswann/lisa` package available under `node_modules`.
153
188
 
@@ -157,6 +192,8 @@ Fail with a message naming what is missing. Never provision-and-hope.
157
192
 
158
193
  On Codex Cloud the bootstrap must be an **environment variable, not a task secret**: setup runs on a new container and maintenance runs on cache resume, both before task secrets exist. The tradeoff is that the variable remains visible during the task, so compensate by keeping the machine account narrowly scoped and instructing the task never to inspect or use it.
159
194
 
195
+ On Claude Code web the same placement applies for the same reason, with the exposure widened rather than narrowed: there is no secrets store, values are stored as plain text, and anyone who uses the environment can read them. On an organization-shared environment that is every member. Keep the bootstrap in a **personal** environment, scope the machine account to the minimum, and treat every other credential as something the session-start hook materializes rather than something a human pastes.
196
+
160
197
  ## Related
161
198
 
162
199
  - `lisa-secrets-access` — owns every part of the secrets contract this skill composes with.
@@ -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 "$@"
@@ -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 and running it on resume is what picks up a
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
- function main() {
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
- console.log("Toolchain:");
205
- applyToolchain(cfg.tools, dryRun);
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
- console.log("\nSecrets:");
208
- const materialize = siblingScript(
209
- "lisa-secrets-access",
210
- "materialize-secrets.mjs"
211
- );
212
- execFileSync("node", dryRun ? [materialize, "--dry-run"] : [materialize], {
213
- stdio: "inherit",
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
- main();
223
- } catch (err) {
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(
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa",
3
- "version": "2.319.0",
3
+ "version": "2.320.0",
4
4
  "description": "Universal governance — agents, skills, commands, hooks, and rules for all projects",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -29,18 +29,35 @@ import { join } from "node:path";
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
31
  *
32
- * The two remote surfaces share the capability but not the timing, and a reader
33
- * adding a third should not assume otherwise. `codex-cloud` re-runs its setup
34
- * script when a container resumes, so materializing there picks up a rotated
35
- * value. `claude-web` *skips* its setup script whenever a filesystem cache
36
- * exists, so materializing there would strand a rotated value until the cache
37
- * expired its materialize step runs from a session-start hook instead.
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.
38
43
  */
39
44
  export const SURFACES = {
40
- local: { materialized: false, mayWriteValues: false },
41
- "github-actions": { materialized: false, mayWriteValues: false },
42
- "codex-cloud": { materialized: true, mayWriteValues: true },
43
- "claude-web": { materialized: true, mayWriteValues: true },
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
+ },
44
61
  };
45
62
 
46
63
  /** Config defaults when `.lisa.config.json` carries no `secrets` block. */
@@ -39,6 +39,20 @@ Omitting the install does not degrade gracefully. The entrypoint exits before th
39
39
 
40
40
  Order matters. The toolchain comes first because the secrets step needs the provider CLI it installs. The hook comes last because it is the only part that may assume everything else is ready.
41
41
 
42
+ ### Which phases run depends on the surface
43
+
44
+ Not every surface runs all three here, and the reason is worth understanding before changing it.
45
+
46
+ A surface that **re-runs this script when a container resumes** — Codex Cloud — should materialize during setup. Re-running is exactly what picks up a rotated value, an edited note, or a changed version pin.
47
+
48
+ A surface that **skips this script whenever a filesystem cache exists** — Claude Code web — must not. Materializing here would write the values once and then never refresh them, so a credential rotated on Tuesday would still be serving Monday's value until the cache expired days later. Those surfaces materialize from a session-start hook instead, which runs every session including a resumed one:
49
+
50
+ ```sh
51
+ bash scripts/lisa-remote-env/session-start.sh # guard; delegates to --phase=secrets
52
+ ```
53
+
54
+ The selection comes from the surface's `materializeAt` capability in `lisa-secrets-access`, not from its name, so adding a surface does not mean editing a branch. The hook is committed to the repository, so it also fires on a developer's machine — it exits `0` immediately there rather than failing, because a correct local session must not look broken.
55
+
42
56
  ## Toolchain manifest — two entry kinds
43
57
 
44
58
  ```json
@@ -131,6 +145,25 @@ Environment vars: LISA_SECRETS_SURFACE=codex-cloud
131
145
  maintenance both run before task secrets exist>
132
146
  ```
133
147
 
148
+ ### Claude Code web is emit-only, and that is not a fallback
149
+
150
+ **For Claude Code web there is no tier 1 or tier 2 to fall back from.** A cloud environment is account-scoped configuration — network access level, environment variables, setup script — edited only in the environment selector at claude.ai/code, which has no settings page, no direct URL, and no API. `/remote-env` selects an environment; it cannot create or edit one.
151
+
152
+ Note what this surface's environment is *not*: it carries no repository. The repository arrives per session, so an environment is reusable across every project, and there is nothing to bind. Its durable handle is the routine that dispatch fires, which is why `remoteEnv.surfaces["claude-web"]` records `routineId` and `fireUrl` rather than a repository.
153
+
154
+ Generate the exact text to paste:
155
+
156
+ ```sh
157
+ node scripts/setup-remote-env.mjs --emit=claude-web
158
+ ```
159
+
160
+ It reads the project's own install command from its lockfile and the bootstrap name from `secrets.bootstrap.key`, then emits the environment fields, the `.claude/settings.json` hook block, and the two base-image surprises worth knowing before they cost an afternoon:
161
+
162
+ - **`gh` is not pre-installed.** If the project's flows shell out to it, add it to `remoteEnv.tools.install`, pinned and checksummed like anything else.
163
+ - **A proxied credential reads as the literal string `proxy-injected`.** Tools that authenticate through the GitHub proxy work; a script that reads the variable directly gets the placeholder. The read-back asserts this rather than leaving it to be discovered against a live service.
164
+
165
+ **Only the bootstrap belongs in the environment-variable box.** Values there are stored as plain text and are readable by anyone who uses the environment — on an organization-shared environment, that is every member of the organization. Everything else is materialized by the session-start hook. There is no dedicated secrets store on this surface, and personal versus shared environments cannot be told apart programmatically, so this one is a rule the operator upholds rather than something the tooling can enforce.
166
+
134
167
  ## Verification is tier-independent
135
168
 
136
169
  **Whatever tier provisioned it, the same read-back proves it.** Trust comes from the verify, not the mechanism — which is what makes emit-tier as trustworthy as API-tier.
@@ -139,7 +172,9 @@ Environment vars: LISA_SECRETS_SURFACE=codex-cloud
139
172
  scripts/verify-remote-env.mjs [SECRETS_DIR]
140
173
  ```
141
174
 
142
- Asserts, without printing a value: each declared tool at its pinned or minimum version, the detected surface, the secrets directory at mode `0700`, both files at mode `0600`, and a clean checkout.
175
+ Asserts, without printing a value: each declared tool at its pinned or minimum version, the detected surface, the secrets directory at mode `0700`, both files at mode `0600`, a clean checkout, and that every name in `secrets.require` resolves to a real credential rather than a proxy placeholder.
176
+
177
+ That last one exists because presence is a weaker claim than usability. A credential the surface keeps outside the sandbox and substitutes at egress is present and non-empty, so a presence check passes — and a script that reads the variable and puts it in a header sends the placeholder and fails against the service, with an error pointing anywhere but at the environment.
143
178
 
144
179
  **Never verify against vendor UI state.** On 2026-08-01 a Codex environments table reported zero tasks for an environment that had demonstrably completed one, because the task records carried a null environment identifier and the `--env` filter was correspondingly unreliable. Reconcile through durable identifiers only.
145
180
 
@@ -147,7 +182,7 @@ Asserts, without printing a value: each declared tool at its pinned or minimum v
147
182
 
148
183
  Checked when setup runs, not at 3am:
149
184
 
150
- - the environment exists and is bound to **this** repository as its default checkout;
185
+ - the environment exists, and on a surface that binds one, is bound to **this** repository as its default checkout — Claude cloud environments bind no repository at all, so there is nothing to check there;
151
186
  - the bootstrap credential resolves;
152
187
  - either a checkout-local Lisa skill is present, or the project dependency install has made the pinned `@codyswann/lisa` package available under `node_modules`.
153
188
 
@@ -157,6 +192,8 @@ Fail with a message naming what is missing. Never provision-and-hope.
157
192
 
158
193
  On Codex Cloud the bootstrap must be an **environment variable, not a task secret**: setup runs on a new container and maintenance runs on cache resume, both before task secrets exist. The tradeoff is that the variable remains visible during the task, so compensate by keeping the machine account narrowly scoped and instructing the task never to inspect or use it.
159
194
 
195
+ On Claude Code web the same placement applies for the same reason, with the exposure widened rather than narrowed: there is no secrets store, values are stored as plain text, and anyone who uses the environment can read them. On an organization-shared environment that is every member. Keep the bootstrap in a **personal** environment, scope the machine account to the minimum, and treat every other credential as something the session-start hook materializes rather than something a human pastes.
196
+
160
197
  ## Related
161
198
 
162
199
  - `lisa-secrets-access` — owns every part of the secrets contract this skill composes with.
@@ -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 "$@"