@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.
Files changed (87) hide show
  1. package/AGENTS.md +9 -5
  2. package/CHANGELOG.md +149 -0
  3. package/DELIVERY.md +72 -59
  4. package/README.md +120 -20
  5. package/VERIFY.md +10 -7
  6. package/demo/README.md +1 -1
  7. package/docs/acp-backend-rail.md +2 -2
  8. package/docs/external-mcp-host.md +26 -4
  9. package/docs/setup-clean-host.md +118 -18
  10. package/mcp/entwurf-bridge/dist/mcp/entwurf-bridge/src/index.js +8 -6
  11. package/mcp/entwurf-bridge/dist/pi-extensions/entwurf-capabilities.json +1 -0
  12. package/mcp/entwurf-bridge/dist/pi-extensions/lib/copilot-fresh-preflight.js +253 -0
  13. package/mcp/entwurf-bridge/dist/pi-extensions/lib/meta-sender-identity.js +10 -2
  14. package/mcp/entwurf-bridge/dist/pi-extensions/lib/meta-session.js +102 -28
  15. package/mcp/entwurf-bridge/dist/pi-extensions/lib/mux-fresh-call.js +77 -11
  16. package/mcp/entwurf-bridge/dist/pi-extensions/meta-bridge-hook-copilot.js +288 -0
  17. package/mcp/entwurf-bridge/src/index.ts +10 -6
  18. package/mcp/entwurf-bridge/tsconfig.build.json +2 -1
  19. package/package.json +22 -11
  20. package/pi/copilot-receive/entwurf-receive/extension.mjs +323 -0
  21. package/pi/entwurf-capabilities.json +1 -0
  22. package/pi/meta-bridge-copilot/.claude-plugin/marketplace.json +11 -0
  23. package/pi/meta-bridge-copilot/entwurf-meta-receive-copilot/.claude-plugin/plugin.json +5 -0
  24. package/pi/meta-bridge-copilot/entwurf-meta-receive-copilot/hooks/hooks.json +7 -0
  25. package/pi/meta-bridge-copilot/entwurf-meta-receive-copilot/scripts/copilot-hook-launch.sh +85 -0
  26. package/pi-extensions/entwurf-control.ts +17 -8
  27. package/pi-extensions/lib/copilot-fresh-preflight.ts +251 -0
  28. package/pi-extensions/lib/meta-sender-identity.ts +10 -2
  29. package/pi-extensions/lib/meta-session.ts +102 -29
  30. package/pi-extensions/lib/mux-fresh-call.ts +82 -11
  31. package/pi-extensions/meta-bridge-hook-copilot.ts +323 -0
  32. package/run.sh +1079 -186
  33. package/scripts/agy-bridge.sh +4 -2
  34. package/scripts/check-copilot-birth-hook.ts +1174 -0
  35. package/scripts/check-copilot-launch.ts +395 -0
  36. package/scripts/check-copilot-receive-arm.ts +734 -0
  37. package/scripts/check-copilot-statusline.ts +122 -0
  38. package/scripts/check-entwurf-capabilities.ts +25 -6
  39. package/scripts/check-fresh-cut-gate.sh +104 -27
  40. package/scripts/check-gate-qualification.ts +10 -1
  41. package/scripts/check-install-container.sh +2 -2
  42. package/scripts/check-install-surface.ts +1 -1
  43. package/scripts/check-meta-capability-source.ts +89 -5
  44. package/scripts/check-meta-doctor-oracle.sh +26 -0
  45. package/scripts/check-meta-manifest-schema.py +38 -0
  46. package/scripts/check-meta-receiver-marker.ts +25 -2
  47. package/scripts/check-meta-session.ts +32 -0
  48. package/scripts/check-setup-qualification.sh +126 -0
  49. package/scripts/copilot-bridge-doctor.sh +242 -0
  50. package/scripts/copilot-bridge-install.sh +384 -0
  51. package/scripts/copilot-bridge-oracle.sh +223 -0
  52. package/scripts/copilot-bridge-uninstall.sh +137 -0
  53. package/scripts/copilot-launch.sh +236 -0
  54. package/scripts/copilot-mcp-bridge.sh +177 -0
  55. package/scripts/copilot-mcp-config.py +221 -0
  56. package/scripts/copilot-receive-bridge.sh +483 -0
  57. package/scripts/copilot-statusline-bridge.sh +149 -0
  58. package/scripts/copilot-statusline-config.py +201 -0
  59. package/scripts/copilot-statusline.sh +68 -0
  60. package/scripts/dev-bin.sh +53 -9
  61. package/scripts/fake-copilot-vendor.sh +67 -0
  62. package/scripts/meta-bridge-hook-log.sh +17 -5
  63. package/scripts/mutants/capability-cache.json +64 -0
  64. package/scripts/mutants/copilot-birth.json +244 -0
  65. package/scripts/mutants/copilot-launch.json +187 -0
  66. package/scripts/mutants/copilot-receive.json +263 -0
  67. package/scripts/mutants/fresh-cut.json +17 -0
  68. package/scripts/mutants/mux-fresh-call.json +216 -2
  69. package/scripts/mutants/pack-install.json +17 -0
  70. package/scripts/mutants/pi-package-ownership.json +92 -0
  71. package/scripts/mutants/setup-verdict.json +148 -0
  72. package/scripts/mutants/source-install.json +32 -0
  73. package/scripts/pi_settings_io.py +22 -0
  74. package/scripts/raw-async-delivery/README.md +170 -125
  75. package/scripts/raw-async-delivery/copilot-enqueue-addressed.sh +35 -0
  76. package/scripts/raw-async-delivery/copilot-extension-receive/extension.mjs +123 -0
  77. package/scripts/register-pi-package.py +525 -7
  78. package/scripts/register-pi-provider.py +162 -12
  79. package/scripts/smoke-agy-install-state.sh +86 -30
  80. package/scripts/smoke-agy-statusline-state.sh +9 -6
  81. package/scripts/smoke-copilot-mcp-state.sh +153 -0
  82. package/scripts/smoke-copilot-statusline-state.sh +131 -0
  83. package/scripts/smoke-mux-fresh-call-live.ts +2 -0
  84. package/scripts/smoke-mux-lifecycle-live.ts +3 -1
  85. package/scripts/smoke-setup-verdict.sh +291 -0
  86. package/scripts/smoke-user-scope-citizen.sh +388 -1
  87. package/scripts/tsconfig.json +1 -0
@@ -0,0 +1,137 @@
1
+ #!/usr/bin/env bash
2
+ # copilot-bridge-uninstall.sh — the package-owned INVERSE of copilot-bridge-install.sh
3
+ # (#86 C3a). Removes exactly what the ownership state records this package installed —
4
+ # the qualified plugin, the local marketplace registration, the assembly — and nothing
5
+ # else. The stale Claude unit is INSTALL-ONLY territory; this inverse never touches it.
6
+ #
7
+ # ORDER AND AUTHORITY (amendment: the ownership preflight is COMPLETE before any vendor
8
+ # mutation). The state is required and validated fail-closed against this package's
9
+ # constants and the effective assembly path; both vendor lists are read next and a
10
+ # failing list is UNKNOWN (an unknown host is not an empty one — refuse); then EVERY
11
+ # ownership and safety fact — marketplace name/path/recorded ownership, assembly path
12
+ # safety, exact plugin row presence — is decided read-only. Only after all of it passes
13
+ # may the first vendor write run. So a refusal, any refusal, is zero vendor/filesystem
14
+ # writes with the state retained. Vendor steps run before filesystem steps, the state
15
+ # is deleted LAST, and any failure stops with the state retained so a rerun can finish
16
+ # the job. `--force` is forbidden: `marketplace remove --force` uninstalls that
17
+ # marketplace's plugins as a side effect (measured 2026-08-27).
18
+ set -euo pipefail
19
+
20
+ HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
21
+ MKT_NAME="meta-bridge-copilot-local"
22
+ PLUGIN="entwurf-meta-receive-copilot"
23
+ QUALIFIED="$PLUGIN@$MKT_NAME"
24
+ ASM="${ENTWURF_COPILOT_ASM:-${XDG_DATA_HOME:-$HOME/.local/share}/entwurf/meta-bridge-copilot/.assembled}"
25
+ STATE_DIR="${XDG_DATA_HOME:-$HOME/.local/share}/entwurf/copilot-bridge"
26
+ STATE_FILE="$STATE_DIR/install-state.json"
27
+
28
+ die() { echo "[copilot-bridge-uninstall] $*" >&2; exit 1; }
29
+
30
+ [ $# -eq 0 ] || die "unknown argument: $1 (this inverse takes no arguments; --force does not exist here)"
31
+ command -v copilot >/dev/null 2>&1 || die "the 'copilot' CLI is not on PATH."
32
+ command -v python3 >/dev/null 2>&1 || die "python3 is required and is not on PATH."
33
+
34
+ # shellcheck source=copilot-bridge-oracle.sh
35
+ . "$HERE/copilot-bridge-oracle.sh"
36
+
37
+ # --- 1. the ownership state is the sole removal authority --------------------
38
+ [ -L "$STATE_FILE" ] && die "ownership state $STATE_FILE is a symlink — refusing to trust it."
39
+ [ -f "$STATE_FILE" ] || die "no ownership state at $STATE_FILE — nothing this package provably owns to remove. A legacy no-state installation is adopted by './run.sh install-copilot-bridge' first, then removed."
40
+ STATE_FACTS="$(copilot_state_read "$STATE_FILE" "$QUALIFIED" "$MKT_NAME" "$ASM")" \
41
+ || die "ownership state $STATE_FILE is corrupt or names a different installation (see above) — refusing with zero vendor/filesystem writes."
42
+ OWNED_MKT="$(printf '%s' "$STATE_FACTS" | cut -d' ' -f1)"
43
+ OWNED_ASM="$(printf '%s' "$STATE_FACTS" | cut -d' ' -f2)"
44
+ STATE_PLUGIN_VERSION="$(printf '%s' "$STATE_FACTS" | cut -d' ' -f3)"
45
+
46
+ # --- 2. read-only vendor facts (a failing list is UNKNOWN, never absence) ----
47
+ if ! INSTALLED_LIST="$(copilot plugin list 2>/dev/null)"; then
48
+ die "'copilot plugin list' failed, so this host's installed plugins are UNKNOWN — refusing with zero writes. Fix the Copilot CLI error and re-run."
49
+ fi
50
+ if ! MKT_LIST="$(copilot plugin marketplace list 2>/dev/null)"; then
51
+ die "'copilot plugin marketplace list' failed, so this host's marketplaces are UNKNOWN — refusing with zero writes. Fix the Copilot CLI error and re-run."
52
+ fi
53
+
54
+ # --- 3. COMPLETE ownership/safety preflight, read-only, before ANY mutation --
55
+ # 3a. the marketplace half: exact name must sit at the recorded path AND be recorded
56
+ # as ours. Refusing HERE — before the plugin uninstall — is the amendment's point:
57
+ # the same qualified id could have been installed from a same-named marketplace at
58
+ # another path, and an inverse that had already uninstalled the plugin would have
59
+ # removed someone else's unit before discovering that.
60
+ MKT_PRESENT=0
61
+ # ONE marketplace-row grammar (C3a amendment, B defects 1+2): the shared oracle
62
+ # refuses malformed/non-Local rows and DUPLICATE same-named rows instead of silently
63
+ # acting on the first — an inverse steered at the wrong same-named marketplace would
64
+ # remove a registration it cannot attribute.
65
+ MKT_ROW="$(copilot_marketplace_local_path "$MKT_LIST" "$MKT_NAME")" \
66
+ || die "the 'copilot plugin marketplace list' rows for '$MKT_NAME' are malformed, non-Local, or duplicated (see above) — refusing with zero vendor/filesystem writes."
67
+ if [ "$MKT_ROW" != "absent" ]; then
68
+ MKT_PRESENT=1
69
+ MKT_PATH="${MKT_ROW#one }"
70
+ if [ "$MKT_PATH" != "$ASM" ]; then
71
+ die "a marketplace named '$MKT_NAME' is registered at '$MKT_PATH', not at the recorded assembly ($ASM). That registration is not provably ours — refusing with zero vendor/filesystem writes; inspect 'copilot plugin marketplace list' manually."
72
+ fi
73
+ if [ "$OWNED_MKT" != "true" ]; then
74
+ die "marketplace '$MKT_NAME' is registered at our recorded path, but the ownership state does not record marketplace ownership. Completing this inverse would leave a registered marketplace pointing at removed backing — refusing with zero vendor/filesystem writes (plugin, marketplace, assembly and state all preserved). Resolve the marketplace registration manually, or re-run './run.sh install-copilot-bridge' to re-bind ownership."
75
+ fi
76
+ fi
77
+ # 3b. assembly delete safety, decided BEFORE any vendor write: the recorded path must
78
+ # be a sane rm target or nothing at all may start.
79
+ if [ "$OWNED_ASM" = "true" ]; then
80
+ case "$ASM" in
81
+ /) die "recorded assembly path is '/' — refusing everything." ;;
82
+ /*) : ;;
83
+ *) die "recorded assembly path is not absolute: $ASM — refusing everything." ;;
84
+ esac
85
+ [ -L "$ASM" ] && die "recorded assembly path $ASM is a symlink — refusing everything; the inverse removes only the real recorded directory."
86
+ if [ -e "$ASM" ] && [ ! -d "$ASM" ]; then
87
+ die "recorded assembly path $ASM exists but is not a directory — refusing everything."
88
+ fi
89
+ fi
90
+ # 3c. the plugin half (final amendment): the exact QUALIFIED must be ABSENT (a previous
91
+ # run got that far — retry-safe) or present EXACTLY ONCE at the state's recorded
92
+ # version. A different version is `version-drift` — something other than the
93
+ # recorded install put it there, and this inverse must not remove what it cannot
94
+ # prove it installed. Malformed/multiple exact rows refuse too. Longer tokens that
95
+ # merely contain the qualified id remain foreign, same as everywhere.
96
+ PLUGIN_PRESENT=0
97
+ LISTED_ROW="$(copilot_exact_row_version "$INSTALLED_LIST" "$QUALIFIED")" \
98
+ || die "the 'copilot plugin list' rows for $QUALIFIED are malformed or ambiguous (see above) — refusing with zero vendor/filesystem writes."
99
+ case "$LISTED_ROW" in
100
+ absent) : ;;
101
+ "one $STATE_PLUGIN_VERSION") PLUGIN_PRESENT=1 ;;
102
+ one\ *)
103
+ die "version-drift: $QUALIFIED is listed at ${LISTED_ROW#one }, but the ownership state recorded v$STATE_PLUGIN_VERSION. This inverse removes only what it provably installed — refusing with zero vendor/filesystem writes (plugin, marketplace, assembly and state all preserved). Re-run './run.sh install-copilot-bridge' to re-bind ownership, then remove." ;;
104
+ *) die "unexpected exact-row verdict '$LISTED_ROW' — refusing with zero writes." ;;
105
+ esac
106
+
107
+ # --- 4. vendor mutation (licensed by the complete preflight above) -----------
108
+ if [ "$PLUGIN_PRESENT" -eq 1 ]; then
109
+ copilot plugin uninstall "$QUALIFIED" >/dev/null \
110
+ || die "'copilot plugin uninstall $QUALIFIED' failed — stopping with the state retained; re-run to finish."
111
+ echo "[copilot-bridge-uninstall] uninstalled $QUALIFIED"
112
+ else
113
+ echo "[copilot-bridge-uninstall] $QUALIFIED (v$STATE_PLUGIN_VERSION) is already absent from 'copilot plugin list' (a previous run got this far) — continuing"
114
+ fi
115
+ if [ "$MKT_PRESENT" -eq 1 ]; then
116
+ copilot plugin marketplace remove "$MKT_NAME" >/dev/null \
117
+ || die "'copilot plugin marketplace remove $MKT_NAME' failed (never retried with --force) — stopping with the state retained; re-run to finish."
118
+ echo "[copilot-bridge-uninstall] removed marketplace $MKT_NAME"
119
+ else
120
+ echo "[copilot-bridge-uninstall] marketplace $MKT_NAME is already absent (a previous run got this far) — continuing"
121
+ fi
122
+
123
+ # --- 5. the assembly: only the recorded directory, only when owned -----------
124
+ if [ "$OWNED_ASM" = "true" ]; then
125
+ if [ -d "$ASM" ]; then
126
+ rm -rf "$ASM"
127
+ echo "[copilot-bridge-uninstall] removed assembly $ASM"
128
+ else
129
+ echo "[copilot-bridge-uninstall] assembly already absent at $ASM (a previous run got this far) — continuing"
130
+ fi
131
+ else
132
+ echo "[copilot-bridge-uninstall] state does not record assembly ownership — preserving $ASM"
133
+ fi
134
+
135
+ # --- 6. the state, LAST ------------------------------------------------------
136
+ rm -f "$STATE_FILE"
137
+ echo "[copilot-bridge-uninstall] DONE — ownership state cleared ($STATE_FILE). The stale Claude unit, if any, was deliberately not touched (install-only territory)."
@@ -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