@junghanacs/entwurf 0.14.2 → 0.15.1
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/AGENTS.md +9 -5
- package/CHANGELOG.md +149 -0
- package/DELIVERY.md +72 -59
- package/README.md +120 -20
- package/VERIFY.md +10 -7
- package/demo/README.md +1 -1
- package/docs/acp-backend-rail.md +2 -2
- package/docs/external-mcp-host.md +26 -4
- package/docs/setup-clean-host.md +118 -18
- package/mcp/entwurf-bridge/dist/mcp/entwurf-bridge/src/index.js +8 -6
- package/mcp/entwurf-bridge/dist/pi-extensions/entwurf-capabilities.json +1 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/copilot-fresh-preflight.js +253 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/meta-sender-identity.js +10 -2
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/meta-session.js +102 -28
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/mux-fresh-call.js +77 -11
- package/mcp/entwurf-bridge/dist/pi-extensions/meta-bridge-hook-copilot.js +288 -0
- package/mcp/entwurf-bridge/src/index.ts +10 -6
- package/mcp/entwurf-bridge/tsconfig.build.json +2 -1
- package/package.json +22 -11
- package/pi/copilot-receive/entwurf-receive/extension.mjs +323 -0
- package/pi/entwurf-capabilities.json +1 -0
- package/pi/meta-bridge-copilot/.claude-plugin/marketplace.json +11 -0
- package/pi/meta-bridge-copilot/entwurf-meta-receive-copilot/.claude-plugin/plugin.json +5 -0
- package/pi/meta-bridge-copilot/entwurf-meta-receive-copilot/hooks/hooks.json +7 -0
- package/pi/meta-bridge-copilot/entwurf-meta-receive-copilot/scripts/copilot-hook-launch.sh +85 -0
- package/pi-extensions/entwurf-control.ts +17 -8
- package/pi-extensions/lib/copilot-fresh-preflight.ts +251 -0
- package/pi-extensions/lib/meta-sender-identity.ts +10 -2
- package/pi-extensions/lib/meta-session.ts +102 -29
- package/pi-extensions/lib/mux-fresh-call.ts +82 -11
- package/pi-extensions/meta-bridge-hook-copilot.ts +323 -0
- package/run.sh +1079 -186
- package/scripts/agy-bridge.sh +4 -2
- package/scripts/check-copilot-birth-hook.ts +1174 -0
- package/scripts/check-copilot-launch.ts +395 -0
- package/scripts/check-copilot-receive-arm.ts +734 -0
- package/scripts/check-copilot-statusline.ts +122 -0
- package/scripts/check-entwurf-capabilities.ts +25 -6
- package/scripts/check-fresh-cut-gate.sh +104 -27
- package/scripts/check-gate-qualification.ts +10 -1
- package/scripts/check-install-container.sh +2 -2
- package/scripts/check-install-surface.ts +1 -1
- package/scripts/check-meta-capability-source.ts +89 -5
- package/scripts/check-meta-doctor-oracle.sh +26 -0
- package/scripts/check-meta-manifest-schema.py +38 -0
- package/scripts/check-meta-receiver-marker.ts +25 -2
- package/scripts/check-meta-session.ts +32 -0
- package/scripts/check-setup-qualification.sh +126 -0
- package/scripts/copilot-bridge-doctor.sh +242 -0
- package/scripts/copilot-bridge-install.sh +384 -0
- package/scripts/copilot-bridge-oracle.sh +223 -0
- package/scripts/copilot-bridge-uninstall.sh +137 -0
- package/scripts/copilot-launch.sh +236 -0
- package/scripts/copilot-mcp-bridge.sh +177 -0
- package/scripts/copilot-mcp-config.py +221 -0
- package/scripts/copilot-receive-bridge.sh +483 -0
- package/scripts/copilot-statusline-bridge.sh +149 -0
- package/scripts/copilot-statusline-config.py +201 -0
- package/scripts/copilot-statusline.sh +68 -0
- package/scripts/dev-bin.sh +53 -9
- package/scripts/fake-copilot-vendor.sh +67 -0
- package/scripts/meta-bridge-hook-log.sh +17 -5
- package/scripts/mutants/capability-cache.json +64 -0
- package/scripts/mutants/copilot-birth.json +244 -0
- package/scripts/mutants/copilot-launch.json +187 -0
- package/scripts/mutants/copilot-receive.json +263 -0
- package/scripts/mutants/fresh-cut.json +17 -0
- package/scripts/mutants/mux-fresh-call.json +216 -2
- package/scripts/mutants/pack-install.json +17 -0
- package/scripts/mutants/pi-package-ownership.json +92 -0
- package/scripts/mutants/setup-verdict.json +148 -0
- package/scripts/mutants/source-install.json +32 -0
- package/scripts/pi_settings_io.py +22 -0
- package/scripts/raw-async-delivery/README.md +170 -125
- package/scripts/raw-async-delivery/copilot-enqueue-addressed.sh +35 -0
- package/scripts/raw-async-delivery/copilot-extension-receive/extension.mjs +123 -0
- package/scripts/register-pi-package.py +525 -7
- package/scripts/register-pi-provider.py +162 -12
- package/scripts/smoke-agy-install-state.sh +86 -30
- package/scripts/smoke-agy-statusline-state.sh +9 -6
- package/scripts/smoke-copilot-mcp-state.sh +153 -0
- package/scripts/smoke-copilot-statusline-state.sh +131 -0
- package/scripts/smoke-mux-fresh-call-live.ts +2 -0
- package/scripts/smoke-mux-lifecycle-live.ts +3 -1
- package/scripts/smoke-setup-verdict.sh +291 -0
- package/scripts/smoke-user-scope-citizen.sh +388 -1
- package/scripts/tsconfig.json +1 -0
|
@@ -0,0 +1,384 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# copilot-bridge-install.sh — operator-grade global installer for the Copilot BIRTH
|
|
3
|
+
# plugin (#82). The Copilot sibling of meta-bridge-install.sh, deliberately NOT a
|
|
4
|
+
# mode of it.
|
|
5
|
+
#
|
|
6
|
+
# WHY A SEPARATE INSTALLER (§4-5, and one measured blocker)
|
|
7
|
+
#
|
|
8
|
+
# 1. The Claude installer assembles ONE marketplace root and copies exactly one
|
|
9
|
+
# plugin out of it (`cp -r "$SRC/$PLUGIN"`). Adding the Copilot unit to that
|
|
10
|
+
# same marketplace.json would publish a manifest whose second `source` is not
|
|
11
|
+
# in the assembly, and `claude plugin validate` runs over that root before any
|
|
12
|
+
# user config is touched. Two marketplace roots, two assemblies (cross-review,
|
|
13
|
+
# terra, 2026-08-20).
|
|
14
|
+
# 2. The Claude doctor certifies Claude's own receiver marker and
|
|
15
|
+
# FileChanged/asyncRewake doorbell. Copilot's receiver is a forked first-party
|
|
16
|
+
# extension with its own installer and its own doctor. Sharing a doctor would
|
|
17
|
+
# make one rail certify evidence owned by another.
|
|
18
|
+
#
|
|
19
|
+
# WHAT THIS INSTALLS. One plugin whose managed job is to mint a meta-record on the
|
|
20
|
+
# first prompt of a Copilot session and name that citizen as this host's sender. MCP
|
|
21
|
+
# wiring has its own installer, and so does the RECEIVER: the doorbell is a forked
|
|
22
|
+
# first-party extension, installed by `run.sh install-copilot-receive`, which also owns
|
|
23
|
+
# the launch-flag check. Four surfaces, four installers, four failure modes.
|
|
24
|
+
#
|
|
25
|
+
# Platform: Linux only, same fence as the Claude installer.
|
|
26
|
+
set -euo pipefail
|
|
27
|
+
|
|
28
|
+
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
29
|
+
REPO="$(cd "$HERE/.." && pwd)"
|
|
30
|
+
MKT_NAME="meta-bridge-copilot-local"
|
|
31
|
+
PLUGIN="entwurf-meta-receive-copilot"
|
|
32
|
+
SRC="$REPO/pi/meta-bridge-copilot"
|
|
33
|
+
# `ENTWURF_COPILOT_ASM` exists for ONE caller: check-copilot-birth-hook drives this
|
|
34
|
+
# script for real, into a temp dir, with --assemble-only. A gate that re-implemented
|
|
35
|
+
# the bake would be testing its own copy of the logic instead of the shipped one.
|
|
36
|
+
ASM="${ENTWURF_COPILOT_ASM:-${XDG_DATA_HOME:-$HOME/.local/share}/entwurf/meta-bridge-copilot/.assembled}"
|
|
37
|
+
# The Claude unit that is currently installed INTO Copilot. It fires there and then
|
|
38
|
+
# dies in hook-launch.sh's no-argv refusal on every prompt, because Copilot's schema
|
|
39
|
+
# has no `args` (measured: that refusal is why copilot held 0 of 409 meta-records).
|
|
40
|
+
#
|
|
41
|
+
# QUALIFIED, never bare. `copilot plugin uninstall` accepts `plugin-name` or
|
|
42
|
+
# `plugin-name@marketplace-name` (measured 2026-08-21 from `plugin uninstall --help`),
|
|
43
|
+
# and `copilot plugin list` prints the qualified id. A bare name would also match a
|
|
44
|
+
# same-named plugin from somebody else's marketplace — this installer may only remove
|
|
45
|
+
# the unit THIS repo installed (cross-review, terra).
|
|
46
|
+
STALE_CLAUDE_UNIT="entwurf-meta-receive@meta-bridge-local"
|
|
47
|
+
QUALIFIED="$PLUGIN@$MKT_NAME"
|
|
48
|
+
# Package-owned ownership state (#86 C3a): the birth unit gets the same discipline the
|
|
49
|
+
# MCP/receiver/footer units already have. The state names exactly what this installer
|
|
50
|
+
# owns — the qualified plugin id, the marketplace name, and the assembly root — so the
|
|
51
|
+
# inverse can remove precisely that and nothing else. No credential, no preimage.
|
|
52
|
+
STATE_DIR="${XDG_DATA_HOME:-$HOME/.local/share}/entwurf/copilot-bridge"
|
|
53
|
+
STATE_FILE="$STATE_DIR/install-state.json"
|
|
54
|
+
KEEP_STALE=0
|
|
55
|
+
# --assemble-only stops after the assembly, before the Copilot CLI is touched. It is
|
|
56
|
+
# how the gate reaches the real bake on a host (and in CI) that has no Copilot.
|
|
57
|
+
ASSEMBLE_ONLY=0
|
|
58
|
+
|
|
59
|
+
die() { echo "[copilot-bridge-install] $*" >&2; exit 1; }
|
|
60
|
+
|
|
61
|
+
for arg in "$@"; do
|
|
62
|
+
case "$arg" in
|
|
63
|
+
--keep-stale-claude-unit) KEEP_STALE=1 ;;
|
|
64
|
+
--assemble-only) ASSEMBLE_ONLY=1 ;;
|
|
65
|
+
*) die "unknown argument: $arg (--keep-stale-claude-unit, --assemble-only)" ;;
|
|
66
|
+
esac
|
|
67
|
+
done
|
|
68
|
+
|
|
69
|
+
[ "$(uname -s)" = "Linux" ] || die "Linux only; $(uname -s) is not a certified axis for this install."
|
|
70
|
+
if [ "$ASSEMBLE_ONLY" -eq 0 ]; then
|
|
71
|
+
command -v copilot >/dev/null 2>&1 || die "the 'copilot' CLI is not on PATH."
|
|
72
|
+
fi
|
|
73
|
+
|
|
74
|
+
# PREFLIGHT, and all of it BEFORE the first destructive step. The assembly is removed
|
|
75
|
+
# and rebuilt in place further down; discovering a missing python3 or an under-floor
|
|
76
|
+
# node AFTER that would leave the operator with no assembly at all (cross-review, terra).
|
|
77
|
+
command -v python3 >/dev/null 2>&1 || die "python3 is required (both bakes run through it) and is not on PATH."
|
|
78
|
+
NODE_BIN="$(command -v node)" || die "node is not on PATH."
|
|
79
|
+
[ -x "$NODE_BIN" ] || die "resolved node is not executable: $NODE_BIN"
|
|
80
|
+
NODE_MAJOR="$("$NODE_BIN" -p 'process.versions.node.split(".")[0]' 2>/dev/null || echo 0)"
|
|
81
|
+
# The dev-clone shape bakes the raw `.ts` entry and relies on strip-types, so an
|
|
82
|
+
# under-floor node does not fail at install — it fails at the operator's first prompt,
|
|
83
|
+
# inside Copilot, where nothing surfaces it. Refuse here instead.
|
|
84
|
+
[ "$NODE_MAJOR" -ge 24 ] 2>/dev/null || die "node >= 24 is required (package engines); resolved $NODE_BIN reports major '$NODE_MAJOR'."
|
|
85
|
+
|
|
86
|
+
# shellcheck source=copilot-bridge-oracle.sh
|
|
87
|
+
. "$HERE/copilot-bridge-oracle.sh"
|
|
88
|
+
|
|
89
|
+
if [ "$ASSEMBLE_ONLY" -eq 1 ]; then
|
|
90
|
+
# --assemble-only is a GATE-ONLY path that deliberately writes no ownership state, so
|
|
91
|
+
# it must never touch the LIVE assembly: without a state, a rebuilt live assembly
|
|
92
|
+
# would drift from what the state/marketplace record. Require the explicit temp
|
|
93
|
+
# override its callers already pass, BEFORE any assembly mutation (amendment,
|
|
94
|
+
# 2026-08-27).
|
|
95
|
+
DEFAULT_ASM="${XDG_DATA_HOME:-$HOME/.local/share}/entwurf/meta-bridge-copilot/.assembled"
|
|
96
|
+
if [ -z "${ENTWURF_COPILOT_ASM:-}" ]; then
|
|
97
|
+
die "--assemble-only requires an explicit ENTWURF_COPILOT_ASM (a temp dir): this gate-only path writes no ownership state and must not rebuild the live assembly."
|
|
98
|
+
fi
|
|
99
|
+
if [ "$(python3 -c 'import os,sys;print(os.path.abspath(sys.argv[1]))' "$ENTWURF_COPILOT_ASM")" = \
|
|
100
|
+
"$(python3 -c 'import os,sys;print(os.path.abspath(sys.argv[1]))' "$DEFAULT_ASM")" ]; then
|
|
101
|
+
die "--assemble-only refuses the DEFAULT live assembly path ($DEFAULT_ASM): pass a temp ENTWURF_COPILOT_ASM instead."
|
|
102
|
+
fi
|
|
103
|
+
fi
|
|
104
|
+
|
|
105
|
+
# The shipped plugin version travels into the ownership state so the post-install list
|
|
106
|
+
# evidence and the doctor can join on it.
|
|
107
|
+
PLUGIN_VERSION="$(python3 -c "import json,sys;print(json.load(open(sys.argv[1]))['version'])" "$SRC/$PLUGIN/.claude-plugin/plugin.json")" \
|
|
108
|
+
|| die "could not read the shipped plugin version from $SRC/$PLUGIN/.claude-plugin/plugin.json"
|
|
109
|
+
# C3a amendment (B defect 4): the version rides space-separated state facts and exact
|
|
110
|
+
# list rows, so a shipped version carrying whitespace/control (or nothing at all)
|
|
111
|
+
# would truncate into a fabricated value downstream. Refuse it at the source.
|
|
112
|
+
case "$PLUGIN_VERSION" in
|
|
113
|
+
""|*[![:graph:]]*) die "shipped plugin version ${PLUGIN_VERSION:-<empty>} is empty or carries whitespace/control characters — it must be one printable token (it travels through space-separated fact transports and exact list rows)." ;;
|
|
114
|
+
esac
|
|
115
|
+
|
|
116
|
+
# Atomic state writer: regular file, temp+rename, exact schema. `ownedMarketplace`
|
|
117
|
+
# flips false→true around the marketplace add so a partial failure leaves an honest
|
|
118
|
+
# record of what is actually owned.
|
|
119
|
+
write_state() { # $1 = ownedMarketplace (true|false)
|
|
120
|
+
mkdir -p "$STATE_DIR"
|
|
121
|
+
STATE_FILE_ENV="$STATE_FILE" QUALIFIED_ENV="$QUALIFIED" MKT_ENV="$MKT_NAME" ASM_ENV="$ASM" \
|
|
122
|
+
PLUGIN_VERSION_ENV="$PLUGIN_VERSION" OWNED_MKT_ENV="$1" python3 - <<'PY' || die "could not write the ownership state at $STATE_FILE"
|
|
123
|
+
import json, os, tempfile
|
|
124
|
+
state_file = os.environ["STATE_FILE_ENV"]
|
|
125
|
+
state = {
|
|
126
|
+
"schemaVersion": 1,
|
|
127
|
+
"qualifiedId": os.environ["QUALIFIED_ENV"],
|
|
128
|
+
"marketplaceName": os.environ["MKT_ENV"],
|
|
129
|
+
"assemblyPath": os.path.abspath(os.environ["ASM_ENV"]),
|
|
130
|
+
"pluginVersion": os.environ["PLUGIN_VERSION_ENV"],
|
|
131
|
+
"ownedMarketplace": os.environ["OWNED_MKT_ENV"] == "true",
|
|
132
|
+
"ownedAssembly": True,
|
|
133
|
+
"installedAt": __import__("datetime").datetime.now(__import__("datetime").timezone.utc).isoformat(),
|
|
134
|
+
}
|
|
135
|
+
fd, tmp = tempfile.mkstemp(dir=os.path.dirname(state_file), prefix=".install-state.")
|
|
136
|
+
with os.fdopen(fd, "w") as fh:
|
|
137
|
+
json.dump(state, fh, indent=2)
|
|
138
|
+
fh.write("\n")
|
|
139
|
+
os.replace(tmp, state_file)
|
|
140
|
+
PY
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
# ── 0. vendor ownership preflight (READ-ONLY; #86 C3a) ────────────────────────
|
|
144
|
+
# Every destructive vendor step below is licensed HERE, from `plugin list` +
|
|
145
|
+
# `plugin marketplace list` plus the ownership state — a failing list is UNKNOWN and
|
|
146
|
+
# refuses (an unknown host is not an empty one), a same-named marketplace at another
|
|
147
|
+
# path is someone else's and refuses, and a no-state host adopts an existing exact
|
|
148
|
+
# QUALIFIED only when the listed marketplace path is OUR effective assembly AND that
|
|
149
|
+
# assembly passes the doctor's structural oracle. Refusals here are zero-write: no
|
|
150
|
+
# assembly rebuild, no state, no vendor mutation.
|
|
151
|
+
MKT_PREREGISTERED=0
|
|
152
|
+
QUALIFIED_LISTED=0
|
|
153
|
+
INSTALLED_LIST=""
|
|
154
|
+
if [ "$ASSEMBLE_ONLY" -eq 0 ]; then
|
|
155
|
+
if ! INSTALLED_LIST="$(copilot plugin list 2>/dev/null)"; then
|
|
156
|
+
die "'copilot plugin list' failed, so this host's installed plugins are UNKNOWN — refusing before any write. Fix the Copilot CLI error and re-run."
|
|
157
|
+
fi
|
|
158
|
+
if ! MKT_LIST="$(copilot plugin marketplace list 2>/dev/null)"; then
|
|
159
|
+
die "'copilot plugin marketplace list' failed, so this host's marketplaces are UNKNOWN — refusing before any write. Fix the Copilot CLI error and re-run."
|
|
160
|
+
fi
|
|
161
|
+
# EXACT row membership (amendment): a substring match would let `$QUALIFIED-extra`
|
|
162
|
+
# or `prefix-$QUALIFIED` from someone else authorize the adoption/uninstall branches.
|
|
163
|
+
# ANY exact version is accepted here — a reinstall over an older shipped version is
|
|
164
|
+
# exactly the bounded upgrade path (the version is recorded below and the sequence
|
|
165
|
+
# still runs the loud exact uninstall first) — but a malformed or ambiguous listing
|
|
166
|
+
# is something nobody may act on and refuses.
|
|
167
|
+
LISTED_ROW="$(copilot_exact_row_version "$INSTALLED_LIST" "$QUALIFIED")" \
|
|
168
|
+
|| die "the 'copilot plugin list' rows for $QUALIFIED are malformed or ambiguous (see above) — refusing before any write."
|
|
169
|
+
if [ "$LISTED_ROW" != "absent" ]; then
|
|
170
|
+
QUALIFIED_LISTED=1
|
|
171
|
+
echo "[copilot-bridge-install] $QUALIFIED currently listed at ${LISTED_ROW#one } (shipped: v$PLUGIN_VERSION) — bounded upgrade via loud exact uninstall"
|
|
172
|
+
fi
|
|
173
|
+
# ONE marketplace-row grammar (C3a amendment, B defects 1+2): the shared oracle
|
|
174
|
+
# parses the measured `<name> (Local: <abs path>)` form, refuses a non-Local or
|
|
175
|
+
# garbled row, and refuses DUPLICATE same-named rows instead of silently taking the
|
|
176
|
+
# first — a second marketplace with our name could otherwise steer every path check
|
|
177
|
+
# at the wrong target. The exact registered path is what ownership joins on.
|
|
178
|
+
MKT_ROW="$(copilot_marketplace_local_path "$MKT_LIST" "$MKT_NAME")" \
|
|
179
|
+
|| die "the 'copilot plugin marketplace list' rows for '$MKT_NAME' are malformed, non-Local, or duplicated (see above) — refusing before any write."
|
|
180
|
+
if [ "$MKT_ROW" != "absent" ]; then
|
|
181
|
+
MKT_PREREGISTERED=1
|
|
182
|
+
MKT_PATH="${MKT_ROW#one }"
|
|
183
|
+
if [ "$MKT_PATH" != "$ASM" ]; then
|
|
184
|
+
die "a marketplace named '$MKT_NAME' is registered at '$MKT_PATH', not at this install's assembly ($ASM). That registration is not provably ours — zero writes. Inspect with 'copilot plugin marketplace list' and remove it manually if it is stale."
|
|
185
|
+
fi
|
|
186
|
+
fi
|
|
187
|
+
|
|
188
|
+
if [ -L "$STATE_FILE" ]; then
|
|
189
|
+
die "ownership state $STATE_FILE is a symlink — refusing to trust or overwrite it."
|
|
190
|
+
fi
|
|
191
|
+
if [ -f "$STATE_FILE" ]; then
|
|
192
|
+
# The ONE fail-closed schema/constants/binding validator, shared with the inverse
|
|
193
|
+
# and the doctor (copilot_state_read in the oracle) — exact keyset, exact types,
|
|
194
|
+
# no flag coercion. pluginVersion equality with the shipped version is deliberately
|
|
195
|
+
# NOT required here: a state written by an older shipped version is exactly the
|
|
196
|
+
# upgrade-reinstall case.
|
|
197
|
+
copilot_state_read "$STATE_FILE" "$QUALIFIED" "$MKT_NAME" "$ASM" >/dev/null \
|
|
198
|
+
|| die "ownership state $STATE_FILE is corrupt or names a different installation (see above) — refusing with zero writes. Inspect it, or remove it manually if it is stale."
|
|
199
|
+
echo "[copilot-bridge-install] ownership state matches this installation (reinstall/repair)"
|
|
200
|
+
else
|
|
201
|
+
# No state. Adoption is EXPLICIT and narrow; anything else that already exists and
|
|
202
|
+
# cannot be proven ours refuses with zero writes.
|
|
203
|
+
if [ "$QUALIFIED_LISTED" -eq 1 ]; then
|
|
204
|
+
if [ "$MKT_PREREGISTERED" -eq 1 ] && copilot_assembly_valid "$ASM" "$PLUGIN"; then
|
|
205
|
+
echo "[copilot-bridge-install] adopting the legacy no-state installation ($QUALIFIED at $ASM; structural oracle green)"
|
|
206
|
+
else
|
|
207
|
+
die "'$QUALIFIED' is installed but carries no ownership state and its assembly/marketplace does not prove it ours (marketplace at ASM: $MKT_PREREGISTERED; structural oracle above). Refusing with zero writes — inspect 'copilot plugin list' / '$ASM' manually."
|
|
208
|
+
fi
|
|
209
|
+
elif [ "$MKT_PREREGISTERED" -eq 1 ]; then
|
|
210
|
+
if copilot_assembly_valid "$ASM" "$PLUGIN"; then
|
|
211
|
+
echo "[copilot-bridge-install] adopting the legacy marketplace registration ($MKT_NAME at $ASM, plugin not installed; structural oracle green)"
|
|
212
|
+
else
|
|
213
|
+
die "marketplace '$MKT_NAME' is registered at this install's assembly path but that assembly fails the structural oracle (see above) and no ownership state exists. Refusing with zero writes — remove the marketplace manually ('copilot plugin marketplace remove $MKT_NAME'), then re-run."
|
|
214
|
+
fi
|
|
215
|
+
fi
|
|
216
|
+
# fresh host (both absent): proceed.
|
|
217
|
+
fi
|
|
218
|
+
fi
|
|
219
|
+
|
|
220
|
+
# Same install-shape split as the Claude installer: an installed package lives below
|
|
221
|
+
# node_modules where Node refuses strip-types on `.ts`, so it runs the tsc-emitted
|
|
222
|
+
# closure; a dev clone (and the XDG artifact) runs the `.ts` source.
|
|
223
|
+
case "$REPO" in
|
|
224
|
+
*/node_modules/@junghanacs/entwurf)
|
|
225
|
+
HOOK_ENTRY="meta-bridge-hook-copilot.js"
|
|
226
|
+
HOOK_SRC="$REPO/mcp/entwurf-bridge/dist/pi-extensions/meta-bridge-hook-copilot.js"
|
|
227
|
+
LIB_SRC="$REPO/mcp/entwurf-bridge/dist/pi-extensions/lib/meta-session.js"
|
|
228
|
+
LIB_EXT="js" ;;
|
|
229
|
+
*)
|
|
230
|
+
HOOK_ENTRY="meta-bridge-hook-copilot.ts"
|
|
231
|
+
HOOK_SRC="$REPO/pi-extensions/meta-bridge-hook-copilot.ts"
|
|
232
|
+
LIB_SRC="$REPO/pi-extensions/lib/meta-session.ts"
|
|
233
|
+
LIB_EXT="ts" ;;
|
|
234
|
+
esac
|
|
235
|
+
|
|
236
|
+
# --- 1. assemble a self-contained, baked plugin -----------------------------
|
|
237
|
+
[ -f "$HOOK_SRC" ] || die "hook artifact missing: $HOOK_SRC (run 'pnpm run build-bridge' in a dev clone, or reinstall the package)."
|
|
238
|
+
[ -f "$LIB_SRC" ] || die "hook lib artifact missing: $LIB_SRC (same build-bridge dist closure)."
|
|
239
|
+
|
|
240
|
+
# ATOMIC: assemble beside the live artifact and swap only once it is complete. The
|
|
241
|
+
# previous shape removed the live assembly first, so any failure between there and the
|
|
242
|
+
# marketplace re-registration left the INSTALLED plugin pointing at a launcher that no
|
|
243
|
+
# longer existed — a working hook broken by a failed upgrade (cross-review, terra).
|
|
244
|
+
STAGE="$ASM.staging.$$"
|
|
245
|
+
rm -rf "$STAGE"
|
|
246
|
+
trap 'rm -rf "$STAGE"' EXIT
|
|
247
|
+
mkdir -p "$STAGE"
|
|
248
|
+
cp -r "$SRC/.claude-plugin" "$STAGE/.claude-plugin"
|
|
249
|
+
cp -r "$SRC/$PLUGIN" "$STAGE/$PLUGIN"
|
|
250
|
+
cp "$HOOK_SRC" "$STAGE/$PLUGIN/$HOOK_ENTRY"
|
|
251
|
+
mkdir -p "$STAGE/$PLUGIN/lib"
|
|
252
|
+
cp "$LIB_SRC" "$STAGE/$PLUGIN/lib/meta-session.$LIB_EXT"
|
|
253
|
+
cp "$REPO/pi-extensions/lib/session-id.js" "$STAGE/$PLUGIN/lib/session-id.js"
|
|
254
|
+
# The capability registry must travel at the plugin ROOT: metaCapabilitiesFilePath()
|
|
255
|
+
# resolves it via `../` from lib/ in the bundle layout. Without it every mint throws.
|
|
256
|
+
cp "$REPO/pi/entwurf-capabilities.json" "$STAGE/$PLUGIN/entwurf-capabilities.json"
|
|
257
|
+
|
|
258
|
+
LAUNCHER="$STAGE/$PLUGIN/scripts/copilot-hook-launch.sh"
|
|
259
|
+
# What gets BAKED into hooks.json is the FINAL path, not the staging one: the manifest
|
|
260
|
+
# has to name where the launcher will live after the swap.
|
|
261
|
+
FINAL_LAUNCHER="$ASM/$PLUGIN/scripts/copilot-hook-launch.sh"
|
|
262
|
+
chmod +x "$LAUNCHER"
|
|
263
|
+
|
|
264
|
+
# Bake node + entry into the LAUNCHER (not into hooks.json): Copilot's `exec` is a
|
|
265
|
+
# single string with no argv beside it, so the payload coordinates cannot ride the
|
|
266
|
+
# manifest the way Claude's `args` array carries them.
|
|
267
|
+
LAUNCHER_PATH="$LAUNCHER" NODE_PATH_TO_BAKE="$NODE_BIN" HOOK_ENTRY_TO_BAKE="$HOOK_ENTRY" python3 - <<'PY'
|
|
268
|
+
from pathlib import Path
|
|
269
|
+
import os
|
|
270
|
+
launcher = Path(os.environ["LAUNCHER_PATH"])
|
|
271
|
+
node = os.environ["NODE_PATH_TO_BAKE"]
|
|
272
|
+
hook_entry = os.environ["HOOK_ENTRY_TO_BAKE"]
|
|
273
|
+
text = launcher.read_text(encoding="utf-8")
|
|
274
|
+
for placeholder in ("__NODE_BIN__", "__HOOK_ENTRY__"):
|
|
275
|
+
if placeholder not in text:
|
|
276
|
+
raise SystemExit(f"launcher bake failed before replacement ({placeholder} absent in {launcher})")
|
|
277
|
+
launcher.write_text(text.replace("__NODE_BIN__", node).replace("__HOOK_ENTRY__", hook_entry), encoding="utf-8")
|
|
278
|
+
PY
|
|
279
|
+
for placeholder in "__NODE_BIN__" "__HOOK_ENTRY__"; do
|
|
280
|
+
grep -q "$placeholder" "$LAUNCHER" && die "launcher bake failed ($placeholder still present in $LAUNCHER)."
|
|
281
|
+
done
|
|
282
|
+
|
|
283
|
+
# Bake the launcher's ABSOLUTE path into hooks.json. `${COPILOT_PLUGIN_ROOT}` and
|
|
284
|
+
# `${CLAUDE_PLUGIN_ROOT}` both appear as recognized names in the shipped runtime, but
|
|
285
|
+
# whether an `exec` string is substituted was never measured — and the assembly path
|
|
286
|
+
# is stable and outside the checkout, so baking needs no such bet.
|
|
287
|
+
HOOKS="$STAGE/$PLUGIN/hooks/hooks.json"
|
|
288
|
+
HOOKS_PATH="$HOOKS" LAUNCHER_TO_BAKE="$FINAL_LAUNCHER" python3 - <<'PY'
|
|
289
|
+
from pathlib import Path
|
|
290
|
+
import os
|
|
291
|
+
hooks = Path(os.environ["HOOKS_PATH"])
|
|
292
|
+
launcher = os.environ["LAUNCHER_TO_BAKE"]
|
|
293
|
+
text = hooks.read_text(encoding="utf-8")
|
|
294
|
+
if "__COPILOT_LAUNCHER__" not in text:
|
|
295
|
+
raise SystemExit(f"hooks bake failed before replacement (__COPILOT_LAUNCHER__ absent in {hooks})")
|
|
296
|
+
hooks.write_text(text.replace("__COPILOT_LAUNCHER__", launcher), encoding="utf-8")
|
|
297
|
+
PY
|
|
298
|
+
grep -q "__COPILOT_LAUNCHER__" "$HOOKS" && die "hooks bake failed (__COPILOT_LAUNCHER__ still present in $HOOKS)."
|
|
299
|
+
|
|
300
|
+
# SWAP. Move the old assembly aside rather than deleting it, so a failed rename can be
|
|
301
|
+
# undone; only then drop it.
|
|
302
|
+
PREV=""
|
|
303
|
+
if [ -d "$ASM" ]; then
|
|
304
|
+
PREV="$ASM.previous.$$"
|
|
305
|
+
mv "$ASM" "$PREV" || die "could not move the previous assembly aside ($ASM)."
|
|
306
|
+
fi
|
|
307
|
+
mkdir -p "$(dirname "$ASM")"
|
|
308
|
+
if ! mv "$STAGE" "$ASM"; then
|
|
309
|
+
[ -n "$PREV" ] && mv "$PREV" "$ASM"
|
|
310
|
+
die "could not publish the staged assembly into $ASM (previous assembly restored)."
|
|
311
|
+
fi
|
|
312
|
+
trap - EXIT
|
|
313
|
+
[ -n "$PREV" ] && rm -rf "$PREV"
|
|
314
|
+
|
|
315
|
+
echo "[copilot-bridge-install] assembled $ASM (birth plugin only; MCP and receive are separate surfaces)"
|
|
316
|
+
|
|
317
|
+
if [ "$ASSEMBLE_ONLY" -eq 1 ]; then
|
|
318
|
+
echo "[copilot-bridge-install] --assemble-only: stopping before the Copilot CLI is touched"
|
|
319
|
+
exit 0
|
|
320
|
+
fi
|
|
321
|
+
|
|
322
|
+
# Ownership state BEFORE any vendor mutation (#86 C3a): once the complete assembly is
|
|
323
|
+
# published, the state exists so every partial vendor failure below leaves a repair and
|
|
324
|
+
# inverse authority behind. `ownedMarketplace` starts true only when the preflight
|
|
325
|
+
# matched/adopted the exact registration; otherwise it flips true after the add. The
|
|
326
|
+
# state is never deleted on a later failure — rerunning this installer is the repair.
|
|
327
|
+
if [ "$MKT_PREREGISTERED" -eq 1 ]; then write_state true; else write_state false; fi
|
|
328
|
+
echo "[copilot-bridge-install] ownership state written: $STATE_FILE"
|
|
329
|
+
|
|
330
|
+
# --- 2. retire the stale Claude unit from Copilot ---------------------------
|
|
331
|
+
# It is ours, it fires on every Copilot prompt, and it exits 1 before node starts.
|
|
332
|
+
# Leaving it installed means the operator keeps paying for a hook that cannot work.
|
|
333
|
+
# The installed-plugin list was captured (and its failure refused) by the shared
|
|
334
|
+
# read-only preflight above — an unknown list never reaches this branch.
|
|
335
|
+
# INSTALL-ONLY territory: the inverse never touches this unit.
|
|
336
|
+
if [ "$KEEP_STALE" -eq 0 ]; then
|
|
337
|
+
case "$INSTALLED_LIST" in
|
|
338
|
+
*"$STALE_CLAUDE_UNIT"*)
|
|
339
|
+
copilot plugin uninstall "$STALE_CLAUDE_UNIT" >/dev/null \
|
|
340
|
+
|| die "the stale Claude unit '$STALE_CLAUDE_UNIT' is installed in Copilot but could not be removed. It fires on every prompt and exits 1 before node starts, so installing on top of it would leave that noise in place. Fix the Copilot CLI error above, or re-run with --keep-stale-claude-unit to proceed deliberately."
|
|
341
|
+
echo "[copilot-bridge-install] removed the stale Claude unit '$STALE_CLAUDE_UNIT' from Copilot (it exits 1 on every prompt there)"
|
|
342
|
+
;;
|
|
343
|
+
*)
|
|
344
|
+
echo "[copilot-bridge-install] stale Claude unit '$STALE_CLAUDE_UNIT' is not installed in Copilot (nothing to remove)"
|
|
345
|
+
;;
|
|
346
|
+
esac
|
|
347
|
+
fi
|
|
348
|
+
|
|
349
|
+
# --- 3. (re)register the marketplace and install ----------------------------
|
|
350
|
+
# LOUD, in a bounded order, licensed by the preflight (#86 C3a): the old blind
|
|
351
|
+
# `|| true` pair could swallow a real vendor failure and then report a working
|
|
352
|
+
# install. Each step now acts only when the preflight saw its subject, and a failure
|
|
353
|
+
# is an honest partial failure — state and assembly stay for the rerun-repair.
|
|
354
|
+
# `--force` is forbidden: `marketplace remove --force` uninstalls that marketplace's
|
|
355
|
+
# plugins as a side effect (measured 2026-08-27), and no entwurf writer takes that
|
|
356
|
+
# shortcut.
|
|
357
|
+
if [ "$QUALIFIED_LISTED" -eq 1 ]; then
|
|
358
|
+
copilot plugin uninstall "$QUALIFIED" >/dev/null \
|
|
359
|
+
|| die "'copilot plugin uninstall $QUALIFIED' failed — honest partial failure; state and assembly are retained, re-run to repair."
|
|
360
|
+
fi
|
|
361
|
+
if [ "$MKT_PREREGISTERED" -eq 1 ]; then
|
|
362
|
+
copilot plugin marketplace remove "$MKT_NAME" >/dev/null \
|
|
363
|
+
|| die "'copilot plugin marketplace remove $MKT_NAME' failed (never retried with --force) — honest partial failure; state and assembly are retained, re-run to repair."
|
|
364
|
+
fi
|
|
365
|
+
copilot plugin marketplace add "$ASM" >/dev/null || die "'copilot plugin marketplace add $ASM' failed — state and assembly are retained, re-run to repair."
|
|
366
|
+
write_state true
|
|
367
|
+
copilot plugin install "$QUALIFIED" >/dev/null || die "'copilot plugin install $QUALIFIED' failed — state and assembly are retained, re-run to repair."
|
|
368
|
+
|
|
369
|
+
# --- 4. evidence ------------------------------------------------------------
|
|
370
|
+
echo "--- copilot plugin list ---"
|
|
371
|
+
# The qualified id AND the shipped version as an EXACT row: a bare-name or substring
|
|
372
|
+
# grep would be satisfied by a same-named plugin from another marketplace, a stale
|
|
373
|
+
# version this run did not install, or a longer id that merely contains ours (list
|
|
374
|
+
# shape measured on copilot 1.0.80).
|
|
375
|
+
POST_LIST="$(copilot plugin list 2>/dev/null)" || die "post-install: 'copilot plugin list' failed — the install evidence is UNKNOWN."
|
|
376
|
+
POST_ROW="$(copilot_exact_row_version "$POST_LIST" "$QUALIFIED")" \
|
|
377
|
+
|| die "post-install: the 'copilot plugin list' rows for $QUALIFIED are malformed or ambiguous (see above)."
|
|
378
|
+
[ "$POST_ROW" = "one $PLUGIN_VERSION" ] \
|
|
379
|
+
|| die "post-install: want exactly one '$QUALIFIED (v$PLUGIN_VERSION)' row, got '$POST_ROW' (install did not take)."
|
|
380
|
+
echo "$QUALIFIED (v$PLUGIN_VERSION) registered"
|
|
381
|
+
echo
|
|
382
|
+
echo "[copilot-bridge-install] DONE. A Copilot session becomes a citizen on its FIRST PROMPT,"
|
|
383
|
+
echo "not when the window opens — sessionStart is deferred to the first prompt (measured)."
|
|
384
|
+
echo "Verify with: ./run.sh doctor-copilot-bridge · inverse: ./run.sh uninstall-copilot-bridge"
|
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# copilot-bridge-oracle.sh — the ONE structural oracle for an assembled Copilot birth
|
|
3
|
+
# unit (#86 C3a). Sourced by the installer (adoption preflight) and the doctor
|
|
4
|
+
# (ownership axis) so "valid assembly" cannot mean two different things: a unit the
|
|
5
|
+
# doctor would flag must never be silently adopted as ours.
|
|
6
|
+
#
|
|
7
|
+
# copilot_assembly_valid <asm-root> <plugin-name>
|
|
8
|
+
# rc 0 = the assembly at <asm-root> is a complete, baked unit
|
|
9
|
+
# rc 1 = it is not; the first failing reason is printed to stderr
|
|
10
|
+
#
|
|
11
|
+
# copilot_state_read <state_file> <qualified> <mkt_name> <asm>
|
|
12
|
+
# Fail-closed EXACT-schema reader for the ownership state — one validator for the
|
|
13
|
+
# installer, the inverse and the doctor so "valid state" cannot drift into three
|
|
14
|
+
# copies. rc 0 prints `<ownedMarketplace> <ownedAssembly> <pluginVersion>`; any
|
|
15
|
+
# missing/unknown key, wrong type, empty string, relative assemblyPath, constant
|
|
16
|
+
# drift or effective-ASM mismatch is rc 1 with the reason on stderr. Flags are never
|
|
17
|
+
# coerced: a missing or non-boolean owned* refuses instead of defaulting to false.
|
|
18
|
+
#
|
|
19
|
+
# copilot_marketplace_local_path <mkt-list-text> <mkt-name>
|
|
20
|
+
# The one exact marketplace-row grammar (see its own header below): absent /
|
|
21
|
+
# exactly-one Local path / malformed-non-Local-or-duplicate refusal.
|
|
22
|
+
#
|
|
23
|
+
# Deliberately BOUNDED: structure, one state schema, one plugin list-row grammar, one
|
|
24
|
+
# marketplace list-row grammar. Registration, records, and hook-log truth stay with
|
|
25
|
+
# the doctor's own axes.
|
|
26
|
+
copilot_state_read() {
|
|
27
|
+
local state_file="$1" qualified="$2" mkt="$3" asm="$4"
|
|
28
|
+
STATE_FILE_ENV="$state_file" QUALIFIED_ENV="$qualified" MKT_ENV="$mkt" ASM_ENV="$asm" python3 - <<'PY'
|
|
29
|
+
import json, os, sys
|
|
30
|
+
try:
|
|
31
|
+
state = json.load(open(os.environ["STATE_FILE_ENV"]))
|
|
32
|
+
except (OSError, json.JSONDecodeError) as err:
|
|
33
|
+
print(f"state parse failed: {err}", file=sys.stderr)
|
|
34
|
+
sys.exit(1)
|
|
35
|
+
EXACT_KEYS = {
|
|
36
|
+
"schemaVersion", "qualifiedId", "marketplaceName", "assemblyPath",
|
|
37
|
+
"pluginVersion", "ownedMarketplace", "ownedAssembly", "installedAt",
|
|
38
|
+
}
|
|
39
|
+
if not isinstance(state, dict) or set(state) != EXACT_KEYS:
|
|
40
|
+
print(f"state keyset drifted: {sorted(state) if isinstance(state, dict) else type(state).__name__} != {sorted(EXACT_KEYS)}", file=sys.stderr)
|
|
41
|
+
sys.exit(1)
|
|
42
|
+
if state["schemaVersion"] != 1:
|
|
43
|
+
print(f"state schemaVersion is {state['schemaVersion']!r}, want the literal 1", file=sys.stderr)
|
|
44
|
+
sys.exit(1)
|
|
45
|
+
for key in ("qualifiedId", "marketplaceName", "assemblyPath", "pluginVersion", "installedAt"):
|
|
46
|
+
if not isinstance(state[key], str) or not state[key]:
|
|
47
|
+
print(f"state {key} must be a nonempty string, got {state[key]!r}", file=sys.stderr)
|
|
48
|
+
sys.exit(1)
|
|
49
|
+
if any(ord(c) < 0x21 or ord(c) == 0x7f for c in state["pluginVersion"]):
|
|
50
|
+
# C3a amendment (B defect 4): these facts travel space-separated and consumers
|
|
51
|
+
# cut field 3, so a version carrying whitespace/control would silently truncate
|
|
52
|
+
# into a FABRICATED value (and a fabricated drift reason downstream). Refuse the
|
|
53
|
+
# state instead of shipping an ambiguous transport.
|
|
54
|
+
print(f"state pluginVersion contains whitespace/control characters: {state['pluginVersion']!r} — "
|
|
55
|
+
"the space-separated fact transport would truncate it into a fabricated version", file=sys.stderr)
|
|
56
|
+
sys.exit(1)
|
|
57
|
+
for key in ("ownedMarketplace", "ownedAssembly"):
|
|
58
|
+
if not isinstance(state[key], bool):
|
|
59
|
+
print(f"state {key} must be a boolean, got {state[key]!r} — flags are never coerced", file=sys.stderr)
|
|
60
|
+
sys.exit(1)
|
|
61
|
+
if not os.path.isabs(state["assemblyPath"]):
|
|
62
|
+
print(f"state assemblyPath must be absolute, got {state['assemblyPath']!r}", file=sys.stderr)
|
|
63
|
+
sys.exit(1)
|
|
64
|
+
for key, want in (("qualifiedId", os.environ["QUALIFIED_ENV"]), ("marketplaceName", os.environ["MKT_ENV"])):
|
|
65
|
+
if state[key] != want:
|
|
66
|
+
print(f"state {key} is {state[key]!r}, want {want!r}", file=sys.stderr)
|
|
67
|
+
sys.exit(1)
|
|
68
|
+
if state["assemblyPath"] != os.path.abspath(os.environ["ASM_ENV"]):
|
|
69
|
+
print(f"state assemblyPath is {state['assemblyPath']!r}, but this host's effective assembly is {os.path.abspath(os.environ['ASM_ENV'])!r}", file=sys.stderr)
|
|
70
|
+
sys.exit(1)
|
|
71
|
+
print(f"{'true' if state['ownedMarketplace'] else 'false'} {'true' if state['ownedAssembly'] else 'false'} {state['pluginVersion']}")
|
|
72
|
+
PY
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
# copilot_exact_row_version <list-text> <qualified>
|
|
76
|
+
# The finer read of the SAME row grammar (final amendment): reports whether the exact
|
|
77
|
+
# QUALIFIED is absent, present exactly once with a parsed nonempty version, or in a
|
|
78
|
+
# shape nobody may act on. rc 0 prints `absent` or `one <version>`; rc 1 with the
|
|
79
|
+
# reason on stderr for a MALFORMED exact row (claims our qualified id but does not
|
|
80
|
+
# parse as `<qualified> (v<nonempty>)`) or MULTIPLE exact rows. Longer tokens that
|
|
81
|
+
# merely contain the qualified id remain foreign and are ignored, same as above.
|
|
82
|
+
copilot_exact_row_version() {
|
|
83
|
+
local list_text="$1" qualified="$2"
|
|
84
|
+
LIST_TEXT_ENV="$list_text" QUALIFIED_ENV="$qualified" python3 - <<'PY'
|
|
85
|
+
import os, sys
|
|
86
|
+
qualified = os.environ["QUALIFIED_ENV"]
|
|
87
|
+
versions = []
|
|
88
|
+
for raw in os.environ["LIST_TEXT_ENV"].splitlines():
|
|
89
|
+
row = raw.strip()
|
|
90
|
+
for glyph in ("•", "◆", "-"):
|
|
91
|
+
if row.startswith(glyph):
|
|
92
|
+
row = row[len(glyph):].strip()
|
|
93
|
+
# C3a amendment (B defect 3): the claim test is the exact QUALIFIED as the row's
|
|
94
|
+
# first whitespace-delimited token. Every claiming row must then parse as the one
|
|
95
|
+
# measured form `<qualified> (v<nonempty>)` IN FULL — a truncated/garbled tail
|
|
96
|
+
# (e.g. `(v0.1` with no closing paren) used to fall through the old
|
|
97
|
+
# startswith/endswith pair and round to ABSENT, which the inverse reads as
|
|
98
|
+
# retry-safe absence. Malformed is malformed; nothing rounds to absent.
|
|
99
|
+
parts = row.split(None, 1)
|
|
100
|
+
if not parts or parts[0] != qualified:
|
|
101
|
+
continue # foreign row, including longer ids that merely contain ours
|
|
102
|
+
rest = parts[1] if len(parts) == 2 else ""
|
|
103
|
+
if not (rest.startswith("(v") and rest.endswith(")")):
|
|
104
|
+
print(f"malformed exact row for {qualified}: {row!r} does not parse as '<qualified> (v<version>)'", file=sys.stderr)
|
|
105
|
+
sys.exit(1)
|
|
106
|
+
version = rest[2:-1]
|
|
107
|
+
if not version:
|
|
108
|
+
print(f"malformed exact row for {qualified}: empty version in {row!r}", file=sys.stderr)
|
|
109
|
+
sys.exit(1)
|
|
110
|
+
if any(ord(c) < 0x21 or ord(c) == 0x7f for c in version):
|
|
111
|
+
# Same transport rule as the state validator: the parsed version rides
|
|
112
|
+
# space-separated verdicts (`one <version>`), so an embedded whitespace or
|
|
113
|
+
# control character is ambiguity, not a version.
|
|
114
|
+
print(f"malformed exact row for {qualified}: version {version!r} carries whitespace/control characters", file=sys.stderr)
|
|
115
|
+
sys.exit(1)
|
|
116
|
+
versions.append(version)
|
|
117
|
+
if len(versions) > 1:
|
|
118
|
+
print(f"multiple exact rows for {qualified}: versions {versions} — nobody may act on an ambiguous listing", file=sys.stderr)
|
|
119
|
+
sys.exit(1)
|
|
120
|
+
print(f"one {versions[0]}" if versions else "absent")
|
|
121
|
+
PY
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
# copilot_marketplace_local_path <mkt-list-text> <mkt-name>
|
|
125
|
+
# The ONE exact marketplace-row grammar (C3a amendment, B defects 1+2) — installer,
|
|
126
|
+
# inverse and doctor all read the `plugin marketplace list` output through THIS
|
|
127
|
+
# parser instead of three copy-pasted `grep|head -1` pipelines that silently took
|
|
128
|
+
# the first of several same-named rows. rc 0 prints `absent` or `one <local abs
|
|
129
|
+
# path>`; rc 1 with the reason on stderr when a row claims the exact name but does
|
|
130
|
+
# not parse as the measured `<name> (Local: <path>)` form (a non-Local source is
|
|
131
|
+
# not provably ours), or when MULTIPLE rows claim the name (duplicates are
|
|
132
|
+
# ambiguity nobody may act on — never "the first one"). Rows whose first token
|
|
133
|
+
# merely contains the name remain foreign and are ignored. Path-vs-assembly drift
|
|
134
|
+
# stays with the callers: this parser reports the exact registered path, and each
|
|
135
|
+
# surface names its own refusal when that path is not its assembly.
|
|
136
|
+
copilot_marketplace_local_path() {
|
|
137
|
+
local list_text="$1" mkt_name="$2"
|
|
138
|
+
LIST_TEXT_ENV="$list_text" MKT_NAME_ENV="$mkt_name" python3 - <<'PY'
|
|
139
|
+
import os, sys
|
|
140
|
+
name = os.environ["MKT_NAME_ENV"]
|
|
141
|
+
paths = []
|
|
142
|
+
for raw in os.environ["LIST_TEXT_ENV"].splitlines():
|
|
143
|
+
row = raw.strip()
|
|
144
|
+
for glyph in ("•", "◆", "-"):
|
|
145
|
+
if row.startswith(glyph):
|
|
146
|
+
row = row[len(glyph):].strip()
|
|
147
|
+
parts = row.split(None, 1)
|
|
148
|
+
if not parts or parts[0] != name:
|
|
149
|
+
continue # foreign row, including longer names that merely contain ours
|
|
150
|
+
rest = parts[1] if len(parts) == 2 else ""
|
|
151
|
+
if not (rest.startswith("(Local: ") and rest.endswith(")")):
|
|
152
|
+
print(f"malformed marketplace row for {name}: {row!r} does not parse as '<name> (Local: <path>)' — "
|
|
153
|
+
"a non-Local or garbled registration is not provably ours", file=sys.stderr)
|
|
154
|
+
sys.exit(1)
|
|
155
|
+
path = rest[len("(Local: "):-1]
|
|
156
|
+
if not path:
|
|
157
|
+
print(f"malformed marketplace row for {name}: empty Local path in {row!r}", file=sys.stderr)
|
|
158
|
+
sys.exit(1)
|
|
159
|
+
paths.append(path)
|
|
160
|
+
if len(paths) > 1:
|
|
161
|
+
print(f"multiple marketplace rows named {name}: paths {paths} — duplicates are ambiguity nobody may act on", file=sys.stderr)
|
|
162
|
+
sys.exit(1)
|
|
163
|
+
print(f"one {paths[0]}" if paths else "absent")
|
|
164
|
+
PY
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
copilot_assembly_valid() {
|
|
168
|
+
local asm="$1" plugin="$2"
|
|
169
|
+
local unit="$asm/$plugin"
|
|
170
|
+
local launcher="$unit/scripts/copilot-hook-launch.sh"
|
|
171
|
+
local hooks="$unit/hooks/hooks.json"
|
|
172
|
+
[ -d "$unit" ] || { echo "structural oracle: unit dir missing: $unit" >&2; return 1; }
|
|
173
|
+
[ -x "$launcher" ] || { echo "structural oracle: launcher missing or not executable: $launcher" >&2; return 1; }
|
|
174
|
+
if grep -q "__NODE_BIN__\|__HOOK_ENTRY__" "$launcher"; then
|
|
175
|
+
echo "structural oracle: launcher still carries an installer placeholder (unbaked): $launcher" >&2
|
|
176
|
+
return 1
|
|
177
|
+
fi
|
|
178
|
+
local baked_node
|
|
179
|
+
baked_node="$(sed -n 's/^NODE_BIN="\(.*\)"$/\1/p' "$launcher" | head -1)"
|
|
180
|
+
[ -n "$baked_node" ] && [ -x "$baked_node" ] || {
|
|
181
|
+
echo "structural oracle: baked node missing or not executable: ${baked_node:-(unparsed)}" >&2
|
|
182
|
+
return 1
|
|
183
|
+
}
|
|
184
|
+
local baked_entry
|
|
185
|
+
baked_entry="$(sed -n 's|^HOOK_ENTRY="\$PLUGIN_ROOT/\(.*\)"$|\1|p' "$launcher" | head -1)"
|
|
186
|
+
[ -n "$baked_entry" ] && [ -f "$unit/$baked_entry" ] || {
|
|
187
|
+
echo "structural oracle: hook entry missing beside the launcher: ${baked_entry:-(unparsed)}" >&2
|
|
188
|
+
return 1
|
|
189
|
+
}
|
|
190
|
+
[ -f "$unit/entwurf-capabilities.json" ] || {
|
|
191
|
+
echo "structural oracle: capability registry missing at the plugin root" >&2
|
|
192
|
+
return 1
|
|
193
|
+
}
|
|
194
|
+
[ -f "$hooks" ] || { echo "structural oracle: hooks.json missing: $hooks" >&2; return 1; }
|
|
195
|
+
if ! HOOKS_PATH="$hooks" ORACLE_LAUNCHER="$launcher" python3 - <<'PY'
|
|
196
|
+
import json, os, sys
|
|
197
|
+
from pathlib import Path
|
|
198
|
+
try:
|
|
199
|
+
hooks = json.loads(Path(os.environ["HOOKS_PATH"]).read_text(encoding="utf-8"))
|
|
200
|
+
except (OSError, json.JSONDecodeError) as err:
|
|
201
|
+
print(f"structural oracle: hooks.json unreadable: {err}", file=sys.stderr)
|
|
202
|
+
sys.exit(1)
|
|
203
|
+
launcher = os.environ["ORACLE_LAUNCHER"]
|
|
204
|
+
if hooks.get("version") != 1:
|
|
205
|
+
print("structural oracle: hooks.json version is not the literal 1", file=sys.stderr)
|
|
206
|
+
sys.exit(1)
|
|
207
|
+
events = hooks.get("hooks") or {}
|
|
208
|
+
if set(events) != {"sessionStart", "userPromptSubmitted"}:
|
|
209
|
+
print(f"structural oracle: hook events drifted: {sorted(events)}", file=sys.stderr)
|
|
210
|
+
sys.exit(1)
|
|
211
|
+
for name, entries in events.items():
|
|
212
|
+
for i, entry in enumerate(entries if isinstance(entries, list) else []):
|
|
213
|
+
exec_value = entry.get("exec")
|
|
214
|
+
if not isinstance(exec_value, str) or exec_value != launcher or "args" in entry:
|
|
215
|
+
print(f"structural oracle: hooks.{name}[{i}] is not the baked exec-string form", file=sys.stderr)
|
|
216
|
+
sys.exit(1)
|
|
217
|
+
sys.exit(0)
|
|
218
|
+
PY
|
|
219
|
+
then
|
|
220
|
+
return 1
|
|
221
|
+
fi
|
|
222
|
+
return 0
|
|
223
|
+
}
|