@junghanacs/entwurf 0.12.5 → 0.12.7

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 (93) hide show
  1. package/AGENTS.md +31 -17
  2. package/BASELINE.md +42 -8
  3. package/CHANGELOG.md +44 -0
  4. package/DELIVERY.md +71 -14
  5. package/README.md +77 -45
  6. package/VERIFY.md +33 -38
  7. package/docs/setup-clean-host.md +80 -22
  8. package/mcp/entwurf-bridge/dist/mcp/entwurf-bridge/src/index.js +90 -66
  9. package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/acp-client.js +54 -0
  10. package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/backend-adapter.js +153 -0
  11. package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/config.js +436 -0
  12. package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/context.js +157 -0
  13. package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/engraving.js +105 -0
  14. package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/models.js +90 -0
  15. package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/overlay.js +194 -0
  16. package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/tool-surface.js +153 -0
  17. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-deliverability.js +42 -9
  18. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-self-address.js +49 -13
  19. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-contract.js +104 -11
  20. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-decider.js +30 -1
  21. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-native-push.js +57 -0
  22. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-production.js +10 -0
  23. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-release.js +9 -0
  24. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-runner.js +21 -0
  25. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-send.js +5 -0
  26. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-surface.js +17 -0
  27. package/mcp/entwurf-bridge/dist/pi-extensions/lib/meta-sender-identity.js +125 -0
  28. package/mcp/entwurf-bridge/dist/pi-extensions/lib/native-push/adapter.js +158 -0
  29. package/mcp/entwurf-bridge/dist/pi-extensions/lib/native-push/register.js +61 -0
  30. package/mcp/entwurf-bridge/dist/scripts/agy-imprint.js +166 -0
  31. package/mcp/entwurf-bridge/dist/scripts/doctor-pi-provider.js +130 -0
  32. package/mcp/entwurf-bridge/dist/scripts/meta-bridge-prune.js +178 -0
  33. package/mcp/entwurf-bridge/dist/scripts/new-session-id.js +24 -0
  34. package/mcp/entwurf-bridge/src/index.ts +101 -67
  35. package/mcp/entwurf-bridge/test.sh +1 -1
  36. package/mcp/entwurf-bridge/tsconfig.build.json +23 -3
  37. package/package.json +10 -6
  38. package/pi-extensions/lib/entwurf-deliverability.ts +62 -9
  39. package/pi-extensions/lib/entwurf-self-address.ts +58 -15
  40. package/pi-extensions/lib/entwurf-v2-contract.ts +120 -12
  41. package/pi-extensions/lib/entwurf-v2-decider.ts +60 -0
  42. package/pi-extensions/lib/entwurf-v2-native-push.ts +86 -0
  43. package/pi-extensions/lib/entwurf-v2-production.ts +20 -0
  44. package/pi-extensions/lib/entwurf-v2-release.ts +9 -0
  45. package/pi-extensions/lib/entwurf-v2-runner.ts +29 -1
  46. package/pi-extensions/lib/entwurf-v2-send.ts +7 -0
  47. package/pi-extensions/lib/entwurf-v2-surface.ts +17 -0
  48. package/pi-extensions/lib/meta-sender-identity.ts +154 -0
  49. package/pi-extensions/lib/native-push/adapter.ts +255 -0
  50. package/pi-extensions/lib/native-push/register.ts +99 -0
  51. package/run.sh +974 -248
  52. package/scripts/agy-bridge-config.py +446 -0
  53. package/scripts/agy-bridge.sh +359 -0
  54. package/scripts/agy-hooks-bridge.sh +193 -0
  55. package/scripts/agy-hooks-config.py +257 -0
  56. package/scripts/agy-imprint.sh +28 -0
  57. package/scripts/agy-imprint.ts +193 -0
  58. package/scripts/agy-statusline-bridge.sh +176 -0
  59. package/scripts/agy-statusline-config.py +213 -0
  60. package/scripts/agy-statusline.sh +256 -0
  61. package/scripts/build-bridge.sh +20 -0
  62. package/scripts/check-agy-sender-identity.ts +364 -0
  63. package/scripts/check-entwurf-bridge-boot.ts +8 -2
  64. package/scripts/check-entwurf-deliverability.ts +34 -0
  65. package/scripts/check-entwurf-self-address.ts +78 -11
  66. package/scripts/check-entwurf-v2-contract.ts +136 -1
  67. package/scripts/check-entwurf-v2-decider.ts +95 -1
  68. package/scripts/check-entwurf-v2-matrix.ts +14 -3
  69. package/scripts/check-entwurf-v2-native-push.ts +193 -0
  70. package/scripts/check-entwurf-v2-production.ts +68 -1
  71. package/scripts/check-entwurf-v2-runner.ts +58 -0
  72. package/scripts/check-entwurf-v2-surface.ts +35 -0
  73. package/scripts/check-install-surface.ts +357 -0
  74. package/scripts/check-native-push-adapter.ts +319 -0
  75. package/scripts/check-native-push-register.ts +130 -0
  76. package/scripts/dev-bin.sh +195 -0
  77. package/scripts/doctor-pi-provider.ts +140 -0
  78. package/scripts/meta-bridge-doctor.sh +47 -9
  79. package/scripts/meta-bridge-install.sh +20 -17
  80. package/scripts/meta-bridge-state.py +40 -2
  81. package/scripts/meta-bridge-uninstall.sh +23 -12
  82. package/scripts/register-pi-package.py +183 -0
  83. package/scripts/register-pi-provider.py +287 -0
  84. package/scripts/smoke-agy-hooks-state.sh +172 -0
  85. package/scripts/smoke-agy-install-state.sh +660 -0
  86. package/scripts/smoke-agy-native-push-live.ts +243 -0
  87. package/scripts/smoke-agy-statusline-state.sh +300 -0
  88. package/scripts/smoke-meta-async-drift.sh +9 -2
  89. package/scripts/smoke-meta-install-state.sh +178 -8
  90. package/scripts/smoke-pi-provider-state.sh +182 -0
  91. package/scripts/smoke-user-scope-citizen.sh +191 -0
  92. package/scripts/with-dist-lock.sh +81 -0
  93. package/scripts/__pycache__/meta-bridge-state.cpython-312.pyc +0 -0
@@ -0,0 +1,660 @@
1
+ #!/usr/bin/env bash
2
+ # smoke-agy-install-state — regression gate for the agy MCP install adapter (봉인 8). Runs the
3
+ # install → doctor → uninstall lifecycle in an ISOLATED HOME + XDG_DATA_HOME with a fake stable
4
+ # bin + fake `pgrep`/`ss` (no real ~/.gemini, no real agy). Asserts:
5
+ # - adopt a regular file: entwurf-bridge registered, UNRELATED servers preserved, state written
6
+ # with the STABLE command (never a repo/git path).
7
+ # - doctor STATIC clean + LIVE SKIP with no agy; LIVE PASS with a fake agy present.
8
+ # - uninstall honest-inverse: unrelated servers survive, entwurf-bridge + state removed.
9
+ # - SYMLINK target → install REFUSES + writes NO state (someone else's SSOT).
10
+ # - DANGLING SYMLINK (departed owner) → install REFUSES the same (islink() is true even when
11
+ # the target is gone), writes NO state, NEVER follows the link to re-materialize the departed
12
+ # file, and leaves the link itself intact (it is a specimen, not ours to silently remove).
13
+ # Structurally reproduces the thinkpad ~/.gemini/*/mcp_config.json → removed agent-config path.
14
+ # - DANGLING command → doctor FAILS (the oracle lesson, structurally reproduced).
15
+ # - LEGACY MIGRATION: install targets the GLOBAL config (~/.gemini/config) and drops the stale
16
+ # entwurf-bridge entry from the LEGACY antigravity-cli root (preserve unrelated / remove-if-ours
17
+ # / never clobber a symlinked SSOT) — the "뭐가 글로벌인지" fix.
18
+ # - LEGACY CACHE PRUNE: install removes the orphaned agy MCP tool-schema cache for cut-over-FROM
19
+ # keys (pi-tools-bridge) — exact-name whitelist, live + unrelated caches preserved, symlink-safe.
20
+ # - CREATE-NEW → uninstall removes the file it created.
21
+ # - SETUP INTEGRATION (막힘 ①): the `wire_agy_bridge` wrapper folded into `./run.sh setup` —
22
+ # agy absent → honest skip + NO state; agy present + regular → idempotent install + state;
23
+ # agy present + symlink/corrupt → NON-FATAL WARN + continue (exit 0, reason-specific, no
24
+ # clobber, no state). Driven via the hidden `wire-agy-bridge` subcommand with AGY_BIN pinned.
25
+ # - DEV BIN (막힘 ②): the managed `entwurf-bridge` symlink dev-bin.sh exposes so the agy
26
+ # config's BARE command resolves in a dev checkout — ownership-checked link (REFUSE a
27
+ # foreign bin, never a blind ln -sf), state + honest inverse (remove only OUR link), and
28
+ # the NON-FATAL setup wrapper (foreign → WARN + continue). Isolated bin dir + fake target.
29
+ # - ⓪ discipline day-one: the checkout stays byte-identical (nothing written under $REPO).
30
+ # Offline + deterministic (deps: bash + python3).
31
+ set -euo pipefail
32
+
33
+ REPO_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
34
+ BRIDGE="$REPO_DIR/scripts/agy-bridge.sh"
35
+
36
+ pass=0
37
+ ok() { printf ' ok %s\n' "$1"; pass=$((pass + 1)); }
38
+ die() { printf 'FAIL: %s\n' "$1" >&2; exit 1; }
39
+ want() { eval "$2" && ok "$1" || die "$1"; }
40
+
41
+ # ── checkout purity baseline: the repo working tree must be identical afterward ──
42
+ REPO_BEFORE="$(cd "$REPO_DIR" && git status --porcelain)"
43
+
44
+ SB="$(mktemp -d)"
45
+ trap 'rm -rf "$SB"' EXIT
46
+ export HOME="$SB/home"
47
+ export XDG_DATA_HOME="$SB/xdg"
48
+ # GLOBAL = the install target (the file live agy actually reads: ~/.gemini/config/mcp_config.json).
49
+ # LEGACY = the stale antigravity-cli root install now CLEANS (agy does not read it as global).
50
+ GLOBAL="$HOME/.gemini/config/mcp_config.json"
51
+ LEGACY="$HOME/.gemini/antigravity-cli/mcp_config.json"
52
+ STATE="$XDG_DATA_HOME/entwurf/agy-bridge/install-state.json"
53
+ PSTATE="$XDG_DATA_HOME/entwurf/agy-bridge/permission-state.json"
54
+ mkdir -p "$(dirname "$GLOBAL")" "$(dirname "$LEGACY")" "$SB/bin"
55
+
56
+ # fake stable bin (on PATH) + fake ss (unused by the deterministic path) — fake agy toggled per case.
57
+ printf '#!/usr/bin/env bash\necho fake-entwurf-bridge\n' > "$SB/bin/entwurf-bridge"
58
+ printf '#!/usr/bin/env bash\nexit 0\n' > "$SB/bin/ss"
59
+ chmod +x "$SB/bin/entwurf-bridge" "$SB/bin/ss"
60
+ export PATH="$SB/bin:$PATH"
61
+ export AGY_MCP_CONFIG="$GLOBAL"
62
+ export AGY_MCP_CONFIG_ALT="$LEGACY"
63
+ # agy MCP tool-schema cache root (sandbox-isolated — HOME is already the sandbox, set explicitly so
64
+ # the legacy-cache prune can NEVER reach a real ~/.gemini during the smoke).
65
+ CACHE="$HOME/.gemini/antigravity-cli/mcp"
66
+ export AGY_MCP_CACHE_DIR="$CACHE"
67
+
68
+ fake_agy() { # install/remove a fake `pgrep` that reports (or not) a live agy
69
+ if [ "$1" = "on" ]; then
70
+ printf '#!/usr/bin/env bash\n[ "$2" = agy ] && { echo 4242; exit 0; }\nexit 1\n' > "$SB/bin/pgrep"
71
+ else
72
+ printf '#!/usr/bin/env bash\nexit 1\n' > "$SB/bin/pgrep"
73
+ fi
74
+ chmod +x "$SB/bin/pgrep"
75
+ }
76
+ fake_agy off
77
+
78
+ # ── A: adopt a regular file — merge + preserve unrelated + record state ───────
79
+ printf '{\n "mcpServers": { "other": { "command": "keepme" } }\n}\n' > "$GLOBAL"
80
+ bash "$BRIDGE" install >/dev/null
81
+ want "install: entwurf-bridge registered" "grep -q '\"entwurf-bridge\"' '$GLOBAL'"
82
+ want "install: unrelated server preserved" "grep -q '\"other\"' '$GLOBAL'"
83
+ want "install: state file written under XDG" "[ -f '$STATE' ]"
84
+ want "install: state records the STABLE command (not a repo/git path)" \
85
+ "python3 -c 'import json,sys; d=json.load(open(sys.argv[1])); print(d[\"command\"])' '$STATE' | grep -qx entwurf-bridge"
86
+ want "install: state preimage null (key was absent)" \
87
+ "python3 -c 'import json,sys; d=json.load(open(sys.argv[1])); sys.exit(0 if d[\"preimage\"] is None else 1)' '$STATE'"
88
+ want "install: managed config command is the stable bin, NOT a repo path" \
89
+ "! grep -q '$REPO_DIR' '$GLOBAL'"
90
+
91
+ # ── B: doctor — static clean, live SKIP (no agy) ──────────────────────────────
92
+ DOC_OUT="$(bash "$BRIDGE" doctor)"; DOC_RC=$?
93
+ want "doctor(no-agy): exits 0 (static clean)" "[ '$DOC_RC' -eq 0 ]"
94
+ want "doctor(no-agy): live tier is an honest SKIP" "printf '%s' \"\$DOC_OUT\" | grep -q 'live: SKIP'"
95
+ want "doctor(no-agy): SKIP is not disguised as a pass" "! printf '%s' \"\$DOC_OUT\" | grep -q 'consistent with runtime wiring'"
96
+ want "doctor(installed): state-evidence confirms the managed config still configured" \
97
+ "printf '%s' \"\$DOC_OUT\" | grep -q 'still configures entwurf-bridge'"
98
+
99
+ # ── C: doctor with a fake agy present → live is CONSISTENT (honest, not overclaimed) ──
100
+ fake_agy on
101
+ DOC_OUT="$(bash "$BRIDGE" doctor)"; DOC_RC=$?
102
+ want "doctor(agy-live): exits 0" "[ '$DOC_RC' -eq 0 ]"
103
+ want "doctor(agy-live): live tier says consistent-with-wiring (not SKIP)" \
104
+ "printf '%s' \"\$DOC_OUT\" | grep -q 'consistent with runtime wiring'"
105
+ want "doctor(agy-live): live tier does NOT overclaim config-read as proven (honest label)" \
106
+ "printf '%s' \"\$DOC_OUT\" | grep -q 'config-read NOT proven'"
107
+ fake_agy off
108
+
109
+ # ── C2 (N1 drift): install-state present but the managed config LOST our key → FAIL ──
110
+ # The real "wiring came loose / '?'" case — distinct from "never installed" (which is a note).
111
+ python3 -c 'import json,sys; p=sys.argv[1]; d=json.load(open(p)); d["mcpServers"].pop("entwurf-bridge",None); json.dump(d,open(p,"w"))' "$GLOBAL"
112
+ if bash "$BRIDGE" doctor >/dev/null 2>&1; then die "drift: doctor should FAIL (state present, key removed)"; fi
113
+ ok "drift: doctor FAILS on state-present + key-removed (installed-then-loosened ≠ never installed)"
114
+ bash "$BRIDGE" install >/dev/null # restore so the honest-inverse uninstall below has a key to remove
115
+ want "drift: re-install restores the key" "grep -q '\"entwurf-bridge\"' '$GLOBAL'"
116
+
117
+ # ── C3 (ORPHANED): install-state present but managed config is completely ABSENT → Auto-clean ──
118
+ rm -f "$GLOBAL"
119
+ DOC_OUT="$(bash "$BRIDGE" doctor 2>&1)"; DOC_RC=$?
120
+ want "orphan: doctor exits 0 when config is completely absent (HOME wiped)" "[ '$DOC_RC' -eq 0 ]"
121
+ want "orphan: doctor logs ORPHANED and auto-cleans" "printf '%s' \"\$DOC_OUT\" | grep -q 'ORPHANED'"
122
+ want "orphan: state file is removed automatically" "[ ! -f '$STATE' ]"
123
+ printf '{\n "mcpServers": { "other": { "command": "keepme" } }\n}\n' > "$GLOBAL"
124
+ bash "$BRIDGE" install >/dev/null # restore for the honest-inverse uninstall below
125
+
126
+ # ── C3b (FOREIGN TARGET): state describes a file this host does not read ──────────────
127
+ # The state is INTACT and the file it names is perfectly configured — it is just not OUR file.
128
+ # A doctor that only inspects the recorded path calls that green while owning nothing here:
129
+ # uninstall would not touch the live config, and the live config has no recorded provenance.
130
+ # This is not hypothetical. A verification run that isolated HOME but SHARED XDG_DATA_HOME wrote
131
+ # sandbox settings and REAL state, and every agy doctor reported green on a host it no longer
132
+ # owned. Isolation must move HOME and XDG_DATA_HOME together; this gate is what says so out loud.
133
+ FOREIGN_DIR="$SB/foreign"; mkdir -p "$FOREIGN_DIR"
134
+ FOREIGN_CFG="$FOREIGN_DIR/mcp_config.json"
135
+ cp "$GLOBAL" "$FOREIGN_CFG" # a VALID, fully-configured config — just not the one agy reads here
136
+ cp "$STATE" "$SB/state-before-foreign.json" # restored verbatim below: see the note at the end
137
+ python3 -c "
138
+ import json,sys
139
+ p=sys.argv[1]; d=json.load(open(p)); d['managedConfigPath']=sys.argv[2]
140
+ json.dump(d, open(p,'w'))" "$STATE" "$FOREIGN_CFG"
141
+ set +e; DOC_OUT="$(bash "$BRIDGE" doctor 2>&1)"; DOC_RC=$?; set -e
142
+ want "foreign-target: doctor FAILS when install-state manages a config this host does not read" "[ '$DOC_RC' -ne 0 ]"
143
+ want "foreign-target: the report names both paths (recorded vs live), not a generic drift" \
144
+ "printf '%s' \"\$DOC_OUT\" | grep -q 'FOREIGN TARGET' && printf '%s' \"\$DOC_OUT\" | grep -qF '$FOREIGN_CFG' && printf '%s' \"\$DOC_OUT\" | grep -qF '$GLOBAL'"
145
+ want "foreign-target: the foreign file is NOT auto-cleaned (it is someone else's, not ours to delete)" "[ -f '$FOREIGN_CFG' ]"
146
+ want "foreign-target: the state file is NOT auto-cleaned (a wrong target is a verdict, not stale state)" "[ -f '$STATE' ]"
147
+ # Restore the state VERBATIM rather than re-installing. A second install over an already-configured
148
+ # config would recapture OUR OWN entry as the preimage — the self-referential provenance trap this
149
+ # suite pins elsewhere — and the honest-inverse uninstall below would then have nothing to remove.
150
+ cp "$SB/state-before-foreign.json" "$STATE"
151
+ rm -f "$FOREIGN_CFG"
152
+
153
+ # State presence is not validity. A malformed body or missing target field must fail loud rather
154
+ # than becoming an empty string that silently skips the ownership check.
155
+ cp "$STATE" "$SB/state-before-corrupt.json"
156
+ printf 'not-json{{{' > "$STATE"
157
+ set +e; DOC_OUT="$(bash "$BRIDGE" doctor 2>&1)"; DOC_RC=$?; set -e
158
+ want "corrupt-state: doctor FAILS on unreadable install-state" "[ '$DOC_RC' -ne 0 ]"
159
+ want "corrupt-state: report names CORRUPT instead of silently skipping ownership" \
160
+ "printf '%s' \"\$DOC_OUT\" | grep -q 'state: CORRUPT'"
161
+
162
+ # A RELATIVE managed path is corrupt too: install only ever records an absolute path, and
163
+ # normalizing a relative one against the doctor's own cwd could bless whatever directory the
164
+ # doctor happens to run from.
165
+ python3 -c "
166
+ import json,sys
167
+ p=sys.argv[1]; d={'managedConfigPath':'.gemini/config/mcp_config.json'}
168
+ json.dump(d, open(p,'w'))" "$STATE"
169
+ set +e; DOC_OUT="$(bash "$BRIDGE" doctor 2>&1)"; DOC_RC=$?; set -e
170
+ want "corrupt-state: a relative managedConfigPath is CORRUPT, never resolved against the doctor's cwd" \
171
+ "[ '$DOC_RC' -ne 0 ] && printf '%s' \"\$DOC_OUT\" | grep -q 'state: CORRUPT'"
172
+ cp "$SB/state-before-corrupt.json" "$STATE"
173
+
174
+ # Permission state is an independent ownership rail. It must be checked even when the MCP config
175
+ # state is absent; nesting it under STATE_FILE would let a foreign permission target pass green.
176
+ cp "$PSTATE" "$SB/pstate-before-independent.json"
177
+ rm -f "$STATE"
178
+ python3 -c "
179
+ import json,sys
180
+ p=sys.argv[1]; d=json.load(open(p)); d['managedSettingsPath']=sys.argv[2]
181
+ json.dump(d, open(p,'w'))" "$PSTATE" "$SB/foreign-settings.json"
182
+ set +e; DOC_OUT="$(bash "$BRIDGE" doctor 2>&1)"; DOC_RC=$?; set -e
183
+ want "permission-foreign-target: doctor FAILS even when config install-state is absent" "[ '$DOC_RC' -ne 0 ]"
184
+ want "permission-foreign-target: report names the independent permission target" \
185
+ "printf '%s' \"\$DOC_OUT\" | grep -q 'FOREIGN TARGET (permission)'"
186
+ cp "$SB/state-before-corrupt.json" "$STATE"
187
+ cp "$SB/pstate-before-independent.json" "$PSTATE"
188
+
189
+ printf 'not-json{{{' > "$PSTATE"
190
+ set +e; DOC_OUT="$(bash "$BRIDGE" doctor 2>&1)"; DOC_RC=$?; set -e
191
+ want "permission-corrupt-state: doctor FAILS on unreadable permission-state" "[ '$DOC_RC' -ne 0 ]"
192
+ want "permission-corrupt-state: report names CORRUPT instead of silently skipping permission ownership" \
193
+ "printf '%s' \"\$DOC_OUT\" | grep -q 'CORRUPT (permission)'"
194
+ cp "$SB/pstate-before-independent.json" "$PSTATE"
195
+
196
+ # ── D: uninstall — honest inverse ─────────────────────────────────────────────
197
+ bash "$BRIDGE" uninstall >/dev/null
198
+ want "uninstall: entwurf-bridge removed" "! grep -q '\"entwurf-bridge\"' '$GLOBAL'"
199
+ want "uninstall: unrelated server survived" "grep -q '\"other\"' '$GLOBAL'"
200
+ want "uninstall: state file removed" "[ ! -f '$STATE' ]"
201
+
202
+ # ── E: SYMLINK target → install REFUSES + writes NO state ──────────────────────
203
+ rm -f "$GLOBAL"
204
+ printf '{"mcpServers":{}}\n' > "$SB/real_config.json"
205
+ ln -s "$SB/real_config.json" "$GLOBAL"
206
+ if bash "$BRIDGE" install >/dev/null 2>&1; then die "symlink: install should have REFUSED"; fi
207
+ ok "symlink: install refused (nonzero exit)"
208
+ want "symlink: NO state written on refusal" "[ ! -f '$STATE' ]"
209
+ want "symlink: the linked SSOT was NOT clobbered" "python3 -c 'import json,sys; d=json.load(open(sys.argv[1])); sys.exit(0 if d[\"mcpServers\"]=={} else 1)' '$SB/real_config.json'"
210
+ rm -f "$GLOBAL"
211
+
212
+ # ── E2: DANGLING SYMLINK (departed owner) → install REFUSES + NO state ─────────
213
+ # The thinkpad specimen: ~/.gemini/*/mcp_config.json is a symlink to a DEPARTED owner's path
214
+ # (agent-config removed the target), so the link dangles. os.path.islink() is true even with an
215
+ # absent target, so install must refuse it the SAME as a live symlink — write NO state, and (the
216
+ # real point) NEVER follow the link to re-materialize the departed owner's file. The link itself
217
+ # is left intact: it is a specimen the device-adoption step removes by hand, not ours to clobber.
218
+ rm -f "$GLOBAL"
219
+ DEPARTED="$SB/departed-owner/mcp_config.json" # target dir/file does NOT exist (departed owner)
220
+ ln -s "$DEPARTED" "$GLOBAL"
221
+ want "dangling-symlink: precondition — link is dangling (target absent)" \
222
+ "[ -L '$GLOBAL' ] && [ ! -e '$GLOBAL' ]"
223
+ set +e; OUT="$(bash "$BRIDGE" install 2>&1)"; RC=$?; set -e
224
+ want "dangling-symlink: install exits nonzero (refused)" "[ '$RC' -ne 0 ]"
225
+ want "dangling-symlink: refusal is the SYMLINK reason (not invalid-json / other)" \
226
+ "printf '%s' \"\$OUT\" | grep -qi 'refused (symlink)'"
227
+ want "dangling-symlink: NO state written on refusal" "[ ! -f '$STATE' ]"
228
+ want "dangling-symlink: link NOT followed — departed target still absent (no re-materialize)" \
229
+ "[ ! -e '$DEPARTED' ]"
230
+ want "dangling-symlink: the dangling link left intact (a specimen, not silently removed)" \
231
+ "[ -L '$GLOBAL' ]"
232
+ rm -f "$GLOBAL"
233
+
234
+ # ── F: DANGLING command → doctor FAILS ────────────────────────────────────────
235
+ printf '{"mcpServers":{"entwurf-bridge":{"command":"/nonexistent/dangling/start.sh"}}}\n' > "$LEGACY"
236
+ if bash "$BRIDGE" doctor >/dev/null 2>&1; then die "dangling: doctor should have FAILED"; fi
237
+ ok "dangling: doctor failed (nonzero exit) on a dangling command"
238
+ rm -f "$LEGACY"
239
+
240
+ # ── G: CREATE-NEW → uninstall removes the created file ────────────────────────
241
+ bash "$BRIDGE" install >/dev/null
242
+ want "create-new: file created" "[ -f '$GLOBAL' ]"
243
+ want "create-new: state detectMode is created-new" \
244
+ "python3 -c 'import json,sys; d=json.load(open(sys.argv[1])); print(d[\"detectMode\"])' '$STATE' | grep -qx created-new"
245
+ bash "$BRIDGE" uninstall >/dev/null
246
+ want "create-new: uninstall removed the file it created (empty)" "[ ! -f '$GLOBAL' ]"
247
+ want "create-new: state removed" "[ ! -f '$STATE' ]"
248
+
249
+ # ── H: uninstall with no state is idempotent (a note, not a failure) ──────────
250
+ bash "$BRIDGE" uninstall >/dev/null 2>&1
251
+ ok "idempotent: uninstall with no state exits 0 (nothing to undo)"
252
+
253
+ # ── H2: legacy migration — install targets GLOBAL and CLEANS the LEGACY root ──────────
254
+ # The "뭐가 글로벌인지" fix: install writes to the GLOBAL config (~/.gemini/config) and, as a
255
+ # one-way migration, drops the stale entwurf-bridge entry from the LEGACY root (~/.gemini/
256
+ # antigravity-cli) which live agy does NOT read as global MCP config. Preserves unrelated servers.
257
+ rm -f "$GLOBAL" "$LEGACY" "$STATE"
258
+ printf '{"mcpServers":{"entwurf-bridge":{"command":"old-wrong-bin"},"other":{"command":"keepme"}}}\n' > "$LEGACY"
259
+ bash "$BRIDGE" install >/dev/null
260
+ want "legacy-migrate: entwurf-bridge registered in the GLOBAL config" "grep -q '\"entwurf-bridge\"' '$GLOBAL'"
261
+ want "legacy-migrate: stale entwurf-bridge removed from the LEGACY root" \
262
+ "! python3 -c 'import json,sys; d=json.load(open(sys.argv[1])); sys.exit(0 if \"entwurf-bridge\" in d[\"mcpServers\"] else 1)' '$LEGACY'"
263
+ want "legacy-migrate: unrelated LEGACY server preserved" "grep -q '\"other\"' '$LEGACY'"
264
+ want "legacy-migrate: state managedConfigPath is the GLOBAL config" \
265
+ "python3 -c 'import json,sys; sys.exit(0 if json.load(open(sys.argv[1]))[\"managedConfigPath\"]==sys.argv[2] else 1)' '$STATE' '$GLOBAL'"
266
+ bash "$BRIDGE" uninstall >/dev/null; rm -f "$GLOBAL" "$LEGACY" "$STATE"
267
+
268
+ # H2b: a LEGACY root holding ONLY entwurf-bridge → install removes the whole file (cleaned-removed)
269
+ printf '{"mcpServers":{"entwurf-bridge":{"command":"old-wrong-bin"}}}\n' > "$LEGACY"
270
+ bash "$BRIDGE" install >/dev/null
271
+ want "legacy-migrate(only-ours): LEGACY file removed when it held only entwurf-bridge" "[ ! -e '$LEGACY' ]"
272
+ bash "$BRIDGE" uninstall >/dev/null; rm -f "$GLOBAL" "$STATE"
273
+
274
+ # H2c: a SYMLINK LEGACY (someone else's SSOT) → install still succeeds, link left intact (not clobbered)
275
+ printf '{"mcpServers":{"entwurf-bridge":{"command":"x"}}}\n' > "$SB/legacy_ssot.json"
276
+ ln -s "$SB/legacy_ssot.json" "$LEGACY"
277
+ bash "$BRIDGE" install >/dev/null
278
+ want "legacy-migrate(symlink): install still registered entwurf-bridge in GLOBAL" "grep -q '\"entwurf-bridge\"' '$GLOBAL'"
279
+ want "legacy-migrate(symlink): symlinked LEGACY SSOT left intact (not clobbered)" \
280
+ "python3 -c 'import json,sys; d=json.load(open(sys.argv[1])); sys.exit(0 if \"entwurf-bridge\" in d[\"mcpServers\"] else 1)' '$SB/legacy_ssot.json'"
281
+ bash "$BRIDGE" uninstall >/dev/null; rm -f "$GLOBAL" "$LEGACY" "$STATE"
282
+
283
+ # ── H3: legacy CACHE prune — install removes ONLY the known-legacy MCP cache, never others ─────
284
+ # The pi-tools-bridge → entwurf-bridge cutover leaves an orphaned agy tool-schema cache dir agy
285
+ # never prunes. install removes it (exact-name whitelist), preserves the live entwurf-bridge cache
286
+ # AND any unrelated server's cache, and never follows a symlink.
287
+ rm -f "$GLOBAL" "$LEGACY" "$STATE"
288
+ mkdir -p "$CACHE/pi-tools-bridge" "$CACHE/entwurf-bridge" "$CACHE/some-other-mcp"
289
+ printf '{}' > "$CACHE/pi-tools-bridge/entwurf.json"
290
+ printf '{}' > "$CACHE/some-other-mcp/tool.json"
291
+ bash "$BRIDGE" install >/dev/null
292
+ want "cache-prune: stale pi-tools-bridge cache removed" "[ ! -e '$CACHE/pi-tools-bridge' ]"
293
+ want "cache-prune: live entwurf-bridge cache preserved" "[ -d '$CACHE/entwurf-bridge' ]"
294
+ want "cache-prune: unrelated MCP server cache preserved (whitelist, not scan-delete)" "[ -d '$CACHE/some-other-mcp' ]"
295
+ bash "$BRIDGE" uninstall >/dev/null; rm -f "$GLOBAL" "$STATE"; rm -rf "$CACHE/entwurf-bridge" "$CACHE/some-other-mcp"
296
+
297
+ # H3b: a SYMLINK legacy cache dir → install leaves it intact (not ours to remove)
298
+ rm -f "$GLOBAL" "$STATE"
299
+ mkdir -p "$SB/foreign-cache"
300
+ ln -s "$SB/foreign-cache" "$CACHE/pi-tools-bridge"
301
+ bash "$BRIDGE" install >/dev/null
302
+ want "cache-prune(symlink): symlinked legacy cache left intact (not clobbered)" \
303
+ "[ -L '$CACHE/pi-tools-bridge' ] && [ -d '$SB/foreign-cache' ]"
304
+ bash "$BRIDGE" uninstall >/dev/null; rm -f "$CACHE/pi-tools-bridge" "$GLOBAL" "$STATE"
305
+
306
+ # ── I: setup integration — wire_agy_bridge (막힘 ①: detection-gated, NON-FATAL) ─────
307
+ # The setup wrapper folded into `./run.sh setup`. Driven here via the hidden `wire-agy-bridge`
308
+ # subcommand with AGY_BIN pinned (a fake agy / a nonexistent path) so detection is hermetic
309
+ # regardless of the CI/dev host's real agy. Locks: agy absent → honest skip + NO state; agy
310
+ # present + regular → idempotent install + state; agy present + symlink/corrupt → NON-FATAL
311
+ # WARN + continue (exit 0 — an optional harness must never brick a pi/Claude setup), reason-
312
+ # specific, no clobber, no state. Clean slate here (H left no config/state).
313
+ rm -f "$GLOBAL" "$LEGACY" "$STATE"
314
+ printf '#!/usr/bin/env bash\necho fake-agy\n' > "$SB/bin/agy"; chmod +x "$SB/bin/agy"
315
+
316
+ # I-1: agy ABSENT → honest skip, no state, exit 0 (AGY_BIN → a nonexistent path)
317
+ set +e; OUT="$(AGY_BIN="$SB/no-such-agy" bash "$REPO_DIR/run.sh" wire-agy-bridge 2>&1)"; RC=$?; set -e
318
+ want "wire(no-agy): exits 0 (non-fatal skip)" "[ '$RC' -eq 0 ]"
319
+ want "wire(no-agy): honest skip message" "printf '%s' \"\$OUT\" | grep -q 'skipping agy bridge wiring'"
320
+ want "wire(no-agy): NO state written" "[ ! -f '$STATE' ]"
321
+ want "wire(no-agy): NO config created" "[ ! -e '$GLOBAL' ]"
322
+
323
+ # I-2: agy PRESENT + regular config → idempotent install + state, exit 0
324
+ printf '{\n "mcpServers": { "other": { "command": "keepme" } }\n}\n' > "$GLOBAL"
325
+ set +e; OUT="$(AGY_BIN="$SB/bin/agy" bash "$REPO_DIR/run.sh" wire-agy-bridge 2>&1)"; RC=$?; set -e
326
+ want "wire(agy+regular): exits 0" "[ '$RC' -eq 0 ]"
327
+ want "wire(agy+regular): entwurf-bridge registered" "grep -q '\"entwurf-bridge\"' '$GLOBAL'"
328
+ want "wire(agy+regular): unrelated server preserved" "grep -q '\"other\"' '$GLOBAL'"
329
+ want "wire(agy+regular): state written" "[ -f '$STATE' ]"
330
+ set +e; OUT="$(AGY_BIN="$SB/bin/agy" bash "$REPO_DIR/run.sh" wire-agy-bridge 2>&1)"; RC=$?; set -e
331
+ want "wire(agy+regular, re-run): idempotent exit 0" "[ '$RC' -eq 0 ]"
332
+ want "wire(agy+regular, re-run): config still valid + entwurf-bridge present" \
333
+ "python3 -c 'import json,sys; d=json.load(open(sys.argv[1])); sys.exit(0 if \"entwurf-bridge\" in d[\"mcpServers\"] else 1)' '$GLOBAL'"
334
+ want "wire(agy+regular, re-run): unrelated server still preserved" "grep -q '\"other\"' '$GLOBAL'"
335
+ bash "$BRIDGE" uninstall >/dev/null; rm -f "$GLOBAL"
336
+
337
+ # I-3: agy PRESENT + SYMLINK config → NON-FATAL WARN + continue (exit 0), no clobber, no state
338
+ printf '{"mcpServers":{}}\n' > "$SB/real_wire_cfg.json"
339
+ ln -s "$SB/real_wire_cfg.json" "$GLOBAL"
340
+ set +e; OUT="$(AGY_BIN="$SB/bin/agy" bash "$REPO_DIR/run.sh" wire-agy-bridge 2>&1)"; RC=$?; set -e
341
+ want "wire(agy+symlink): exits 0 (NON-FATAL — setup not bricked)" "[ '$RC' -eq 0 ]"
342
+ want "wire(agy+symlink): reason-specific WARN names the symlink/SSOT" \
343
+ "printf '%s' \"\$OUT\" | grep -qi 'symlink'"
344
+ want "wire(agy+symlink): linked SSOT NOT clobbered" \
345
+ "python3 -c 'import json,sys; d=json.load(open(sys.argv[1])); sys.exit(0 if d[\"mcpServers\"]=={} else 1)' '$SB/real_wire_cfg.json'"
346
+ want "wire(agy+symlink): NO state written" "[ ! -f '$STATE' ]"
347
+ rm -f "$GLOBAL"
348
+
349
+ # I-4: agy PRESENT + CORRUPT config (invalid JSON) → NON-FATAL WARN + continue, corrupt-specific
350
+ printf 'this is not json{{{' > "$GLOBAL"
351
+ set +e; OUT="$(AGY_BIN="$SB/bin/agy" bash "$REPO_DIR/run.sh" wire-agy-bridge 2>&1)"; RC=$?; set -e
352
+ want "wire(agy+corrupt): exits 0 (NON-FATAL)" "[ '$RC' -eq 0 ]"
353
+ want "wire(agy+corrupt): reason-specific WARN flags invalid JSON (not a silent skip)" \
354
+ "printf '%s' \"\$OUT\" | grep -qi 'invalid JSON'"
355
+ want "wire(agy+corrupt): NO state written" "[ ! -f '$STATE' ]"
356
+ rm -f "$GLOBAL" "$SB/bin/agy"
357
+
358
+ # ── J: dev bin exposure — dev-bin.sh (막힘 ②: managed stable-bin symlinks) ─────────
359
+ # dev-bin.sh now manages MULTIPLE bins (entwurf-bridge + entwurf-agy-statusline), each with its
360
+ # OWN <name>.install-state.json. J drives the entwurf-bridge bin by NAME so these locks stay
361
+ # byte-for-byte the pre-multi-bin regression (무회귀 판정): ownership-checked link (REFUSE
362
+ # foreign, never a blind ln -sf), state + honest inverse, remove only OUR link, NON-FATAL setup
363
+ # wrapper on a foreign bin. J-5 adds the new legacy-state migration. Isolated: a sandbox bin dir
364
+ # + fake executable targets + the sandbox XDG state.
365
+ DEVBIN="$REPO_DIR/scripts/dev-bin.sh"
366
+ DBIN_DIR="$SB/devbin"
367
+ DLINK="$DBIN_DIR/entwurf-bridge"
368
+ DSTATE="$XDG_DATA_HOME/entwurf/dev-bin/entwurf-bridge.install-state.json" # bin-scoped state
369
+ printf '#!/usr/bin/env bash\necho fake-bridge\n' > "$SB/fake-start.sh"; chmod +x "$SB/fake-start.sh"
370
+ printf '#!/usr/bin/env bash\necho fake-status\n' > "$SB/fake-status.sh"; chmod +x "$SB/fake-status.sh"
371
+ export ENTWURF_DEV_BIN_DIR="$DBIN_DIR"
372
+ export ENTWURF_BRIDGE_TARGET="$SB/fake-start.sh"
373
+ export ENTWURF_AGY_STATUSLINE_TARGET="$SB/fake-status.sh" # for the no-arg setup wrapper (two bins)
374
+
375
+ # J-1: expose the entwurf-bridge bin BY NAME → creates the managed symlink + state (created-new)
376
+ bash "$DEVBIN" expose entwurf-bridge >/dev/null 2>&1
377
+ want "dev-bin expose: symlink created" "[ -L '$DLINK' ]"
378
+ want "dev-bin expose: symlink points at our target" "[ \"\$(readlink '$DLINK')\" = '$SB/fake-start.sh' ]"
379
+ want "dev-bin expose: bin-scoped state written under XDG" "[ -f '$DSTATE' ]"
380
+ want "dev-bin expose: state records our linkPath" \
381
+ "python3 -c 'import json,sys; d=json.load(open(sys.argv[1])); sys.exit(0 if d[\"linkPath\"]==sys.argv[2] else 1)' '$DSTATE' '$DLINK'"
382
+ want "dev-bin expose: detectMode created-new" \
383
+ "python3 -c 'import json,sys; print(json.load(open(sys.argv[1]))[\"detectMode\"])' '$DSTATE' | grep -qx created-new"
384
+
385
+ # J-2: re-expose → idempotent refresh (still our link, detectMode refresh-ours)
386
+ bash "$DEVBIN" expose entwurf-bridge >/dev/null 2>&1
387
+ want "dev-bin re-expose: idempotent — still our symlink" "[ \"\$(readlink '$DLINK')\" = '$SB/fake-start.sh' ]"
388
+ want "dev-bin re-expose: detectMode now refresh-ours" \
389
+ "python3 -c 'import json,sys; print(json.load(open(sys.argv[1]))[\"detectMode\"])' '$DSTATE' | grep -qx refresh-ours"
390
+
391
+ # J-2b (GPT R): state present but our link REPLACED by a foreign symlink → expose must REFUSE.
392
+ # linkPath-match alone must NOT authorize a clobber — the swapped-in symlink points elsewhere
393
+ # (readlink != state.target), so it is someone else's, not a moved-checkout relink of ours.
394
+ rm -f "$DLINK"; ln -s "$SB/foreign-target" "$DLINK" # foreign symlink at our recorded path (dangling ok)
395
+ if bash "$DEVBIN" expose entwurf-bridge >/dev/null 2>&1; then die "dev-bin: expose should REFUSE a foreign symlink swapped in at our recorded path"; fi
396
+ ok "dev-bin expose: refused a foreign symlink at our recorded path (readlink != state.target)"
397
+ want "dev-bin expose: foreign symlink NOT clobbered" "[ \"\$(readlink '$DLINK')\" = '$SB/foreign-target' ]"
398
+ rm -f "$DLINK"
399
+ bash "$DEVBIN" expose entwurf-bridge >/dev/null 2>&1 # restore our link + state for the sub-cases below
400
+
401
+ # J-3: FOREIGN bin already at the link path → REFUSE (exit 3), no clobber, no state
402
+ bash "$DEVBIN" remove entwurf-bridge >/dev/null 2>&1 # clear our link + state first
403
+ printf 'FOREIGN NPM BIN\n' > "$DLINK" # someone else's regular-file bin
404
+ if bash "$DEVBIN" expose entwurf-bridge >/dev/null 2>&1; then die "dev-bin: expose should REFUSE a foreign bin"; fi
405
+ ok "dev-bin expose: refused a foreign bin (nonzero exit)"
406
+ want "dev-bin expose: foreign bin NOT clobbered" "[ \"\$(cat '$DLINK')\" = 'FOREIGN NPM BIN' ]"
407
+ want "dev-bin expose: no state written on foreign refuse" "[ ! -f '$DSTATE' ]"
408
+ # the NON-FATAL setup wrapper (no-arg → all bins) turns that refuse into a WARN + continue. The
409
+ # foreign entwurf-bridge is the FIRST managed bin, so the wrapper stops there (statusline unreached).
410
+ set +e; OUT="$(bash "$REPO_DIR/run.sh" expose-dev-bin 2>&1)"; RC=$?; set -e
411
+ want "dev-bin wrapper(foreign): setup wrapper exits 0 (NON-FATAL)" "[ '$RC' -eq 0 ]"
412
+ want "dev-bin wrapper(foreign): WARNs about a foreign bin (not ours)" "printf '%s' \"\$OUT\" | grep -qi 'not ours'"
413
+ rm -f "$DLINK"
414
+
415
+ # J-4: remove is honest-inverse — removes ONLY our link, refuses a link that became foreign
416
+ bash "$DEVBIN" expose entwurf-bridge >/dev/null 2>&1
417
+ bash "$DEVBIN" remove entwurf-bridge >/dev/null 2>&1
418
+ want "dev-bin remove: our link removed" "[ ! -e '$DLINK' ]"
419
+ want "dev-bin remove: state removed" "[ ! -f '$DSTATE' ]"
420
+ bash "$DEVBIN" remove entwurf-bridge >/dev/null 2>&1
421
+ ok "dev-bin remove: idempotent with no state (exit 0)"
422
+ bash "$DEVBIN" expose entwurf-bridge >/dev/null 2>&1
423
+ rm -f "$DLINK"; printf 'FOREIGN\n' > "$DLINK" # our link replaced by a foreign regular file
424
+ if bash "$DEVBIN" remove entwurf-bridge >/dev/null 2>&1; then die "dev-bin: remove should REFUSE a now-foreign link"; fi
425
+ ok "dev-bin remove: refused removing a now-foreign link"
426
+ want "dev-bin remove: foreign file left intact" "[ \"\$(cat '$DLINK')\" = 'FOREIGN' ]"
427
+ rm -f "$DLINK" "$DSTATE"
428
+
429
+ # J-5 (multi-bin migration, 페블 A): a pre-multi-bin single `install-state.json` is ADOPTED as
430
+ # `entwurf-bridge.install-state.json` (content-checked: linkPath basename == entwurf-bridge), old
431
+ # name dropped (new first, then old — atomic rename); corrupt/foreign legacy → refuse (never guess).
432
+ bash "$DEVBIN" remove entwurf-bridge >/dev/null 2>&1 # clean slate
433
+ LEGACY="$XDG_DATA_HOME/entwurf/dev-bin/install-state.json"
434
+ mkdir -p "$(dirname "$LEGACY")"
435
+ printf '{"schemaVersion":1,"linkPath":"%s","target":"%s","detectMode":"created-new","stampedAt":"x"}\n' "$DLINK" "$SB/fake-start.sh" > "$LEGACY"
436
+ bash "$DEVBIN" expose entwurf-bridge >/dev/null 2>&1
437
+ want "dev-bin migrate: legacy install-state.json adopted as entwurf-bridge.install-state.json" "[ -f '$DSTATE' ]"
438
+ want "dev-bin migrate: legacy name gone (new first, old dropped)" "[ ! -f '$LEGACY' ]"
439
+ want "dev-bin migrate: adopted state preserves linkPath" \
440
+ "python3 -c 'import json,sys; sys.exit(0 if json.load(open(sys.argv[1]))[\"linkPath\"]==sys.argv[2] else 1)' '$DSTATE' '$DLINK'"
441
+ bash "$DEVBIN" remove entwurf-bridge >/dev/null 2>&1
442
+ mkdir -p "$(dirname "$LEGACY")" # remove may have rmdir'd the empty state dir
443
+ printf 'not json{{{' > "$LEGACY" # corrupt legacy → refuse
444
+ if bash "$DEVBIN" expose entwurf-bridge >/dev/null 2>&1; then die "dev-bin migrate: corrupt legacy should REFUSE"; fi
445
+ ok "dev-bin migrate: corrupt legacy refused (nonzero, no guess)"
446
+ rm -f "$LEGACY" "$DLINK" "$DSTATE"
447
+ mkdir -p "$(dirname "$LEGACY")"
448
+ printf '{"linkPath":"%s/something-else","target":"x"}\n' "$DBIN_DIR" > "$LEGACY" # foreign basename → refuse
449
+ if bash "$DEVBIN" expose entwurf-bridge >/dev/null 2>&1; then die "dev-bin migrate: foreign legacy should REFUSE"; fi
450
+ ok "dev-bin migrate: foreign legacy refused (linkPath basename != entwurf-bridge)"
451
+ rm -f "$LEGACY" "$DLINK" "$DSTATE"
452
+ unset ENTWURF_DEV_BIN_DIR ENTWURF_BRIDGE_TARGET ENTWURF_AGY_STATUSLINE_TARGET
453
+
454
+ # ── K: permission grant — the OTHER half of a usable bridge ───────────────────
455
+ # Registering the server only makes the tool REACHABLE. agy defaults every `mcp` action to Ask, so
456
+ # without an allow rule every entwurf_v2 call stops for a y/n — a registered-but-ungranted bridge is
457
+ # half-installed. We grant exactly ONE string in `permissions.allow`; the operator's own rules are
458
+ # preserved, never managed (granting ourselves command(*) would be their trust decision, not ours).
459
+ SETTINGS="$HOME/.gemini/antigravity-cli/settings.json"
460
+ SLSTATE="$XDG_DATA_HOME/entwurf/agy-statusline/install-state.json"
461
+ STATUSLINE="$REPO_DIR/scripts/agy-statusline-bridge.sh"
462
+ RULE='mcp(entwurf-bridge/entwurf_v2)'
463
+ has_rule() { python3 -c "
464
+ import json,sys
465
+ d=json.load(open('$1'))
466
+ sys.exit(0 if '$RULE' in (d.get('permissions') or {}).get('allow', []) else 1)"; }
467
+
468
+ rm -f "$SETTINGS" "$PSTATE" "$STATE" "$GLOBAL"
469
+ bash "$BRIDGE" install >/dev/null 2>&1
470
+ want "permission: install grants the rule into permissions.allow" "has_rule '$SETTINGS'"
471
+
472
+ # IDEMPOTENCY, and not only of the file: a re-install must not rewrite PROVENANCE either. Re-reading
473
+ # the rule we ourselves wrote as "the operator already had it" would strand it forever (the inverse
474
+ # would decline to remove it). Installers are re-run on every upgrade, so this path is the norm.
475
+ bash "$BRIDGE" install >/dev/null 2>&1
476
+ bash "$BRIDGE" install >/dev/null 2>&1
477
+ want "permission: re-install is idempotent (rule appears exactly once)" \
478
+ "[ \"\$(python3 -c \"import json;print(json.load(open('$SETTINGS'))['permissions']['allow'].count('$RULE'))\")\" = 1 ]"
479
+ want "permission: re-install does NOT re-attribute OUR rule to the operator (provenance is sticky)" \
480
+ "[ \"\$(python3 -c \"import json;print(json.load(open('$PSTATE'))['ruleExistedBefore'])\")\" = False ]"
481
+
482
+ # Honest inverse after those re-installs: WE created the file and both containers, so nothing of
483
+ # ours may survive. (Before the provenance fix, install×2 → uninstall left the rule behind.)
484
+ bash "$BRIDGE" uninstall >/dev/null 2>&1
485
+ want "permission: uninstall after re-installs still removes everything we created" "[ ! -e '$SETTINGS' ]"
486
+ want "permission: uninstall clears the permission state" "[ ! -e '$PSTATE' ]"
487
+
488
+ # The operator's file: unrelated keys and their OWN rules survive us, coming and going.
489
+ printf '{"model":"x","permissions":{"allow":["command(*)"],"deny":["read_file(/etc)"]}}\n' > "$SETTINGS"
490
+ bash "$BRIDGE" install >/dev/null 2>&1
491
+ want "permission: install preserves the operator's own allow/deny rules" \
492
+ "python3 -c \"import json,sys;p=json.load(open('$SETTINGS'))['permissions'];sys.exit(0 if 'command(*)' in p['allow'] and p['deny']==['read_file(/etc)'] else 1)\""
493
+ want "permission: install preserves unrelated settings keys" \
494
+ "[ \"\$(python3 -c \"import json;print(json.load(open('$SETTINGS')).get('model'))\")\" = x ]"
495
+ bash "$BRIDGE" uninstall >/dev/null 2>&1
496
+ want "permission: uninstall takes back ONLY our rule, leaving the operator's structure intact" \
497
+ "python3 -c \"import json,sys;p=json.load(open('$SETTINGS'))['permissions'];sys.exit(0 if p['allow']==['command(*)'] and p['deny']==['read_file(/etc)'] else 1)\""
498
+
499
+ # The rule was ALREADY the operator's before we ever installed → it was never ours to take away.
500
+ printf '{"permissions":{"allow":["%s"]}}\n' "$RULE" > "$SETTINGS"
501
+ bash "$BRIDGE" install >/dev/null 2>&1
502
+ want "permission: an operator's pre-existing rule is recorded as theirs (ruleExistedBefore)" \
503
+ "[ \"\$(python3 -c \"import json;print(json.load(open('$PSTATE'))['ruleExistedBefore'])\")\" = True ]"
504
+ bash "$BRIDGE" uninstall >/dev/null 2>&1
505
+ want "permission: uninstall does NOT revoke a rule the operator already had" "has_rule '$SETTINGS'"
506
+
507
+ # ── K2: TWO adapters, ONE file — element ownership is what keeps them apart ────
508
+ # The statusline adapter owns the `statusLine` subtree of this same settings.json; we own one string
509
+ # in `permissions.allow`. Neither may restore a whole-file preimage, or uninstalling one would
510
+ # silently revert the other. Both orders, both inverses.
511
+ rm -f "$SETTINGS" "$PSTATE" "$SLSTATE"
512
+ bash "$STATUSLINE" install >/dev/null 2>&1
513
+ bash "$BRIDGE" install >/dev/null 2>&1
514
+ want "two-adapters: bridge's grant does not disturb the statusline subtree" \
515
+ "python3 -c \"import json,sys;d=json.load(open('$SETTINGS'));sys.exit(0 if d['statusLine']['command']=='entwurf-agy-statusline' else 1)\""
516
+ want "two-adapters: statusline install did not block the grant" "has_rule '$SETTINGS'"
517
+ bash "$BRIDGE" uninstall >/dev/null 2>&1
518
+ want "two-adapters: uninstalling the bridge leaves the statusline intact (no whole-file preimage)" \
519
+ "python3 -c \"import json,sys;d=json.load(open('$SETTINGS'));sys.exit(0 if d['statusLine']['command']=='entwurf-agy-statusline' else 1)\""
520
+ want "two-adapters: uninstalling the bridge removed OUR rule" "! has_rule '$SETTINGS'"
521
+
522
+ # Reverse order: bridge first, statusline second, uninstall the statusline.
523
+ rm -f "$SETTINGS" "$PSTATE" "$SLSTATE"
524
+ bash "$BRIDGE" install >/dev/null 2>&1
525
+ bash "$STATUSLINE" install >/dev/null 2>&1
526
+ bash "$STATUSLINE" uninstall >/dev/null 2>&1
527
+ want "two-adapters(reverse): uninstalling the statusline leaves OUR grant intact" "has_rule '$SETTINGS'"
528
+ want "two-adapters(reverse): the statusline key is gone" \
529
+ "! python3 -c \"import json,sys;sys.exit(0 if 'statusLine' in json.load(open('$SETTINGS')) else 1)\""
530
+
531
+ # ── K3: doctor evidence — "why does agy ask me every time?" is never a mystery ──
532
+ # A registered server with no grant is a HALF-installed bridge: it works, but stops for a y/n on
533
+ # every call. Naming that in a message while exiting 0 would be the same lie in a friendlier voice,
534
+ # so the doctor must FAIL on it — for an INSTALLED host. On a host that never installed the bridge
535
+ # there is nothing to grant, and that stays a note.
536
+ rm -f "$SETTINGS" "$PSTATE" "$SLSTATE"
537
+ bash "$BRIDGE" install >/dev/null 2>&1
538
+ python3 -c "import json;d=json.load(open('$SETTINGS'));d['permissions'].pop('allow',None);json.dump(d,open('$SETTINGS','w'))"
539
+ DOC_OUT="$(bash "$BRIDGE" doctor 2>&1 || true)"
540
+ want "permission: doctor names the missing grant (not a silent pass)" \
541
+ "printf '%s' \"\$DOC_OUT\" | grep -q 'DRIFT'"
542
+ if bash "$BRIDGE" doctor >/dev/null 2>&1; then die "grant-drift: doctor should FAIL (installed bridge, grant gone)"; fi
543
+ ok "permission: doctor EXITS NONZERO on an installed-but-ungranted bridge (a half-install is not green)"
544
+
545
+ # THE SUBTLE ONE: agy evaluates Deny > Ask > Allow, so an operator rule like mcp(*) in their ask
546
+ # list silently OVERRIDES our allow — agy prompts again while our install-state still looks green.
547
+ printf '{"permissions":{"allow":["%s"],"ask":["mcp(*)"]}}\n' "$RULE" > "$SETTINGS"
548
+ if bash "$BRIDGE" doctor >/dev/null 2>&1; then die "shadow: doctor should FAIL when ask/deny overrides our allow"; fi
549
+ ok "permission: doctor FAILS when a higher-precedence ask/deny rule shadows our allow"
550
+ DOC_OUT="$(bash "$BRIDGE" doctor 2>&1 || true)"
551
+ want "permission: the shadow report names the offending list and rule" \
552
+ "printf '%s' \"\$DOC_OUT\" | grep -q 'SHADOWED'"
553
+
554
+ # THE MIRROR OF IT: the same rules that shadow our allow from ask/deny COVER it from allow. An
555
+ # operator who granted a broad mcp(*) has already made entwurf_v2 callable — reporting that host as
556
+ # "NOT granted, agy prompts on EVERY call" is a false red about a surface that works. It is still
557
+ # THEIR rule, not ours, so it is a NOTE (exit 0 + named owner), never a silent pass and never DRIFT.
558
+ # The never-installed shape: no permission-state claims ownership here (the owned-drift mirror of
559
+ # THIS case — state says we added the rule, rule gone, wildcard covering — is pinned red below).
560
+ rm -f "$STATE" "$PSTATE"
561
+ for broad in 'mcp(*)' 'mcp(entwurf-bridge)'; do
562
+ printf '{"permissions":{"allow":["%s"]}}\n' "$broad" > "$SETTINGS"
563
+ DOC_OUT="$(bash "$BRIDGE" doctor 2>&1 || true)"
564
+ if ! bash "$BRIDGE" doctor >/dev/null 2>&1; then
565
+ die "covered: doctor should NOT fail when the operator's '$broad' already grants our tool"
566
+ fi
567
+ ok "permission: an operator's broad '$broad' in allow is not reported as drift (no false red)"
568
+ want "permission: the note names '$broad' as the covering rule, and it is NOT called granted-by-us" \
569
+ "printf '%s' \"\$DOC_OUT\" | grep -q 'NOTE' && printf '%s' \"\$DOC_OUT\" | grep -qF '$broad' && ! printf '%s' \"\$DOC_OUT\" | grep -q 'DRIFT'"
570
+ # …and precedence still wins: the same broad rule in deny must override its own allow.
571
+ printf '{"permissions":{"allow":["%s"],"deny":["%s"]}}\n' "$broad" "$broad" > "$SETTINGS"
572
+ if bash "$BRIDGE" doctor >/dev/null 2>&1; then
573
+ die "covered: a deny of '$broad' must still shadow, even though the same rule sits in allow"
574
+ fi
575
+ ok "permission: '$broad' in deny still shadows its own allow (Deny > Allow, not order-of-lists)"
576
+ done
577
+
578
+ # OWNERSHIP BEATS COVERAGE (hard rule 12): our state says WE added the exact rule, the rule is
579
+ # gone, and an operator wildcard keeps calls working. Runtime works — but the grant entwurf owns
580
+ # (and repairs) vanished, so the verdict stays red with both axes named. This is the exact shape a
581
+ # whole-file settings relink (agent-config ensure_link) produces: statusline drifts loudly, and the
582
+ # permission half must not be the silent one.
583
+ printf '{"permissions":{"allow":[]}}\n' > "$SETTINGS"
584
+ bash "$BRIDGE" install >/dev/null 2>&1 # state records ruleExistedBefore=false — the rule is OURS
585
+ python3 -c "import json;d=json.load(open('$SETTINGS'));d['permissions']['allow']=['mcp(*)'];json.dump(d,open('$SETTINGS','w'))"
586
+ set +e; DOC_OUT="$(bash "$BRIDGE" doctor 2>&1)"; DOC_RC=$?; set -e
587
+ want "permission: an operator wildcard does NOT mask drift of the rule WE installed (doctor fails)" "[ '$DOC_RC' -ne 0 ]"
588
+ want "permission: the owned-drift report names both axes (our grant gone, their rule covering)" \
589
+ "printf '%s' \"\$DOC_OUT\" | grep -q 'DRIFT' && printf '%s' \"\$DOC_OUT\" | grep -qF 'mcp(*)'"
590
+
591
+ # …but an operator's OWN pre-existing rule vanishing is not our drift: ruleExistedBefore=true means
592
+ # the rule was never ours to lose. Their file, their edit; the wildcard covering it stays a NOTE.
593
+ rm -f "$STATE" "$PSTATE"
594
+ printf '{"permissions":{"allow":["%s"]}}\n' "$RULE" > "$SETTINGS"
595
+ bash "$BRIDGE" install >/dev/null 2>&1 # rule pre-existed → recorded as theirs
596
+ python3 -c "import json;d=json.load(open('$SETTINGS'));d['permissions']['allow']=['mcp(*)'];json.dump(d,open('$SETTINGS','w'))"
597
+ if ! bash "$BRIDGE" doctor >/dev/null 2>&1; then
598
+ die "covered: losing the OPERATOR's own pre-existing rule must not be reported as OUR drift"
599
+ fi
600
+ ok "permission: an operator's own vanished rule under a covering wildcard stays green (not our element)"
601
+
602
+ # ── K4: a grant we cannot write FAILS the explicit install (setup degrades it, not us) ──
603
+ # A symlinked settings.json is someone else's SSOT (an agent-config link) — never clobber it. But
604
+ # refusing to write is not the same as succeeding: the explicit installer must exit nonzero, because
605
+ # it registered a server it could not make callable. Tolerance belongs one level up, in setup, where
606
+ # agy is optional and must not brick a pi/Claude host.
607
+ rm -f "$SETTINGS" "$PSTATE" "$STATE" "$GLOBAL"
608
+ printf '{"permissions":{"allow":[]}}\n' > "$SB/foreign-settings.json"
609
+ ln -s "$SB/foreign-settings.json" "$SETTINGS"
610
+ if bash "$BRIDGE" install >/dev/null 2>&1; then die "grant-symlink: explicit install should FAIL when it cannot grant"; fi
611
+ ok "permission: explicit install EXITS NONZERO when the grant cannot be written (no half-install reported as success)"
612
+ want "permission: a symlinked settings.json is left untouched (someone else's SSOT)" \
613
+ "! has_rule '$SB/foreign-settings.json'"
614
+ want "permission: no permission state is written for a refused symlink" "[ ! -e '$PSTATE' ]"
615
+
616
+ # …and the SAME failure, seen from setup: NON-FATAL, reason-specific, never silent.
617
+ # Detection must be hermetic here exactly as it is in section I: re-mint the fake agy (I tore it
618
+ # down at its end) and pin AGY_BIN at it. Unpinned, the wrapper falls back to the host's PATH —
619
+ # it finds a real agy on a dev box and degrades honestly, but on a CI runner with no agy it takes
620
+ # the skip branch and exits 0 with no WARN. The exit-0 assertion below passes either way; only the
621
+ # NOT-GRANTED one can tell a degrade from a skip, which is the whole point of this pair.
622
+ printf '#!/usr/bin/env bash\necho fake-agy\n' > "$SB/bin/agy"; chmod +x "$SB/bin/agy"
623
+ set +e; OUT="$(AGY_BIN="$SB/bin/agy" bash "$REPO_DIR/run.sh" wire-agy-bridge 2>&1)"; RC=$?; set -e
624
+ want "permission(setup): the wrapper keeps setup alive (exit 0) despite the failed grant" "[ '$RC' -eq 0 ]"
625
+ want "permission(setup): the wrapper says the bridge is REGISTERED but NOT GRANTED (no silent pass)" \
626
+ "printf '%s' \"\$OUT\" | grep -q 'NOT GRANTED'"
627
+ rm -f "$SETTINGS" "$PSTATE" "$STATE" "$GLOBAL" "$SB/bin/agy"
628
+
629
+ # Corrupt settings.json: same contract — the explicit install fails loud, and says what to repair.
630
+ printf 'not json\n' > "$SETTINGS"
631
+ if bash "$BRIDGE" install >/dev/null 2>&1; then die "grant-corrupt: explicit install should FAIL on an unparseable settings.json"; fi
632
+ ok "permission: explicit install EXITS NONZERO on a corrupt settings.json (repair it, do not guess)"
633
+ rm -f "$SETTINGS" "$PSTATE" "$STATE" "$GLOBAL"
634
+
635
+ # ── K5: a revoke we cannot perform is a FAILED inverse, not a footnote ────────
636
+ # Install cleanly, then have the settings file become someone else's SSOT (a symlink) before the
637
+ # uninstall. We refuse to write through it — correct — but "uninstalled" over a rule still sitting
638
+ # in the operator's settings would be a lie in the one direction that must never lie. It fails, the
639
+ # foreign file is untouched, and the permission-state SURVIVES so a retry (or a hand-repair) still
640
+ # knows what we owe.
641
+ rm -f "$SETTINGS" "$PSTATE" "$STATE" "$GLOBAL"
642
+ bash "$BRIDGE" install >/dev/null 2>&1
643
+ want "inverse-symlink: precondition — the grant is in place before we break the path" "has_rule '$SETTINGS'"
644
+ printf '{"permissions":{"allow":["command(*)"]}}\n' > "$SB/foreign-uninstall.json"
645
+ rm -f "$SETTINGS"
646
+ ln -s "$SB/foreign-uninstall.json" "$SETTINGS"
647
+ if bash "$BRIDGE" uninstall >/dev/null 2>&1; then die "inverse-symlink: uninstall should FAIL when it cannot revoke the grant"; fi
648
+ ok "permission: uninstall EXITS NONZERO when the grant cannot be revoked (honest inverse, not a WARN)"
649
+ want "inverse-symlink: the foreign settings file is untouched (never written through a symlink)" \
650
+ "python3 -c \"import json,sys;p=json.load(open('$SB/foreign-uninstall.json'))['permissions'];sys.exit(0 if p['allow']==['command(*)'] else 1)\""
651
+ want "inverse-symlink: the permission state SURVIVES the failure (a retry still knows what we owe)" \
652
+ "[ -e '$PSTATE' ]"
653
+ rm -f "$SETTINGS" "$PSTATE" "$STATE" "$GLOBAL"
654
+
655
+ # ── ⓪ checkout purity: the working tree is byte-identical (nothing under $REPO) ─
656
+ REPO_AFTER="$(cd "$REPO_DIR" && git status --porcelain)"
657
+ want "purity: checkout unchanged (0 impurity — all writes stayed in the sandbox HOME+XDG)" \
658
+ "[ \"\$REPO_BEFORE\" = \"\$REPO_AFTER\" ]"
659
+
660
+ printf '\nsmoke-agy-install-state: %d checks passed\n' "$pass"