@junghanacs/entwurf 0.14.1 → 0.15.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +8 -5
- package/CHANGELOG.md +113 -0
- package/DELIVERY.md +73 -3
- package/README.md +55 -4
- package/VERIFY.md +6 -6
- package/demo/README.md +3 -1
- package/demo/demo-baseline.sh +12 -1
- package/demo/demo.sh +9 -1
- package/docs/acp-backend-rail.md +103 -4
- package/docs/external-mcp-host.md +26 -4
- package/docs/setup-clean-host.md +3 -3
- 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/dist/scripts/doctor-pi-provider.js +139 -47
- package/mcp/entwurf-bridge/dist/scripts/probe-bridge-command.js +294 -0
- package/mcp/entwurf-bridge/src/index.ts +10 -6
- package/mcp/entwurf-bridge/tsconfig.build.json +17 -6
- package/package.json +23 -12
- 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/acp/backend.ts +229 -9
- 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 +389 -26
- package/scripts/agy-bridge-config.py +47 -13
- package/scripts/agy-bridge.sh +73 -23
- package/scripts/check-acp-prompt-lifecycle.ts +221 -9
- package/scripts/check-copilot-birth-hook.ts +497 -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-bridge-boot.ts +28 -0
- package/scripts/check-entwurf-capabilities.ts +25 -6
- package/scripts/check-fresh-cut-gate.sh +92 -23
- package/scripts/check-gate-qualification.ts +10 -3
- 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-probe-bridge-command.ts +201 -0
- package/scripts/check-release-gate-outcomes.ts +54 -1
- package/scripts/copilot-bridge-doctor.sh +209 -0
- package/scripts/copilot-bridge-install.sh +224 -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 +4 -1
- package/scripts/doctor-pi-provider.ts +155 -51
- package/scripts/meta-bridge-hook-log.sh +17 -5
- package/scripts/mutants/acp-prompt-lifecycle.json +25 -3
- package/scripts/mutants/bridge-command-boot.json +107 -0
- package/scripts/mutants/capability-cache.json +64 -0
- package/scripts/mutants/copilot-birth.json +155 -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/release-gate.json +13 -0
- package/scripts/probe-bridge-command.ts +330 -0
- package/scripts/raw-async-delivery/README.md +203 -1
- 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/raw-async-delivery/copilot-ui-server-probe.mjs +337 -0
- package/scripts/smoke-acp-raw-turn-live.ts +1 -1
- package/scripts/smoke-agy-install-state.sh +76 -2
- package/scripts/smoke-copilot-mcp-state.sh +153 -0
- package/scripts/smoke-copilot-statusline-state.sh +131 -0
- package/scripts/smoke-entwurf-chain-live.ts +1 -1
- package/scripts/smoke-entwurf-v2-matrix-live.ts +2 -2
- package/scripts/smoke-mux-fresh-call-live.ts +3 -1
- package/scripts/smoke-mux-lifecycle-live.ts +4 -2
- package/scripts/smoke-pi-provider-state.sh +135 -6
- package/scripts/smoke-resident-garden-guard.sh +2 -2
- package/scripts/tsconfig.json +1 -0
|
@@ -0,0 +1,224 @@
|
|
|
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
|
+
KEEP_STALE=0
|
|
49
|
+
# --assemble-only stops after the assembly, before the Copilot CLI is touched. It is
|
|
50
|
+
# how the gate reaches the real bake on a host (and in CI) that has no Copilot.
|
|
51
|
+
ASSEMBLE_ONLY=0
|
|
52
|
+
|
|
53
|
+
die() { echo "[copilot-bridge-install] $*" >&2; exit 1; }
|
|
54
|
+
|
|
55
|
+
for arg in "$@"; do
|
|
56
|
+
case "$arg" in
|
|
57
|
+
--keep-stale-claude-unit) KEEP_STALE=1 ;;
|
|
58
|
+
--assemble-only) ASSEMBLE_ONLY=1 ;;
|
|
59
|
+
*) die "unknown argument: $arg (--keep-stale-claude-unit, --assemble-only)" ;;
|
|
60
|
+
esac
|
|
61
|
+
done
|
|
62
|
+
|
|
63
|
+
[ "$(uname -s)" = "Linux" ] || die "Linux only; $(uname -s) is not a certified axis for this install."
|
|
64
|
+
if [ "$ASSEMBLE_ONLY" -eq 0 ]; then
|
|
65
|
+
command -v copilot >/dev/null 2>&1 || die "the 'copilot' CLI is not on PATH."
|
|
66
|
+
fi
|
|
67
|
+
|
|
68
|
+
# PREFLIGHT, and all of it BEFORE the first destructive step. The assembly is removed
|
|
69
|
+
# and rebuilt in place further down; discovering a missing python3 or an under-floor
|
|
70
|
+
# node AFTER that would leave the operator with no assembly at all (cross-review, terra).
|
|
71
|
+
command -v python3 >/dev/null 2>&1 || die "python3 is required (both bakes run through it) and is not on PATH."
|
|
72
|
+
NODE_BIN="$(command -v node)" || die "node is not on PATH."
|
|
73
|
+
[ -x "$NODE_BIN" ] || die "resolved node is not executable: $NODE_BIN"
|
|
74
|
+
NODE_MAJOR="$("$NODE_BIN" -p 'process.versions.node.split(".")[0]' 2>/dev/null || echo 0)"
|
|
75
|
+
# The dev-clone shape bakes the raw `.ts` entry and relies on strip-types, so an
|
|
76
|
+
# under-floor node does not fail at install — it fails at the operator's first prompt,
|
|
77
|
+
# inside Copilot, where nothing surfaces it. Refuse here instead.
|
|
78
|
+
[ "$NODE_MAJOR" -ge 24 ] 2>/dev/null || die "node >= 24 is required (package engines); resolved $NODE_BIN reports major '$NODE_MAJOR'."
|
|
79
|
+
|
|
80
|
+
# Same install-shape split as the Claude installer: an installed package lives below
|
|
81
|
+
# node_modules where Node refuses strip-types on `.ts`, so it runs the tsc-emitted
|
|
82
|
+
# closure; a dev clone (and the XDG artifact) runs the `.ts` source.
|
|
83
|
+
case "$REPO" in
|
|
84
|
+
*/node_modules/@junghanacs/entwurf)
|
|
85
|
+
HOOK_ENTRY="meta-bridge-hook-copilot.js"
|
|
86
|
+
HOOK_SRC="$REPO/mcp/entwurf-bridge/dist/pi-extensions/meta-bridge-hook-copilot.js"
|
|
87
|
+
LIB_SRC="$REPO/mcp/entwurf-bridge/dist/pi-extensions/lib/meta-session.js"
|
|
88
|
+
LIB_EXT="js" ;;
|
|
89
|
+
*)
|
|
90
|
+
HOOK_ENTRY="meta-bridge-hook-copilot.ts"
|
|
91
|
+
HOOK_SRC="$REPO/pi-extensions/meta-bridge-hook-copilot.ts"
|
|
92
|
+
LIB_SRC="$REPO/pi-extensions/lib/meta-session.ts"
|
|
93
|
+
LIB_EXT="ts" ;;
|
|
94
|
+
esac
|
|
95
|
+
|
|
96
|
+
# --- 1. assemble a self-contained, baked plugin -----------------------------
|
|
97
|
+
[ -f "$HOOK_SRC" ] || die "hook artifact missing: $HOOK_SRC (run 'pnpm run build-bridge' in a dev clone, or reinstall the package)."
|
|
98
|
+
[ -f "$LIB_SRC" ] || die "hook lib artifact missing: $LIB_SRC (same build-bridge dist closure)."
|
|
99
|
+
|
|
100
|
+
# ATOMIC: assemble beside the live artifact and swap only once it is complete. The
|
|
101
|
+
# previous shape removed the live assembly first, so any failure between there and the
|
|
102
|
+
# marketplace re-registration left the INSTALLED plugin pointing at a launcher that no
|
|
103
|
+
# longer existed — a working hook broken by a failed upgrade (cross-review, terra).
|
|
104
|
+
STAGE="$ASM.staging.$$"
|
|
105
|
+
rm -rf "$STAGE"
|
|
106
|
+
trap 'rm -rf "$STAGE"' EXIT
|
|
107
|
+
mkdir -p "$STAGE"
|
|
108
|
+
cp -r "$SRC/.claude-plugin" "$STAGE/.claude-plugin"
|
|
109
|
+
cp -r "$SRC/$PLUGIN" "$STAGE/$PLUGIN"
|
|
110
|
+
cp "$HOOK_SRC" "$STAGE/$PLUGIN/$HOOK_ENTRY"
|
|
111
|
+
mkdir -p "$STAGE/$PLUGIN/lib"
|
|
112
|
+
cp "$LIB_SRC" "$STAGE/$PLUGIN/lib/meta-session.$LIB_EXT"
|
|
113
|
+
cp "$REPO/pi-extensions/lib/session-id.js" "$STAGE/$PLUGIN/lib/session-id.js"
|
|
114
|
+
# The capability registry must travel at the plugin ROOT: metaCapabilitiesFilePath()
|
|
115
|
+
# resolves it via `../` from lib/ in the bundle layout. Without it every mint throws.
|
|
116
|
+
cp "$REPO/pi/entwurf-capabilities.json" "$STAGE/$PLUGIN/entwurf-capabilities.json"
|
|
117
|
+
|
|
118
|
+
LAUNCHER="$STAGE/$PLUGIN/scripts/copilot-hook-launch.sh"
|
|
119
|
+
# What gets BAKED into hooks.json is the FINAL path, not the staging one: the manifest
|
|
120
|
+
# has to name where the launcher will live after the swap.
|
|
121
|
+
FINAL_LAUNCHER="$ASM/$PLUGIN/scripts/copilot-hook-launch.sh"
|
|
122
|
+
chmod +x "$LAUNCHER"
|
|
123
|
+
|
|
124
|
+
# Bake node + entry into the LAUNCHER (not into hooks.json): Copilot's `exec` is a
|
|
125
|
+
# single string with no argv beside it, so the payload coordinates cannot ride the
|
|
126
|
+
# manifest the way Claude's `args` array carries them.
|
|
127
|
+
LAUNCHER_PATH="$LAUNCHER" NODE_PATH_TO_BAKE="$NODE_BIN" HOOK_ENTRY_TO_BAKE="$HOOK_ENTRY" python3 - <<'PY'
|
|
128
|
+
from pathlib import Path
|
|
129
|
+
import os
|
|
130
|
+
launcher = Path(os.environ["LAUNCHER_PATH"])
|
|
131
|
+
node = os.environ["NODE_PATH_TO_BAKE"]
|
|
132
|
+
hook_entry = os.environ["HOOK_ENTRY_TO_BAKE"]
|
|
133
|
+
text = launcher.read_text(encoding="utf-8")
|
|
134
|
+
for placeholder in ("__NODE_BIN__", "__HOOK_ENTRY__"):
|
|
135
|
+
if placeholder not in text:
|
|
136
|
+
raise SystemExit(f"launcher bake failed before replacement ({placeholder} absent in {launcher})")
|
|
137
|
+
launcher.write_text(text.replace("__NODE_BIN__", node).replace("__HOOK_ENTRY__", hook_entry), encoding="utf-8")
|
|
138
|
+
PY
|
|
139
|
+
for placeholder in "__NODE_BIN__" "__HOOK_ENTRY__"; do
|
|
140
|
+
grep -q "$placeholder" "$LAUNCHER" && die "launcher bake failed ($placeholder still present in $LAUNCHER)."
|
|
141
|
+
done
|
|
142
|
+
|
|
143
|
+
# Bake the launcher's ABSOLUTE path into hooks.json. `${COPILOT_PLUGIN_ROOT}` and
|
|
144
|
+
# `${CLAUDE_PLUGIN_ROOT}` both appear as recognized names in the shipped runtime, but
|
|
145
|
+
# whether an `exec` string is substituted was never measured — and the assembly path
|
|
146
|
+
# is stable and outside the checkout, so baking needs no such bet.
|
|
147
|
+
HOOKS="$STAGE/$PLUGIN/hooks/hooks.json"
|
|
148
|
+
HOOKS_PATH="$HOOKS" LAUNCHER_TO_BAKE="$FINAL_LAUNCHER" python3 - <<'PY'
|
|
149
|
+
from pathlib import Path
|
|
150
|
+
import os
|
|
151
|
+
hooks = Path(os.environ["HOOKS_PATH"])
|
|
152
|
+
launcher = os.environ["LAUNCHER_TO_BAKE"]
|
|
153
|
+
text = hooks.read_text(encoding="utf-8")
|
|
154
|
+
if "__COPILOT_LAUNCHER__" not in text:
|
|
155
|
+
raise SystemExit(f"hooks bake failed before replacement (__COPILOT_LAUNCHER__ absent in {hooks})")
|
|
156
|
+
hooks.write_text(text.replace("__COPILOT_LAUNCHER__", launcher), encoding="utf-8")
|
|
157
|
+
PY
|
|
158
|
+
grep -q "__COPILOT_LAUNCHER__" "$HOOKS" && die "hooks bake failed (__COPILOT_LAUNCHER__ still present in $HOOKS)."
|
|
159
|
+
|
|
160
|
+
# SWAP. Move the old assembly aside rather than deleting it, so a failed rename can be
|
|
161
|
+
# undone; only then drop it.
|
|
162
|
+
PREV=""
|
|
163
|
+
if [ -d "$ASM" ]; then
|
|
164
|
+
PREV="$ASM.previous.$$"
|
|
165
|
+
mv "$ASM" "$PREV" || die "could not move the previous assembly aside ($ASM)."
|
|
166
|
+
fi
|
|
167
|
+
mkdir -p "$(dirname "$ASM")"
|
|
168
|
+
if ! mv "$STAGE" "$ASM"; then
|
|
169
|
+
[ -n "$PREV" ] && mv "$PREV" "$ASM"
|
|
170
|
+
die "could not publish the staged assembly into $ASM (previous assembly restored)."
|
|
171
|
+
fi
|
|
172
|
+
trap - EXIT
|
|
173
|
+
[ -n "$PREV" ] && rm -rf "$PREV"
|
|
174
|
+
|
|
175
|
+
echo "[copilot-bridge-install] assembled $ASM (birth plugin only; MCP and receive are separate surfaces)"
|
|
176
|
+
|
|
177
|
+
if [ "$ASSEMBLE_ONLY" -eq 1 ]; then
|
|
178
|
+
echo "[copilot-bridge-install] --assemble-only: stopping before the Copilot CLI is touched"
|
|
179
|
+
exit 0
|
|
180
|
+
fi
|
|
181
|
+
|
|
182
|
+
# --- 2. retire the stale Claude unit from Copilot ---------------------------
|
|
183
|
+
# It is ours, it fires on every Copilot prompt, and it exits 1 before node starts.
|
|
184
|
+
# Leaving it installed means the operator keeps paying for a hook that cannot work.
|
|
185
|
+
if [ "$KEEP_STALE" -eq 0 ]; then
|
|
186
|
+
# ASK FIRST, then act. Treating every nonzero uninstall as "not present" would report
|
|
187
|
+
# a CLI, config or permission failure as an absence and then install on top of a unit
|
|
188
|
+
# that is still firing (cross-review, terra). `copilot plugin list` prints the
|
|
189
|
+
# QUALIFIED id, which is also what distinguishes our unit from a same-named plugin
|
|
190
|
+
# somebody else installed from another marketplace.
|
|
191
|
+
# The LIST failing is not an empty list. `|| true` would hand the "nothing installed"
|
|
192
|
+
# arm a broken, unauthenticated or permission-denied Copilot CLI and then install on
|
|
193
|
+
# top of whatever is really there (cross-review, terra). The assignment IS the
|
|
194
|
+
# condition so `set -e` cannot kill us before we can say why.
|
|
195
|
+
if ! INSTALLED_LIST="$(copilot plugin list 2>/dev/null)"; then
|
|
196
|
+
die "'copilot plugin list' failed, so this host's installed plugins are UNKNOWN. An unknown list is not an empty one — the stale Claude unit may still be firing. Fix the Copilot CLI error, or re-run with --keep-stale-claude-unit to skip this check deliberately."
|
|
197
|
+
fi
|
|
198
|
+
case "$INSTALLED_LIST" in
|
|
199
|
+
*"$STALE_CLAUDE_UNIT"*)
|
|
200
|
+
copilot plugin uninstall "$STALE_CLAUDE_UNIT" >/dev/null \
|
|
201
|
+
|| 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."
|
|
202
|
+
echo "[copilot-bridge-install] removed the stale Claude unit '$STALE_CLAUDE_UNIT' from Copilot (it exits 1 on every prompt there)"
|
|
203
|
+
;;
|
|
204
|
+
*)
|
|
205
|
+
echo "[copilot-bridge-install] stale Claude unit '$STALE_CLAUDE_UNIT' is not installed in Copilot (nothing to remove)"
|
|
206
|
+
;;
|
|
207
|
+
esac
|
|
208
|
+
fi
|
|
209
|
+
|
|
210
|
+
# --- 3. (re)register the marketplace and install ----------------------------
|
|
211
|
+
copilot plugin uninstall "$QUALIFIED" >/dev/null 2>&1 || true
|
|
212
|
+
copilot plugin marketplace remove "$MKT_NAME" >/dev/null 2>&1 || true
|
|
213
|
+
copilot plugin marketplace add "$ASM" >/dev/null || die "'copilot plugin marketplace add $ASM' failed."
|
|
214
|
+
copilot plugin install "$QUALIFIED" >/dev/null || die "'copilot plugin install $QUALIFIED' failed."
|
|
215
|
+
|
|
216
|
+
# --- 4. evidence ------------------------------------------------------------
|
|
217
|
+
echo "--- copilot plugin list ---"
|
|
218
|
+
# The qualified id, not the bare name: a bare grep would be satisfied by a same-named
|
|
219
|
+
# plugin from another marketplace and report an install that never happened.
|
|
220
|
+
copilot plugin list 2>/dev/null | grep -F "$QUALIFIED" || die "post-install: $QUALIFIED is not in 'copilot plugin list' (install did not take)."
|
|
221
|
+
echo
|
|
222
|
+
echo "[copilot-bridge-install] DONE. A Copilot session becomes a citizen on its FIRST PROMPT,"
|
|
223
|
+
echo "not when the window opens — sessionStart is deferred to the first prompt (measured)."
|
|
224
|
+
echo "Verify with: ./run.sh doctor-copilot-bridge"
|
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# copilot-launch.sh — the managed Copilot launch, `entwurf copilot` (#82 RAIL 7).
|
|
3
|
+
#
|
|
4
|
+
# WHY THIS EXISTS. The receiver installer owns the extension artifact but it cannot
|
|
5
|
+
# arm anything by itself: Copilot scans for extensions only when the CLI is started
|
|
6
|
+
# with `COPILOT_CLI_ENABLED_FEATURE_FLAGS=EXTENSIONS`, and that lives in the
|
|
7
|
+
# environment of the operator's own launch. Without it the scan is skipped SILENTLY —
|
|
8
|
+
# no error, no log line, no receiver — so a perfectly installed unit stays inert and
|
|
9
|
+
# the citizen never becomes deliverable. entwurf does not own the operator's shell,
|
|
10
|
+
# and it will not write their rc files. What it can own is ONE invocation: this leaf
|
|
11
|
+
# sets the flag for the process it is about to become, and nothing outside it.
|
|
12
|
+
#
|
|
13
|
+
# WHAT IT IS NOT. It is not a launcher rail. It does not place a tmux window, it is
|
|
14
|
+
# not fresh-call, and it mints NO citizen: birth authority stays exactly where it was,
|
|
15
|
+
# on the Copilot session's FIRST PROMPT (Hard Rule 2 — the record is the sole garden
|
|
16
|
+
# address authority, and nothing here writes a record). It `exec`s in the caller's own
|
|
17
|
+
# terminal, so cwd, tty, pid and exit status are the vendor's, unchanged.
|
|
18
|
+
#
|
|
19
|
+
# CONSENT. Calling `entwurf copilot` IS the consent to the managed profile: EXTENSIONS
|
|
20
|
+
# on, `--model auto` when no model was given, and `--yolo` when no explicit permission
|
|
21
|
+
# or surface policy was given. An operator who wants the plain vendor behaviour keeps
|
|
22
|
+
# running `copilot` directly — that path is untouched and is not deprecated.
|
|
23
|
+
#
|
|
24
|
+
# FAIL-CLOSED SCAN. The argv scan is element-wise and deliberately naive: it does not
|
|
25
|
+
# model which options consume the next word. Both of its possible mistakes are safe.
|
|
26
|
+
# Reading an option's VALUE as a policy token suppresses an injected default; reading
|
|
27
|
+
# it as a model suppresses `--model auto`. Every error direction is "inject less",
|
|
28
|
+
# never "widen the operator's permissions behind their back".
|
|
29
|
+
#
|
|
30
|
+
# PRECONDITION SCOPE, deliberately unchanged by #82 RAIL 9. This launcher guards the
|
|
31
|
+
# RECEIVER and nothing else, because that is the promise the EXTENSIONS flag makes. The
|
|
32
|
+
# fresh-call lane needs more than that (birth, MCP hand, receiver AND visible footer), but
|
|
33
|
+
# those belong to `pi-extensions/lib/copilot-fresh-preflight.ts`, which decides them BEFORE
|
|
34
|
+
# tmux opens a window. A refusal here necessarily happens inside a window that already
|
|
35
|
+
# exists, so widening this list would only turn a clean pre-mutation refusal into a dead
|
|
36
|
+
# window plus a launch receipt for a sibling that never was. The manual `entwurf copilot`
|
|
37
|
+
# contract stays receive-only.
|
|
38
|
+
set -euo pipefail
|
|
39
|
+
|
|
40
|
+
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
41
|
+
REPO_DIR="$(cd "$HERE/.." && pwd)"
|
|
42
|
+
|
|
43
|
+
UNIT="entwurf-receive"
|
|
44
|
+
EXT_ROOT="${COPILOT_EXTENSIONS_DIR:-$HOME/.copilot/extensions}"
|
|
45
|
+
DEST="$EXT_ROOT/$UNIT"
|
|
46
|
+
STATE_DIR="${XDG_DATA_HOME:-$HOME/.local/share}/entwurf/copilot-receive"
|
|
47
|
+
STATE_FILE="$STATE_DIR/install-state.json"
|
|
48
|
+
FLAG_ENV="COPILOT_CLI_ENABLED_FEATURE_FLAGS"
|
|
49
|
+
FLAG_VALUE="EXTENSIONS"
|
|
50
|
+
REPAIR="entwurf install-copilot-receive"
|
|
51
|
+
|
|
52
|
+
# The vendor command this leaf manages. A CONSTANT, and deliberately not overridable:
|
|
53
|
+
# an env seam here would be a production switch for "which binary is the Copilot CLI",
|
|
54
|
+
# and anything that can redirect an exec is an authority, not a test convenience. The
|
|
55
|
+
# gate proves the real contract instead — it puts its fake vendor on a sandbox PATH under
|
|
56
|
+
# this exact name and drives the public address.
|
|
57
|
+
VENDOR_CMD="copilot"
|
|
58
|
+
|
|
59
|
+
fail() { printf 'FAIL: %s\n' "$*" >&2; exit 1; }
|
|
60
|
+
|
|
61
|
+
# --- recursion fence -------------------------------------------------------
|
|
62
|
+
# A `copilot` earlier on PATH that itself calls `entwurf copilot` would spin forever,
|
|
63
|
+
# and the symptom would be a hung terminal rather than an error. One sentinel closes
|
|
64
|
+
# it: the flag is invocation-local, so seeing it already set means we are inside our
|
|
65
|
+
# own exec chain.
|
|
66
|
+
if [ -n "${ENTWURF_COPILOT_LAUNCH_ACTIVE:-}" ]; then
|
|
67
|
+
fail "recursive managed launch detected (ENTWURF_COPILOT_LAUNCH_ACTIVE is already set).
|
|
68
|
+
Something on PATH named '$VENDOR_CMD' resolves back to this launcher. Fix PATH so
|
|
69
|
+
'$VENDOR_CMD' is the GitHub Copilot CLI, or run the vendor binary by its full path."
|
|
70
|
+
fi
|
|
71
|
+
|
|
72
|
+
# --- precondition: the receiver this launch promises to arm -----------------
|
|
73
|
+
# The flag is a PROMISE of a doorbell. Setting it while no receiver unit is installed
|
|
74
|
+
# would produce a session that looks managed and can never be delivered to, which is
|
|
75
|
+
# the exact false-success this repo refuses. Every failure below names the one command
|
|
76
|
+
# that repairs it.
|
|
77
|
+
[ -f "$STATE_FILE" ] \
|
|
78
|
+
|| fail "no receiver install-state at $STATE_FILE.
|
|
79
|
+
'$VENDOR_CMD' launched with $FLAG_ENV=$FLAG_VALUE would scan for an extension that is
|
|
80
|
+
not there, and the session would never become deliverable. Run: $REPAIR"
|
|
81
|
+
|
|
82
|
+
claimed_path=""
|
|
83
|
+
claimed_unit=""
|
|
84
|
+
if ! claimed_path="$(python3 -c 'import json,sys;print(json.load(open(sys.argv[1])).get("path",""))' "$STATE_FILE" 2>/dev/null)"; then
|
|
85
|
+
fail "receiver install-state at $STATE_FILE is not readable JSON — refusing to launch on a
|
|
86
|
+
state file we cannot verify. Run: $REPAIR"
|
|
87
|
+
fi
|
|
88
|
+
claimed_unit="$(python3 -c 'import json,sys;print(json.load(open(sys.argv[1])).get("unit",""))' "$STATE_FILE" 2>/dev/null || true)"
|
|
89
|
+
|
|
90
|
+
[ -n "$claimed_path" ] \
|
|
91
|
+
|| fail "receiver install-state at $STATE_FILE names no path. Run: $REPAIR"
|
|
92
|
+
[ "$claimed_unit" = "$UNIT" ] \
|
|
93
|
+
|| fail "receiver install-state at $STATE_FILE claims unit '${claimed_unit:-<empty>}', expected '$UNIT'. Run: $REPAIR"
|
|
94
|
+
# Path mismatch is its own failure, not a missing file: it means the state file and the
|
|
95
|
+
# directory this launch would arm from disagree, and the extensions root the CLI will
|
|
96
|
+
# actually scan is $EXT_ROOT.
|
|
97
|
+
[ "$claimed_path" = "$DEST" ] \
|
|
98
|
+
|| fail "receiver install-state points at '$claimed_path' but this environment scans '$DEST'.
|
|
99
|
+
The CLI would read a different extensions root than the one that was installed
|
|
100
|
+
(COPILOT_EXTENSIONS_DIR / HOME differ from install time). Run: $REPAIR"
|
|
101
|
+
[ -d "$claimed_path" ] \
|
|
102
|
+
|| fail "receiver unit directory '$claimed_path' is missing. Run: $REPAIR"
|
|
103
|
+
[ -f "$claimed_path/extension.mjs" ] \
|
|
104
|
+
|| fail "receiver unit at '$claimed_path' holds no extension.mjs — it is not our unit any more. Run: $REPAIR"
|
|
105
|
+
|
|
106
|
+
# --- resolve the vendor executable -----------------------------------------
|
|
107
|
+
# `type -P` returns the PATH hit only — never a shell function, alias or builtin — so
|
|
108
|
+
# what is exec'd is a real external file. `exec command copilot` is deliberately NOT
|
|
109
|
+
# used: it would re-enter shell lookup at exec time and could pick up something other
|
|
110
|
+
# than the file that was validated here.
|
|
111
|
+
copilot_bin="$(type -P "$VENDOR_CMD" 2>/dev/null || true)"
|
|
112
|
+
[ -n "$copilot_bin" ] \
|
|
113
|
+
|| fail "no '$VENDOR_CMD' executable found on PATH.
|
|
114
|
+
The managed launch runs the GitHub Copilot CLI; install it, or put it on PATH."
|
|
115
|
+
[ -f "$copilot_bin" ] && [ -x "$copilot_bin" ] \
|
|
116
|
+
|| fail "'$copilot_bin' is not an executable regular file — refusing to exec it."
|
|
117
|
+
|
|
118
|
+
# Self-exec fence, the second half of the recursion guard: resolve symlinks and refuse
|
|
119
|
+
# anything that is one of our own entrypoints even if the sentinel was stripped.
|
|
120
|
+
resolved_bin="$(readlink -f "$copilot_bin" 2>/dev/null || printf '%s' "$copilot_bin")"
|
|
121
|
+
for own in "$HERE/copilot-launch.sh" "$REPO_DIR/run.sh"; do
|
|
122
|
+
own_resolved="$(readlink -f "$own" 2>/dev/null || printf '%s' "$own")"
|
|
123
|
+
[ "$resolved_bin" = "$own_resolved" ] \
|
|
124
|
+
&& fail "'$VENDOR_CMD' on PATH resolves to entwurf's own '$own_resolved' — that is a launch loop, not the vendor CLI."
|
|
125
|
+
done
|
|
126
|
+
|
|
127
|
+
# --- argv scan: everything BEFORE the first literal `--` --------------------
|
|
128
|
+
# The terminator and everything after it belong to the user/vendor as data. We neither
|
|
129
|
+
# read policy out of it nor inject into it: injected defaults go immediately BEFORE the
|
|
130
|
+
# terminator so they remain options, and every original element crosses byte-identical.
|
|
131
|
+
args=("$@")
|
|
132
|
+
term_index=-1
|
|
133
|
+
for i in "${!args[@]}"; do
|
|
134
|
+
if [ "${args[$i]}" = "--" ]; then
|
|
135
|
+
term_index="$i"
|
|
136
|
+
break
|
|
137
|
+
fi
|
|
138
|
+
done
|
|
139
|
+
scan_end=$(( term_index >= 0 ? term_index : ${#args[@]} ))
|
|
140
|
+
|
|
141
|
+
# The explicit permission / surface policy overrides, measured against GitHub Copilot
|
|
142
|
+
# CLI 1.0.80 `--help`. Two groups, one rule: if the operator named ANY of them before
|
|
143
|
+
# the terminator, they have stated their own policy and we add none.
|
|
144
|
+
# authorization: --yolo, --allow-all{,-tools,-paths,-urls}, --allow-tool, --deny-tool,
|
|
145
|
+
# --allow-url, --deny-url
|
|
146
|
+
# tool surface : --available-tools, --excluded-tools
|
|
147
|
+
# Narrowing flags count too: appending `--yolo` (= all tools + paths + URLs) beside an
|
|
148
|
+
# operator's `--allow-url=https://one.example` would silently widen exactly what they
|
|
149
|
+
# were narrowing.
|
|
150
|
+
# NOT overrides, deliberately: `--allow-all-mcp-server-instructions` is prompt content,
|
|
151
|
+
# not authorization, and `--autopilot` is a mode. Matching is EXACT on the token head,
|
|
152
|
+
# so neither is caught by the `--allow-all` entry that is a prefix of one of them.
|
|
153
|
+
POLICY_OVERRIDES=(
|
|
154
|
+
--yolo
|
|
155
|
+
--allow-all
|
|
156
|
+
--allow-all-tools
|
|
157
|
+
--allow-all-paths
|
|
158
|
+
--allow-all-urls
|
|
159
|
+
--allow-tool
|
|
160
|
+
--deny-tool
|
|
161
|
+
--allow-url
|
|
162
|
+
--deny-url
|
|
163
|
+
--available-tools
|
|
164
|
+
--excluded-tools
|
|
165
|
+
)
|
|
166
|
+
|
|
167
|
+
has_model=0
|
|
168
|
+
has_policy=0
|
|
169
|
+
for (( i = 0; i < scan_end; i++ )); do
|
|
170
|
+
tok="${args[$i]}"
|
|
171
|
+
# `--opt=value` and `--opt value` are the same option; compare the head only.
|
|
172
|
+
head="${tok%%=*}"
|
|
173
|
+
if [ "$head" = "--model" ]; then
|
|
174
|
+
has_model=1
|
|
175
|
+
fi
|
|
176
|
+
for ov in "${POLICY_OVERRIDES[@]}"; do
|
|
177
|
+
if [ "$head" = "$ov" ]; then
|
|
178
|
+
has_policy=1
|
|
179
|
+
break
|
|
180
|
+
fi
|
|
181
|
+
done
|
|
182
|
+
done
|
|
183
|
+
|
|
184
|
+
injected=()
|
|
185
|
+
[ "$has_model" -eq 0 ] && injected+=(--model auto)
|
|
186
|
+
[ "$has_policy" -eq 0 ] && injected+=(--yolo)
|
|
187
|
+
|
|
188
|
+
# Rebuild argv with the defaults before the terminator. Original elements are copied by
|
|
189
|
+
# index, so empty strings, embedded spaces and post-terminator data all survive intact.
|
|
190
|
+
final_args=()
|
|
191
|
+
for (( i = 0; i < scan_end; i++ )); do final_args+=("${args[$i]}"); done
|
|
192
|
+
if [ "${#injected[@]}" -gt 0 ]; then final_args+=("${injected[@]}"); fi
|
|
193
|
+
for (( i = scan_end; i < ${#args[@]}; i++ )); do final_args+=("${args[$i]}"); done
|
|
194
|
+
|
|
195
|
+
# --- the feature flag, invocation-local ------------------------------------
|
|
196
|
+
# Preserve the operator's existing tokens and their order, drop empties, drop
|
|
197
|
+
# duplicates, and guarantee EXTENSIONS is present exactly once. Nothing is written to
|
|
198
|
+
# any config or rc file: this export reaches only the image this process becomes.
|
|
199
|
+
flags_out=""
|
|
200
|
+
seen_extensions=0
|
|
201
|
+
if [ -n "${!FLAG_ENV:-}" ]; then
|
|
202
|
+
IFS=',' read -r -a existing <<< "${!FLAG_ENV}"
|
|
203
|
+
for tok in "${existing[@]}"; do
|
|
204
|
+
# Vendor tokens are comma-separated names; surrounding whitespace is noise, and an
|
|
205
|
+
# empty field (",," or a trailing comma) is not a token.
|
|
206
|
+
tok="${tok#"${tok%%[![:space:]]*}"}"
|
|
207
|
+
tok="${tok%"${tok##*[![:space:]]}"}"
|
|
208
|
+
[ -n "$tok" ] || continue
|
|
209
|
+
case ",$flags_out," in *",$tok,"*) continue ;; esac
|
|
210
|
+
[ "$tok" = "$FLAG_VALUE" ] && seen_extensions=1
|
|
211
|
+
flags_out="${flags_out:+$flags_out,}$tok"
|
|
212
|
+
done
|
|
213
|
+
fi
|
|
214
|
+
[ "$seen_extensions" -eq 1 ] || flags_out="${flags_out:+$flags_out,}$FLAG_VALUE"
|
|
215
|
+
|
|
216
|
+
export "$FLAG_ENV=$flags_out"
|
|
217
|
+
export ENTWURF_COPILOT_LAUNCH_ACTIVE=1
|
|
218
|
+
|
|
219
|
+
# --- foreign identity carriers, removed before exec ------------------------
|
|
220
|
+
# The MCP bridge trusts a COMPLETE `PI_SESSION_ID` + `PI_AGENT_ID` pair ahead of a native
|
|
221
|
+
# sender marker when it resolves who is speaking. Inside the pi process that planted them
|
|
222
|
+
# from record birth that is correct. In another harness they are somebody else's identity:
|
|
223
|
+
# start Copilot from a pi citizen's bash and this session — plus every MCP child and
|
|
224
|
+
# extension child that inherits from it — can speak under the PARENT pi garden id, and the
|
|
225
|
+
# delivery layer will stamp envelopes with an address this session does not hold.
|
|
226
|
+
#
|
|
227
|
+
# Both go, together. Clearing one only changes the wording of the failure while leaving a
|
|
228
|
+
# carrier for a future partial reader to pick up, so an incomplete pair is not a repair.
|
|
229
|
+
# Nothing is substituted: identity for this session comes from its OWN trusted birth marker
|
|
230
|
+
# on its first prompt, which is exactly where the record authority already lives.
|
|
231
|
+
#
|
|
232
|
+
# `unset` is unconditional on purpose. Branching on "was it set?" would only add a way to
|
|
233
|
+
# be wrong; unsetting an absent variable is a no-op in every shell this runs on.
|
|
234
|
+
unset PI_SESSION_ID PI_AGENT_ID
|
|
235
|
+
|
|
236
|
+
exec "$copilot_bin" "${final_args[@]}"
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# Copilot MCP install adapter. Birth plugin and personal MCP stay separate.
|
|
3
|
+
# Owns ONE mcpServers.entwurf-bridge key; file wrapper is always mcpServers.
|
|
4
|
+
# Entry type is "local" (Copilot CLI writer), never "stdio".
|
|
5
|
+
set -euo pipefail
|
|
6
|
+
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
7
|
+
REPO_DIR="$(cd "$HERE/.." && pwd)"
|
|
8
|
+
CONFIG_PY="$HERE/copilot-mcp-config.py"
|
|
9
|
+
CONFIG="${COPILOT_MCP_CONFIG:-$HOME/.copilot/mcp-config.json}"
|
|
10
|
+
COMMAND="${COPILOT_MCP_COMMAND:-entwurf-bridge}"
|
|
11
|
+
STATE_DIR="${XDG_DATA_HOME:-$HOME/.local/share}/entwurf/copilot-mcp"
|
|
12
|
+
STATE_FILE="$STATE_DIR/install-state.json"
|
|
13
|
+
log() { printf '%s\n' "$*"; }
|
|
14
|
+
fail() { printf 'FAIL: %s\n' "$*" >&2; exit 1; }
|
|
15
|
+
command_resolvable() {
|
|
16
|
+
case "$1" in
|
|
17
|
+
*/*) [ -x "$1" ] ;;
|
|
18
|
+
*) command -v "$1" >/dev/null 2>&1 ;;
|
|
19
|
+
esac
|
|
20
|
+
}
|
|
21
|
+
run_config() {
|
|
22
|
+
local out rc
|
|
23
|
+
set +e
|
|
24
|
+
out="$(python3 "$CONFIG_PY" "$@" 2>&1)"
|
|
25
|
+
rc=$?
|
|
26
|
+
set -e
|
|
27
|
+
printf '%s\n' "$out"
|
|
28
|
+
return "$rc"
|
|
29
|
+
}
|
|
30
|
+
BOOT_PROBED_INVOCATION=""
|
|
31
|
+
BOOT_PROBED_RC=1
|
|
32
|
+
BOOT_PROBED_OUT=""
|
|
33
|
+
BOOT_DETAIL=""
|
|
34
|
+
command_boots() {
|
|
35
|
+
local invocation="$1" out rc
|
|
36
|
+
if [ "$invocation" = "$BOOT_PROBED_INVOCATION" ]; then
|
|
37
|
+
BOOT_DETAIL="$BOOT_PROBED_OUT"
|
|
38
|
+
return "$BOOT_PROBED_RC"
|
|
39
|
+
fi
|
|
40
|
+
set +e
|
|
41
|
+
out="$("$REPO_DIR/run.sh" probe-bridge-command --invocation-json "$invocation" 2>&1)"
|
|
42
|
+
rc=$?
|
|
43
|
+
set -e
|
|
44
|
+
BOOT_PROBED_INVOCATION="$invocation"; BOOT_PROBED_RC="$rc"; BOOT_PROBED_OUT="$out"; BOOT_DETAIL="$out"
|
|
45
|
+
return "$rc"
|
|
46
|
+
}
|
|
47
|
+
do_install() {
|
|
48
|
+
log "[copilot-mcp install]"
|
|
49
|
+
log " target: $CONFIG"
|
|
50
|
+
log " command: $COMMAND"
|
|
51
|
+
log " state: $STATE_FILE"
|
|
52
|
+
if ! command_resolvable "$COMMAND"; then
|
|
53
|
+
log " note: '$COMMAND' is not currently resolvable; install records it and doctor stays red until it is on PATH"
|
|
54
|
+
fi
|
|
55
|
+
local out rc
|
|
56
|
+
set +e
|
|
57
|
+
out="$(run_config install "$CONFIG" "$COMMAND" "$STATE_FILE")"
|
|
58
|
+
rc=$?
|
|
59
|
+
set -e
|
|
60
|
+
case "$rc" in
|
|
61
|
+
0) log " ok: $out" ;;
|
|
62
|
+
3) fail "refused (symlink) — $out" ;;
|
|
63
|
+
4) fail "invalid config/state — $out" ;;
|
|
64
|
+
*) fail "install error (rc=$rc) — $out" ;;
|
|
65
|
+
esac
|
|
66
|
+
log " installed. Verify with: ./run.sh doctor-copilot-mcp"
|
|
67
|
+
}
|
|
68
|
+
do_uninstall() {
|
|
69
|
+
log "[copilot-mcp uninstall]"
|
|
70
|
+
local out rc
|
|
71
|
+
set +e
|
|
72
|
+
out="$(run_config uninstall "$STATE_FILE")"
|
|
73
|
+
rc=$?
|
|
74
|
+
set -e
|
|
75
|
+
case "$rc" in
|
|
76
|
+
0) log " ok: $out" ;;
|
|
77
|
+
2) log " note: $out" ;;
|
|
78
|
+
3) fail "refused (symlink) — $out" ;;
|
|
79
|
+
*) fail "uninstall error (rc=$rc) — $out" ;;
|
|
80
|
+
esac
|
|
81
|
+
}
|
|
82
|
+
state_target() {
|
|
83
|
+
python3 - "$STATE_FILE" <<'PY'
|
|
84
|
+
import json, os, sys
|
|
85
|
+
try:
|
|
86
|
+
state = json.load(open(sys.argv[1]))
|
|
87
|
+
required = ("serverKey", "command", "detectMode", "configExistedBefore", "preimage")
|
|
88
|
+
if state.get("schemaVersion") != 1 or any(key not in state for key in required): raise ValueError()
|
|
89
|
+
if state.get("serverKey") != "entwurf-bridge": raise ValueError()
|
|
90
|
+
value = state.get("managedConfigPath")
|
|
91
|
+
if not isinstance(value, str) or not os.path.isabs(value): raise ValueError()
|
|
92
|
+
print(os.path.abspath(value))
|
|
93
|
+
except Exception:
|
|
94
|
+
raise SystemExit(1)
|
|
95
|
+
PY
|
|
96
|
+
}
|
|
97
|
+
do_doctor() {
|
|
98
|
+
log "[copilot-mcp doctor]"
|
|
99
|
+
local hard_fail=0 status out rc installed=0
|
|
100
|
+
[ -f "$STATE_FILE" ] && installed=1
|
|
101
|
+
set +e
|
|
102
|
+
out="$(run_config doctor-static "$CONFIG" "$COMMAND")"
|
|
103
|
+
rc=$?
|
|
104
|
+
set -e
|
|
105
|
+
status="${out##*$'\n'}"
|
|
106
|
+
if [ "$rc" -ne 0 ]; then
|
|
107
|
+
log " config: unexpected config reader failure (rc=$rc): $out"
|
|
108
|
+
if [ "$installed" -eq 1 ]; then hard_fail=1; fi
|
|
109
|
+
else
|
|
110
|
+
case "$status" in
|
|
111
|
+
configured\ *)
|
|
112
|
+
local invocation
|
|
113
|
+
if ! invocation="$(python3 "$CONFIG_PY" doctor-invocation "$CONFIG" "$COMMAND")"; then
|
|
114
|
+
log " config: configured → '$COMMAND' but its exact invocation is unreadable"
|
|
115
|
+
[ "$installed" -eq 1 ] && hard_fail=1
|
|
116
|
+
elif command_boots "$invocation"; then
|
|
117
|
+
log " config: configured → '$COMMAND' (the exact configured invocation boots the entwurf MCP surface)"
|
|
118
|
+
else
|
|
119
|
+
log " config: configured → '$COMMAND' does NOT serve MCP with its configured args/env"
|
|
120
|
+
log " $BOOT_DETAIL"
|
|
121
|
+
[ "$installed" -eq 1 ] && hard_fail=1
|
|
122
|
+
fi ;;
|
|
123
|
+
symlink)
|
|
124
|
+
log " config: REFUSED symlink (someone else's SSOT)"
|
|
125
|
+
[ "$installed" -eq 1 ] && hard_fail=1 ;;
|
|
126
|
+
invalid-json|invalid-entry)
|
|
127
|
+
log " config: $status"
|
|
128
|
+
[ "$installed" -eq 1 ] && hard_fail=1 ;;
|
|
129
|
+
file-absent|not-ours)
|
|
130
|
+
if [ "$installed" -eq 1 ]; then
|
|
131
|
+
log " config: $status (owned state present — this is drift)"
|
|
132
|
+
hard_fail=1
|
|
133
|
+
else
|
|
134
|
+
log " config: $status (not ours; run install-copilot-mcp to adopt)"
|
|
135
|
+
fi ;;
|
|
136
|
+
*)
|
|
137
|
+
log " config: unexpected status '$out'"
|
|
138
|
+
[ "$installed" -eq 1 ] && hard_fail=1 ;;
|
|
139
|
+
esac
|
|
140
|
+
fi
|
|
141
|
+
if [ -f "$STATE_FILE" ]; then
|
|
142
|
+
local managed expected managed_status
|
|
143
|
+
expected="$(python3 -c 'import os,sys; print(os.path.abspath(sys.argv[1]))' "$CONFIG")"
|
|
144
|
+
if ! managed="$(state_target)"; then
|
|
145
|
+
log " state: CORRUPT — $STATE_FILE has no absolute managedConfigPath"
|
|
146
|
+
hard_fail=1
|
|
147
|
+
elif [ "$managed" != "$expected" ]; then
|
|
148
|
+
log " state: FOREIGN TARGET — state manages '$managed', Copilot reads '$expected'"
|
|
149
|
+
hard_fail=1
|
|
150
|
+
else
|
|
151
|
+
managed_status="$(run_config doctor-static "$managed" "$COMMAND" | tail -1)"
|
|
152
|
+
case "$managed_status" in
|
|
153
|
+
configured\ *) log " state: install-state present; managed config still configures entwurf-bridge." ;;
|
|
154
|
+
file-absent)
|
|
155
|
+
log " state: ORPHANED — managed config disappeared; removing stale state."
|
|
156
|
+
rm -f "$STATE_FILE" ;;
|
|
157
|
+
*)
|
|
158
|
+
log " state: DRIFT — managed config no longer satisfies the MCP contract ($managed_status)."
|
|
159
|
+
hard_fail=1 ;;
|
|
160
|
+
esac
|
|
161
|
+
fi
|
|
162
|
+
else
|
|
163
|
+
log " state: absent (no MCP ownership recorded)"
|
|
164
|
+
fi
|
|
165
|
+
|
|
166
|
+
if [ "$hard_fail" -ne 0 ]; then
|
|
167
|
+
fail "doctor found a broken Copilot MCP configuration."
|
|
168
|
+
fi
|
|
169
|
+
log "doctor: ok."
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
case "${1:-}" in
|
|
173
|
+
install) do_install ;;
|
|
174
|
+
uninstall) do_uninstall ;;
|
|
175
|
+
doctor) do_doctor ;;
|
|
176
|
+
*) echo "usage: copilot-mcp-bridge.sh <install|uninstall|doctor>" >&2; exit 2 ;;
|
|
177
|
+
esac
|