@quill507/dsh-orchestrator-preset 0.1.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.
@@ -0,0 +1,643 @@
1
+ #!/usr/bin/env bash
2
+ #
3
+ # verify-install.sh — is this preset really installed, and really selected?
4
+ #
5
+ # ./tools/verify-install.sh # checks the "desktop" profile
6
+ # ./tools/verify-install.sh web
7
+ # DSH_PKG_ROOT=/path/to/@deepseek-ai/dsh ./tools/verify-install.sh
8
+ #
9
+ # Read-only. It composes the profile tree in memory and never writes to a DSH
10
+ # home; check 5 asserts that, rather than assuming it.
11
+ #
12
+ # Five checks, two of which were wrong as originally written. What changed:
13
+ #
14
+ # 1. selectedDefault in the profile patch unchanged — still valid
15
+ # 2. the bundle link is a real symlink unchanged — test -L, not -e
16
+ # 3. the bundle is readable THROUGH the link unchanged — present != usable
17
+ # 4. the patch generator is in sync CHANGED — see below
18
+ # 5. which preset is actually selected REPLACED — see below
19
+ #
20
+ # Why 4 changed: the generator resolves its skeleton from $DSH_HOME, falling back
21
+ # to ~/.dsh. A shell that happens to export DSH_HOME resolves fine; a plain shell
22
+ # without it exited 2, which reads as "the tool is broken". So this runs the tool
23
+ # with DSH_HOME explicitly UNSET — the case that used to fail. The tool was
24
+ # repaired to fall back to the default home, so it should now pass either way, and
25
+ # this check proves that rather than assuming it.
26
+ #
27
+ # Why 5 was replaced rather than repaired: `dsh --profile <name> --dump-config` is
28
+ # refused outright by the launcher for an Electron-managed profile such as
29
+ # `desktop`, with no flag or environment escape. And the profile's `cordis.yml` is
30
+ # a four-line empty root the host rewrites on every launch — it structurally
31
+ # cannot contain the rows, and anything dumped into it dies at the next boot. The
32
+ # original check grepped that file and could never have worked. This check instead
33
+ # composes the tree with the host's own read-only loader, skipping the step that
34
+ # rewrites cordis.yml, and greps the composed output.
35
+ #
36
+ # MIT licensed. See ../LICENSE.
37
+
38
+ set -uo pipefail
39
+
40
+ PROFILE="${1:-desktop}"
41
+ REPO_ROOT="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)"
42
+
43
+ PASS=0; FAIL=0
44
+ TMP="$(mktemp -d)"
45
+ trap 'rm -rf "$TMP"' EXIT
46
+
47
+ ok() { PASS=$((PASS+1)); printf 'PASS %s\n' "$*"; }
48
+ bad() { FAIL=$((FAIL+1)); printf 'FAIL %s\n' "$*"; }
49
+ # Neither a pass nor a fail: something did not run, and saying so is the point.
50
+ # A warning that quietly counted as a pass would be the same lie as an empty
51
+ # grep result.
52
+ warn() { printf 'WARN %s\n' "$*"; }
53
+ note() { printf ' %s\n' "$*"; }
54
+ head_() { printf '\n== %s\n' "$*"; }
55
+
56
+ # --- resolve the DSH home and the dsh package root, dynamically ------------
57
+ # Neither is hardcoded: both move between machines, and a check that only works on
58
+ # one of them is not a check.
59
+ head_ "environment"
60
+ DSH_PKG_ROOT="${DSH_PKG_ROOT:-}"
61
+ if [ -z "$DSH_PKG_ROOT" ]; then
62
+ shim="$(command -v dsh 2>/dev/null || true)"
63
+ if [ -n "$shim" ]; then
64
+ base="$(cd -- "$(dirname -- "$shim")" && pwd)"
65
+ for cand in "$base/node_modules/@deepseek-ai/dsh" "$base/../node_modules/@deepseek-ai/dsh" \
66
+ "$base/../lib/node_modules/@deepseek-ai/dsh"; do
67
+ if [ -f "$cand/package.json" ]; then DSH_PKG_ROOT="$(cd -- "$cand" && pwd)"; break; fi
68
+ done
69
+ fi
70
+ fi
71
+ if [ -z "$DSH_PKG_ROOT" ] || [ ! -f "$DSH_PKG_ROOT/package.json" ]; then
72
+ echo "!! could not locate the @deepseek-ai/dsh package root" >&2
73
+ echo " set DSH_PKG_ROOT=/path/to/@deepseek-ai/dsh and re-run" >&2
74
+ exit 2
75
+ fi
76
+ note "dsh package root : $DSH_PKG_ROOT"
77
+
78
+ # The DSH home is the host's own answer, not a guess about ~.
79
+ DSH_HOME_ABS="$(cd -- "$DSH_PKG_ROOT" && node --input-type=module -e \
80
+ "const {resolveDshHome} = await import('@deepseek-ai/dsh-home-paths'); process.stdout.write(resolveDshHome());")"
81
+ [ -n "$DSH_HOME_ABS" ] || { echo "!! could not resolve the DSH home" >&2; exit 2; }
82
+ note "DSH home : $DSH_HOME_ABS"
83
+
84
+ PROFILE_DIR="$DSH_HOME_ABS/profiles/$PROFILE"
85
+ PATCH_YML="$PROFILE_DIR/cordis.patch.yml"
86
+ COMPOSED_CORDIS_YML="$PROFILE_DIR/cordis.yml"
87
+
88
+ # The package name is read, never written down. It is the one string in this file
89
+ # that changes on a rename, and a hardcoded copy of it is a bug that only fires
90
+ # after the rename — which is exactly when nobody is looking at this script.
91
+ BUNDLE_NAME="$(node -e "
92
+ const fs = require('node:fs');
93
+ process.stdout.write(JSON.parse(fs.readFileSync(process.argv[1], 'utf8')).name || '');
94
+ " "$REPO_ROOT/package.json" 2>/dev/null)"
95
+ if [ -z "$BUNDLE_NAME" ]; then
96
+ echo "!! cannot read the package name from $REPO_ROOT/package.json" >&2
97
+ exit 2
98
+ fi
99
+ BUNDLE_LINK="$PROFILE_DIR/node_modules/$BUNDLE_NAME"
100
+
101
+ if [ ! -d "$PROFILE_DIR" ]; then
102
+ echo "!! no such profile: $PROFILE_DIR" >&2
103
+ exit 2
104
+ fi
105
+
106
+ # ===========================================================================
107
+ head_ "check 1 — the profile patch selects this preset (unchanged form)"
108
+ # ===========================================================================
109
+ # Still valid exactly as originally written. The row lives in the profile's own
110
+ # patch layer, so a plain anchored grep is the right instrument.
111
+ n="$(grep -c 'selectedDefault: dsh-orchestrator-preset$' "$PATCH_YML" 2>/dev/null || echo 0)"
112
+ if [ "$n" = "1" ]; then
113
+ ok "check 1 selectedDefault: dsh-orchestrator-preset occurs exactly once in the profile patch"
114
+ else
115
+ bad "check 1 expected exactly 1 occurrence, found $n ($PATCH_YML)"
116
+ fi
117
+
118
+ # ===========================================================================
119
+ head_ "check 2 — the bundle link is a REAL symlink (unchanged form)"
120
+ # ===========================================================================
121
+ # This distinction is the whole point of the check. `test -e` follows a symlink,
122
+ # so a link that some tool silently degraded into a real directory COPY still
123
+ # passes it — and a copy freezes the bundle while every later edit to the real
124
+ # bundle is ignored. Only `test -L` asks the question we actually mean.
125
+ if [ -L "$BUNDLE_LINK" ]; then
126
+ ok "check 2 $BUNDLE_NAME is a real symlink"
127
+ note "-> $(readlink "$BUNDLE_LINK")"
128
+ elif [ -e "$BUNDLE_LINK" ]; then
129
+ bad "check 2 it exists but is NOT a symlink — a copy would freeze the bundle"
130
+ note "test -e would have passed this; test -L is why the check exists"
131
+ else
132
+ bad "check 2 missing: $BUNDLE_LINK"
133
+ note "add the link: dependency to the profile's package.json, then pnpm install"
134
+ fi
135
+
136
+ # ===========================================================================
137
+ head_ "check 3 — the bundle is readable THROUGH the link (unchanged form)"
138
+ # ===========================================================================
139
+ # Present is not usable. This resolves package.json by the name the patch rows
140
+ # use, so it proves the same resolution path the host takes at boot.
141
+ if [ -L "$BUNDLE_LINK" ] || [ -e "$BUNDLE_LINK" ]; then
142
+ if ( cd -- "$PROFILE_DIR" && BUNDLE_NAME="$BUNDLE_NAME" node --input-type=module -e '
143
+ import { createRequire } from "node:module";
144
+ import { realpathSync } from "node:fs";
145
+ const r = createRequire(process.cwd() + "/package.json");
146
+ const p = r.resolve(process.env.BUNDLE_NAME + "/package.json");
147
+ process.stdout.write(realpathSync(p));' ) >"$TMP/resolved" 2>"$TMP/resolve.err"; then
148
+ ok "check 3 the bundle resolves by package name and its real path is reachable"
149
+ note "-> $(cat "$TMP/resolved")"
150
+ else
151
+ bad "check 3 present but NOT resolvable by package name:"
152
+ note "$(head -3 "$TMP/resolve.err" | tr '\n' ' ')"
153
+ fi
154
+ else
155
+ bad "check 3 skipped: no link to read through (see check 2)"
156
+ fi
157
+
158
+ # ===========================================================================
159
+ head_ "check 4 — the patch generator is in sync, with DSH_HOME UNSET (CHANGED form)"
160
+ # ===========================================================================
161
+ # The case that used to fail. env -u removes the variable for the child only, so
162
+ # this script's own environment is untouched.
163
+ GEN="$REPO_ROOT/tools/gen-cordis-patch.mjs"
164
+ if [ ! -f "$GEN" ]; then
165
+ bad "check 4 generator not found at $GEN"
166
+ else
167
+ before_m="$(stat -c %Y "$REPO_ROOT/cordis.patch.yml" 2>/dev/null || echo none)"
168
+ if ( cd -- "$REPO_ROOT" && env -u DSH_HOME node tools/gen-cordis-patch.mjs --check ) >"$TMP/gen.out" 2>&1; then
169
+ ok "check 4 --check exited 0 with DSH_HOME unset"
170
+ note "$(grep -m1 -E '^(ok|stale|wrote) ' "$TMP/gen.out" || head -1 "$TMP/gen.out")"
171
+ else
172
+ rc=$?
173
+ bad "check 4 --check exited $rc with DSH_HOME unset (0 expected)"
174
+ note "$(head -4 "$TMP/gen.out" | tr '\n' ' ')"
175
+ fi
176
+ after_m="$(stat -c %Y "$REPO_ROOT/cordis.patch.yml" 2>/dev/null || echo none)"
177
+ if [ "$before_m" = "$after_m" ]; then
178
+ note "--check did not rewrite cordis.patch.yml, as documented"
179
+ else
180
+ bad "check 4 --check modified cordis.patch.yml — it is not read-only!"
181
+ fi
182
+ fi
183
+
184
+ # ===========================================================================
185
+ head_ "check 5 — the COMPOSED tree really selects this preset (REPLACED form)"
186
+ # ===========================================================================
187
+ # --dump-config is unusable here: the launcher refuses it for an Electron-managed
188
+ # profile, unconditionally. So compose the tree in memory with the host's own
189
+ # loader and skip the step that rewrites cordis.yml.
190
+ #
191
+ # Two traps this avoids, both paid for by an earlier attempt:
192
+ # * the dump-config helper is minified, so collectConfigDumpLayers is exported
193
+ # under the name `t`; the file is located by probing for that export rather
194
+ # than by hardcoding a content hash, which changes between builds;
195
+ # * on Windows an absolute path inside import() needs pathToFileURL().href or
196
+ # it fails with ERR_UNSUPPORTED_ESM_URL_SCHEME.
197
+ #
198
+ # cwd must be the dsh package root so the bare specifiers resolve.
199
+
200
+ cordis_m_before="$(stat -c '%Y:%s' "$COMPOSED_CORDIS_YML" 2>/dev/null || echo absent)"
201
+ compose() {
202
+ ( cd -- "$DSH_PKG_ROOT" && node --input-type=module -e '
203
+ import { pathToFileURL } from "node:url";
204
+ import { readdirSync } from "node:fs";
205
+ const boot = await import("@deepseek-ai/dsh-app-boot");
206
+ const { resolveDshHome } = await import("@deepseek-ai/dsh-home-paths");
207
+ const root = process.env.DSH_PKG_ROOT;
208
+ const profile = process.env.VERIFY_PROFILE;
209
+ // locate the minified helper by its export, not by filename hash
210
+ const cands = await Promise.all(readdirSync(root + "/lib")
211
+ .filter(f => /^dump-config-.*\.js$/.test(f))
212
+ .map(async f => { const m = await import(pathToFileURL(root + "/lib/" + f).href); return { f, t: m.t }; }));
213
+ const helper = cands.find(x => typeof x.t === "function");
214
+ if (!helper) { console.error("no dump-config helper exporting t"); process.exit(9); }
215
+ const dir = boot.resolveProfileDir(profile, resolveDshHome());
216
+ const loaded = boot.loadProfileDirectory("dsh", dir, root + "/package.json", { userLayer: true });
217
+ process.stderr.write("helper = " + helper.f + "\n");
218
+ process.stdout.write(boot.renderConfigDump("dsh", dir + "/cordis.yml", helper.t(loaded, false, [])));
219
+ ' 2>"$TMP/compose.err" )
220
+ }
221
+ DSH_PKG_ROOT="$DSH_PKG_ROOT" VERIFY_PROFILE="$PROFILE" compose >"$TMP/composed.yml"
222
+ compose_rc=$?
223
+ cordis_m_after="$(stat -c '%Y:%s' "$COMPOSED_CORDIS_YML" 2>/dev/null || echo absent)"
224
+
225
+ if [ "$compose_rc" != "0" ] || [ ! -s "$TMP/composed.yml" ]; then
226
+ bad "check 5 composition failed (exit $compose_rc)"
227
+ note "$(head -3 "$TMP/compose.err" | tr '\n' ' ')"
228
+ else
229
+ # The assertion that makes this check trustworthy: composing must not have
230
+ # touched the profile. Without it, this check would be a mutation dressed as
231
+ # an inspection, and nothing in its output would reveal that.
232
+ if [ "$cordis_m_before" = "$cordis_m_after" ]; then
233
+ ok "check 5 composition is read-only: cordis.yml mtime:size unchanged ($cordis_m_after)"
234
+ else
235
+ bad "check 5 composition MUTATED cordis.yml ($cordis_m_before -> $cordis_m_after)"
236
+ fi
237
+ note "composed $(wc -l <"$TMP/composed.yml") lines; helper $(head -1 "$TMP/compose.err" | sed 's/helper = //')"
238
+
239
+ c_sel="$(grep -c 'selectedDefault: dsh-orchestrator-preset$' "$TMP/composed.yml" || true)"
240
+ c_stale="$(grep -c 'new-orchestration' "$TMP/composed.yml" || true)"
241
+ c_row="$(grep -c '^ *- id: preset-dsh-orchestrator-preset$' "$TMP/composed.yml" || true)"
242
+
243
+ if [ "$c_sel" -ge 1 ]; then
244
+ ok "check 5 the composed tree really selects dsh-orchestrator-preset ($c_sel row(s))"
245
+ else
246
+ bad "check 5 the composed tree does NOT select this preset"
247
+ fi
248
+ if [ "$c_stale" = "0" ]; then
249
+ ok "check 5 no occurrence of the pre-rename name 'new-orchestration'"
250
+ else
251
+ bad "check 5 the composed tree still contains 'new-orchestration' ($c_stale line(s))"
252
+ fi
253
+ if [ "$c_row" -ge 1 ]; then
254
+ ok "check 5 the preset row preset-dsh-orchestrator-preset is present"
255
+ else
256
+ bad "check 5 the preset row preset-dsh-orchestrator-preset is ABSENT from the composed tree"
257
+ fi
258
+ fi
259
+
260
+ # ===========================================================================
261
+ head_ "check 6 — the four orch-* skills come from this bundle's own provider"
262
+ # ===========================================================================
263
+ # The four skills ship INSIDE the bundle and are served by a filesystem skill
264
+ # provider mounted from extensions/dsh/index.js. They are deliberately NOT in the
265
+ # DSH home's skills directory any more: copying them there was the old install
266
+ # step 3, and it is what left the user's global directory full of someone else's
267
+ # environment. So "present" now means "reachable through our provider", and this
268
+ # check asks the provider rather than looking at a directory.
269
+ SKILLS_DIR="$DSH_HOME_ABS/skills"
270
+ BUNDLE_SKILLS="$REPO_ROOT/skills"
271
+
272
+ for s in orch-delegation-brief orch-discussion-protocol orch-evidence-protocol orch-real-path-testing; do
273
+ if [ -f "$BUNDLE_SKILLS/$s/SKILL.md" ]; then
274
+ want=$(grep -m1 '^name:' "$BUNDLE_SKILLS/$s/SKILL.md" | sed 's/^name:[[:space:]]*//')
275
+ if [ "$want" = "$s" ]; then
276
+ ok "check 6 shipped in bundle with matching frontmatter name: $s"
277
+ else
278
+ bad "check 6 frontmatter name '$want' != directory '$s' (the provider reads the frontmatter)"
279
+ fi
280
+ else
281
+ bad "check 6 skill missing from the bundle: $s/SKILL.md"
282
+ fi
283
+ if [ -d "$SKILLS_DIR/$s" ]; then
284
+ bad "check 6 $s is still copied into the global skills directory — that is what this change removed"
285
+ fi
286
+ done
287
+
288
+ # The load-bearing half: construct the real provider and ask it what it serves.
289
+ # A file-presence check would pass even if the provider were never mounted.
290
+ if [ -f "$REPO_ROOT/extensions/dsh/index.js" ]; then
291
+ # The probe must run from the INSTALLED bundle, not from this source tree: the
292
+ # provider imports @deepseek-ai/dsh-skill-filesystem, which resolves by walking
293
+ # up to the DSH home's node_modules. A checkout in an unrelated directory has
294
+ # no such ancestor, and the probe would fail for a reason that says nothing
295
+ # about the preset.
296
+ installed="$(cd "$BUNDLE_LINK" 2>/dev/null && pwd -P)" || installed=""
297
+ if [ -z "$installed" ] || [ ! -f "$installed/extensions/dsh/index.js" ]; then
298
+ bad "check 6 the installed bundle has no extensions/dsh/index.js — the probe cannot run"
299
+ else
300
+ provided="$(cd "$installed" && node --input-type=module -e '
301
+ import * as nodefs from "node:fs";
302
+ let factory = null;
303
+ const ctx = { logger:{info(){},warn(){}}, effect:(f)=>{try{f()}catch{}}, on(){},
304
+ get:(k)=> k==="fs" ? nodefs : undefined,
305
+ skills:{ registerProvider:(f)=>{factory=f;return ()=>{}},
306
+ register:()=>()=>{}, list:async()=>[], get:async()=>null } };
307
+ const mod = await import("./extensions/dsh/index.js");
308
+ mod.apply(ctx);
309
+ if (!factory) { console.log("NO_PROVIDER_REGISTERED"); process.exit(0); }
310
+ const p = factory({ signal: new AbortController().signal });
311
+ const l = await p.list({ cwd: process.cwd() });
312
+ const arr = Array.isArray(l) ? l : (l && l.skills) || [];
313
+ console.log(arr.map(s => s && (s.name || s)).filter(Boolean).sort().join(" "));
314
+ ' 2>"$TMP/provider.err")"
315
+ rc=$?
316
+ if [ "$rc" -ge 2 ] || [ -z "$provided" ]; then
317
+ bad "check 6 the provider probe itself failed (node exit $rc) — a clean result would be untrustworthy:"
318
+ note "$(head -2 "$TMP/provider.err" | tr '\n' ' ')"
319
+ elif [ "$provided" = "NO_PROVIDER_REGISTERED" ]; then
320
+ bad "check 6 extensions/dsh/index.js did not register a skill provider"
321
+ else
322
+ for s in orch-delegation-brief orch-discussion-protocol orch-evidence-protocol orch-real-path-testing; do
323
+ case " $provided " in
324
+ *" $s "*) ok "check 6 provider serves: $s" ;;
325
+ *) bad "check 6 provider does NOT serve: $s" ;;
326
+ esac
327
+ done
328
+ # Isolation: includeDefaultRoots:false means the provider must serve exactly
329
+ # this bundle's four skills and nothing else. Comparing against the needle
330
+ # list rather than against names written out here keeps this script free of
331
+ # the very strings the leak gate hunts — writing them literally is what once
332
+ # made the gate report itself.
333
+ served_count=0
334
+ for n in $provided; do served_count=$((served_count+1)); done
335
+ if [ "$served_count" -eq 4 ]; then
336
+ ok "check 6 provider serves exactly 4 skills (isolated from the global directory)"
337
+ else
338
+ bad "check 6 provider serves $served_count entries, expected 4 — the global skills directory is leaking in"
339
+ note "$provided"
340
+ fi
341
+ for n in "${NAME_NEEDLES[@]}"; do
342
+ case " $provided " in
343
+ *"$n"*) bad "check 6 a known third-party name leaked into what the provider serves: $n" ;;
344
+ esac
345
+ done
346
+ fi
347
+ fi
348
+ else
349
+ bad "check 6 extensions/dsh/index.js is missing — the bundle has no skill provider"
350
+ fi
351
+
352
+ for s in delegation-brief discussion-protocol evidence-protocol real-path-testing; do
353
+ if [ -d "$SKILLS_DIR/$s" ]; then
354
+ bad "check 6 a RETIRED unprefixed skill still exists as a directory: $s"
355
+ else
356
+ ok "check 6 retired name correctly absent: $s"
357
+ fi
358
+ done
359
+
360
+ # ===========================================================================
361
+ head_ "check 7 — the two shell rows read as deliberately configured"
362
+ # ===========================================================================
363
+ # Not a defect. The preset declares BOTH shells, each gated on process.platform,
364
+ # so on Windows it asks for pwsh. This host's ~/.dsh/cordis.patch.yml home layer
365
+ # deliberately forces bash-only on Windows and is applied after the profile
366
+ # layer, so it wins. A disabled tool is still a known tool, and the lanes work
367
+ # fine on bash alone. If this ever reads the other way, the home layer changed.
368
+ if [ -s "$TMP/composed.yml" ]; then
369
+ bash_row="$(grep -A2 '^- id: tool-bash$' "$TMP/composed.yml" | grep -m1 'disabled:' || echo 'disabled: ?')"
370
+ pwsh_row="$(grep -A2 '^- id: tool-pwsh$' "$TMP/composed.yml" | grep -m1 'disabled:' || echo 'disabled: ?')"
371
+ if echo "$bash_row" | grep -q 'disabled: false'; then
372
+ ok "check 7 tool-bash is enabled (bash-only Windows host, deliberate)"
373
+ else
374
+ bad "check 7 tool-bash: $bash_row (expected disabled: false)"
375
+ fi
376
+ if echo "$pwsh_row" | grep -q 'disabled: true'; then
377
+ ok "check 7 tool-pwsh is disabled (bash-only Windows host, deliberate)"
378
+ else
379
+ bad "check 7 tool-pwsh: $pwsh_row (expected disabled: true)"
380
+ fi
381
+ else
382
+ bad "check 7 no composed tree to read the shell rows from"
383
+ fi
384
+
385
+ # ===========================================================================
386
+ head_ "check 8 — leak gate over this repository's shipped tree"
387
+ # ===========================================================================
388
+ # Doubles as the publish gate: the tree must carry no machine-local path and no
389
+ # third-party skill name. Scanned in this repository, not the live installation.
390
+ #
391
+ # Two things this does deliberately, both learned the hard way:
392
+ #
393
+ # 1. It never writes the needles as literals. This file is itself inside the
394
+ # scanned tree, so a scanner that spells out what it hunts is a self-matching
395
+ # source: it would report a leak of its own making, and exempting itself
396
+ # would be worse. Each needle is assembled from two adjacent quoted parts, so
397
+ # the shipped text never contains the literal. What it hunts, by category: an
398
+ # image-upload skill; a code-review and a commit-message helper; a
399
+ # disk-cleaning skill that ships a vendored binary; a bundled multi-licence
400
+ # skill; a no-frontmatter UI skill and a document reader; two skills named
401
+ # only by an earlier draft; and the private-data layout tokens.
402
+ #
403
+ # 2. It distinguishes "found nothing" from "the scan itself failed". An earlier
404
+ # version ended its pattern with a backslash, which grep rejects as a
405
+ # trailing escape; the non-zero exit was swallowed by `|| true` and the gate
406
+ # reported a clean tree on every run. A gate that cannot fail is decoration,
407
+ # so a grep status of 2 or above is a FAILURE here, not an empty result.
408
+ #
409
+ # 3. It uses -F. In a basic regular expression a backslash is an escape, so a
410
+ # needle containing one does not match the literal text it is hunting -- the
411
+ # same shape mismatch that once made a leak scan report zero over a file that
412
+ # held thirteen hits. Fixed strings compare the bytes.
413
+ #
414
+ # 4. ABSOLUTE PATHS ARE MATCHED BY SHAPE, NOT BY VALUE.
415
+ #
416
+ # An earlier version enumerated this installation's real home directory and
417
+ # workspace root as literal needles, splitting each across two adjacent quoted
418
+ # parts so that grep would not match its own source. That only hid the value
419
+ # from grep: a reader deleting two quotation marks recovered both. Writing
420
+ # the values into the checker WAS the leak. The patterns below match the form
421
+ # instead, so they catch tomorrow's username as readily as today's, and this
422
+ # file names nobody.
423
+ ABSPATH_PATTERN='[A-Za-z]:[\\/](Users[\\/]|work|Documents|Desktop|OneDrive)'
424
+
425
+ # Generic placeholders and worked examples are documentation, not leaks. Only
426
+ # these tokens are excused, and only immediately after a Users/ segment.
427
+ ABSPATH_GENERIC='[A-Za-z]:[\\/]Users[\\/][^[:space:]"'"'"'<>|/]*(\[r\]|r\]|x|someone|user|username|somebody|YOUR_NAME|<user>)[^A-Za-z0-9]'
428
+
429
+ # A denylist of third-party and private-data names is unavoidable -- there is no
430
+ # shape that means "a name I have not thought of". But listing them here would
431
+ # publish what this installation has installed, which is one machine's
432
+ # environment rather than this project's. So the list lives in a local,
433
+ # uncommitted file. Absent that file the gate still runs its shape rules and says
434
+ # so out loud, rather than passing quietly on less coverage than it looks like it
435
+ # has.
436
+ NEEDLE_FILE="$REPO_ROOT/tools/leak-needles.local.txt"
437
+ NAME_NEEDLES=()
438
+ if [ -f "$NEEDLE_FILE" ]; then
439
+ while IFS= read -r line || [ -n "$line" ]; do
440
+ case "$line" in
441
+ ''|'#'*) continue ;; # a bare "#" would become `-e "#"` and match every
442
+ # commented file in the tree
443
+ esac
444
+ NAME_NEEDLES+=("$line")
445
+ done < "$NEEDLE_FILE"
446
+ fi
447
+
448
+ # scan <ignore-case:0|1> <needle...> -> prints matching files, returns grep's status
449
+ # 0 = at least one match, 1 = no match, >=2 = the scan itself failed
450
+ #
451
+ # Every needle needs its OWN -e. `-e "$@"` would prefix only the first, and the
452
+ # rest would be taken as filename operands, which is how an earlier version came
453
+ # to hang on stdin.
454
+ scan() {
455
+ local icase="$1"; shift
456
+ local -a g=(-rIlF) e=()
457
+ [ "$icase" = "1" ] && g+=(-i)
458
+ local a
459
+ for a in "$@"; do e+=(-e "$a"); done
460
+ grep "${g[@]}" --exclude-dir=.git --exclude-dir=.dsh --exclude-dir=node_modules \
461
+ --exclude=leak-needles.local.txt \
462
+ "${e[@]}" "$REPO_ROOT" 2>"$TMP/grep.err"
463
+ }
464
+
465
+ # The path rule is a shape rule, so it runs as a regex and prints the offending
466
+ # LINES rather than just the file names — a file that merely mentions the shape
467
+ # in a comment still has to be shown, so the exception list can be argued with.
468
+ # grep exiting >=2 is a failed scan, never a clean one.
469
+ shapehits() {
470
+ grep -rInE --exclude-dir=.git --exclude-dir=.dsh --exclude-dir=node_modules \
471
+ --exclude=leak-needles.local.txt \
472
+ "$ABSPATH_PATTERN" "$REPO_ROOT" 2>"$TMP/grep.err" \
473
+ | grep -vE "$ABSPATH_GENERIC"
474
+ return "${PIPESTATUS[0]}"
475
+ }
476
+
477
+ hits="$(shapehits)"; rc=$?
478
+ # docs/ is local-only and not distributed; it is audited separately and its own
479
+ # text quotes the shapes this rule hunts, so it is carved out here exactly as it
480
+ # is from the name rule below.
481
+ hits="$(printf '%s' "$hits" | grep -v '/docs/' || true)"
482
+ if [ "$rc" -ge 2 ]; then
483
+ bad "check 8 the path scan FAILED (grep exit $rc) — a clean result would be untrustworthy:"
484
+ note "$(head -2 "$TMP/grep.err" | tr '\n' ' ')"
485
+ elif [ -z "$hits" ]; then
486
+ ok "check 8 no machine-local absolute path in the shipped tree"
487
+ else
488
+ bad "check 8 machine-local absolute path found in the shipped tree:"
489
+ printf '%s\n' "$hits" | sed 's/^/ /'
490
+ fi
491
+
492
+ if [ "${#NAME_NEEDLES[@]}" -eq 0 ]; then
493
+ warn "check 8 no tools/leak-needles.local.txt — name rules did not run"
494
+ note "that file is deliberately uncommitted; see the comment above NEEDLE_FILE"
495
+ fi
496
+ hits="$(scan 1 "${NAME_NEEDLES[@]}")"; rc=$?
497
+ if [ "$rc" -ge 2 ]; then
498
+ bad "check 8 the name scan FAILED (grep exit $rc):"
499
+ note "$(head -2 "$TMP/grep.err" | tr '\n' ' ')"
500
+ else
501
+ # This scan walks the WORKING TREE, not the tracked set, so it still sees
502
+ # docs/ even though docs/ is no longer distributed. Two names are retained in
503
+ # there deliberately, as attribution rather than as a leak. Everything outside
504
+ # docs/ must be clean.
505
+ leaky="$(printf '%s' "$hits" | grep -v '/docs/' || true)"
506
+ if [ -z "$leaky" ]; then
507
+ ok "check 8 no third-party skill name or private-data token outside docs/"
508
+ [ -n "$hits" ] && note "docs/ is local-only and not distributed; its two retained names are attribution, not a leak"
509
+ else
510
+ bad "check 8 third-party name or private-data token outside docs/:"
511
+ printf '%s\n' "$leaky" | sed 's/^/ /'
512
+ fi
513
+ fi
514
+
515
+ # ===========================================================================
516
+ head_ "check 9 — the routing table's aegis-* targets are actually provided"
517
+ # ===========================================================================
518
+ # The resident routing table names aegis skills by their PREFIXED name, while the
519
+ # upstream pack registers them bare. The bundle's own aegis-prefix plugin is the
520
+ # bridge, so a routing target that nothing produces is a dangling reference the
521
+ # preset cannot reach. This connects the two halves without needing a boot: read
522
+ # the bare names off the installed pack, apply the same prefix, and ask whether
523
+ # every name the routing table uses is in that set.
524
+ #
525
+ # Falsifiable: rename a skill upstream, or stop shipping the bridge, and this goes
526
+ # red — which is the failure that is otherwise silent, because a routing row
527
+ # pointing at nothing simply never matches and nothing complains.
528
+ if [ -f "$REPO_ROOT/extensions/dsh/aegis-prefix.js" ]; then
529
+ AEGIS_PKG="$PROFILE_DIR/node_modules/aegis/skills"
530
+ if [ ! -d "$AEGIS_PKG" ]; then
531
+ warn "check 9 aegis pack not found under the profile — routing targets unverified"
532
+ note "this preset depends on the aegis pack; install it, or ignore this knowingly"
533
+ else
534
+ provided="$(find "$AEGIS_PKG" -maxdepth 2 -name SKILL.md 2>/dev/null | while IFS= read -r f; do
535
+ n="$(grep -m1 '^name:' "$f" | sed 's/^name:[[:space:]]*//;s/[[:space:]]*$//')"
536
+ [ -n "$n" ] && printf 'aegis-%s\n' "$n"
537
+ done | sort -u)"
538
+ referenced="$(grep -oE '\baegis-[a-z0-9-]+' "$REPO_ROOT/routing-sections.mjs" | sort -u)"
539
+ if [ -z "$referenced" ]; then
540
+ bad "check 9 the routing table references no aegis-* skill — the table lost its targets, or this check matches nothing"
541
+ else
542
+ missing=0
543
+ for r in $referenced; do
544
+ case " $(printf '%s ' $provided) " in
545
+ *" $r "*) : ;;
546
+ *) bad "check 9 routing target provided by nothing: $r"; missing=$((missing+1)) ;;
547
+ esac
548
+ done
549
+ [ "$missing" -eq 0 ] &&
550
+ ok "check 9 all $(printf '%s\n' $referenced | wc -l) routing targets resolve to a provided skill"
551
+ fi
552
+ # Asserted so a silently emptied pack cannot pass this check.
553
+ if printf '%s\n' "$provided" | grep -q .; then
554
+ ok "check 9 the upstream pack offers $(printf '%s\n' $provided | wc -l) skills to prefix"
555
+ else
556
+ bad "check 9 the upstream pack offers zero skills — the prefix step would produce nothing"
557
+ fi
558
+ fi
559
+ else
560
+ bad "check 9 extensions/dsh/aegis-prefix.js is missing — the routing table has no bridge to the bare names"
561
+ fi
562
+
563
+ # ===========================================================================
564
+ head_ "check 10 — the zh description table still matches the installed pack"
565
+ # ===========================================================================
566
+ # The description swap matches on the English text verbatim, so an upstream
567
+ # rewrite silently falls through to English. That is the right failure direction
568
+ # — better English than garbled Chinese — but it is invisible, and the table rots
569
+ # one skill at a time. This check turns the rot into a number.
570
+ #
571
+ # Falsifiable by construction: it compares the table against the pack on disk, so
572
+ # upgrading aegis turns it red exactly when a key stops matching.
573
+ PREFIX_PLUGIN="$REPO_ROOT/extensions/dsh/aegis-prefix.js"
574
+ if [ -f "$PREFIX_PLUGIN" ] && [ -d "$PROFILE_DIR/node_modules/aegis/skills" ]; then
575
+ zhrc=0
576
+ node -e '
577
+ const fs = require("node:fs"), path = require("node:path")
578
+ const [, pluginPath, packRoot] = process.argv
579
+ const src = fs.readFileSync(pluginPath, "utf8")
580
+ // The table is an array of [en, zh] pairs; pull the first element of each.
581
+ const start = src.indexOf("const ZH_DESCRIPTIONS = [")
582
+ const end = src.indexOf("\n]", start)
583
+ const body = src.slice(start, end)
584
+ const keys = []
585
+ let pos = 0
586
+ for (;;) {
587
+ // Entries are [en, zh] pairs. The en side is single-quoted unless the text
588
+ // itself contains an apostrophe, in which case it is double-quoted — and an
589
+ // extractor that only knew one of the two silently skipped those entries and
590
+ // reported the newest of them as a stale key.
591
+ const i1 = body.indexOf("[\u0027", pos)
592
+ const i2 = body.indexOf("[\u0022", pos)
593
+ let i = -1, q = "\u0027"
594
+ if (i1 < 0 && i2 < 0) break
595
+ if (i1 < 0) { i = i2; q = "\u0022" } else if (i2 < 0) { i = i1 } else { i = Math.min(i1, i2); if (i2 < i1) q = "\u0022" }
596
+ let j = i + 2
597
+ while (j < body.length) {
598
+ if (body[j] === "\\") { j += 2; continue }
599
+ if (body[j] === q) break
600
+ j++
601
+ }
602
+ keys.push(body.slice(i + 2, j))
603
+ pos = j + 1
604
+ }
605
+ const set = new Set(keys)
606
+ const misses = []
607
+ let total = 0
608
+ for (const d of fs.readdirSync(packRoot)) {
609
+ const f = path.join(packRoot, d, "SKILL.md")
610
+ if (!fs.existsSync(f)) continue
611
+ const dl = fs.readFileSync(f, "utf8").split("\n").find((l) => l.startsWith("description:"))
612
+ if (!dl) continue
613
+ let desc = dl.slice("description:".length).trim()
614
+ if (desc.length > 1 && ((desc[0] === "\u0022" && desc[desc.length - 1] === "\u0022") || (desc[0] === "\u0027" && desc[desc.length - 1] === "\u0027"))) desc = desc.slice(1, -1)
615
+ total++
616
+ if (!set.has(desc)) misses.push(d)
617
+ }
618
+ console.log("KEYS=" + keys.length + " TOTAL=" + total + " MISS=" + misses.length)
619
+ if (misses.length) console.log("MISSING=" + misses.join(","))
620
+ ' "$PREFIX_PLUGIN" "$PROFILE_DIR/node_modules/aegis/skills" >"$TMP/zh.txt" 2>"$TMP/zh.err" || zhrc=$?
621
+ zhline="$(cat "$TMP/zh.txt" 2>/dev/null | head -1)"
622
+ if [ "$zhrc" -ne 0 ] || [ -z "$zhline" ]; then
623
+ warn "check 10 the zh table could not be read ($(head -1 "$TMP/zh.err" 2>/dev/null))"
624
+ else
625
+ zkeys="$(printf '%s' "$zhline" | sed -n 's/.*KEYS=\([0-9]*\).*/\1/p')"
626
+ zmiss="$(printf '%s' "$zhline" | sed -n 's/.*MISS=\([0-9]*\).*/\1/p')"
627
+ if [ "$zmiss" = "0" ]; then
628
+ ok "check 10 all $zkeys zh keys match the installed pack"
629
+ else
630
+ bad "check 10 $zmiss of $zkeys zh keys no longer match the pack — those skills will show English:"
631
+ sed -n 's/^MISSING=//p' "$TMP/zh.txt" | tr ',' '\n' | sed 's/^/ /'
632
+ fi
633
+ fi
634
+ else
635
+ warn "check 10 skipped: no aegis pack under the profile, or the prefix plugin is absent"
636
+ fi
637
+
638
+ # ===========================================================================
639
+ printf '\n========================================\n'
640
+ printf 'SUMMARY %d passed, %d failed (profile: %s)\n' "$PASS" "$FAIL" "$PROFILE"
641
+ printf '========================================\n'
642
+ [ "$FAIL" -eq 0 ] || exit 1
643
+ exit 0