@junghanacs/entwurf 0.12.4 → 0.12.6
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/CHANGELOG.md +36 -0
- package/README.md +9 -1
- package/VERIFY.md +18 -28
- package/docs/setup-clean-host.md +27 -11
- package/mcp/entwurf-bridge/dist/pi-extensions/meta-bridge-hook.js +218 -0
- package/mcp/entwurf-bridge/tsconfig.build.json +18 -1
- package/package.json +4 -4
- package/pi/meta-bridge/entwurf-meta-receive/hooks/hooks.json +3 -3
- package/pi-extensions/meta-bridge-hook.ts +8 -3
- package/run.sh +351 -42
- package/scripts/__pycache__/meta-bridge-state.cpython-312.pyc +0 -0
- package/scripts/__pycache__/meta-bridge-state.cpython-313.pyc +0 -0
- package/scripts/__pycache__/register-pi-package.cpython-313.pyc +0 -0
- package/scripts/meta-bridge-doctor.sh +54 -9
- package/scripts/meta-bridge-install.sh +50 -13
- package/scripts/meta-bridge-state.py +60 -3
- package/scripts/meta-bridge-uninstall.sh +24 -0
- package/scripts/register-pi-package.py +149 -0
- package/scripts/smoke-meta-install-state.sh +164 -8
- package/scripts/smoke-user-scope-citizen.sh +129 -0
|
@@ -4,7 +4,8 @@
|
|
|
4
4
|
# needs, so a user never hand-edits hook/plugin settings or passes --plugin-dir.
|
|
5
5
|
#
|
|
6
6
|
# Mechanism (all proven on 2026-06-05):
|
|
7
|
-
# 1. ASSEMBLE a self-contained plugin under
|
|
7
|
+
# 1. ASSEMBLE a self-contained plugin under the version-stable XDG data dir
|
|
8
|
+
# ($XDG_DATA_HOME/entwurf/meta-bridge/.assembled) — NEVER inside the checkout:
|
|
8
9
|
# copy the committed skeleton, copy the entry shell + its lib (so
|
|
9
10
|
# ${CLAUDE_PLUGIN_ROOT} self-locates them), and BAKE the node abspath into
|
|
10
11
|
# hooks.json. The node path is the ONLY templated surface — the mailbox /
|
|
@@ -13,8 +14,10 @@
|
|
|
13
14
|
# entwurf_inbox_read tool comes from USER-scope entwurf-bridge MCP wiring
|
|
14
15
|
# (`claude mcp add -s user ...`). Project-scoped .mcp.json is deliberately
|
|
15
16
|
# not enough: a /tmp native session would wake without a receipt tool.
|
|
16
|
-
# 2. marketplace add <
|
|
17
|
-
#
|
|
17
|
+
# 2. marketplace add <stable XDG .assembled> (both dev clone and installed
|
|
18
|
+
# package assemble into the same version-stable XDG data dir; NOT /tmp —
|
|
19
|
+
# ephemeral source would break `claude plugin marketplace update`, and NOT
|
|
20
|
+
# the checkout — repo housekeeping must never cut the live user-scope wiring).
|
|
18
21
|
# 3. install entwurf-meta-receive@meta-bridge-local --scope user (= global:
|
|
19
22
|
# every native session auto-loads it; no manual --plugin-dir).
|
|
20
23
|
# 4. install/update USER-scope entwurf-bridge MCP, so every native session has
|
|
@@ -30,7 +33,32 @@ REPO="$(cd "$HERE/.." && pwd)"
|
|
|
30
33
|
MKT_NAME="meta-bridge-local"
|
|
31
34
|
PLUGIN="entwurf-meta-receive"
|
|
32
35
|
SRC="$REPO/pi/meta-bridge"
|
|
33
|
-
|
|
36
|
+
# The live marketplace artifact ALWAYS assembles under the version-stable XDG data
|
|
37
|
+
# dir — dev clone and installed package alike. Claude settings store this directory
|
|
38
|
+
# path and package-manager upgrades do not rewrite it (a pnpm-store path would go
|
|
39
|
+
# stale on version/peer churn). Critically it lives OUTSIDE the checkout, so repo
|
|
40
|
+
# housekeeping (git clean -xfd, check/smoke) can never cut the global user-scope
|
|
41
|
+
# wiring: dev vs installed is a difference of SOURCE ORIGIN (repo tree vs npm
|
|
42
|
+
# package we assemble FROM), never of where the live artifact lands.
|
|
43
|
+
ASM="${XDG_DATA_HOME:-$HOME/.local/share}/entwurf/meta-bridge/.assembled"
|
|
44
|
+
# The hook ARTIFACT form still splits by install shape (0.12.5): an installed
|
|
45
|
+
# package lives below node_modules, where Node REFUSES `--experimental-strip-types`
|
|
46
|
+
# on `.ts` (ERR_UNSUPPORTED_NODE_MODULES_TYPE_STRIPPING), so it runs the tsc-emitted
|
|
47
|
+
# `meta-bridge-hook.js` closure (mirrors start.sh/store-doctor). A dev clone lives
|
|
48
|
+
# outside node_modules — and so does the XDG artifact — so it runs the `.ts` source
|
|
49
|
+
# directly. HOOK_ENTRY is baked into hooks.json.
|
|
50
|
+
case "$REPO" in
|
|
51
|
+
*/node_modules/@junghanacs/entwurf)
|
|
52
|
+
HOOK_ENTRY="meta-bridge-hook.js"
|
|
53
|
+
HOOK_SRC="$REPO/mcp/entwurf-bridge/dist/pi-extensions/meta-bridge-hook.js"
|
|
54
|
+
LIB_SRC="$REPO/mcp/entwurf-bridge/dist/pi-extensions/lib/meta-session.js"
|
|
55
|
+
LIB_EXT="js" ;;
|
|
56
|
+
*)
|
|
57
|
+
HOOK_ENTRY="meta-bridge-hook.ts"
|
|
58
|
+
HOOK_SRC="$REPO/pi-extensions/meta-bridge-hook.ts"
|
|
59
|
+
LIB_SRC="$REPO/pi-extensions/lib/meta-session.ts"
|
|
60
|
+
LIB_EXT="ts" ;;
|
|
61
|
+
esac
|
|
34
62
|
|
|
35
63
|
die() { echo "meta-bridge-install: $*" >&2; exit 1; }
|
|
36
64
|
|
|
@@ -65,9 +93,12 @@ mkdir -p "$ASM"
|
|
|
65
93
|
cp -r "$SRC/.claude-plugin" "$ASM/.claude-plugin"
|
|
66
94
|
cp -r "$SRC/$PLUGIN" "$ASM/$PLUGIN"
|
|
67
95
|
# entry shell + its lib travel WITH the plugin so the install copy is self-contained.
|
|
68
|
-
|
|
96
|
+
# Installed → tsc-emitted JS (dist, node_modules-safe); dev clone → strip-types .ts.
|
|
97
|
+
[ -f "$HOOK_SRC" ] || die "hook artifact missing: $HOOK_SRC (installed package ships it via prepack build-bridge → dist; reinstall @junghanacs/entwurf, or run 'pnpm run build-bridge' in a dev clone)."
|
|
98
|
+
[ -f "$LIB_SRC" ] || die "hook lib artifact missing: $LIB_SRC (same build-bridge dist closure)."
|
|
99
|
+
cp "$HOOK_SRC" "$ASM/$PLUGIN/$HOOK_ENTRY"
|
|
69
100
|
mkdir -p "$ASM/$PLUGIN/lib"
|
|
70
|
-
cp "$
|
|
101
|
+
cp "$LIB_SRC" "$ASM/$PLUGIN/lib/meta-session.$LIB_EXT"
|
|
71
102
|
cp "$REPO/pi-extensions/lib/session-id.js" "$ASM/$PLUGIN/lib/session-id.js"
|
|
72
103
|
# v2 writer (3D-3+) reads the capability registry at runtime
|
|
73
104
|
# (loadMetaCapabilityRegistry). It MUST travel at the plugin ROOT — meta-session's
|
|
@@ -76,23 +107,29 @@ cp "$REPO/pi-extensions/lib/session-id.js" "$ASM/$PLUGIN/lib/session-id.js"
|
|
|
76
107
|
# Without this, a v2 writer throws on every mint/parse. doctor-meta-bridge asserts it.
|
|
77
108
|
cp "$REPO/pi/entwurf-capabilities.json" "$ASM/$PLUGIN/entwurf-capabilities.json"
|
|
78
109
|
chmod +x "$ASM/$PLUGIN/scripts/doorbell.sh"
|
|
79
|
-
# Bake the node abspath into hooks.json — the
|
|
80
|
-
#
|
|
110
|
+
# Bake the node abspath AND the hook entry filename into hooks.json — the two
|
|
111
|
+
# templated surfaces (0.12.5: HOOK_ENTRY is meta-bridge-hook.js when installed,
|
|
112
|
+
# .ts in a dev clone). mailbox / meta-record dirs resolve at runtime inside the
|
|
113
|
+
# hook itself (<pi-agent-dir>, fixed ~/).
|
|
81
114
|
# The plugin owns ONLY the wake/record hooks; the receiver-side entwurf_inbox_read
|
|
82
115
|
# tool is NOT the plugin's job. It comes from USER-scope entwurf-bridge MCP
|
|
83
116
|
# wiring (`claude mcp add -s user ...`), never a plugin .mcp.json duplicate.
|
|
84
117
|
HOOKS="$ASM/$PLUGIN/hooks/hooks.json"
|
|
85
|
-
HOOKS_PATH="$HOOKS" NODE_PATH_TO_BAKE="$NODE_BIN" python3 - <<'PY'
|
|
118
|
+
HOOKS_PATH="$HOOKS" NODE_PATH_TO_BAKE="$NODE_BIN" HOOK_ENTRY_TO_BAKE="$HOOK_ENTRY" python3 - <<'PY'
|
|
86
119
|
from pathlib import Path
|
|
87
120
|
import os
|
|
88
121
|
hooks = Path(os.environ["HOOKS_PATH"])
|
|
89
122
|
node = os.environ["NODE_PATH_TO_BAKE"]
|
|
123
|
+
hook_entry = os.environ["HOOK_ENTRY_TO_BAKE"]
|
|
90
124
|
text = hooks.read_text(encoding="utf-8")
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
125
|
+
for placeholder in ("__NODE_BIN__", "__HOOK_ENTRY__"):
|
|
126
|
+
if placeholder not in text:
|
|
127
|
+
raise SystemExit(f"hooks bake failed before replacement ({placeholder} absent in {hooks})")
|
|
128
|
+
hooks.write_text(text.replace("__NODE_BIN__", node).replace("__HOOK_ENTRY__", hook_entry), encoding="utf-8")
|
|
94
129
|
PY
|
|
95
|
-
|
|
130
|
+
for placeholder in "__NODE_BIN__" "__HOOK_ENTRY__"; do
|
|
131
|
+
grep -q "$placeholder" "$HOOKS" && die "hooks bake failed ($placeholder still present in $HOOKS)."
|
|
132
|
+
done
|
|
96
133
|
echo "[meta-bridge-install] assembled $ASM (node baked, entry+lib bundled; MCP wiring is NOT plugin-owned)"
|
|
97
134
|
|
|
98
135
|
# --- validate the manifests before touching user config ---------------------
|
|
@@ -306,6 +306,12 @@ def desired_mcp(repo: Path) -> dict[str, Any]:
|
|
|
306
306
|
|
|
307
307
|
|
|
308
308
|
def desired_statusline(repo: Path) -> dict[str, Any]:
|
|
309
|
+
if is_installed_package(repo):
|
|
310
|
+
# Installed package: mirror the MCP stable-bin pattern. The statusLine is
|
|
311
|
+
# executed at render time, so a bare bin shim lets npm/pnpm update the
|
|
312
|
+
# target on package upgrade without rewriting Claude settings. Dev clones
|
|
313
|
+
# stay pinned to their checkout below.
|
|
314
|
+
return {"type": "command", "command": "entwurf-statusline"}
|
|
309
315
|
return {"type": "command", "command": str((repo / "scripts" / "meta-bridge-statusline.sh").resolve())}
|
|
310
316
|
|
|
311
317
|
|
|
@@ -507,9 +513,33 @@ def check(repo: Path, asm: Path) -> None:
|
|
|
507
513
|
failures.append(f"{claude_root_config_path()} root is not an object")
|
|
508
514
|
root = {}
|
|
509
515
|
|
|
516
|
+
# The marketplace source path in settings was written by apply() from the
|
|
517
|
+
# RECORDED assembledMarketplacePath. Compare against that recorded value, not the
|
|
518
|
+
# --asm passed here: a doctor/check run under a different XDG_DATA_HOME than
|
|
519
|
+
# install must not false-FAIL a correctly-wired marketplace. Fall back to --asm
|
|
520
|
+
# only if the recorded field is somehow absent.
|
|
521
|
+
recorded_asm = state.get("assembledMarketplacePath")
|
|
522
|
+
# Shape-validate the recorded path (basename included). Every state our code
|
|
523
|
+
# writes carries this field (init_state/prepare/apply), so a missing/empty/
|
|
524
|
+
# non-string value is itself corruption — NOT a reason to fall back to --asm and
|
|
525
|
+
# PASS. And a malformed value that matches a same-malformed settings entry (both
|
|
526
|
+
# hand-corrupted) would otherwise slip the comparison below and greenlight a
|
|
527
|
+
# bogus marketplace source. This mirrors the uninstall.sh honest-inverse guard:
|
|
528
|
+
# only the exact install suffix …/entwurf/meta-bridge/.assembled is valid.
|
|
529
|
+
ASM_SUFFIX = "/entwurf/meta-bridge/.assembled"
|
|
530
|
+
if not isinstance(recorded_asm, str) or not recorded_asm.endswith(ASM_SUFFIX):
|
|
531
|
+
failures.append(
|
|
532
|
+
f"install-state assembledMarketplacePath is missing/malformed ('{recorded_asm}'); "
|
|
533
|
+
f"must end in {ASM_SUFFIX} — repair state or re-run install-meta-bridge"
|
|
534
|
+
)
|
|
535
|
+
marketplace_expected = (
|
|
536
|
+
{"source": {"source": "directory", "path": recorded_asm}}
|
|
537
|
+
if recorded_asm
|
|
538
|
+
else desired_marketplace(asm)
|
|
539
|
+
)
|
|
510
540
|
checks = [
|
|
511
541
|
(["enabledPlugins", PLUGIN_REF], True, "enabled plugin"),
|
|
512
|
-
(["extraKnownMarketplaces", MARKETPLACE],
|
|
542
|
+
(["extraKnownMarketplaces", MARKETPLACE], marketplace_expected, "known marketplace"),
|
|
513
543
|
(["statusLine"], desired_statusline(repo), "statusLine"),
|
|
514
544
|
] + [(path_, desired, name) for name, path_, desired in MANAGED_SETTINGS_SCALARS]
|
|
515
545
|
for path_, expected, label in checks:
|
|
@@ -543,13 +573,29 @@ def main() -> int:
|
|
|
543
573
|
parser = argparse.ArgumentParser(description="entwurf meta-bridge state manager")
|
|
544
574
|
parser.add_argument(
|
|
545
575
|
"command",
|
|
546
|
-
choices=[
|
|
576
|
+
choices=[
|
|
577
|
+
"prepare",
|
|
578
|
+
"apply",
|
|
579
|
+
"preflight-uninstall",
|
|
580
|
+
"uninstall",
|
|
581
|
+
"assembled-path",
|
|
582
|
+
"check",
|
|
583
|
+
"managed-keys",
|
|
584
|
+
"desired-mcp",
|
|
585
|
+
"desired-statusline",
|
|
586
|
+
],
|
|
547
587
|
)
|
|
548
588
|
parser.add_argument("--repo", default=Path(__file__).resolve().parents[1], type=Path)
|
|
549
589
|
parser.add_argument("--asm", default=None, type=Path)
|
|
550
590
|
args = parser.parse_args()
|
|
551
591
|
repo = args.repo.resolve()
|
|
552
|
-
|
|
592
|
+
# The live artifact always lives under the XDG data dir — dev clone and
|
|
593
|
+
# installed package alike (mirror meta-bridge-install.sh's ASM resolution).
|
|
594
|
+
# install/doctor pass --asm explicitly; this default is the same XDG path so a
|
|
595
|
+
# bare invocation never falls back to a repo-internal marketplace source.
|
|
596
|
+
xdg_data = Path(os.environ.get("XDG_DATA_HOME") or (Path.home() / ".local" / "share"))
|
|
597
|
+
default_asm = xdg_data / "entwurf" / "meta-bridge" / ".assembled"
|
|
598
|
+
asm = (args.asm or default_asm).resolve()
|
|
553
599
|
try:
|
|
554
600
|
if args.command == "prepare":
|
|
555
601
|
prepare(repo, asm)
|
|
@@ -559,6 +605,13 @@ def main() -> int:
|
|
|
559
605
|
preflight_uninstall()
|
|
560
606
|
elif args.command == "uninstall":
|
|
561
607
|
uninstall()
|
|
608
|
+
elif args.command == "assembled-path":
|
|
609
|
+
# Print the RECORDED assembled marketplace path so the honest inverse
|
|
610
|
+
# (uninstall) removes exactly what install created — not a path
|
|
611
|
+
# recomputed from a possibly-changed XDG_DATA_HOME. Requires state.
|
|
612
|
+
state = load_state(required=True)
|
|
613
|
+
assert state is not None # load_state(required=True) dies otherwise
|
|
614
|
+
print(state.get("assembledMarketplacePath", ""))
|
|
562
615
|
elif args.command == "check":
|
|
563
616
|
check(repo, asm)
|
|
564
617
|
elif args.command == "managed-keys":
|
|
@@ -568,6 +621,10 @@ def main() -> int:
|
|
|
568
621
|
# write for --repo. Lets a deterministic guard assert the installed-vs-clone
|
|
569
622
|
# dual-mode without spinning up a real `claude` CLI. --repo need not exist.
|
|
570
623
|
print(json.dumps(desired_mcp(repo), indent=2))
|
|
624
|
+
elif args.command == "desired-statusline":
|
|
625
|
+
# Same guard surface for the statusLine dual-mode: dev clones pin the
|
|
626
|
+
# checkout path; installed packages use the stable bin shim.
|
|
627
|
+
print(json.dumps(desired_statusline(repo), indent=2))
|
|
571
628
|
except StateError as exc:
|
|
572
629
|
print(f"meta-bridge-state: {exc}", file=sys.stderr)
|
|
573
630
|
return 1
|
|
@@ -26,6 +26,23 @@ command -v python3 >/dev/null || die "'python3' not on PATH. It is required for
|
|
|
26
26
|
# delete live plugin/MCP entries before failing — guessing by side effect.
|
|
27
27
|
python3 "$REPO/scripts/meta-bridge-state.py" preflight-uninstall --repo "$REPO"
|
|
28
28
|
|
|
29
|
+
# Read + shape-validate the RECORDED assembled path BEFORE any side effect. The
|
|
30
|
+
# honest inverse must remove exactly what install created; recomputing from
|
|
31
|
+
# ${XDG_DATA_HOME} would orphan the real artifact if it changed since install. If
|
|
32
|
+
# the recorded path is missing/corrupt we CANNOT safely remove the artifact, so we
|
|
33
|
+
# fail loud HERE — before touching any Claude registration or the state file (no
|
|
34
|
+
# guessing, no partial uninstall, no side-effect-then-WARN).
|
|
35
|
+
ASM_RECORDED="$(python3 "$REPO/scripts/meta-bridge-state.py" assembled-path --repo "$REPO")"
|
|
36
|
+
# Validate the FULL recorded path — basename included, not just the parent dir. The
|
|
37
|
+
# rm below targets MB_DIR (the parent meta-bridge dir), so a parent-only check would
|
|
38
|
+
# let a corrupt basename (…/entwurf/meta-bridge/not-assembled) pass as "well-formed"
|
|
39
|
+
# and still nuke the real .assembled + Claude registrations. Only the exact install
|
|
40
|
+
# suffix …/entwurf/meta-bridge/.assembled is a safe honest-inverse target.
|
|
41
|
+
case "$ASM_RECORDED" in
|
|
42
|
+
*/entwurf/meta-bridge/.assembled) MB_DIR="$(dirname "$ASM_RECORDED")" ;; # …/entwurf/meta-bridge
|
|
43
|
+
*) die "install-state assembledMarketplacePath is missing/corrupt ('$ASM_RECORDED'); refusing to uninstall so the live artifact is not orphaned. Repair the state file or re-run install-meta-bridge, then uninstall." ;;
|
|
44
|
+
esac
|
|
45
|
+
|
|
29
46
|
if command -v claude >/dev/null; then
|
|
30
47
|
claude plugin uninstall "$PLUGIN@$MKT_NAME" >/dev/null 2>&1 || true
|
|
31
48
|
claude plugin marketplace remove "$MKT_NAME" >/dev/null 2>&1 || true
|
|
@@ -36,4 +53,11 @@ else
|
|
|
36
53
|
fi
|
|
37
54
|
|
|
38
55
|
python3 "$REPO/scripts/meta-bridge-state.py" uninstall --repo "$REPO"
|
|
56
|
+
|
|
57
|
+
# Remove the assembled marketplace source (validated above): the parent meta-bridge
|
|
58
|
+
# dir of the RECORDED .assembled, then the now-empty entwurf dir. This rm can NEVER
|
|
59
|
+
# reach inside the checkout, so repo housekeeping and this uninstall are structurally
|
|
60
|
+
# disjoint (the 0.12.x statusline-`?` impurity class is extinct, not guarded).
|
|
61
|
+
rm -rf "$MB_DIR"
|
|
62
|
+
rmdir "$(dirname "$MB_DIR")" 2>/dev/null || true # …/entwurf if empty
|
|
39
63
|
echo "[meta-bridge-uninstall] DONE"
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Register (or --remove) entwurf in a pi settings.json packages[].
|
|
3
|
+
|
|
4
|
+
The SINGLE predicate / idempotency / fail-loud SSOT shared by BOTH scopes and by
|
|
5
|
+
remove, so install and uninstall can never drift to different meanings:
|
|
6
|
+
- project <repo>/.pi/settings.json (run.sh install_local_package / remove_local_package)
|
|
7
|
+
- user ~/.pi/agent/settings.json (run.sh register_user_scope_citizen)
|
|
8
|
+
|
|
9
|
+
Register is idempotent: absent → append REPO_DIR; already the sole canonical
|
|
10
|
+
entry → no-op (file not rewritten, mtime stable); any other entwurf entry (object
|
|
11
|
+
form, stale path, duplicate) collapses into one canonical string form. Remove
|
|
12
|
+
drops every entwurf entry. Both use is_entwurf_source(), so a look-alike repo
|
|
13
|
+
(entwurf-notes, openclaw-entwurf) is neither wrongly registered-over nor wrongly
|
|
14
|
+
removed. Every non-entwurf package and every other settings key is preserved.
|
|
15
|
+
|
|
16
|
+
This wiring (user scope) dropped when `pi install` was removed from setup
|
|
17
|
+
(2026-07-03: `--entwurf-control` unknown in a foreign cwd). Extracting it here
|
|
18
|
+
lets run.sh (both scopes + remove) and smoke-user-scope-citizen share ONE
|
|
19
|
+
implementation — mirrors the meta-bridge-state.py split.
|
|
20
|
+
|
|
21
|
+
Usage: register-pi-package.py <settings.json> <repo_dir> [--remove]
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
import json
|
|
27
|
+
import sys
|
|
28
|
+
from pathlib import Path
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def source_of(item: object) -> object:
|
|
32
|
+
"""The package spec of a packages[] entry — string form or {"source": …}."""
|
|
33
|
+
return item.get("source") if isinstance(item, dict) else item
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def is_entwurf_source(source: str, repo_dir: str) -> bool:
|
|
37
|
+
"""True iff this package entry points at THIS entwurf — the only entries
|
|
38
|
+
register/remove may touch. Strict on purpose: user-scope settings are GLOBAL,
|
|
39
|
+
so a substring "entwurf" match would wrongly eat unrelated repos like
|
|
40
|
+
entwurf-notes, openclaw-entwurf, or somebody else's git repo named entwurf.
|
|
41
|
+
|
|
42
|
+
Managed shapes:
|
|
43
|
+
- the exact resolved repo dir;
|
|
44
|
+
- an npm install path ending in node_modules/@junghanacs/entwurf;
|
|
45
|
+
- an explicit npm package source for @junghanacs/entwurf;
|
|
46
|
+
- a local filesystem path whose final directory is literally "entwurf"
|
|
47
|
+
(dev clone / stale move). Remote URL/git-like strings are NOT treated as
|
|
48
|
+
local paths merely because their last segment is "entwurf".
|
|
49
|
+
"""
|
|
50
|
+
p = source.rstrip("/")
|
|
51
|
+
if p == repo_dir or p.endswith("/node_modules/@junghanacs/entwurf"):
|
|
52
|
+
return True
|
|
53
|
+
if p == "npm:@junghanacs/entwurf" or p.startswith("npm:@junghanacs/entwurf@"):
|
|
54
|
+
return True
|
|
55
|
+
local_like = p.startswith(("/", "./", "../", "~"))
|
|
56
|
+
return local_like and Path(p).name == "entwurf"
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _load(settings_path: Path) -> dict:
|
|
60
|
+
if settings_path.exists():
|
|
61
|
+
data = json.loads(settings_path.read_text())
|
|
62
|
+
if not isinstance(data, dict):
|
|
63
|
+
raise SystemExit(f"{settings_path} is not a JSON object")
|
|
64
|
+
return data
|
|
65
|
+
return {}
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _packages(settings_path: Path, data: dict) -> list:
|
|
69
|
+
packages = data.get("packages")
|
|
70
|
+
if packages is None:
|
|
71
|
+
return []
|
|
72
|
+
if not isinstance(packages, list):
|
|
73
|
+
# A settings file with a corrupt packages shape must NOT be silently
|
|
74
|
+
# coerced to [] — that would drop the operator's real packages.
|
|
75
|
+
raise SystemExit(f"{settings_path}: packages is not a JSON array")
|
|
76
|
+
return packages
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _entwurf_matches(packages: list, repo_dir: str) -> list:
|
|
80
|
+
return [
|
|
81
|
+
item for item in packages
|
|
82
|
+
if isinstance(source_of(item), str) and is_entwurf_source(source_of(item), repo_dir) # type: ignore[arg-type]
|
|
83
|
+
]
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def register(settings_path: Path, repo_dir_arg: str) -> str:
|
|
87
|
+
""""noop" if entwurf is already the sole canonical entry (file untouched),
|
|
88
|
+
else "registered" (rewritten with a single canonical entry)."""
|
|
89
|
+
repo_dir = str(Path(repo_dir_arg).resolve())
|
|
90
|
+
settings_path.parent.mkdir(parents=True, exist_ok=True)
|
|
91
|
+
data = _load(settings_path)
|
|
92
|
+
packages = _packages(settings_path, data)
|
|
93
|
+
|
|
94
|
+
entwurf_entries = _entwurf_matches(packages, repo_dir)
|
|
95
|
+
# Already correct iff exactly ONE entwurf entry and it is the canonical string
|
|
96
|
+
# form at repo_dir. Order-insensitive; no rewrite → mtime stable.
|
|
97
|
+
if len(entwurf_entries) == 1 and entwurf_entries[0] == repo_dir:
|
|
98
|
+
return "noop"
|
|
99
|
+
|
|
100
|
+
filtered = [item for item in packages if item not in entwurf_entries]
|
|
101
|
+
data["packages"] = filtered + [repo_dir]
|
|
102
|
+
settings_path.write_text(json.dumps(data, indent=2) + "\n")
|
|
103
|
+
return "registered"
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def remove(settings_path: Path, repo_dir_arg: str) -> int:
|
|
107
|
+
"""Drop every entwurf entry (any shape/path). Returns the count removed."""
|
|
108
|
+
repo_dir = str(Path(repo_dir_arg).resolve())
|
|
109
|
+
if not settings_path.exists():
|
|
110
|
+
return 0
|
|
111
|
+
data = _load(settings_path)
|
|
112
|
+
packages = _packages(settings_path, data)
|
|
113
|
+
|
|
114
|
+
entwurf_entries = _entwurf_matches(packages, repo_dir)
|
|
115
|
+
if not entwurf_entries:
|
|
116
|
+
return 0
|
|
117
|
+
data["packages"] = [item for item in packages if item not in entwurf_entries]
|
|
118
|
+
settings_path.write_text(json.dumps(data, indent=2) + "\n")
|
|
119
|
+
return len(entwurf_entries)
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def main(argv: list[str]) -> int:
|
|
123
|
+
args = [a for a in argv[1:] if a != "--remove"]
|
|
124
|
+
do_remove = "--remove" in argv[1:]
|
|
125
|
+
if len(args) != 2:
|
|
126
|
+
raise SystemExit("usage: register-pi-package.py <settings.json> <repo_dir> [--remove]")
|
|
127
|
+
settings_path = Path(args[0])
|
|
128
|
+
repo_dir_arg = args[1]
|
|
129
|
+
resolved = str(Path(repo_dir_arg).resolve())
|
|
130
|
+
|
|
131
|
+
if do_remove:
|
|
132
|
+
n = remove(settings_path, repo_dir_arg)
|
|
133
|
+
if n:
|
|
134
|
+
print(f"remove: removed {n} entwurf packages[] entr{'y' if n == 1 else 'ies'} from {settings_path}")
|
|
135
|
+
else:
|
|
136
|
+
print(f"remove: no entwurf packages[] entry to remove ({settings_path})")
|
|
137
|
+
return 0
|
|
138
|
+
|
|
139
|
+
result = register(settings_path, repo_dir_arg)
|
|
140
|
+
if result == "noop":
|
|
141
|
+
print(f"install: entwurf package already registered (no-op) -> {resolved}")
|
|
142
|
+
else:
|
|
143
|
+
print(f"install: registered entwurf package -> {settings_path}")
|
|
144
|
+
print(f"install: package source -> {resolved}")
|
|
145
|
+
return 0
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
if __name__ == "__main__":
|
|
149
|
+
raise SystemExit(main(sys.argv))
|
|
@@ -22,10 +22,58 @@ TMP="$(mktemp -d -t psa-meta-install-state.XXXXXX)"
|
|
|
22
22
|
cleanup() { rm -rf "$TMP"; }
|
|
23
23
|
trap cleanup EXIT
|
|
24
24
|
|
|
25
|
+
# ⓪ 설치 경계 봉쇄 (2026-07-03 `?` 사건의 구조적 소멸): source origin(repo/npm)과
|
|
26
|
+
# live artifact(XDG)를 분리한다. dev clone과 installed 소비자는 같은 user-scope
|
|
27
|
+
# install이고, 둘 다 marketplace source를 XDG data dir에 조립한다 — 절대 checkout
|
|
28
|
+
# 내부가 아니다. 이 smoke의 어떤 단계도 repo 안에 live source를 만들지 않음을
|
|
29
|
+
# top/bottom fingerprint로 감싸 증명하고, 아래 fake-install로 "install → XDG"를 직접 건다.
|
|
30
|
+
# present/absent만 보면 present→present 덮어쓰기를 놓치므로 내용 해시까지 비교한다.
|
|
31
|
+
asm_fingerprint() {
|
|
32
|
+
if [ -e "$REPO/pi/meta-bridge/.assembled" ]; then (cd "$REPO/pi/meta-bridge/.assembled" && find . -type f -exec sha256sum {} + 2>/dev/null | sort); else echo ABSENT; fi
|
|
33
|
+
}
|
|
34
|
+
REPO_ASM_FP_START="$(asm_fingerprint)"
|
|
35
|
+
|
|
36
|
+
# 실제 meta-bridge-install.sh를 격리된 HOME + XDG_DATA_HOME + fake claude로 돌려
|
|
37
|
+
# live marketplace source가 XDG 아래에 조립되고 `plugin marketplace add`가 그 XDG
|
|
38
|
+
# 경로를 받는지 직접 증명한다(오프라인: 실제 claude 없음).
|
|
39
|
+
INS_HOME="$TMP/ins-home"; INS_XDG="$TMP/ins-xdg"; INS_BIN="$TMP/ins-bin"
|
|
40
|
+
mkdir -p "$INS_HOME/.claude" "$INS_BIN"
|
|
41
|
+
echo '{}' > "$INS_HOME/.claude/settings.json"
|
|
42
|
+
echo '{}' > "$INS_HOME/.claude.json"
|
|
43
|
+
INS_LOG="$TMP/ins-claude.log"
|
|
44
|
+
cat > "$INS_BIN/claude" <<'SH'
|
|
45
|
+
#!/usr/bin/env bash
|
|
46
|
+
printf '%s\n' "$*" >> "$FAKE_CLAUDE_LOG"
|
|
47
|
+
case "$1${2:+ $2}" in
|
|
48
|
+
"plugin list") printf '%s\n' "entwurf-meta-receive@meta-bridge-local" " Status: enabled" ;;
|
|
49
|
+
"mcp get") printf '%s\n' "Scope: User config" "Status: Connected" ;;
|
|
50
|
+
*) : ;;
|
|
51
|
+
esac
|
|
52
|
+
exit 0
|
|
53
|
+
SH
|
|
54
|
+
chmod +x "$INS_BIN/claude"
|
|
55
|
+
if env HOME="$INS_HOME" CLAUDE_CONFIG_DIR="$INS_HOME/.claude" XDG_DATA_HOME="$INS_XDG" \
|
|
56
|
+
FAKE_CLAUDE_LOG="$INS_LOG" PATH="$INS_BIN:$PATH" \
|
|
57
|
+
bash "$REPO/scripts/meta-bridge-install.sh" >/dev/null 2>&1; then
|
|
58
|
+
if [ -e "$INS_XDG/entwurf/meta-bridge/.assembled/entwurf-meta-receive" ]; then ok "dev install assembles the live marketplace source under XDG (not the checkout)"; else bad "dev install did not assemble under XDG_DATA_HOME"; fi
|
|
59
|
+
MKT_ADD="$(grep '^plugin marketplace add ' "$INS_LOG" | head -1 || true)"
|
|
60
|
+
if printf '%s' "$MKT_ADD" | grep -Fq "$INS_XDG/entwurf/meta-bridge/.assembled"; then ok "claude plugin marketplace add received the XDG artifact path"; else bad "marketplace add did not point at XDG: $MKT_ADD"; fi
|
|
61
|
+
else
|
|
62
|
+
bad "real install-meta-bridge failed under isolated HOME/XDG/fake-claude:"$'\n'"$(cat "$INS_LOG" 2>/dev/null)"
|
|
63
|
+
fi
|
|
64
|
+
|
|
25
65
|
export HOME="$TMP/home"
|
|
66
|
+
# The wrapper-uninstall below removes ${XDG_DATA_HOME:-$HOME/.local/share}/entwurf/
|
|
67
|
+
# meta-bridge. Pin XDG_DATA_HOME into the sandbox too, so a developer who runs this
|
|
68
|
+
# smoke with XDG_DATA_HOME set in their shell can never have their real live
|
|
69
|
+
# artifact removed — the boundary that keeps check/smoke off global user wiring.
|
|
70
|
+
export XDG_DATA_HOME="$TMP/xdg"
|
|
26
71
|
export CLAUDE_CONFIG_DIR="$HOME/.claude"
|
|
27
72
|
mkdir -p "$CLAUDE_CONFIG_DIR"
|
|
28
|
-
|
|
73
|
+
# Canonical-suffix asm: production install ALWAYS records a path ending in
|
|
74
|
+
# …/entwurf/meta-bridge/.assembled, and state.py check now shape-validates that
|
|
75
|
+
# suffix — so the survival harness must use a realistic path, not a bare stub.
|
|
76
|
+
ASM="$TMP/asm-xdg/entwurf/meta-bridge/.assembled"
|
|
29
77
|
export ASM
|
|
30
78
|
mkdir -p "$ASM"
|
|
31
79
|
|
|
@@ -71,6 +119,12 @@ JSON
|
|
|
71
119
|
|
|
72
120
|
py() { python3 "$STATE" "$@" --repo "$REPO" --asm "$ASM"; }
|
|
73
121
|
|
|
122
|
+
DEV_STATUSLINE="$(python3 "$STATE" desired-statusline --repo "$REPO" | python3 -c 'import json,sys; print(json.load(sys.stdin)["command"])')"
|
|
123
|
+
if [ "$DEV_STATUSLINE" = "$REPO/scripts/meta-bridge-statusline.sh" ]; then ok "dev statusLine pins the checkout script"; else bad "dev statusLine command drifted: $DEV_STATUSLINE"; fi
|
|
124
|
+
FAKE_INSTALLED_REPO="$TMP/npmroot/node_modules/@junghanacs/entwurf"
|
|
125
|
+
INSTALLED_STATUSLINE="$(python3 "$STATE" desired-statusline --repo "$FAKE_INSTALLED_REPO" | python3 -c 'import json,sys; print(json.load(sys.stdin)["command"])')"
|
|
126
|
+
if [ "$INSTALLED_STATUSLINE" = "entwurf-statusline" ]; then ok "installed statusLine uses the stable bin shim"; else bad "installed statusLine command drifted: $INSTALLED_STATUSLINE"; fi
|
|
127
|
+
|
|
74
128
|
py prepare >/dev/null
|
|
75
129
|
STATE_FILE="$CLAUDE_CONFIG_DIR/entwurf.install-state.json"
|
|
76
130
|
[ -f "$STATE_FILE" ] && ok "prepare writes install-state before any merge" || bad "state file missing after prepare"
|
|
@@ -207,8 +261,10 @@ then ok "uninstall restores scalars/maps and removes only managed array addition
|
|
|
207
261
|
|
|
208
262
|
if py uninstall >/dev/null 2>&1; then bad "state manager uninstall without state should not guess"; else ok "state manager uninstall without state fails instead of guessing"; fi
|
|
209
263
|
|
|
210
|
-
#
|
|
211
|
-
#
|
|
264
|
+
# ⓪ 봉쇄: wrapper-uninstall을 실제 $REPO 스크립트로 직접 돌린다. 이제 uninstall이
|
|
265
|
+
# 지우는 live artifact는 ${XDG_DATA_HOME}/entwurf/meta-bridge 하나뿐이고, 위에서
|
|
266
|
+
# HOME + XDG_DATA_HOME를 샌드박스로 고정했으므로 실제 개발자 배선/체크아웃에는
|
|
267
|
+
# 닿을 수 없다(2026-07-03 `?` 사건의 구조적 소멸 — 사본 repo 우회가 더는 불필요).
|
|
212
268
|
FAKE_BIN="$TMP/fake-bin"; mkdir -p "$FAKE_BIN"
|
|
213
269
|
cat > "$FAKE_BIN/claude" <<'SH'
|
|
214
270
|
#!/usr/bin/env bash
|
|
@@ -216,6 +272,9 @@ printf '%s\n' "$*" >> "$FAKE_CLAUDE_LOG"
|
|
|
216
272
|
exit 0
|
|
217
273
|
SH
|
|
218
274
|
chmod +x "$FAKE_BIN/claude"
|
|
275
|
+
|
|
276
|
+
# The wrapper must refuse before side effects. A fake claude records whether
|
|
277
|
+
# plugin/MCP removals were attempted; no state means the log must stay empty.
|
|
219
278
|
FAKE_CLAUDE_LOG="$TMP/fake-claude-nostate.log"
|
|
220
279
|
if PATH="$FAKE_BIN:$PATH" FAKE_CLAUDE_LOG="$FAKE_CLAUDE_LOG" bash "$REPO/scripts/meta-bridge-uninstall.sh" >/dev/null 2>&1; then
|
|
221
280
|
bad "wrapper uninstall without state should fail"
|
|
@@ -223,10 +282,14 @@ else
|
|
|
223
282
|
if [ ! -s "$FAKE_CLAUDE_LOG" ]; then ok "wrapper uninstall without state has zero Claude side effects"; else bad "wrapper uninstall without state touched Claude: $(cat "$FAKE_CLAUDE_LOG")"; fi
|
|
224
283
|
fi
|
|
225
284
|
|
|
226
|
-
# With valid state, the wrapper
|
|
227
|
-
#
|
|
228
|
-
|
|
229
|
-
|
|
285
|
+
# With valid state, the wrapper removes Claude registrations, restores JSON state,
|
|
286
|
+
# AND removes the XDG live artifact RECORDED in state — never the checkout, never a
|
|
287
|
+
# path recomputed from the env. Record an XDG-structured assembled path and seed the
|
|
288
|
+
# sentinel artifact so we can prove uninstall drops exactly the recorded tree.
|
|
289
|
+
WASM="$XDG_DATA_HOME/entwurf/meta-bridge/.assembled"
|
|
290
|
+
python3 "$STATE" prepare --repo "$REPO" --asm "$WASM" >/dev/null
|
|
291
|
+
python3 "$STATE" apply --repo "$REPO" --asm "$WASM" >/dev/null
|
|
292
|
+
mkdir -p "$WASM"; : > "$WASM/.sentinel"
|
|
230
293
|
FAKE_CLAUDE_LOG="$TMP/fake-claude-state.log"
|
|
231
294
|
if PATH="$FAKE_BIN:$PATH" FAKE_CLAUDE_LOG="$FAKE_CLAUDE_LOG" bash "$REPO/scripts/meta-bridge-uninstall.sh" >/dev/null 2>&1; then
|
|
232
295
|
if grep -q 'plugin uninstall entwurf-meta-receive@meta-bridge-local' "$FAKE_CLAUDE_LOG" && grep -q 'mcp remove entwurf-bridge -s user' "$FAKE_CLAUDE_LOG" && [ ! -f "$STATE_FILE" ]; then
|
|
@@ -234,10 +297,97 @@ if PATH="$FAKE_BIN:$PATH" FAKE_CLAUDE_LOG="$FAKE_CLAUDE_LOG" bash "$REPO/scripts
|
|
|
234
297
|
else
|
|
235
298
|
bad "wrapper uninstall with state missed expected side effects/state removal"
|
|
236
299
|
fi
|
|
300
|
+
# 봉쇄가 회피가 아니라 구조임을 증명: uninstall이 지우는 건 recorded XDG artifact뿐.
|
|
301
|
+
if [ ! -e "$XDG_DATA_HOME/entwurf/meta-bridge" ]; then ok "wrapper removes the recorded XDG live artifact tree, not the checkout"; else bad "wrapper did not remove the recorded XDG live artifact"; fi
|
|
237
302
|
else
|
|
238
303
|
bad "wrapper uninstall with valid state failed"
|
|
239
304
|
fi
|
|
240
305
|
|
|
306
|
+
# Honest inverse under a CHANGED XDG_DATA_HOME (GPT hardening): uninstall must remove
|
|
307
|
+
# the RECORDED artifact path, never one recomputed from the current env. Record A,
|
|
308
|
+
# run uninstall with the env pointing at B, and prove A is gone while B is untouched.
|
|
309
|
+
XDGA="$TMP/xdgA"; XDGB="$TMP/xdgB"
|
|
310
|
+
ASM_A="$XDGA/entwurf/meta-bridge/.assembled"
|
|
311
|
+
python3 "$STATE" prepare --repo "$REPO" --asm "$ASM_A" >/dev/null
|
|
312
|
+
python3 "$STATE" apply --repo "$REPO" --asm "$ASM_A" >/dev/null
|
|
313
|
+
mkdir -p "$ASM_A"; : > "$ASM_A/.sentinel"
|
|
314
|
+
mkdir -p "$XDGB/entwurf/meta-bridge/.assembled"; : > "$XDGB/entwurf/meta-bridge/.assembled/.sentinel"
|
|
315
|
+
if XDG_DATA_HOME="$XDGB" PATH="$FAKE_BIN:$PATH" FAKE_CLAUDE_LOG="$TMP/fake-claude-mismatch.log" bash "$REPO/scripts/meta-bridge-uninstall.sh" >/dev/null 2>&1; then
|
|
316
|
+
if [ ! -e "$XDGA/entwurf/meta-bridge" ]; then ok "uninstall removes the RECORDED artifact (XDG A) even when env XDG_DATA_HOME differs"; else bad "uninstall did not remove the recorded artifact A — recomputed from env instead"; fi
|
|
317
|
+
if [ -e "$XDGB/entwurf/meta-bridge/.assembled/.sentinel" ]; then ok "uninstall leaves the current-env XDG (B) untouched (no env recompute)"; else bad "uninstall wrongly removed the current-env XDG B"; fi
|
|
318
|
+
else
|
|
319
|
+
bad "wrapper uninstall (recorded-path mismatch case) failed"
|
|
320
|
+
fi
|
|
321
|
+
|
|
322
|
+
# GPT hardening: a corrupt recorded assembledMarketplacePath must fail the wrapper
|
|
323
|
+
# uninstall LOUD and BEFORE any side effect — no Claude removal, state file intact
|
|
324
|
+
# (honest inverse never guesses / never partially uninstalls / never WARN-then-DONE).
|
|
325
|
+
# Two corrupt shapes: a trivially-bad "/" AND the basename-corrupt case that a
|
|
326
|
+
# PARENT-ONLY guard would silently pass (…/entwurf/meta-bridge/not-assembled) — the
|
|
327
|
+
# rm targets the parent meta-bridge dir, so that shape must be refused by the FULL
|
|
328
|
+
# suffix guard or it nukes the real artifact + Claude registrations (2026-07-03).
|
|
329
|
+
for CORRUPT in "/" "$XDG_DATA_HOME/entwurf/meta-bridge/not-assembled"; do
|
|
330
|
+
python3 "$STATE" prepare --repo "$REPO" --asm "$XDG_DATA_HOME/entwurf/meta-bridge/.assembled" >/dev/null
|
|
331
|
+
python3 "$STATE" apply --repo "$REPO" --asm "$XDG_DATA_HOME/entwurf/meta-bridge/.assembled" >/dev/null
|
|
332
|
+
# seed the real artifact so an over-broad rm would be observable as its removal
|
|
333
|
+
mkdir -p "$XDG_DATA_HOME/entwurf/meta-bridge/.assembled"; : > "$XDG_DATA_HOME/entwurf/meta-bridge/.assembled/.sentinel"
|
|
334
|
+
CORRUPT="$CORRUPT" python3 - <<'PY'
|
|
335
|
+
import json, os
|
|
336
|
+
p = os.environ['CLAUDE_CONFIG_DIR'] + '/entwurf.install-state.json'
|
|
337
|
+
s = json.load(open(p)); s['assembledMarketplacePath'] = os.environ['CORRUPT']
|
|
338
|
+
json.dump(s, open(p, 'w'), indent=2)
|
|
339
|
+
PY
|
|
340
|
+
FAKE_CLAUDE_LOG="$TMP/fake-claude-corrupt.log"; : > "$FAKE_CLAUDE_LOG"
|
|
341
|
+
if PATH="$FAKE_BIN:$PATH" FAKE_CLAUDE_LOG="$FAKE_CLAUDE_LOG" bash "$REPO/scripts/meta-bridge-uninstall.sh" >/dev/null 2>&1; then
|
|
342
|
+
bad "wrapper uninstall with corrupt recorded path '$CORRUPT' should fail loud"
|
|
343
|
+
elif [ -s "$FAKE_CLAUDE_LOG" ] || [ ! -f "$STATE_FILE" ] || [ ! -e "$XDG_DATA_HOME/entwurf/meta-bridge/.assembled/.sentinel" ]; then
|
|
344
|
+
bad "corrupt-path uninstall '$CORRUPT' leaked side effects/removed artifact: claude_log=[$(cat "$FAKE_CLAUDE_LOG")] state_exists=$([ -f "$STATE_FILE" ] && echo yes || echo no) artifact=$([ -e "$XDG_DATA_HOME/entwurf/meta-bridge/.assembled/.sentinel" ] && echo intact || echo REMOVED)"
|
|
345
|
+
else
|
|
346
|
+
ok "corrupt recorded path '$CORRUPT' → uninstall fails BEFORE side effects (no Claude removal, state + artifact intact)"
|
|
347
|
+
fi
|
|
348
|
+
rm -f "$STATE_FILE"; rm -rf "$XDG_DATA_HOME/entwurf/meta-bridge"
|
|
349
|
+
done
|
|
350
|
+
|
|
351
|
+
# GPT hardening: state.py check must compare the RECORDED marketplace path, not the
|
|
352
|
+
# --asm handed in. Install/apply with A, then check with a DIFFERENT --asm B: it must
|
|
353
|
+
# still PASS (Claude settings + state both hold A; only the caller's XDG differs).
|
|
354
|
+
CKA="$TMP/ck-xdgA/entwurf/meta-bridge/.assembled"
|
|
355
|
+
CKB="$TMP/ck-xdgB/entwurf/meta-bridge/.assembled"
|
|
356
|
+
python3 "$STATE" prepare --repo "$REPO" --asm "$CKA" >/dev/null
|
|
357
|
+
python3 "$STATE" apply --repo "$REPO" --asm "$CKA" >/dev/null
|
|
358
|
+
if python3 "$STATE" check --repo "$REPO" --asm "$CKB" >/dev/null 2>&1; then ok "state.py check passes when --asm differs from the recorded path (compares recorded, no XDG false-fail)"; else bad "state.py check false-FAILed on a recorded≠--asm mismatch"; fi
|
|
359
|
+
rm -f "$STATE_FILE"
|
|
360
|
+
|
|
361
|
+
# GPT hardening: check must shape-validate the recorded path, not just compare it to
|
|
362
|
+
# settings. Corrupt BOTH state + settings to the same basename-bad value: a pure
|
|
363
|
+
# consistency compare would PASS (both agree) and greenlight a bogus marketplace
|
|
364
|
+
# source. The suffix guard must FAIL it instead.
|
|
365
|
+
python3 "$STATE" prepare --repo "$REPO" --asm "$XDG_DATA_HOME/entwurf/meta-bridge/.assembled" >/dev/null
|
|
366
|
+
python3 "$STATE" apply --repo "$REPO" --asm "$XDG_DATA_HOME/entwurf/meta-bridge/.assembled" >/dev/null
|
|
367
|
+
python3 - <<'PY'
|
|
368
|
+
import json, os
|
|
369
|
+
xdg = os.environ['XDG_DATA_HOME']; corrupt = xdg + '/entwurf/meta-bridge/not-assembled'
|
|
370
|
+
sp = os.environ['CLAUDE_CONFIG_DIR'] + '/entwurf.install-state.json'
|
|
371
|
+
s = json.load(open(sp)); s['assembledMarketplacePath'] = corrupt; json.dump(s, open(sp, 'w'), indent=2)
|
|
372
|
+
cp = os.environ['CLAUDE_CONFIG_DIR'] + '/settings.json'
|
|
373
|
+
c = json.load(open(cp)); c['extraKnownMarketplaces']['meta-bridge-local'] = {'source': {'source': 'directory', 'path': corrupt}}; json.dump(c, open(cp, 'w'), indent=2)
|
|
374
|
+
PY
|
|
375
|
+
if python3 "$STATE" check --repo "$REPO" --asm "$XDG_DATA_HOME/entwurf/meta-bridge/.assembled" >/dev/null 2>&1; then bad "state.py check greenlit a both-corrupt (state+settings) malformed marketplace path"; else ok "state.py check fails a malformed recorded path even when settings agree (shape guard, not just consistency)"; fi
|
|
376
|
+
rm -f "$STATE_FILE"
|
|
377
|
+
|
|
378
|
+
# GPT hardening: a MISSING/empty recorded path is corruption too — every state our
|
|
379
|
+
# code writes carries the field, so check must NOT fall back to --asm and PASS.
|
|
380
|
+
# Delete the field with settings still valid; the shape guard must FAIL, not greenlight.
|
|
381
|
+
python3 "$STATE" prepare --repo "$REPO" --asm "$XDG_DATA_HOME/entwurf/meta-bridge/.assembled" >/dev/null
|
|
382
|
+
python3 "$STATE" apply --repo "$REPO" --asm "$XDG_DATA_HOME/entwurf/meta-bridge/.assembled" >/dev/null
|
|
383
|
+
python3 - <<'PY'
|
|
384
|
+
import json, os
|
|
385
|
+
sp = os.environ['CLAUDE_CONFIG_DIR'] + '/entwurf.install-state.json'
|
|
386
|
+
s = json.load(open(sp)); s.pop('assembledMarketplacePath', None); json.dump(s, open(sp, 'w'), indent=2)
|
|
387
|
+
PY
|
|
388
|
+
if python3 "$STATE" check --repo "$REPO" --asm "$XDG_DATA_HOME/entwurf/meta-bridge/.assembled" >/dev/null 2>&1; then bad "state.py check PASSed with a MISSING recorded assembledMarketplacePath (fell back to --asm)"; else ok "state.py check fails a missing/empty recorded path (no --asm fallback greenlight)"; fi
|
|
389
|
+
rm -f "$STATE_FILE"
|
|
390
|
+
|
|
241
391
|
# Doctor hook-log recovery predicate: only a later `INFO armed watch` clears an
|
|
242
392
|
# ERROR. A UserPromptSubmit `INFO attach record` is degraded backfill, not wake
|
|
243
393
|
# recovery.
|
|
@@ -359,7 +509,7 @@ if node --experimental-strip-types "$STORE_DOCTOR" "$STORE" >/dev/null 2>&1; the
|
|
|
359
509
|
# chain to its final summary line — i.e. no early set -e death anywhere.
|
|
360
510
|
DOC_HOME="$TMP/doctor-home"; DOC_CFG="$DOC_HOME/.claude"
|
|
361
511
|
DOC_AGENT="$TMP/doctor-agent"; DOC_STORE="$DOC_AGENT/meta-sessions"
|
|
362
|
-
DOC_BIN="$TMP/doctor-bin"; DOC_ASM="$
|
|
512
|
+
DOC_BIN="$TMP/doctor-bin"; DOC_ASM="$XDG_DATA_HOME/entwurf/meta-bridge/.assembled" # SAME asm the doctor computes (XDG), so only the MCP drifts
|
|
363
513
|
mkdir -p "$DOC_CFG" "$DOC_STORE" "$DOC_BIN"
|
|
364
514
|
echo '{}' > "$DOC_CFG/settings.json"
|
|
365
515
|
echo '{}' > "$DOC_HOME/.claude.json"
|
|
@@ -399,5 +549,11 @@ if printf '%s\n' "$DOC_OUT" | grep -q 'user MCP entwurf-bridge'; then ok "doctor
|
|
|
399
549
|
if printf '%s\n' "$DOC_OUT" | grep -q '\[plugin install'; then ok "doctor continues to a later section after the drift (no early set -e death)"; else bad "doctor did not reach a later section header after the drift:"$'\n'"$DOC_OUT"; fi
|
|
400
550
|
if printf '%s\n' "$DOC_OUT" | grep -q 'meta-bridge doctor: FAIL'; then ok "doctor runs the whole chain to its final summary line"; else bad "doctor did not reach its final summary line (mid-run death):"$'\n'"$DOC_OUT"; fi
|
|
401
551
|
|
|
552
|
+
# ⓪ 경계 종료 단언: 이 smoke 전체(install 조립 + state + wrapper-uninstall + doctor)가
|
|
553
|
+
# 끝난 뒤에도 checkout 안에는 live marketplace source가 생기지 않았다 — source
|
|
554
|
+
# origin(repo)과 live artifact(XDG)가 구조적으로 분리됐다는 최종 증명. 미래 회귀가
|
|
555
|
+
# repo 내부 ASM을 되살리거나 덮어쓰면(내용 해시 변화 포함) 여기서 잡힌다.
|
|
556
|
+
if [ "$(asm_fingerprint)" = "$REPO_ASM_FP_START" ]; then ok "checkout-internal marketplace source is byte-identical before/after — no op created or mutated \$REPO/.assembled (source origin ≠ live artifact)"; else bad "a meta-bridge operation created/mutated \$REPO/pi/meta-bridge/.assembled — repo boundary breached"; fi
|
|
557
|
+
|
|
402
558
|
echo
|
|
403
559
|
if [ "$fail" -eq 0 ]; then echo "smoke-meta-install-state: PASS"; else echo "smoke-meta-install-state: FAIL (see above)"; exit 1; fi
|