@codyswann/lisa 2.332.2 → 2.332.4
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 +4 -2
- 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/materialize-secrets.mjs +83 -1
- package/plugins/lisa/.codex-plugin/skills/lisa-setup-remote-env/scripts/setup-remote-env.mjs +109 -0
- package/plugins/lisa/skills/lisa-secrets-access/scripts/materialize-secrets.mjs +83 -1
- package/plugins/lisa/skills/lisa-setup-remote-env/scripts/setup-remote-env.mjs +109 -0
- package/plugins/lisa-agy/plugin.json +1 -1
- package/plugins/lisa-agy/skills/lisa-secrets-access/scripts/materialize-secrets.mjs +83 -1
- package/plugins/lisa-agy/skills/lisa-setup-remote-env/scripts/setup-remote-env.mjs +109 -0
- 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/materialize-secrets.mjs +83 -1
- package/plugins/lisa-copilot/skills/lisa-setup-remote-env/scripts/setup-remote-env.mjs +109 -0
- package/plugins/lisa-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-cursor/skills/lisa-secrets-access/scripts/materialize-secrets.mjs +83 -1
- package/plugins/lisa-cursor/skills/lisa-setup-remote-env/scripts/setup-remote-env.mjs +109 -0
- 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/materialize-secrets.mjs +83 -1
- package/plugins/src/base/skills/lisa-setup-remote-env/scripts/setup-remote-env.mjs +109 -0
|
@@ -38,6 +38,7 @@ import {
|
|
|
38
38
|
rmSync,
|
|
39
39
|
writeFileSync,
|
|
40
40
|
} from "node:fs";
|
|
41
|
+
import { homedir } from "node:os";
|
|
41
42
|
import { delimiter, dirname, join, resolve } from "node:path";
|
|
42
43
|
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
43
44
|
|
|
@@ -583,6 +584,101 @@ export const SETUP_FIELD =
|
|
|
583
584
|
'PWD=$PWD HOME=$HOME" >&2; ls -1 . "$HOME" /workspace 2>&1 | head -40 >&2; exit 1; }; ' +
|
|
584
585
|
'exit "$rc"';
|
|
585
586
|
|
|
587
|
+
/**
|
|
588
|
+
* Register the session-start hook at USER scope, so it fires wherever the
|
|
589
|
+
* session opens.
|
|
590
|
+
*
|
|
591
|
+
* The committed `.claude/settings.json` hook is *project*-scoped: it loads only
|
|
592
|
+
* when Claude Code's project directory is that repository. A cloud session does
|
|
593
|
+
* not reliably start there. With several repositories it starts in the shared
|
|
594
|
+
* parent, which is not a git repository at all; even with one, the checkout sits
|
|
595
|
+
* at `$HOME/<repo>` while the session may open at `$HOME`. In those sessions no
|
|
596
|
+
* project settings load, the hook never registers, and nothing materializes —
|
|
597
|
+
* the failure that `materializeAt: "both"` was introduced to paper over, and
|
|
598
|
+
* which it could not fix, because the setup phase cannot see the bootstrap
|
|
599
|
+
* credential in the first place.
|
|
600
|
+
*
|
|
601
|
+
* A user-scoped hook has neither problem: `~/.claude/settings.json` is read for
|
|
602
|
+
* every session regardless of project directory, and it runs in-session where
|
|
603
|
+
* the vendor's configured variables ARE present.
|
|
604
|
+
*
|
|
605
|
+
* Only ever on a remote surface. This writes machine state in an ephemeral
|
|
606
|
+
* container, which is fine there and would be an intrusion on a developer's
|
|
607
|
+
* laptop, where the project hook already works and the user owns that file.
|
|
608
|
+
*
|
|
609
|
+
* Merged, never clobbered, and idempotent on the exact command — an existing
|
|
610
|
+
* user settings file may carry hooks this knows nothing about.
|
|
611
|
+
* @param {string} repoRoot Absolute path to the checkout.
|
|
612
|
+
* @param {object} options Home directory, dry-run flag, and file seams.
|
|
613
|
+
* @returns {{action: string, path: string, reason?: string}} What was done.
|
|
614
|
+
*/
|
|
615
|
+
export function installUserSessionHook(repoRoot, options = {}) {
|
|
616
|
+
const {
|
|
617
|
+
home = process.env.HOME || homedir(),
|
|
618
|
+
dryRun = false,
|
|
619
|
+
exists = existsSync,
|
|
620
|
+
read = readFileSync,
|
|
621
|
+
write = writeFileSync,
|
|
622
|
+
mkdir = mkdirSync,
|
|
623
|
+
} = options;
|
|
624
|
+
|
|
625
|
+
const script = join(repoRoot, "scripts/lisa-remote-env/session-start.sh");
|
|
626
|
+
if (!exists(script)) {
|
|
627
|
+
return { action: "skipped", path: script, reason: "no session-start.sh" };
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
const dir = join(home, ".claude");
|
|
631
|
+
const settingsPath = join(dir, "settings.json");
|
|
632
|
+
const command = `bash ${script}`;
|
|
633
|
+
|
|
634
|
+
let settings = {};
|
|
635
|
+
if (exists(settingsPath)) {
|
|
636
|
+
try {
|
|
637
|
+
settings = JSON.parse(String(read(settingsPath, "utf8"))) || {};
|
|
638
|
+
} catch {
|
|
639
|
+
// Refuse rather than overwrite. A settings file that does not parse is
|
|
640
|
+
// still someone's configuration, and replacing it would be the careless
|
|
641
|
+
// destruction this whole function is written to avoid.
|
|
642
|
+
return {
|
|
643
|
+
action: "failed",
|
|
644
|
+
path: settingsPath,
|
|
645
|
+
reason: "existing settings.json is not valid JSON; left untouched",
|
|
646
|
+
};
|
|
647
|
+
}
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
const hooks = settings.hooks ?? {};
|
|
651
|
+
const sessionStart = Array.isArray(hooks.SessionStart)
|
|
652
|
+
? hooks.SessionStart
|
|
653
|
+
: [];
|
|
654
|
+
const already = sessionStart.some(entry =>
|
|
655
|
+
(entry?.hooks ?? []).some(hook => hook?.command === command)
|
|
656
|
+
);
|
|
657
|
+
if (already) {
|
|
658
|
+
return { action: "present", path: settingsPath };
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
const updated = {
|
|
662
|
+
...settings,
|
|
663
|
+
hooks: {
|
|
664
|
+
...hooks,
|
|
665
|
+
SessionStart: [
|
|
666
|
+
...sessionStart,
|
|
667
|
+
{
|
|
668
|
+
matcher: "startup|resume",
|
|
669
|
+
hooks: [{ type: "command", command }],
|
|
670
|
+
},
|
|
671
|
+
],
|
|
672
|
+
},
|
|
673
|
+
};
|
|
674
|
+
|
|
675
|
+
if (dryRun) return { action: "would-register", path: settingsPath };
|
|
676
|
+
|
|
677
|
+
mkdir(dir, { recursive: true });
|
|
678
|
+
write(settingsPath, `${JSON.stringify(updated, null, 2)}\n`);
|
|
679
|
+
return { action: "registered", path: settingsPath };
|
|
680
|
+
}
|
|
681
|
+
|
|
586
682
|
/**
|
|
587
683
|
* The settings block that wires the session-start hook into a repository.
|
|
588
684
|
*
|
|
@@ -936,6 +1032,19 @@ async function main() {
|
|
|
936
1032
|
// codex-cloud both have no second chance, so a silent pass there would
|
|
937
1033
|
// hand back a session with no credentials and call it ready.
|
|
938
1034
|
const retried = !requested && materializesAtSessionStart(materializeAt);
|
|
1035
|
+
|
|
1036
|
+
// If the hook is what will retry, make sure the hook can actually fire.
|
|
1037
|
+
// Registering it at user scope is what makes the retry real rather than
|
|
1038
|
+
// assumed: the committed project hook does not load when the session opens
|
|
1039
|
+
// above the checkout, which is the norm in a cloud container.
|
|
1040
|
+
// `materializeAt` being set is what "remote surface" means here. The
|
|
1041
|
+
// toolchain phase derives a local `remote` from it, but that binding is
|
|
1042
|
+
// scoped to its own block and is not in scope in this one.
|
|
1043
|
+
if (retried && Boolean(materializeAt)) {
|
|
1044
|
+
const hook = installUserSessionHook(process.cwd(), { dryRun });
|
|
1045
|
+
console.log(` session-start hook: ${hook.action} (${hook.path})`);
|
|
1046
|
+
if (hook.reason) console.log(` ${hook.reason}`);
|
|
1047
|
+
}
|
|
939
1048
|
try {
|
|
940
1049
|
execFileSync(
|
|
941
1050
|
"node",
|
|
@@ -21,11 +21,15 @@
|
|
|
21
21
|
|
|
22
22
|
import {
|
|
23
23
|
chmodSync,
|
|
24
|
+
existsSync,
|
|
24
25
|
mkdirSync,
|
|
26
|
+
readFileSync,
|
|
25
27
|
renameSync,
|
|
26
28
|
rmSync,
|
|
27
29
|
writeFileSync,
|
|
28
30
|
} from "node:fs";
|
|
31
|
+
import { homedir } from "node:os";
|
|
32
|
+
import { join } from "node:path";
|
|
29
33
|
|
|
30
34
|
import { deriveAwsEnvironment } from "./aws-bootstrap.mjs";
|
|
31
35
|
import { renderEnv, renderNotes } from "./envfile.mjs";
|
|
@@ -58,6 +62,70 @@ function writeAtomic(destination, contents) {
|
|
|
58
62
|
* @param {object} [cfg] Resolved configuration.
|
|
59
63
|
* @returns {{count: number, dir: string}} What was written, and where.
|
|
60
64
|
*/
|
|
65
|
+
/** Marks the block this owns, so it is replaced rather than appended twice. */
|
|
66
|
+
const PROFILE_MARKER = "# >>> lisa secrets (managed) >>>";
|
|
67
|
+
|
|
68
|
+
/** Closes the managed block. */
|
|
69
|
+
const PROFILE_END = "# <<< lisa secrets (managed) <<<";
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Make every shell in this container load the materialized secrets.
|
|
73
|
+
*
|
|
74
|
+
* `set -a` so the values are exported rather than merely set as shell
|
|
75
|
+
* variables, and sourced LAST so they win over anything the host injected —
|
|
76
|
+
* environment variables outrank profile files in AWS's credential chain, which
|
|
77
|
+
* is the whole reason a valid credential was being ignored.
|
|
78
|
+
*
|
|
79
|
+
* Guarded on the file existing, so a shell still starts cleanly before the
|
|
80
|
+
* first materialization or if the file is removed. Written to both `.bashrc`
|
|
81
|
+
* and `.profile` because which one a given shell reads depends on whether it is
|
|
82
|
+
* interactive or a login shell, and an agent's tool calls are not reliably
|
|
83
|
+
* either.
|
|
84
|
+
* @param {string} valuesFile Absolute path to the materialized env file.
|
|
85
|
+
* @param {object} [options] Home directory and file seams, for tests.
|
|
86
|
+
* @returns {string[]} The profile files that now source it.
|
|
87
|
+
*/
|
|
88
|
+
export function installProfileSourcing(valuesFile, options = {}) {
|
|
89
|
+
const {
|
|
90
|
+
home = process.env.HOME || homedir(),
|
|
91
|
+
exists = existsSync,
|
|
92
|
+
read = readFileSync,
|
|
93
|
+
write = writeFileSync,
|
|
94
|
+
} = options;
|
|
95
|
+
|
|
96
|
+
const block = [
|
|
97
|
+
PROFILE_MARKER,
|
|
98
|
+
`if [ -f "${valuesFile}" ]; then`,
|
|
99
|
+
` set -a`,
|
|
100
|
+
` . "${valuesFile}"`,
|
|
101
|
+
` set +a`,
|
|
102
|
+
`fi`,
|
|
103
|
+
PROFILE_END,
|
|
104
|
+
].join("\n");
|
|
105
|
+
|
|
106
|
+
const updated = [];
|
|
107
|
+
for (const name of [".bashrc", ".profile"]) {
|
|
108
|
+
const file = join(home, name);
|
|
109
|
+
const current = exists(file) ? String(read(file, "utf8")) : "";
|
|
110
|
+
|
|
111
|
+
// Replace an existing managed block rather than appending another: this
|
|
112
|
+
// runs on every session, and an appended-forever profile is its own bug.
|
|
113
|
+
const start = current.indexOf(PROFILE_MARKER);
|
|
114
|
+
const next =
|
|
115
|
+
start === -1
|
|
116
|
+
? `${current}${current.endsWith("\n") || !current ? "" : "\n"}\n${block}\n`
|
|
117
|
+
: `${current.slice(0, start)}${block}${current.slice(
|
|
118
|
+
current.indexOf(PROFILE_END, start) + PROFILE_END.length
|
|
119
|
+
)}`;
|
|
120
|
+
|
|
121
|
+
if (next !== current) {
|
|
122
|
+
write(file, next, { mode: 0o600 });
|
|
123
|
+
updated.push(file);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
return updated;
|
|
127
|
+
}
|
|
128
|
+
|
|
61
129
|
export function materialize(cfg = readConfig()) {
|
|
62
130
|
if (!cfg.capabilities.mayWriteValues) {
|
|
63
131
|
throw new Error(
|
|
@@ -88,7 +156,21 @@ export function materialize(cfg = readConfig()) {
|
|
|
88
156
|
chmodSync(dir, 0o700);
|
|
89
157
|
writeAtomic(valuesFile, renderEnv(selected));
|
|
90
158
|
writeAtomic(notesFile, renderNotes(selected));
|
|
91
|
-
|
|
159
|
+
|
|
160
|
+
// Writing the file is not the same as the values being in effect.
|
|
161
|
+
//
|
|
162
|
+
// A materialized secrets.env that nothing sources changes nothing: the agent's
|
|
163
|
+
// shell starts from the container's own environment, so a host-injected
|
|
164
|
+
// AWS_ACCESS_KEY_ID keeps winning and every call fails with
|
|
165
|
+
// InvalidClientTokenId while the correct credential sits on disk, correct and
|
|
166
|
+
// unused. Deriving the variables (above) only fixes precedence WITHIN the
|
|
167
|
+
// file — something still has to load the file.
|
|
168
|
+
//
|
|
169
|
+
// So the shell profile sources it. That is what makes "the credential is
|
|
170
|
+
// materialized" and "the credential is usable" the same statement.
|
|
171
|
+
const sourced = installProfileSourcing(valuesFile);
|
|
172
|
+
|
|
173
|
+
return { count: selected.size, derived: derived.size, dir, sourced };
|
|
92
174
|
}
|
|
93
175
|
|
|
94
176
|
function main() {
|
|
@@ -38,6 +38,7 @@ import {
|
|
|
38
38
|
rmSync,
|
|
39
39
|
writeFileSync,
|
|
40
40
|
} from "node:fs";
|
|
41
|
+
import { homedir } from "node:os";
|
|
41
42
|
import { delimiter, dirname, join, resolve } from "node:path";
|
|
42
43
|
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
43
44
|
|
|
@@ -583,6 +584,101 @@ export const SETUP_FIELD =
|
|
|
583
584
|
'PWD=$PWD HOME=$HOME" >&2; ls -1 . "$HOME" /workspace 2>&1 | head -40 >&2; exit 1; }; ' +
|
|
584
585
|
'exit "$rc"';
|
|
585
586
|
|
|
587
|
+
/**
|
|
588
|
+
* Register the session-start hook at USER scope, so it fires wherever the
|
|
589
|
+
* session opens.
|
|
590
|
+
*
|
|
591
|
+
* The committed `.claude/settings.json` hook is *project*-scoped: it loads only
|
|
592
|
+
* when Claude Code's project directory is that repository. A cloud session does
|
|
593
|
+
* not reliably start there. With several repositories it starts in the shared
|
|
594
|
+
* parent, which is not a git repository at all; even with one, the checkout sits
|
|
595
|
+
* at `$HOME/<repo>` while the session may open at `$HOME`. In those sessions no
|
|
596
|
+
* project settings load, the hook never registers, and nothing materializes —
|
|
597
|
+
* the failure that `materializeAt: "both"` was introduced to paper over, and
|
|
598
|
+
* which it could not fix, because the setup phase cannot see the bootstrap
|
|
599
|
+
* credential in the first place.
|
|
600
|
+
*
|
|
601
|
+
* A user-scoped hook has neither problem: `~/.claude/settings.json` is read for
|
|
602
|
+
* every session regardless of project directory, and it runs in-session where
|
|
603
|
+
* the vendor's configured variables ARE present.
|
|
604
|
+
*
|
|
605
|
+
* Only ever on a remote surface. This writes machine state in an ephemeral
|
|
606
|
+
* container, which is fine there and would be an intrusion on a developer's
|
|
607
|
+
* laptop, where the project hook already works and the user owns that file.
|
|
608
|
+
*
|
|
609
|
+
* Merged, never clobbered, and idempotent on the exact command — an existing
|
|
610
|
+
* user settings file may carry hooks this knows nothing about.
|
|
611
|
+
* @param {string} repoRoot Absolute path to the checkout.
|
|
612
|
+
* @param {object} options Home directory, dry-run flag, and file seams.
|
|
613
|
+
* @returns {{action: string, path: string, reason?: string}} What was done.
|
|
614
|
+
*/
|
|
615
|
+
export function installUserSessionHook(repoRoot, options = {}) {
|
|
616
|
+
const {
|
|
617
|
+
home = process.env.HOME || homedir(),
|
|
618
|
+
dryRun = false,
|
|
619
|
+
exists = existsSync,
|
|
620
|
+
read = readFileSync,
|
|
621
|
+
write = writeFileSync,
|
|
622
|
+
mkdir = mkdirSync,
|
|
623
|
+
} = options;
|
|
624
|
+
|
|
625
|
+
const script = join(repoRoot, "scripts/lisa-remote-env/session-start.sh");
|
|
626
|
+
if (!exists(script)) {
|
|
627
|
+
return { action: "skipped", path: script, reason: "no session-start.sh" };
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
const dir = join(home, ".claude");
|
|
631
|
+
const settingsPath = join(dir, "settings.json");
|
|
632
|
+
const command = `bash ${script}`;
|
|
633
|
+
|
|
634
|
+
let settings = {};
|
|
635
|
+
if (exists(settingsPath)) {
|
|
636
|
+
try {
|
|
637
|
+
settings = JSON.parse(String(read(settingsPath, "utf8"))) || {};
|
|
638
|
+
} catch {
|
|
639
|
+
// Refuse rather than overwrite. A settings file that does not parse is
|
|
640
|
+
// still someone's configuration, and replacing it would be the careless
|
|
641
|
+
// destruction this whole function is written to avoid.
|
|
642
|
+
return {
|
|
643
|
+
action: "failed",
|
|
644
|
+
path: settingsPath,
|
|
645
|
+
reason: "existing settings.json is not valid JSON; left untouched",
|
|
646
|
+
};
|
|
647
|
+
}
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
const hooks = settings.hooks ?? {};
|
|
651
|
+
const sessionStart = Array.isArray(hooks.SessionStart)
|
|
652
|
+
? hooks.SessionStart
|
|
653
|
+
: [];
|
|
654
|
+
const already = sessionStart.some(entry =>
|
|
655
|
+
(entry?.hooks ?? []).some(hook => hook?.command === command)
|
|
656
|
+
);
|
|
657
|
+
if (already) {
|
|
658
|
+
return { action: "present", path: settingsPath };
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
const updated = {
|
|
662
|
+
...settings,
|
|
663
|
+
hooks: {
|
|
664
|
+
...hooks,
|
|
665
|
+
SessionStart: [
|
|
666
|
+
...sessionStart,
|
|
667
|
+
{
|
|
668
|
+
matcher: "startup|resume",
|
|
669
|
+
hooks: [{ type: "command", command }],
|
|
670
|
+
},
|
|
671
|
+
],
|
|
672
|
+
},
|
|
673
|
+
};
|
|
674
|
+
|
|
675
|
+
if (dryRun) return { action: "would-register", path: settingsPath };
|
|
676
|
+
|
|
677
|
+
mkdir(dir, { recursive: true });
|
|
678
|
+
write(settingsPath, `${JSON.stringify(updated, null, 2)}\n`);
|
|
679
|
+
return { action: "registered", path: settingsPath };
|
|
680
|
+
}
|
|
681
|
+
|
|
586
682
|
/**
|
|
587
683
|
* The settings block that wires the session-start hook into a repository.
|
|
588
684
|
*
|
|
@@ -936,6 +1032,19 @@ async function main() {
|
|
|
936
1032
|
// codex-cloud both have no second chance, so a silent pass there would
|
|
937
1033
|
// hand back a session with no credentials and call it ready.
|
|
938
1034
|
const retried = !requested && materializesAtSessionStart(materializeAt);
|
|
1035
|
+
|
|
1036
|
+
// If the hook is what will retry, make sure the hook can actually fire.
|
|
1037
|
+
// Registering it at user scope is what makes the retry real rather than
|
|
1038
|
+
// assumed: the committed project hook does not load when the session opens
|
|
1039
|
+
// above the checkout, which is the norm in a cloud container.
|
|
1040
|
+
// `materializeAt` being set is what "remote surface" means here. The
|
|
1041
|
+
// toolchain phase derives a local `remote` from it, but that binding is
|
|
1042
|
+
// scoped to its own block and is not in scope in this one.
|
|
1043
|
+
if (retried && Boolean(materializeAt)) {
|
|
1044
|
+
const hook = installUserSessionHook(process.cwd(), { dryRun });
|
|
1045
|
+
console.log(` session-start hook: ${hook.action} (${hook.path})`);
|
|
1046
|
+
if (hook.reason) console.log(` ${hook.reason}`);
|
|
1047
|
+
}
|
|
939
1048
|
try {
|
|
940
1049
|
execFileSync(
|
|
941
1050
|
"node",
|
|
@@ -21,11 +21,15 @@
|
|
|
21
21
|
|
|
22
22
|
import {
|
|
23
23
|
chmodSync,
|
|
24
|
+
existsSync,
|
|
24
25
|
mkdirSync,
|
|
26
|
+
readFileSync,
|
|
25
27
|
renameSync,
|
|
26
28
|
rmSync,
|
|
27
29
|
writeFileSync,
|
|
28
30
|
} from "node:fs";
|
|
31
|
+
import { homedir } from "node:os";
|
|
32
|
+
import { join } from "node:path";
|
|
29
33
|
|
|
30
34
|
import { deriveAwsEnvironment } from "./aws-bootstrap.mjs";
|
|
31
35
|
import { renderEnv, renderNotes } from "./envfile.mjs";
|
|
@@ -58,6 +62,70 @@ function writeAtomic(destination, contents) {
|
|
|
58
62
|
* @param {object} [cfg] Resolved configuration.
|
|
59
63
|
* @returns {{count: number, dir: string}} What was written, and where.
|
|
60
64
|
*/
|
|
65
|
+
/** Marks the block this owns, so it is replaced rather than appended twice. */
|
|
66
|
+
const PROFILE_MARKER = "# >>> lisa secrets (managed) >>>";
|
|
67
|
+
|
|
68
|
+
/** Closes the managed block. */
|
|
69
|
+
const PROFILE_END = "# <<< lisa secrets (managed) <<<";
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Make every shell in this container load the materialized secrets.
|
|
73
|
+
*
|
|
74
|
+
* `set -a` so the values are exported rather than merely set as shell
|
|
75
|
+
* variables, and sourced LAST so they win over anything the host injected —
|
|
76
|
+
* environment variables outrank profile files in AWS's credential chain, which
|
|
77
|
+
* is the whole reason a valid credential was being ignored.
|
|
78
|
+
*
|
|
79
|
+
* Guarded on the file existing, so a shell still starts cleanly before the
|
|
80
|
+
* first materialization or if the file is removed. Written to both `.bashrc`
|
|
81
|
+
* and `.profile` because which one a given shell reads depends on whether it is
|
|
82
|
+
* interactive or a login shell, and an agent's tool calls are not reliably
|
|
83
|
+
* either.
|
|
84
|
+
* @param {string} valuesFile Absolute path to the materialized env file.
|
|
85
|
+
* @param {object} [options] Home directory and file seams, for tests.
|
|
86
|
+
* @returns {string[]} The profile files that now source it.
|
|
87
|
+
*/
|
|
88
|
+
export function installProfileSourcing(valuesFile, options = {}) {
|
|
89
|
+
const {
|
|
90
|
+
home = process.env.HOME || homedir(),
|
|
91
|
+
exists = existsSync,
|
|
92
|
+
read = readFileSync,
|
|
93
|
+
write = writeFileSync,
|
|
94
|
+
} = options;
|
|
95
|
+
|
|
96
|
+
const block = [
|
|
97
|
+
PROFILE_MARKER,
|
|
98
|
+
`if [ -f "${valuesFile}" ]; then`,
|
|
99
|
+
` set -a`,
|
|
100
|
+
` . "${valuesFile}"`,
|
|
101
|
+
` set +a`,
|
|
102
|
+
`fi`,
|
|
103
|
+
PROFILE_END,
|
|
104
|
+
].join("\n");
|
|
105
|
+
|
|
106
|
+
const updated = [];
|
|
107
|
+
for (const name of [".bashrc", ".profile"]) {
|
|
108
|
+
const file = join(home, name);
|
|
109
|
+
const current = exists(file) ? String(read(file, "utf8")) : "";
|
|
110
|
+
|
|
111
|
+
// Replace an existing managed block rather than appending another: this
|
|
112
|
+
// runs on every session, and an appended-forever profile is its own bug.
|
|
113
|
+
const start = current.indexOf(PROFILE_MARKER);
|
|
114
|
+
const next =
|
|
115
|
+
start === -1
|
|
116
|
+
? `${current}${current.endsWith("\n") || !current ? "" : "\n"}\n${block}\n`
|
|
117
|
+
: `${current.slice(0, start)}${block}${current.slice(
|
|
118
|
+
current.indexOf(PROFILE_END, start) + PROFILE_END.length
|
|
119
|
+
)}`;
|
|
120
|
+
|
|
121
|
+
if (next !== current) {
|
|
122
|
+
write(file, next, { mode: 0o600 });
|
|
123
|
+
updated.push(file);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
return updated;
|
|
127
|
+
}
|
|
128
|
+
|
|
61
129
|
export function materialize(cfg = readConfig()) {
|
|
62
130
|
if (!cfg.capabilities.mayWriteValues) {
|
|
63
131
|
throw new Error(
|
|
@@ -88,7 +156,21 @@ export function materialize(cfg = readConfig()) {
|
|
|
88
156
|
chmodSync(dir, 0o700);
|
|
89
157
|
writeAtomic(valuesFile, renderEnv(selected));
|
|
90
158
|
writeAtomic(notesFile, renderNotes(selected));
|
|
91
|
-
|
|
159
|
+
|
|
160
|
+
// Writing the file is not the same as the values being in effect.
|
|
161
|
+
//
|
|
162
|
+
// A materialized secrets.env that nothing sources changes nothing: the agent's
|
|
163
|
+
// shell starts from the container's own environment, so a host-injected
|
|
164
|
+
// AWS_ACCESS_KEY_ID keeps winning and every call fails with
|
|
165
|
+
// InvalidClientTokenId while the correct credential sits on disk, correct and
|
|
166
|
+
// unused. Deriving the variables (above) only fixes precedence WITHIN the
|
|
167
|
+
// file — something still has to load the file.
|
|
168
|
+
//
|
|
169
|
+
// So the shell profile sources it. That is what makes "the credential is
|
|
170
|
+
// materialized" and "the credential is usable" the same statement.
|
|
171
|
+
const sourced = installProfileSourcing(valuesFile);
|
|
172
|
+
|
|
173
|
+
return { count: selected.size, derived: derived.size, dir, sourced };
|
|
92
174
|
}
|
|
93
175
|
|
|
94
176
|
function main() {
|
|
@@ -38,6 +38,7 @@ import {
|
|
|
38
38
|
rmSync,
|
|
39
39
|
writeFileSync,
|
|
40
40
|
} from "node:fs";
|
|
41
|
+
import { homedir } from "node:os";
|
|
41
42
|
import { delimiter, dirname, join, resolve } from "node:path";
|
|
42
43
|
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
43
44
|
|
|
@@ -583,6 +584,101 @@ export const SETUP_FIELD =
|
|
|
583
584
|
'PWD=$PWD HOME=$HOME" >&2; ls -1 . "$HOME" /workspace 2>&1 | head -40 >&2; exit 1; }; ' +
|
|
584
585
|
'exit "$rc"';
|
|
585
586
|
|
|
587
|
+
/**
|
|
588
|
+
* Register the session-start hook at USER scope, so it fires wherever the
|
|
589
|
+
* session opens.
|
|
590
|
+
*
|
|
591
|
+
* The committed `.claude/settings.json` hook is *project*-scoped: it loads only
|
|
592
|
+
* when Claude Code's project directory is that repository. A cloud session does
|
|
593
|
+
* not reliably start there. With several repositories it starts in the shared
|
|
594
|
+
* parent, which is not a git repository at all; even with one, the checkout sits
|
|
595
|
+
* at `$HOME/<repo>` while the session may open at `$HOME`. In those sessions no
|
|
596
|
+
* project settings load, the hook never registers, and nothing materializes —
|
|
597
|
+
* the failure that `materializeAt: "both"` was introduced to paper over, and
|
|
598
|
+
* which it could not fix, because the setup phase cannot see the bootstrap
|
|
599
|
+
* credential in the first place.
|
|
600
|
+
*
|
|
601
|
+
* A user-scoped hook has neither problem: `~/.claude/settings.json` is read for
|
|
602
|
+
* every session regardless of project directory, and it runs in-session where
|
|
603
|
+
* the vendor's configured variables ARE present.
|
|
604
|
+
*
|
|
605
|
+
* Only ever on a remote surface. This writes machine state in an ephemeral
|
|
606
|
+
* container, which is fine there and would be an intrusion on a developer's
|
|
607
|
+
* laptop, where the project hook already works and the user owns that file.
|
|
608
|
+
*
|
|
609
|
+
* Merged, never clobbered, and idempotent on the exact command — an existing
|
|
610
|
+
* user settings file may carry hooks this knows nothing about.
|
|
611
|
+
* @param {string} repoRoot Absolute path to the checkout.
|
|
612
|
+
* @param {object} options Home directory, dry-run flag, and file seams.
|
|
613
|
+
* @returns {{action: string, path: string, reason?: string}} What was done.
|
|
614
|
+
*/
|
|
615
|
+
export function installUserSessionHook(repoRoot, options = {}) {
|
|
616
|
+
const {
|
|
617
|
+
home = process.env.HOME || homedir(),
|
|
618
|
+
dryRun = false,
|
|
619
|
+
exists = existsSync,
|
|
620
|
+
read = readFileSync,
|
|
621
|
+
write = writeFileSync,
|
|
622
|
+
mkdir = mkdirSync,
|
|
623
|
+
} = options;
|
|
624
|
+
|
|
625
|
+
const script = join(repoRoot, "scripts/lisa-remote-env/session-start.sh");
|
|
626
|
+
if (!exists(script)) {
|
|
627
|
+
return { action: "skipped", path: script, reason: "no session-start.sh" };
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
const dir = join(home, ".claude");
|
|
631
|
+
const settingsPath = join(dir, "settings.json");
|
|
632
|
+
const command = `bash ${script}`;
|
|
633
|
+
|
|
634
|
+
let settings = {};
|
|
635
|
+
if (exists(settingsPath)) {
|
|
636
|
+
try {
|
|
637
|
+
settings = JSON.parse(String(read(settingsPath, "utf8"))) || {};
|
|
638
|
+
} catch {
|
|
639
|
+
// Refuse rather than overwrite. A settings file that does not parse is
|
|
640
|
+
// still someone's configuration, and replacing it would be the careless
|
|
641
|
+
// destruction this whole function is written to avoid.
|
|
642
|
+
return {
|
|
643
|
+
action: "failed",
|
|
644
|
+
path: settingsPath,
|
|
645
|
+
reason: "existing settings.json is not valid JSON; left untouched",
|
|
646
|
+
};
|
|
647
|
+
}
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
const hooks = settings.hooks ?? {};
|
|
651
|
+
const sessionStart = Array.isArray(hooks.SessionStart)
|
|
652
|
+
? hooks.SessionStart
|
|
653
|
+
: [];
|
|
654
|
+
const already = sessionStart.some(entry =>
|
|
655
|
+
(entry?.hooks ?? []).some(hook => hook?.command === command)
|
|
656
|
+
);
|
|
657
|
+
if (already) {
|
|
658
|
+
return { action: "present", path: settingsPath };
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
const updated = {
|
|
662
|
+
...settings,
|
|
663
|
+
hooks: {
|
|
664
|
+
...hooks,
|
|
665
|
+
SessionStart: [
|
|
666
|
+
...sessionStart,
|
|
667
|
+
{
|
|
668
|
+
matcher: "startup|resume",
|
|
669
|
+
hooks: [{ type: "command", command }],
|
|
670
|
+
},
|
|
671
|
+
],
|
|
672
|
+
},
|
|
673
|
+
};
|
|
674
|
+
|
|
675
|
+
if (dryRun) return { action: "would-register", path: settingsPath };
|
|
676
|
+
|
|
677
|
+
mkdir(dir, { recursive: true });
|
|
678
|
+
write(settingsPath, `${JSON.stringify(updated, null, 2)}\n`);
|
|
679
|
+
return { action: "registered", path: settingsPath };
|
|
680
|
+
}
|
|
681
|
+
|
|
586
682
|
/**
|
|
587
683
|
* The settings block that wires the session-start hook into a repository.
|
|
588
684
|
*
|
|
@@ -936,6 +1032,19 @@ async function main() {
|
|
|
936
1032
|
// codex-cloud both have no second chance, so a silent pass there would
|
|
937
1033
|
// hand back a session with no credentials and call it ready.
|
|
938
1034
|
const retried = !requested && materializesAtSessionStart(materializeAt);
|
|
1035
|
+
|
|
1036
|
+
// If the hook is what will retry, make sure the hook can actually fire.
|
|
1037
|
+
// Registering it at user scope is what makes the retry real rather than
|
|
1038
|
+
// assumed: the committed project hook does not load when the session opens
|
|
1039
|
+
// above the checkout, which is the norm in a cloud container.
|
|
1040
|
+
// `materializeAt` being set is what "remote surface" means here. The
|
|
1041
|
+
// toolchain phase derives a local `remote` from it, but that binding is
|
|
1042
|
+
// scoped to its own block and is not in scope in this one.
|
|
1043
|
+
if (retried && Boolean(materializeAt)) {
|
|
1044
|
+
const hook = installUserSessionHook(process.cwd(), { dryRun });
|
|
1045
|
+
console.log(` session-start hook: ${hook.action} (${hook.path})`);
|
|
1046
|
+
if (hook.reason) console.log(` ${hook.reason}`);
|
|
1047
|
+
}
|
|
939
1048
|
try {
|
|
940
1049
|
execFileSync(
|
|
941
1050
|
"node",
|