mirror-mind 0.34.0 → 0.36.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/.pi/skills/mm-build/SKILL.md +1 -1
- package/.pi/skills/mm-explore/SKILL.md +1 -1
- package/.pi/skills/mm-help/SKILL.md +2 -2
- package/.pi/skills/mm-soul/SKILL.md +1 -1
- package/AGENTS.md +7 -6
- package/README.md +5 -6
- package/docs/releases/index.md +2 -0
- package/docs/releases/v0.35.0.md +199 -0
- package/docs/releases/v0.36.0.md +222 -0
- package/package.json +2 -4
- package/plugins/mirror-mind/.claude-plugin/plugin.json +1 -1
- package/plugins/mirror-mind/agy/pre-invocation.sh +92 -0
- package/plugins/mirror-mind/agy/session-start.sh +92 -0
- package/plugins/mirror-mind/agy/stop.sh +92 -0
- package/plugins/mirror-mind/hooks.json +26 -0
- package/plugins/mirror-mind/mcp_config.json +11 -0
- package/plugins/mirror-mind/plugin.json +14 -0
- package/plugins/mirror-mind/rules/AGENTS.md +189 -0
- package/plugins/mirror-mind/skills/mm-build/SKILL.md +1 -1
- package/ts/src/conversation/agyTranscript.ts +105 -0
- package/ts/src/conversation/commandTurn.ts +1 -1
- package/ts/src/conversation/deferredCloseTail.ts +2 -2
- package/ts/src/conversation/logger.ts +1 -1
- package/ts/src/conversation/runtimePolicy.ts +36 -4
- package/ts/src/docs/docsLint.ts +14 -1
- package/ts/src/extensions/bindings.ts +53 -2
- package/ts/src/extensions/catalogCommands.ts +82 -23
- package/ts/src/extensions/catalogWrites.ts +12 -0
- package/ts/src/extensions/contextRuntime.ts +36 -1
- package/ts/src/extensions/dispatch.ts +43 -10
- package/ts/src/extensions/extensionPlugin.ts +325 -0
- package/ts/src/extensions/manifest.ts +7 -1
- package/ts/src/extensions/settings.ts +147 -0
- package/ts/src/frontDoor/cli.ts +16 -1
- package/ts/src/frontDoor/extensionCatalogRoute.ts +8 -2
- package/ts/src/frontDoor/runtimeRoute.ts +11 -0
- package/ts/src/frontDoor/stdinSource.ts +2 -2
- package/ts/src/guards/claudePlugin.ts +91 -0
- package/ts/src/guards/packContents.ts +3 -2
- package/ts/src/guards/piSourcedSkills.ts +1 -1
- package/ts/src/guards/retiredSurfaces.ts +31 -0
- package/ts/src/hooks/agy.ts +229 -0
- package/ts/src/hooks/claude.ts +6 -0
- package/ts/src/hooks/main.ts +23 -16
- package/ts/src/hooks/payload.ts +8 -15
- package/ts/src/hooks/runtime.ts +11 -10
- package/ts/src/runtime/brokenPipe.ts +52 -0
- package/ts/src/runtime/diagnose.ts +122 -13
- package/ts/src/runtime/extensionPluginWiring.ts +222 -0
- package/ts/src/runtime/pluginWiring.ts +168 -29
- package/ts/src/runtime/status.ts +28 -1
- package/ts/src/runtime/update.ts +25 -11
- package/ts/src/runtime/wiring.ts +149 -35
- package/.gemini/hooks/log-assistant.sh +0 -85
- package/.gemini/hooks/log-user.sh +0 -85
- package/.gemini/hooks/session-end.sh +0 -85
- package/.gemini/hooks/session-start.sh +0 -85
- package/ts/src/hooks/gemini.ts +0 -128
|
@@ -0,0 +1,325 @@
|
|
|
1
|
+
// CV21.DS5.US2 — the per-home extensions plugin.
|
|
2
|
+
//
|
|
3
|
+
// A Mirror home's installed extensions reach Claude Code, Codex, Antigravity, and
|
|
4
|
+
// Grok Build as one generated plugin per home, in the canonical plugin's format:
|
|
5
|
+
//
|
|
6
|
+
// <home>/runtime/plugin/
|
|
7
|
+
// .agents/plugins/marketplace.json the Codex marketplace, named for the home
|
|
8
|
+
// mirror-ext-<user>/
|
|
9
|
+
// .claude-plugin/plugin.json name, version, description — no hooks, no server
|
|
10
|
+
// plugin.json the root marker Grok and agy read (DS10.US1)
|
|
11
|
+
// skills/<dir>/SKILL.md each installed extension's `claude` skill
|
|
12
|
+
//
|
|
13
|
+
// DS5.TS1 measured the three shapes DS12.TS5 left open and chose this one: a plugin is
|
|
14
|
+
// listed and found on all four runtimes, needs no manifest rewritten (it carries the
|
|
15
|
+
// `claude` skill the manifests already declare), and installs beside another home's.
|
|
16
|
+
// Its price is that Codex, agy, and Grok copy a plugin, so a changed extension is live
|
|
17
|
+
// only after a reinstall — which is why the version matters: Codex keys its cache by
|
|
18
|
+
// it and `runtime diagnose` compares it.
|
|
19
|
+
//
|
|
20
|
+
// The generator is a pure function from the home's `claude` catalog — the same reader
|
|
21
|
+
// `expose-claude` projects from, so the plugin carries what `expose-claude` exposes —
|
|
22
|
+
// to a sorted file map. The version is `0.1.<generation>`, advanced by one when the
|
|
23
|
+
// tree differs from the one on disk and held when it does not: an identical
|
|
24
|
+
// regeneration must not tell every runtime it is stale. The writer replaces the tree
|
|
25
|
+
// atomically (write `plugin.next`, rename over `plugin`), so Claude Code, which reads
|
|
26
|
+
// the directory in place through a link, never sees a half tree. The tree holds only
|
|
27
|
+
// regular files the generator wrote.
|
|
28
|
+
|
|
29
|
+
import {
|
|
30
|
+
type Dirent,
|
|
31
|
+
existsSync,
|
|
32
|
+
mkdirSync,
|
|
33
|
+
readdirSync,
|
|
34
|
+
readFileSync,
|
|
35
|
+
renameSync,
|
|
36
|
+
rmSync,
|
|
37
|
+
writeFileSync,
|
|
38
|
+
} from "node:fs";
|
|
39
|
+
import { basename, dirname, join } from "node:path";
|
|
40
|
+
import { pythonJsonDumpsIndentedOrdered } from "#util/pyGenerators.ts";
|
|
41
|
+
import { discoverExtensions, extensionsRootForHome, runtimeSkillsRootForHome } from "./catalog.ts";
|
|
42
|
+
import { filesystemSkillDirName } from "./catalogWrites.ts";
|
|
43
|
+
|
|
44
|
+
export const EXTENSION_PLUGIN_SEGMENTS: readonly string[] = ["runtime", "plugin"];
|
|
45
|
+
const MARKETPLACE_RELATIVE = ".agents/plugins/marketplace.json";
|
|
46
|
+
const VERSION_PREFIX = "0.1.";
|
|
47
|
+
|
|
48
|
+
/** The plugin's name, which is also its Codex marketplace's: one per home. */
|
|
49
|
+
export function extensionPluginName(user: string): string {
|
|
50
|
+
return `mirror-ext-${user}`;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** The marketplace root: what `codex plugin marketplace add` takes. */
|
|
54
|
+
export function extensionPluginRoot(mirrorHome: string): string {
|
|
55
|
+
return join(mirrorHome, ...EXTENSION_PLUGIN_SEGMENTS);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** The plugin directory: what Claude Code links, and agy and Grok install. */
|
|
59
|
+
export function extensionPluginDirectory(mirrorHome: string): string {
|
|
60
|
+
return join(extensionPluginRoot(mirrorHome), extensionPluginName(basename(mirrorHome)));
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Relative path → file content, sorted by path. */
|
|
64
|
+
export type PluginFileMap = Readonly<Record<string, string>>;
|
|
65
|
+
|
|
66
|
+
export interface PluginEntry {
|
|
67
|
+
/** The `claude` block's `command_name`, e.g. `ext:review-copy`. */
|
|
68
|
+
readonly commandName: string;
|
|
69
|
+
/** The installed skill file's bytes, copied verbatim: frontmatter is the author's. */
|
|
70
|
+
readonly content: string;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export interface PluginPlan {
|
|
74
|
+
readonly name: string;
|
|
75
|
+
readonly version: string;
|
|
76
|
+
readonly generation: number;
|
|
77
|
+
readonly skills: number;
|
|
78
|
+
/** True when the tree differs from `previous` (or there was none). */
|
|
79
|
+
readonly changed: boolean;
|
|
80
|
+
readonly files: PluginFileMap;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export interface PreviousPlugin {
|
|
84
|
+
readonly generation: number;
|
|
85
|
+
readonly files: PluginFileMap;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function render(user: string, entries: readonly PluginEntry[], generation: number): PluginFileMap {
|
|
89
|
+
const name = extensionPluginName(user);
|
|
90
|
+
const version = `${VERSION_PREFIX}${generation}`;
|
|
91
|
+
const sorted = [...entries].sort((a, b) =>
|
|
92
|
+
a.commandName < b.commandName ? -1 : a.commandName > b.commandName ? 1 : 0,
|
|
93
|
+
);
|
|
94
|
+
const skills = sorted.length;
|
|
95
|
+
const manifest = {
|
|
96
|
+
name,
|
|
97
|
+
version,
|
|
98
|
+
description: `Extensions of the Mirror home ${user}: ${skills} skill${skills === 1 ? "" : "s"}`,
|
|
99
|
+
};
|
|
100
|
+
const marketplace = {
|
|
101
|
+
name,
|
|
102
|
+
owner: { name: "Mirror Mind" },
|
|
103
|
+
plugins: [
|
|
104
|
+
{
|
|
105
|
+
name,
|
|
106
|
+
source: { source: "local", path: `./${name}` },
|
|
107
|
+
description: manifest.description,
|
|
108
|
+
},
|
|
109
|
+
],
|
|
110
|
+
};
|
|
111
|
+
const files: Record<string, string> = {
|
|
112
|
+
[MARKETPLACE_RELATIVE]: `${pythonJsonDumpsIndentedOrdered(marketplace)}\n`,
|
|
113
|
+
[`${name}/.claude-plugin/plugin.json`]: `${pythonJsonDumpsIndentedOrdered(manifest)}\n`,
|
|
114
|
+
[`${name}/plugin.json`]: `${pythonJsonDumpsIndentedOrdered(manifest)}\n`,
|
|
115
|
+
};
|
|
116
|
+
for (const entry of sorted) {
|
|
117
|
+
files[`${name}/skills/${filesystemSkillDirName(entry.commandName)}/SKILL.md`] = entry.content;
|
|
118
|
+
}
|
|
119
|
+
return Object.fromEntries(Object.entries(files).sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)));
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function sameFiles(a: PluginFileMap, b: PluginFileMap): boolean {
|
|
123
|
+
const keysA = Object.keys(a);
|
|
124
|
+
const keysB = Object.keys(b);
|
|
125
|
+
if (keysA.length !== keysB.length) return false;
|
|
126
|
+
return keysA.every((key) => key in b && a[key] === b[key]);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* The plan: the tree at the previous generation, compared to the previous tree;
|
|
131
|
+
* when it differs, the tree at the next generation. The version appears in both
|
|
132
|
+
* manifests, so a changed tree is re-rendered after the bump.
|
|
133
|
+
*/
|
|
134
|
+
export function planExtensionPlugin(input: {
|
|
135
|
+
user: string;
|
|
136
|
+
entries: readonly PluginEntry[];
|
|
137
|
+
previous: PreviousPlugin | null;
|
|
138
|
+
}): PluginPlan {
|
|
139
|
+
const name = extensionPluginName(input.user);
|
|
140
|
+
const skills = input.entries.length;
|
|
141
|
+
if (input.previous === null) {
|
|
142
|
+
const files = render(input.user, input.entries, 1);
|
|
143
|
+
return { name, version: `${VERSION_PREFIX}1`, generation: 1, skills, changed: true, files };
|
|
144
|
+
}
|
|
145
|
+
const held = render(input.user, input.entries, input.previous.generation);
|
|
146
|
+
if (sameFiles(held, input.previous.files)) {
|
|
147
|
+
return {
|
|
148
|
+
name,
|
|
149
|
+
version: `${VERSION_PREFIX}${input.previous.generation}`,
|
|
150
|
+
generation: input.previous.generation,
|
|
151
|
+
skills,
|
|
152
|
+
changed: false,
|
|
153
|
+
files: held,
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
const generation = input.previous.generation + 1;
|
|
157
|
+
return {
|
|
158
|
+
name,
|
|
159
|
+
version: `${VERSION_PREFIX}${generation}`,
|
|
160
|
+
generation,
|
|
161
|
+
skills,
|
|
162
|
+
changed: true,
|
|
163
|
+
files: render(input.user, input.entries, generation),
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** The tree on disk as a file map with its generation, or null when there is none. */
|
|
168
|
+
export function readExtensionPlugin(mirrorHome: string): PreviousPlugin | null {
|
|
169
|
+
const root = extensionPluginRoot(mirrorHome);
|
|
170
|
+
const manifestPath = join(extensionPluginDirectory(mirrorHome), ".claude-plugin", "plugin.json");
|
|
171
|
+
if (!existsSync(manifestPath)) return null;
|
|
172
|
+
let generation: number;
|
|
173
|
+
try {
|
|
174
|
+
const parsed: unknown = JSON.parse(readFileSync(manifestPath, "utf8"));
|
|
175
|
+
const version =
|
|
176
|
+
typeof parsed === "object" && parsed !== null
|
|
177
|
+
? (parsed as Record<string, unknown>).version
|
|
178
|
+
: undefined;
|
|
179
|
+
const match = typeof version === "string" ? /^0\.1\.(\d+)$/.exec(version) : null;
|
|
180
|
+
if (match === null) return null;
|
|
181
|
+
generation = Number(match[1]);
|
|
182
|
+
} catch {
|
|
183
|
+
return null;
|
|
184
|
+
}
|
|
185
|
+
const files: Record<string, string> = {};
|
|
186
|
+
for (const path of walk(root)) {
|
|
187
|
+
files[path.slice(root.length + 1)] = readFileSync(path, "utf8");
|
|
188
|
+
}
|
|
189
|
+
return {
|
|
190
|
+
generation,
|
|
191
|
+
files: Object.fromEntries(
|
|
192
|
+
Object.entries(files).sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)),
|
|
193
|
+
),
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function walk(root: string): string[] {
|
|
198
|
+
const found: string[] = [];
|
|
199
|
+
let entries: Dirent[];
|
|
200
|
+
try {
|
|
201
|
+
entries = readdirSync(root, { withFileTypes: true });
|
|
202
|
+
} catch {
|
|
203
|
+
return found;
|
|
204
|
+
}
|
|
205
|
+
for (const entry of entries) {
|
|
206
|
+
const path = join(root, entry.name);
|
|
207
|
+
if (entry.isDirectory()) found.push(...walk(path));
|
|
208
|
+
else if (entry.isFile()) found.push(path);
|
|
209
|
+
}
|
|
210
|
+
return found.sort();
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* Replace the tree atomically: the map is written to `plugin.next`, then renamed over
|
|
215
|
+
* `plugin`. The path a runtime links or installed from is unchanged; a removed skill's
|
|
216
|
+
* directory is gone because the whole tree is new. Mode 0700, like the rest of the home.
|
|
217
|
+
*/
|
|
218
|
+
export function writeExtensionPlugin(mirrorHome: string, files: PluginFileMap): void {
|
|
219
|
+
const root = extensionPluginRoot(mirrorHome);
|
|
220
|
+
const next = `${root}.next`;
|
|
221
|
+
const old = `${root}.old`;
|
|
222
|
+
rmSync(next, { recursive: true, force: true });
|
|
223
|
+
mkdirSync(next, { recursive: true, mode: 0o700 });
|
|
224
|
+
for (const [relative, content] of Object.entries(files)) {
|
|
225
|
+
const path = join(next, relative);
|
|
226
|
+
mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
|
|
227
|
+
writeFileSync(path, content, { encoding: "utf8", mode: 0o600 });
|
|
228
|
+
}
|
|
229
|
+
rmSync(old, { recursive: true, force: true });
|
|
230
|
+
if (existsSync(root)) renameSync(root, old);
|
|
231
|
+
renameSync(next, root);
|
|
232
|
+
rmSync(old, { recursive: true, force: true });
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
export interface ExtensionPluginReport {
|
|
236
|
+
readonly name: string;
|
|
237
|
+
readonly directory: string;
|
|
238
|
+
readonly marketplaceRoot: string;
|
|
239
|
+
readonly version: string;
|
|
240
|
+
readonly skills: number;
|
|
241
|
+
readonly changed: boolean;
|
|
242
|
+
/** Installed extensions that declare no `claude` block, and so are not carried. */
|
|
243
|
+
readonly notCarried: readonly string[];
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/** The home's `claude` catalog entries that still have a skill file, as plugin entries. */
|
|
247
|
+
export function claudeCatalogEntries(mirrorHome: string): PluginEntry[] | null {
|
|
248
|
+
const catalogPath = join(runtimeSkillsRootForHome(mirrorHome, "claude"), "extensions.json");
|
|
249
|
+
if (!existsSync(catalogPath)) return null;
|
|
250
|
+
let items: unknown[];
|
|
251
|
+
try {
|
|
252
|
+
const parsed: unknown = JSON.parse(readFileSync(catalogPath, "utf8"));
|
|
253
|
+
const list =
|
|
254
|
+
typeof parsed === "object" && parsed !== null
|
|
255
|
+
? (parsed as Record<string, unknown>).extensions
|
|
256
|
+
: undefined;
|
|
257
|
+
items = Array.isArray(list) ? list : [];
|
|
258
|
+
} catch {
|
|
259
|
+
items = [];
|
|
260
|
+
}
|
|
261
|
+
const entries: PluginEntry[] = [];
|
|
262
|
+
for (const item of items) {
|
|
263
|
+
if (typeof item !== "object" || item === null || Array.isArray(item)) continue;
|
|
264
|
+
const record = item as Record<string, unknown>;
|
|
265
|
+
const commandName = record.command_name;
|
|
266
|
+
const skillPath = record.installed_skill_path;
|
|
267
|
+
if (typeof commandName !== "string" || typeof skillPath !== "string") continue;
|
|
268
|
+
if (!existsSync(skillPath)) continue;
|
|
269
|
+
entries.push({ commandName, content: readFileSync(skillPath, "utf8") });
|
|
270
|
+
}
|
|
271
|
+
return entries;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/**
|
|
275
|
+
* Regenerate the home's plugin from its `claude` catalog. A home with no catalog has
|
|
276
|
+
* never installed an extension that declares `claude`, and gets no plugin.
|
|
277
|
+
*/
|
|
278
|
+
export function regenerateExtensionPlugin(mirrorHome: string): ExtensionPluginReport | null {
|
|
279
|
+
const entries = claudeCatalogEntries(mirrorHome);
|
|
280
|
+
if (entries === null) return null;
|
|
281
|
+
const carried = new Set(readCatalogIds(mirrorHome) ?? []);
|
|
282
|
+
const notCarried = discoverExtensions(extensionsRootForHome(mirrorHome))
|
|
283
|
+
.manifests.map((manifest) => manifest.id)
|
|
284
|
+
.filter((id) => !carried.has(id))
|
|
285
|
+
.sort();
|
|
286
|
+
const plan = planExtensionPlugin({
|
|
287
|
+
user: basename(mirrorHome),
|
|
288
|
+
entries,
|
|
289
|
+
previous: readExtensionPlugin(mirrorHome),
|
|
290
|
+
});
|
|
291
|
+
if (plan.changed || readExtensionPlugin(mirrorHome) === null)
|
|
292
|
+
writeExtensionPlugin(mirrorHome, plan.files);
|
|
293
|
+
return {
|
|
294
|
+
name: plan.name,
|
|
295
|
+
directory: extensionPluginDirectory(mirrorHome),
|
|
296
|
+
marketplaceRoot: extensionPluginRoot(mirrorHome),
|
|
297
|
+
version: plan.version,
|
|
298
|
+
skills: plan.skills,
|
|
299
|
+
changed: plan.changed,
|
|
300
|
+
notCarried,
|
|
301
|
+
};
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/** The extension ids the home's `claude` catalog names, or null without a catalog. */
|
|
305
|
+
function readCatalogIds(mirrorHome: string): string[] | null {
|
|
306
|
+
const catalogPath = join(runtimeSkillsRootForHome(mirrorHome, "claude"), "extensions.json");
|
|
307
|
+
if (!existsSync(catalogPath)) return null;
|
|
308
|
+
try {
|
|
309
|
+
const parsed: unknown = JSON.parse(readFileSync(catalogPath, "utf8"));
|
|
310
|
+
const list =
|
|
311
|
+
typeof parsed === "object" && parsed !== null
|
|
312
|
+
? (parsed as Record<string, unknown>).extensions
|
|
313
|
+
: undefined;
|
|
314
|
+
if (!Array.isArray(list)) return [];
|
|
315
|
+
return list
|
|
316
|
+
.map((item) =>
|
|
317
|
+
typeof item === "object" && item !== null && !Array.isArray(item)
|
|
318
|
+
? (item as Record<string, unknown>).id
|
|
319
|
+
: undefined,
|
|
320
|
+
)
|
|
321
|
+
.filter((id): id is string => typeof id === "string");
|
|
322
|
+
} catch {
|
|
323
|
+
return [];
|
|
324
|
+
}
|
|
325
|
+
}
|
|
@@ -31,7 +31,13 @@ import { tablePrefixFor } from "./migrations.ts";
|
|
|
31
31
|
|
|
32
32
|
const ALLOWED_KINDS = new Set(["prompt-skill", "command-skill"]);
|
|
33
33
|
const RUNTIME_NAME_RE = /^[a-z][a-z0-9_-]*$/;
|
|
34
|
-
|
|
34
|
+
/**
|
|
35
|
+
* What an extension id may look like. `install` is the authority on ids, so
|
|
36
|
+
* this is THE grammar; the settings reader (CR145) builds a path from an id
|
|
37
|
+
* only after it passes here. Two older copies of the rule still live in
|
|
38
|
+
* `contextRuntime.ts` and `dispatch.ts` -- CR147 reconciles them.
|
|
39
|
+
*/
|
|
40
|
+
export const SKILL_ID_RE = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
|
|
35
41
|
const REQUIRED_FIELDS = ["id", "name", "category", "kind", "summary", "runtimes"] as const;
|
|
36
42
|
const PATH_LIKE_SUFFIXES = new Set([".js", ".mjs", ".cjs", ".py"]);
|
|
37
43
|
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
// An extension's installation settings: `<mirror-home>/extensions/<id>.env` (CR145).
|
|
2
|
+
//
|
|
3
|
+
// The `mirror-cli-v1` and `mirror-context-v1` contracts hand a spawned
|
|
4
|
+
// extension the environment the `mirror` process had, and nothing else. That
|
|
5
|
+
// left an extension's own installation settings -- which host, which TLS mode,
|
|
6
|
+
// which certificate on this machine -- with nowhere to live but the login
|
|
7
|
+
// shell, where two of them (`PGSSLMODE`, `NODE_EXTRA_CA_CERTS`) broke every
|
|
8
|
+
// other Postgres client and Node process on the machine. Node reads
|
|
9
|
+
// `NODE_EXTRA_CA_CERTS` before any extension code runs, so only the spawner can
|
|
10
|
+
// set it, and the spawner is Mirror.
|
|
11
|
+
//
|
|
12
|
+
// The file sits BESIDE the installed directory, not inside it, for two reasons
|
|
13
|
+
// measured on the maintainer's machine: `extensions install` rewrites the
|
|
14
|
+
// directory, and in a development home the directory is a symlink into the
|
|
15
|
+
// extension's own git repository, where a `.env` would be one `git add .` away
|
|
16
|
+
// from a push.
|
|
17
|
+
//
|
|
18
|
+
// Precedence is the ecosystem's and Mirror's own (`runtime/config.ts`): a
|
|
19
|
+
// variable already in the environment wins, the file fills the gaps, and the
|
|
20
|
+
// five `MIRROR_*` names the dispatcher owns can never come from the file. The
|
|
21
|
+
// merged environment reaches the one spawned process; `process.env` is read,
|
|
22
|
+
// never written.
|
|
23
|
+
|
|
24
|
+
import { readFileSync, statSync } from "node:fs";
|
|
25
|
+
import { join } from "node:path";
|
|
26
|
+
import { parseEnv } from "node:util";
|
|
27
|
+
import { extensionsRootForHome } from "./catalog.ts";
|
|
28
|
+
import { SKILL_ID_RE } from "./manifest.ts";
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* The names the dispatcher sets last for a subcommand, and that a provider
|
|
32
|
+
* receives on stdin rather than in its environment. Dropped from the file at
|
|
33
|
+
* the merge, at both spawn points, so the file can steer neither.
|
|
34
|
+
*/
|
|
35
|
+
export const RESERVED_SETTINGS_NAMES: readonly string[] = [
|
|
36
|
+
"MIRROR_HOME",
|
|
37
|
+
"MIRROR_DATABASE_PATH",
|
|
38
|
+
"MIRROR_EXTENSION_ID",
|
|
39
|
+
"MIRROR_EXTENSION_ROOT",
|
|
40
|
+
"MIRROR_TABLE_PREFIX",
|
|
41
|
+
];
|
|
42
|
+
|
|
43
|
+
export type ExtensionSettings =
|
|
44
|
+
| { kind: "absent"; path: string }
|
|
45
|
+
| {
|
|
46
|
+
kind: "read";
|
|
47
|
+
path: string;
|
|
48
|
+
/** Sorted by name, as `parseEnv` yields them; a duplicate key's last value. */
|
|
49
|
+
variables: Record<string, string>;
|
|
50
|
+
/** The mode, when group or others can read the file; null when only the owner can. */
|
|
51
|
+
exposedMode: string | null;
|
|
52
|
+
}
|
|
53
|
+
| { kind: "unreadable"; path: string; reason: string };
|
|
54
|
+
|
|
55
|
+
export function extensionSettingsPath(mirrorHome: string, extensionId: string): string {
|
|
56
|
+
return join(extensionsRootForHome(mirrorHome), `${extensionId}.env`);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Read the settings file, as a result and never a throw.
|
|
61
|
+
*
|
|
62
|
+
* An id outside the manifest's grammar is `unreadable` before any path is
|
|
63
|
+
* built: the context runtime takes ids from `_ext_bindings` rows, and a path
|
|
64
|
+
* must not be steerable by database content. "Unreadable" is the fail-closed
|
|
65
|
+
* answer at both call sites -- the dispatcher refuses, the provider reports.
|
|
66
|
+
*
|
|
67
|
+
* A UTF-8 BOM is stripped before parsing: `parseEnv` would otherwise keep it
|
|
68
|
+
* in the first key's name, and the editor that wrote it never shows it.
|
|
69
|
+
*/
|
|
70
|
+
export function readExtensionSettings(mirrorHome: string, extensionId: string): ExtensionSettings {
|
|
71
|
+
if (!SKILL_ID_RE.test(extensionId)) {
|
|
72
|
+
return {
|
|
73
|
+
kind: "unreadable",
|
|
74
|
+
path: join(extensionsRootForHome(mirrorHome), "<id>.env"),
|
|
75
|
+
reason: `'${extensionId}' is not a valid extension id`,
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
const path = extensionSettingsPath(mirrorHome, extensionId);
|
|
79
|
+
let text: string;
|
|
80
|
+
let mode: number;
|
|
81
|
+
try {
|
|
82
|
+
const stat = statSync(path, { throwIfNoEntry: false });
|
|
83
|
+
if (stat === undefined) return { kind: "absent", path };
|
|
84
|
+
mode = stat.mode;
|
|
85
|
+
text = readFileSync(path, "utf8");
|
|
86
|
+
} catch (error) {
|
|
87
|
+
const code = (error as NodeJS.ErrnoException).code;
|
|
88
|
+
return { kind: "unreadable", path, reason: code ?? (error as Error).message };
|
|
89
|
+
}
|
|
90
|
+
const variables: Record<string, string> = {};
|
|
91
|
+
for (const [key, value] of Object.entries(parseEnv(text.replace(/^\uFEFF/, "")))) {
|
|
92
|
+
if (value !== undefined) variables[key] = value;
|
|
93
|
+
}
|
|
94
|
+
return { kind: "read", path, variables, exposedMode: exposedModeOf(mode) };
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* The file's mode as `0644` when group or others can read it, else null.
|
|
99
|
+
* Mode bits mean nothing on Windows, where every file would read as exposed.
|
|
100
|
+
*/
|
|
101
|
+
function exposedModeOf(mode: number): string | null {
|
|
102
|
+
if (process.platform === "win32") return null;
|
|
103
|
+
const permissions = mode & 0o777;
|
|
104
|
+
return (permissions & 0o044) === 0 ? null : `0${permissions.toString(8)}`;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* The file UNDER the environment: `{ ...file, ...environment }`, with the
|
|
109
|
+
* reserved names removed from the file first. A key present in the
|
|
110
|
+
* environment -- even as an empty string -- is already set and wins, the rule
|
|
111
|
+
* `loadEnvFileWithoutOverride` applies to the home's own files.
|
|
112
|
+
*/
|
|
113
|
+
export function settingsUnderEnvironment(
|
|
114
|
+
environment: NodeJS.ProcessEnv,
|
|
115
|
+
settings: ExtensionSettings,
|
|
116
|
+
): NodeJS.ProcessEnv {
|
|
117
|
+
if (settings.kind !== "read") return { ...environment };
|
|
118
|
+
const fromFile: Record<string, string> = {};
|
|
119
|
+
for (const [key, value] of Object.entries(settings.variables)) {
|
|
120
|
+
if (RESERVED_SETTINGS_NAMES.includes(key)) continue;
|
|
121
|
+
fromFile[key] = value;
|
|
122
|
+
}
|
|
123
|
+
return { ...fromFile, ...environment };
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* The `ext <id>` listing's last line. Keys only, never a value: a key's name
|
|
128
|
+
* says a setting exists, which is what a person checking the file needs; its
|
|
129
|
+
* value is what must not end up in a transcript.
|
|
130
|
+
*/
|
|
131
|
+
export function describeExtensionSettings(settings: ExtensionSettings): string {
|
|
132
|
+
switch (settings.kind) {
|
|
133
|
+
case "absent":
|
|
134
|
+
return `settings: ${settings.path} (not present — create it to pass settings to this extension)`;
|
|
135
|
+
case "unreadable":
|
|
136
|
+
return `settings: ${settings.path} (unreadable: ${settings.reason})`;
|
|
137
|
+
case "read": {
|
|
138
|
+
const keys = Object.keys(settings.variables);
|
|
139
|
+
const state = keys.length === 0 ? "no variables" : keys.join(", ");
|
|
140
|
+
const exposure =
|
|
141
|
+
settings.exposedMode === null
|
|
142
|
+
? ""
|
|
143
|
+
: `; mode ${settings.exposedMode}, readable by other users`;
|
|
144
|
+
return `settings: ${settings.path} (${state}${exposure})`;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
}
|
package/ts/src/frontDoor/cli.ts
CHANGED
|
@@ -19,7 +19,7 @@
|
|
|
19
19
|
// so neither a `bin` shim nor a skill line needs `--env-file` or
|
|
20
20
|
// `NODE_OPTIONS=--no-warnings`. Importing this module does neither.
|
|
21
21
|
|
|
22
|
-
import { existsSync, readFileSync } from "node:fs";
|
|
22
|
+
import { accessSync, constants, existsSync, readFileSync } from "node:fs";
|
|
23
23
|
import { basename, dirname } from "node:path";
|
|
24
24
|
import { pathToFileURL } from "node:url";
|
|
25
25
|
import {
|
|
@@ -68,6 +68,7 @@ import {
|
|
|
68
68
|
JOURNAL_TRANSPORT,
|
|
69
69
|
WEEK_PLAN_TRANSPORT,
|
|
70
70
|
} from "#providers/transport.ts";
|
|
71
|
+
import { exitQuietlyOnBrokenPipe } from "#runtime/brokenPipe.ts";
|
|
71
72
|
import {
|
|
72
73
|
configFilePath,
|
|
73
74
|
loadConfiguration,
|
|
@@ -769,6 +770,17 @@ async function runExtensionCatalog(argv: readonly string[]): Promise<DispatchOut
|
|
|
769
770
|
deps: { nowIso: () => pythonUtcIsoformat() },
|
|
770
771
|
withReadOnlyDatabase: (run) => withMirrorReadDb(argv, run),
|
|
771
772
|
withWritableDatabase: (run) => withMirrorWriteDb(argv, run),
|
|
773
|
+
// CV21.DS5.US2: the extensions-plugin readers take their homes and the
|
|
774
|
+
// PATH from the process environment here, and from nowhere else.
|
|
775
|
+
environment: process.env,
|
|
776
|
+
isExecutable: (path) => {
|
|
777
|
+
try {
|
|
778
|
+
accessSync(path, constants.X_OK);
|
|
779
|
+
return true;
|
|
780
|
+
} catch {
|
|
781
|
+
return false;
|
|
782
|
+
}
|
|
783
|
+
},
|
|
772
784
|
});
|
|
773
785
|
}
|
|
774
786
|
|
|
@@ -1871,6 +1883,9 @@ export async function main(rawArgv = process.argv.slice(2)): Promise<number> {
|
|
|
1871
1883
|
*/
|
|
1872
1884
|
export async function runAsEntry(rawArgv = process.argv.slice(2)): Promise<number> {
|
|
1873
1885
|
silenceExperimentalWarnings();
|
|
1886
|
+
// Before anything that could write (CR148): `mirror … | head -1` ends
|
|
1887
|
+
// quietly, exit 0, instead of an unhandled EPIPE and a stack trace.
|
|
1888
|
+
exitQuietlyOnBrokenPipe(process.exit);
|
|
1874
1889
|
loadConfiguration({ entryPath: import.meta.filename });
|
|
1875
1890
|
return main(rawArgv);
|
|
1876
1891
|
}
|
|
@@ -62,6 +62,8 @@ export interface ExtensionRouteDeps {
|
|
|
62
62
|
run: (db: WritableDatabase) => Promise<number> | number,
|
|
63
63
|
) => Promise<number>;
|
|
64
64
|
environment?: NodeJS.ProcessEnv;
|
|
65
|
+
/** Whether a PATH candidate is executable, for the extensions-plugin block (CV21.DS5.US2). */
|
|
66
|
+
isExecutable?: (path: string) => boolean;
|
|
65
67
|
}
|
|
66
68
|
|
|
67
69
|
/** The `extensions` verbs that need a writable handle; the rest read. */
|
|
@@ -140,7 +142,11 @@ function firstPositional(args: readonly string[]): string | null {
|
|
|
140
142
|
}
|
|
141
143
|
|
|
142
144
|
function readContextFor(route: ExtensionRouteDeps): CatalogContext {
|
|
143
|
-
return {
|
|
145
|
+
return {
|
|
146
|
+
mirrorHome: route.mirrorHome,
|
|
147
|
+
...(route.environment ? { env: route.environment } : {}),
|
|
148
|
+
...(route.isExecutable ? { isExecutable: route.isExecutable } : {}),
|
|
149
|
+
};
|
|
144
150
|
}
|
|
145
151
|
|
|
146
152
|
function dispatchOptions(route: ExtensionRouteDeps) {
|
|
@@ -151,7 +157,7 @@ function dispatchOptions(route: ExtensionRouteDeps) {
|
|
|
151
157
|
}
|
|
152
158
|
|
|
153
159
|
function writeContextFor(route: ExtensionRouteDeps, db: WritableDatabase): ExtWriteContext {
|
|
154
|
-
return {
|
|
160
|
+
return { ...readContextFor(route), db, deps: route.deps };
|
|
155
161
|
}
|
|
156
162
|
|
|
157
163
|
export async function runExtensionCatalogRoute(
|
|
@@ -24,6 +24,7 @@ import {
|
|
|
24
24
|
import { channelFor, channelForReport, renderChannel, setChannel } from "#runtime/channel.ts";
|
|
25
25
|
import {
|
|
26
26
|
diagnoseRuntime,
|
|
27
|
+
extensionPluginFindings,
|
|
27
28
|
hookFailureFindings,
|
|
28
29
|
hookNodeFindings,
|
|
29
30
|
pluginWiringFindings,
|
|
@@ -336,6 +337,16 @@ export async function runRuntimeReadRoute(
|
|
|
336
337
|
// after an npm update; Claude Code's link can dangle. Absence is not
|
|
337
338
|
// drift, so an unwired machine stays green.
|
|
338
339
|
...pluginWiringFindings(installRoot, env),
|
|
340
|
+
// CV21.DS5.US2: the home's extensions plugin, as each runtime on the
|
|
341
|
+
// PATH holds it. A stale copy is any version other than the home's.
|
|
342
|
+
...extensionPluginFindings(report.mirror_home, env, (path) => {
|
|
343
|
+
try {
|
|
344
|
+
accessSync(path, constants.X_OK);
|
|
345
|
+
return true;
|
|
346
|
+
} catch {
|
|
347
|
+
return false;
|
|
348
|
+
}
|
|
349
|
+
}),
|
|
339
350
|
// No catalog provider until DS8: inconclusive, which is also the
|
|
340
351
|
// oracle's answer when it cannot reach OpenRouter.
|
|
341
352
|
...(await probeModelPins(null, "")),
|
|
@@ -10,8 +10,8 @@
|
|
|
10
10
|
// A one-shot handoff rather than an argument because the hooks run the front
|
|
11
11
|
// door in-process (`runFrontDoor`), through the same `main(argv)` every other
|
|
12
12
|
// caller uses, and a payload does not belong in argv: argv is what `ps`
|
|
13
|
-
// shows, and what the Gemini hooks
|
|
14
|
-
// this seam avoids repeating.
|
|
13
|
+
// shows, and what the Gemini CLI hooks (retired by CV21.DS8.US1) put a prompt
|
|
14
|
+
// into is the exception this seam avoids repeating.
|
|
15
15
|
|
|
16
16
|
import { readFileSync } from "node:fs";
|
|
17
17
|
|