@junghanacs/entwurf 0.12.6 → 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.
- package/AGENTS.md +31 -17
- package/BASELINE.md +42 -8
- package/CHANGELOG.md +22 -0
- package/DELIVERY.md +71 -14
- package/README.md +77 -45
- package/VERIFY.md +18 -13
- package/docs/setup-clean-host.md +80 -22
- package/mcp/entwurf-bridge/dist/mcp/entwurf-bridge/src/index.js +90 -66
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/acp-client.js +54 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/backend-adapter.js +153 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/config.js +436 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/context.js +157 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/engraving.js +105 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/models.js +90 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/overlay.js +194 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/tool-surface.js +153 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-deliverability.js +42 -9
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-self-address.js +49 -13
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-contract.js +104 -11
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-decider.js +30 -1
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-native-push.js +57 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-production.js +10 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-release.js +9 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-runner.js +21 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-send.js +5 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-surface.js +17 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/meta-sender-identity.js +125 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/native-push/adapter.js +158 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/native-push/register.js +61 -0
- package/mcp/entwurf-bridge/dist/scripts/agy-imprint.js +166 -0
- package/mcp/entwurf-bridge/dist/scripts/doctor-pi-provider.js +130 -0
- package/mcp/entwurf-bridge/dist/scripts/meta-bridge-prune.js +178 -0
- package/mcp/entwurf-bridge/dist/scripts/new-session-id.js +24 -0
- package/mcp/entwurf-bridge/src/index.ts +101 -67
- package/mcp/entwurf-bridge/test.sh +1 -1
- package/mcp/entwurf-bridge/tsconfig.build.json +23 -3
- package/package.json +10 -5
- package/pi-extensions/lib/entwurf-deliverability.ts +62 -9
- package/pi-extensions/lib/entwurf-self-address.ts +58 -15
- package/pi-extensions/lib/entwurf-v2-contract.ts +120 -12
- package/pi-extensions/lib/entwurf-v2-decider.ts +60 -0
- package/pi-extensions/lib/entwurf-v2-native-push.ts +86 -0
- package/pi-extensions/lib/entwurf-v2-production.ts +20 -0
- package/pi-extensions/lib/entwurf-v2-release.ts +9 -0
- package/pi-extensions/lib/entwurf-v2-runner.ts +29 -1
- package/pi-extensions/lib/entwurf-v2-send.ts +7 -0
- package/pi-extensions/lib/entwurf-v2-surface.ts +17 -0
- package/pi-extensions/lib/meta-sender-identity.ts +154 -0
- package/pi-extensions/lib/native-push/adapter.ts +255 -0
- package/pi-extensions/lib/native-push/register.ts +99 -0
- package/run.sh +756 -214
- package/scripts/agy-bridge-config.py +446 -0
- package/scripts/agy-bridge.sh +359 -0
- package/scripts/agy-hooks-bridge.sh +193 -0
- package/scripts/agy-hooks-config.py +257 -0
- package/scripts/agy-imprint.sh +28 -0
- package/scripts/agy-imprint.ts +193 -0
- package/scripts/agy-statusline-bridge.sh +176 -0
- package/scripts/agy-statusline-config.py +213 -0
- package/scripts/agy-statusline.sh +256 -0
- package/scripts/build-bridge.sh +20 -0
- package/scripts/check-agy-sender-identity.ts +364 -0
- package/scripts/check-entwurf-bridge-boot.ts +8 -2
- package/scripts/check-entwurf-deliverability.ts +34 -0
- package/scripts/check-entwurf-self-address.ts +78 -11
- package/scripts/check-entwurf-v2-contract.ts +136 -1
- package/scripts/check-entwurf-v2-decider.ts +95 -1
- package/scripts/check-entwurf-v2-matrix.ts +14 -3
- package/scripts/check-entwurf-v2-native-push.ts +193 -0
- package/scripts/check-entwurf-v2-production.ts +68 -1
- package/scripts/check-entwurf-v2-runner.ts +58 -0
- package/scripts/check-entwurf-v2-surface.ts +35 -0
- package/scripts/check-install-surface.ts +357 -0
- package/scripts/check-native-push-adapter.ts +319 -0
- package/scripts/check-native-push-register.ts +130 -0
- package/scripts/dev-bin.sh +195 -0
- package/scripts/doctor-pi-provider.ts +140 -0
- package/scripts/meta-bridge-doctor.sh +36 -2
- package/scripts/register-pi-package.py +37 -3
- package/scripts/register-pi-provider.py +287 -0
- package/scripts/smoke-agy-hooks-state.sh +172 -0
- package/scripts/smoke-agy-install-state.sh +660 -0
- package/scripts/smoke-agy-native-push-live.ts +243 -0
- package/scripts/smoke-agy-statusline-state.sh +300 -0
- package/scripts/smoke-meta-async-drift.sh +9 -2
- package/scripts/smoke-meta-install-state.sh +20 -0
- package/scripts/smoke-pi-provider-state.sh +182 -0
- package/scripts/smoke-user-scope-citizen.sh +62 -0
- package/scripts/with-dist-lock.sh +81 -0
- package/scripts/__pycache__/meta-bridge-state.cpython-312.pyc +0 -0
- package/scripts/__pycache__/meta-bridge-state.cpython-313.pyc +0 -0
- package/scripts/__pycache__/register-pi-package.cpython-313.pyc +0 -0
|
@@ -0,0 +1,446 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""agy-bridge-config — the JSON state operations for the agy (Antigravity) MCP install
|
|
3
|
+
adapter (봉인 7). SEPARATE from the Claude marketplace install (no generalization); only
|
|
4
|
+
the reporting/runner is shared, in agy-bridge.sh. stdlib only.
|
|
5
|
+
|
|
6
|
+
Subcommands (argv[1]):
|
|
7
|
+
install <config_path> <command> <state_path>
|
|
8
|
+
Adopt an agy mcp_config.json and register ONE server entry (serverKey
|
|
9
|
+
"entwurf-bridge") pointing at <command> (a STABLE bin — never a repo/git hash path).
|
|
10
|
+
REFUSES a symlink target (exit 3 — it is someone else's SSOT, e.g. an agent-config
|
|
11
|
+
link; clobbering it would write into that repo). A regular file is adopted (the prior
|
|
12
|
+
value of the key is captured as the preimage for an honest inverse); an absent file is
|
|
13
|
+
created. Invalid JSON fails loud (exit 4). Writes install-state atomically to
|
|
14
|
+
<state_path> (checkout-outside XDG path — the caller resolves it).
|
|
15
|
+
|
|
16
|
+
uninstall <state_path>
|
|
17
|
+
Honest inverse from install-state: restore the captured preimage (remove the key if it
|
|
18
|
+
was absent, else set it back). If WE created the whole file and it now carries nothing
|
|
19
|
+
else, remove the file. REFUSES if the managed config became a symlink since install
|
|
20
|
+
(exit 3). No state → nothing to do (exit 2).
|
|
21
|
+
|
|
22
|
+
clean-legacy <config_path>
|
|
23
|
+
One-way MIGRATION cleanup: remove OUR server key from a LEGACY (wrong-root) config that
|
|
24
|
+
live agy does NOT read as the global MCP config (the doc-correct global is
|
|
25
|
+
~/.gemini/config/mcp_config.json; the antigravity-cli copy was a mis-wiring). Idempotent.
|
|
26
|
+
Preserves unrelated servers. Removes the file only if it becomes an empty mcpServers-only
|
|
27
|
+
object. REFUSES to touch a symlink (someone else's SSOT) — reports skip, never clobbers.
|
|
28
|
+
This is NOT tracked for an honest inverse: the legacy entry was wrong and stays gone.
|
|
29
|
+
|
|
30
|
+
doctor-static <config_path>
|
|
31
|
+
Print one line describing the candidate for the shell doctor: `absent` / `symlink ->
|
|
32
|
+
<target>` prefix / `invalid-json` / `not-configured` / `command <cmd>`. Never mutates.
|
|
33
|
+
|
|
34
|
+
permission-install <settings_path> <state_path>
|
|
35
|
+
The OTHER half of "agy can call our bridge": registering the server (above) makes the tool
|
|
36
|
+
reachable, but agy's permission engine defaults every `mcp` action to Ask, so every single
|
|
37
|
+
entwurf_v2 call stops for a y/n. This adds our allow rule to agy's settings.json.
|
|
38
|
+
|
|
39
|
+
Ownership is ELEMENT-level, not subtree: we own exactly the string
|
|
40
|
+
`mcp(entwurf-bridge/entwurf_v2)` inside `permissions.allow`. The operator's own rules
|
|
41
|
+
(command(*), their ask/deny lists, everything else) are ours to preserve, never to manage —
|
|
42
|
+
granting ourselves broad permissions would be a trust decision that is not the installer's
|
|
43
|
+
to make. Same discipline as the single mcpServers key above. Idempotent; refuses a symlink.
|
|
44
|
+
|
|
45
|
+
permission-uninstall <state_path>
|
|
46
|
+
Honest inverse: remove OUR rule only if WE added it (an operator who already had the rule
|
|
47
|
+
keeps it), then drop the `allow`/`permissions` containers only if we created them and they
|
|
48
|
+
are now empty. Removes the settings file only if we created it and nothing else remains.
|
|
49
|
+
|
|
50
|
+
permission-doctor <settings_path>
|
|
51
|
+
One token line: `absent` / `invalid-json` / `not-configured` / `configured` /
|
|
52
|
+
`shadowed-by-<list> <rule>`. The shadow check exists because agy evaluates
|
|
53
|
+
Deny > Ask > Allow: an operator rule like `mcp(*)` in their ask list SILENTLY defeats our
|
|
54
|
+
allow, and agy starts prompting again with our install still green. That would be a
|
|
55
|
+
debugging hole ("why is it asking me every time?"), so the doctor names it instead.
|
|
56
|
+
|
|
57
|
+
Exit codes: 0 ok · 2 no-state · 3 refuse-symlink · 4 invalid-json · 5 usage.
|
|
58
|
+
"""
|
|
59
|
+
|
|
60
|
+
import json
|
|
61
|
+
import os
|
|
62
|
+
import sys
|
|
63
|
+
|
|
64
|
+
SERVER_KEY = "entwurf-bridge"
|
|
65
|
+
STATE_SCHEMA_VERSION = 1
|
|
66
|
+
|
|
67
|
+
# The ONE permission rule we own. Scoped to a single tool on our own server — not mcp(*), not the
|
|
68
|
+
# server-wide mcp(entwurf-bridge): an installer grants itself the narrowest rule that makes the
|
|
69
|
+
# thing it installed work, and nothing more. INSTALL still writes only this.
|
|
70
|
+
ALLOW_RULE = f"mcp({SERVER_KEY}/entwurf_v2)"
|
|
71
|
+
# Rules that MATCH our tool: the exact grant, the server-wide rule, and the action wildcard.
|
|
72
|
+
# Membership here is a statement about agy's matcher, not about which list the rule sits in — the
|
|
73
|
+
# same three rules cover entwurf_v2 wherever they appear. From a higher-precedence list they
|
|
74
|
+
# override our allow (shadowing); from `allow` itself they already grant it (covering).
|
|
75
|
+
#
|
|
76
|
+
# The doctor used to read them ONE WAY ONLY — shadowing — and demanded a literal ALLOW_RULE string
|
|
77
|
+
# in `allow`. So a host whose operator had granted a broad `mcp(*)` (their trust decision, not
|
|
78
|
+
# ours) was reported as "NOT granted, agy prompts on EVERY entwurf_v2 call": a false red about a
|
|
79
|
+
# surface that in fact works. Reading the same coverage in both directions is the fix; treating
|
|
80
|
+
# mcp(*) as matching in deny/ask but not in allow was never defensible.
|
|
81
|
+
MATCHING_RULES = (ALLOW_RULE, f"mcp({SERVER_KEY})", "mcp(*)")
|
|
82
|
+
SHADOWING_RULES = MATCHING_RULES # same set, higher-precedence lists
|
|
83
|
+
# Deny > Ask > Allow (agy permissions engine).
|
|
84
|
+
SHADOWING_LISTS = ("deny", "ask")
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _die(code: int, msg: str) -> "None":
|
|
88
|
+
sys.stderr.write(msg.rstrip("\n") + "\n")
|
|
89
|
+
sys.exit(code)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _atomic_write(path: str, text: str) -> None:
|
|
93
|
+
os.makedirs(os.path.dirname(os.path.abspath(path)), exist_ok=True)
|
|
94
|
+
tmp = f"{path}.tmp-{os.getpid()}"
|
|
95
|
+
with open(tmp, "w", encoding="utf-8") as fh:
|
|
96
|
+
fh.write(text)
|
|
97
|
+
os.replace(tmp, path)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _load_config(path: str) -> dict:
|
|
101
|
+
with open(path, "r", encoding="utf-8") as fh:
|
|
102
|
+
raw = fh.read()
|
|
103
|
+
if raw.strip() == "":
|
|
104
|
+
return {}
|
|
105
|
+
try:
|
|
106
|
+
data = json.loads(raw)
|
|
107
|
+
except json.JSONDecodeError as err:
|
|
108
|
+
_die(4, f"agy-bridge: {path} is not valid JSON: {err}")
|
|
109
|
+
if not isinstance(data, dict):
|
|
110
|
+
_die(4, f"agy-bridge: {path} top-level must be a JSON object")
|
|
111
|
+
return data
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _dump(data: dict) -> str:
|
|
115
|
+
return json.dumps(data, indent=2) + "\n"
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _prior_state(state_path: str, managed_key: str, managed_path: str) -> dict:
|
|
119
|
+
"""The state of OUR FIRST install of this exact target, if we are re-installing over it.
|
|
120
|
+
|
|
121
|
+
An installer is re-run routinely (every package upgrade). Re-capturing the preimage on each run
|
|
122
|
+
would capture OUR OWN previous write as "what was there before" — and then uninstall faithfully
|
|
123
|
+
restores us, leaving the very entry it was supposed to take away. Provenance must be recorded
|
|
124
|
+
once, at the first install, and carried forward unchanged: preimage, and whether the file
|
|
125
|
+
existed before us, are facts about a moment that has already passed.
|
|
126
|
+
"""
|
|
127
|
+
if not os.path.exists(state_path):
|
|
128
|
+
return {}
|
|
129
|
+
try:
|
|
130
|
+
prior = _load_config(state_path)
|
|
131
|
+
except SystemExit:
|
|
132
|
+
raise
|
|
133
|
+
if prior.get(managed_key) != os.path.abspath(managed_path):
|
|
134
|
+
return {} # re-targeted somewhere else — this is a fresh install for that path
|
|
135
|
+
return prior
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def cmd_install(config_path: str, command: str, state_path: str) -> None:
|
|
139
|
+
# REFUSE a symlink — it is someone else's SSOT (an agent-config link). Never clobber.
|
|
140
|
+
if os.path.islink(config_path):
|
|
141
|
+
target = os.readlink(config_path)
|
|
142
|
+
_die(3, f"agy-bridge: refusing to adopt {config_path} — it is a symlink to {target} (someone else's SSOT). "
|
|
143
|
+
f"Manage it there, or replace it with a regular file, then retry.")
|
|
144
|
+
|
|
145
|
+
config_existed = os.path.exists(config_path)
|
|
146
|
+
if config_existed:
|
|
147
|
+
data = _load_config(config_path)
|
|
148
|
+
detect_mode = "adopt-regular-file"
|
|
149
|
+
else:
|
|
150
|
+
data = {}
|
|
151
|
+
detect_mode = "created-new"
|
|
152
|
+
|
|
153
|
+
servers = data.get("mcpServers")
|
|
154
|
+
if servers is None:
|
|
155
|
+
servers = {}
|
|
156
|
+
data["mcpServers"] = servers
|
|
157
|
+
if not isinstance(servers, dict):
|
|
158
|
+
_die(4, f"agy-bridge: {config_path} mcpServers must be a JSON object")
|
|
159
|
+
|
|
160
|
+
# Capture the preimage of OUR key (None = the key was absent) for the honest inverse — but ONLY
|
|
161
|
+
# on the first install of this target. On a re-install the key on disk is our own previous
|
|
162
|
+
# write; capturing it would make uninstall "restore" us and leave the entry behind.
|
|
163
|
+
prior = _prior_state(state_path, "managedConfigPath", config_path)
|
|
164
|
+
if prior:
|
|
165
|
+
preimage = prior.get("preimage", None)
|
|
166
|
+
detect_mode = prior.get("detectMode", detect_mode)
|
|
167
|
+
config_existed = prior.get("configExistedBefore", config_existed)
|
|
168
|
+
else:
|
|
169
|
+
preimage = servers.get(SERVER_KEY, None)
|
|
170
|
+
servers[SERVER_KEY] = {"command": command, "args": []}
|
|
171
|
+
|
|
172
|
+
_atomic_write(config_path, _dump(data))
|
|
173
|
+
|
|
174
|
+
state = {
|
|
175
|
+
"schemaVersion": STATE_SCHEMA_VERSION,
|
|
176
|
+
"managedConfigPath": os.path.abspath(config_path),
|
|
177
|
+
"serverKey": SERVER_KEY,
|
|
178
|
+
"command": command,
|
|
179
|
+
"detectMode": detect_mode,
|
|
180
|
+
"configExistedBefore": config_existed,
|
|
181
|
+
"preimage": preimage, # null = key was absent before install
|
|
182
|
+
"installedAt": _now(),
|
|
183
|
+
}
|
|
184
|
+
_atomic_write(state_path, _dump(state))
|
|
185
|
+
sys.stdout.write(f"{detect_mode} {os.path.abspath(config_path)}\n")
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def cmd_uninstall(state_path: str) -> None:
|
|
189
|
+
if not os.path.exists(state_path):
|
|
190
|
+
_die(2, f"agy-bridge: no install-state at {state_path} — nothing to uninstall "
|
|
191
|
+
f"(never installed, or already uninstalled).")
|
|
192
|
+
state = _load_config(state_path)
|
|
193
|
+
config_path = state.get("managedConfigPath")
|
|
194
|
+
if not isinstance(config_path, str):
|
|
195
|
+
_die(4, f"agy-bridge: install-state {state_path} has no managedConfigPath")
|
|
196
|
+
|
|
197
|
+
if os.path.islink(config_path):
|
|
198
|
+
_die(3, f"agy-bridge: refusing to uninstall — {config_path} became a symlink since install "
|
|
199
|
+
f"(someone else's SSOT now). Resolve by hand.")
|
|
200
|
+
|
|
201
|
+
if os.path.exists(config_path):
|
|
202
|
+
data = _load_config(config_path)
|
|
203
|
+
servers = data.get("mcpServers")
|
|
204
|
+
if isinstance(servers, dict):
|
|
205
|
+
preimage = state.get("preimage", None)
|
|
206
|
+
if preimage is None:
|
|
207
|
+
servers.pop(SERVER_KEY, None)
|
|
208
|
+
else:
|
|
209
|
+
servers[SERVER_KEY] = preimage
|
|
210
|
+
# If WE created the whole file and nothing else remains, remove it (honest inverse).
|
|
211
|
+
created_new = state.get("detectMode") == "created-new" and state.get("configExistedBefore") is False
|
|
212
|
+
only_our_empty = created_new and len(servers) == 0 and set(data.keys()) == {"mcpServers"}
|
|
213
|
+
if only_our_empty:
|
|
214
|
+
os.remove(config_path)
|
|
215
|
+
else:
|
|
216
|
+
_atomic_write(config_path, _dump(data))
|
|
217
|
+
|
|
218
|
+
os.remove(state_path)
|
|
219
|
+
sys.stdout.write(f"uninstalled {config_path}\n")
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def cmd_clean_legacy(config_path: str) -> None:
|
|
223
|
+
# Symlink = someone else's SSOT (e.g. an agent-config link). Never clobber; report + skip.
|
|
224
|
+
if os.path.islink(config_path):
|
|
225
|
+
sys.stdout.write(f"skip-symlink {config_path}\n")
|
|
226
|
+
return
|
|
227
|
+
if not os.path.exists(config_path):
|
|
228
|
+
sys.stdout.write(f"absent {config_path}\n")
|
|
229
|
+
return
|
|
230
|
+
data = _load_config(config_path)
|
|
231
|
+
servers = data.get("mcpServers")
|
|
232
|
+
if not isinstance(servers, dict) or SERVER_KEY not in servers:
|
|
233
|
+
sys.stdout.write(f"not-present {config_path}\n")
|
|
234
|
+
return
|
|
235
|
+
servers.pop(SERVER_KEY, None)
|
|
236
|
+
# Remove the file only if nothing else remains and it was a pure mcpServers object (ours to
|
|
237
|
+
# tidy). Otherwise rewrite it, preserving unrelated servers / top-level keys.
|
|
238
|
+
if len(servers) == 0 and set(data.keys()) == {"mcpServers"}:
|
|
239
|
+
os.remove(config_path)
|
|
240
|
+
sys.stdout.write(f"cleaned-removed {config_path}\n")
|
|
241
|
+
else:
|
|
242
|
+
_atomic_write(config_path, _dump(data))
|
|
243
|
+
sys.stdout.write(f"cleaned-kept {config_path}\n")
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def cmd_doctor_static(config_path: str) -> None:
|
|
247
|
+
# Report the RESOLVED path's config status in one shell-parseable token line. Symlink
|
|
248
|
+
# detection/reporting is the shell's job (realpath here just follows any link).
|
|
249
|
+
real = os.path.realpath(config_path)
|
|
250
|
+
if not os.path.exists(real):
|
|
251
|
+
sys.stdout.write("absent\n")
|
|
252
|
+
return
|
|
253
|
+
try:
|
|
254
|
+
with open(real, "r", encoding="utf-8") as fh:
|
|
255
|
+
data = json.loads(fh.read() or "{}")
|
|
256
|
+
except (json.JSONDecodeError, OSError):
|
|
257
|
+
sys.stdout.write("invalid-json\n")
|
|
258
|
+
return
|
|
259
|
+
server = (data.get("mcpServers") or {}).get(SERVER_KEY) if isinstance(data, dict) else None
|
|
260
|
+
if not isinstance(server, dict) or not server.get("command"):
|
|
261
|
+
sys.stdout.write("not-configured\n")
|
|
262
|
+
return
|
|
263
|
+
# "configured <command>" — command is the trailing token(s); shell takes field 2..N.
|
|
264
|
+
sys.stdout.write(f"configured {server['command']}\n")
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def cmd_permission_install(settings_path: str, state_path: str) -> None:
|
|
268
|
+
if os.path.islink(settings_path):
|
|
269
|
+
target = os.readlink(settings_path)
|
|
270
|
+
_die(3, f"agy-bridge: refusing to adopt {settings_path} — it is a symlink to {target} (someone else's SSOT). "
|
|
271
|
+
f"Manage it there, or replace it with a regular file, then retry.")
|
|
272
|
+
|
|
273
|
+
settings_existed = os.path.exists(settings_path)
|
|
274
|
+
data = _load_config(settings_path) if settings_existed else {}
|
|
275
|
+
detect_mode = "adopt-regular-file" if settings_existed else "created-new"
|
|
276
|
+
|
|
277
|
+
perms = data.get("permissions")
|
|
278
|
+
permissions_existed = isinstance(perms, dict)
|
|
279
|
+
if perms is None:
|
|
280
|
+
perms = {}
|
|
281
|
+
data["permissions"] = perms
|
|
282
|
+
if not isinstance(perms, dict):
|
|
283
|
+
_die(4, f"agy-bridge: {settings_path} permissions must be a JSON object")
|
|
284
|
+
|
|
285
|
+
allow = perms.get("allow")
|
|
286
|
+
allow_existed = isinstance(allow, list)
|
|
287
|
+
if allow is None:
|
|
288
|
+
allow = []
|
|
289
|
+
perms["allow"] = allow
|
|
290
|
+
if not isinstance(allow, list):
|
|
291
|
+
_die(4, f"agy-bridge: {settings_path} permissions.allow must be a JSON array")
|
|
292
|
+
|
|
293
|
+
# Idempotent, and the provenance that makes the inverse honest: if the operator ALREADY had this
|
|
294
|
+
# rule, we did not add it, so uninstall must not take it away. On a RE-install the rule on disk
|
|
295
|
+
# is our own previous write — reading it as "pre-existing" would hand the operator credit for
|
|
296
|
+
# our entry and strand it forever. So provenance is taken from the first install and carried.
|
|
297
|
+
on_disk = ALLOW_RULE in allow # what the file says NOW (may be our own earlier write)
|
|
298
|
+
rule_existed = on_disk
|
|
299
|
+
prior = _prior_state(state_path, "managedSettingsPath", settings_path)
|
|
300
|
+
if prior:
|
|
301
|
+
rule_existed = prior.get("ruleExistedBefore", rule_existed)
|
|
302
|
+
detect_mode = prior.get("detectMode", detect_mode)
|
|
303
|
+
settings_existed = prior.get("settingsExistedBefore", settings_existed)
|
|
304
|
+
permissions_existed = prior.get("permissionsExistedBefore", permissions_existed)
|
|
305
|
+
allow_existed = prior.get("allowExistedBefore", allow_existed)
|
|
306
|
+
if ALLOW_RULE not in allow:
|
|
307
|
+
allow.append(ALLOW_RULE)
|
|
308
|
+
|
|
309
|
+
_atomic_write(settings_path, _dump(data))
|
|
310
|
+
|
|
311
|
+
state = {
|
|
312
|
+
"schemaVersion": STATE_SCHEMA_VERSION,
|
|
313
|
+
"managedSettingsPath": os.path.abspath(settings_path),
|
|
314
|
+
"rule": ALLOW_RULE,
|
|
315
|
+
"detectMode": detect_mode,
|
|
316
|
+
"settingsExistedBefore": settings_existed,
|
|
317
|
+
"permissionsExistedBefore": permissions_existed,
|
|
318
|
+
"allowExistedBefore": allow_existed,
|
|
319
|
+
"ruleExistedBefore": rule_existed, # true = the operator's rule, not ours to remove
|
|
320
|
+
"installedAt": _now(),
|
|
321
|
+
}
|
|
322
|
+
_atomic_write(state_path, _dump(state))
|
|
323
|
+
sys.stdout.write(f"{'already-present' if on_disk else 'added'} {ALLOW_RULE}\n")
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
def cmd_permission_uninstall(state_path: str) -> None:
|
|
327
|
+
if not os.path.exists(state_path):
|
|
328
|
+
_die(2, f"agy-bridge: no permission install-state at {state_path} — nothing to uninstall.")
|
|
329
|
+
state = _load_config(state_path)
|
|
330
|
+
settings_path = state.get("managedSettingsPath")
|
|
331
|
+
if not isinstance(settings_path, str):
|
|
332
|
+
_die(4, f"agy-bridge: permission install-state {state_path} has no managedSettingsPath")
|
|
333
|
+
|
|
334
|
+
if os.path.islink(settings_path):
|
|
335
|
+
_die(3, f"agy-bridge: refusing to uninstall — {settings_path} became a symlink since install "
|
|
336
|
+
f"(someone else's SSOT now). Resolve by hand.")
|
|
337
|
+
|
|
338
|
+
if os.path.exists(settings_path):
|
|
339
|
+
data = _load_config(settings_path)
|
|
340
|
+
perms = data.get("permissions")
|
|
341
|
+
if isinstance(perms, dict):
|
|
342
|
+
allow = perms.get("allow")
|
|
343
|
+
# The operator already had the rule before us → it is theirs; leave it.
|
|
344
|
+
if isinstance(allow, list) and not state.get("ruleExistedBefore", False):
|
|
345
|
+
perms["allow"] = [r for r in allow if r != ALLOW_RULE]
|
|
346
|
+
# Drop only the containers WE created, and only while they are empty. An operator
|
|
347
|
+
# who has since added their own rules keeps their structure untouched.
|
|
348
|
+
if not state.get("allowExistedBefore", False) and perms["allow"] == []:
|
|
349
|
+
perms.pop("allow", None)
|
|
350
|
+
if not state.get("permissionsExistedBefore", False) and perms == {}:
|
|
351
|
+
data.pop("permissions", None)
|
|
352
|
+
created_new = state.get("detectMode") == "created-new" and state.get("settingsExistedBefore") is False
|
|
353
|
+
if created_new and data == {}:
|
|
354
|
+
os.remove(settings_path)
|
|
355
|
+
else:
|
|
356
|
+
_atomic_write(settings_path, _dump(data))
|
|
357
|
+
|
|
358
|
+
os.remove(state_path)
|
|
359
|
+
sys.stdout.write(f"uninstalled {ALLOW_RULE} from {settings_path}\n")
|
|
360
|
+
|
|
361
|
+
|
|
362
|
+
def cmd_permission_doctor(settings_path: str) -> None:
|
|
363
|
+
real = os.path.realpath(settings_path)
|
|
364
|
+
if not os.path.exists(real):
|
|
365
|
+
sys.stdout.write("absent\n")
|
|
366
|
+
return
|
|
367
|
+
try:
|
|
368
|
+
with open(real, "r", encoding="utf-8") as fh:
|
|
369
|
+
data = json.loads(fh.read() or "{}")
|
|
370
|
+
except (json.JSONDecodeError, OSError):
|
|
371
|
+
sys.stdout.write("invalid-json\n")
|
|
372
|
+
return
|
|
373
|
+
perms = data.get("permissions") if isinstance(data, dict) else None
|
|
374
|
+
perms = perms if isinstance(perms, dict) else {}
|
|
375
|
+
|
|
376
|
+
# Precedence FIRST (Deny > Ask > Allow): a matching rule in a higher list means agy prompts (or
|
|
377
|
+
# blocks) no matter what our allow says. Reporting `configured` there would be a green light on
|
|
378
|
+
# a surface that is actually still stopping every call.
|
|
379
|
+
for list_name in SHADOWING_LISTS:
|
|
380
|
+
rules = perms.get(list_name)
|
|
381
|
+
if not isinstance(rules, list):
|
|
382
|
+
continue
|
|
383
|
+
for rule in rules:
|
|
384
|
+
if rule in SHADOWING_RULES:
|
|
385
|
+
sys.stdout.write(f"shadowed-by-{list_name} {rule}\n")
|
|
386
|
+
return
|
|
387
|
+
|
|
388
|
+
allow = perms.get("allow")
|
|
389
|
+
if isinstance(allow, list):
|
|
390
|
+
if ALLOW_RULE in allow:
|
|
391
|
+
sys.stdout.write("configured\n")
|
|
392
|
+
return
|
|
393
|
+
# No rule of ours, but a BROADER operator rule in the same list already matches our tool.
|
|
394
|
+
# agy will not prompt; the call works. Say so — and say whose rule is carrying it, because
|
|
395
|
+
# the day the operator narrows that wildcard, our unowned grant disappears with it.
|
|
396
|
+
for rule in MATCHING_RULES:
|
|
397
|
+
if rule != ALLOW_RULE and rule in allow:
|
|
398
|
+
sys.stdout.write(f"covered-by-allow {rule}\n")
|
|
399
|
+
return
|
|
400
|
+
sys.stdout.write("not-configured\n")
|
|
401
|
+
|
|
402
|
+
|
|
403
|
+
def _now() -> str:
|
|
404
|
+
import datetime
|
|
405
|
+
|
|
406
|
+
return datetime.datetime.now(datetime.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
407
|
+
|
|
408
|
+
|
|
409
|
+
def main(argv: list) -> None:
|
|
410
|
+
if len(argv) < 2:
|
|
411
|
+
_die(5, "usage: agy-bridge-config.py <install|uninstall|doctor-static> ...")
|
|
412
|
+
sub = argv[1]
|
|
413
|
+
if sub == "install":
|
|
414
|
+
if len(argv) != 5:
|
|
415
|
+
_die(5, "usage: agy-bridge-config.py install <config_path> <command> <state_path>")
|
|
416
|
+
cmd_install(argv[2], argv[3], argv[4])
|
|
417
|
+
elif sub == "uninstall":
|
|
418
|
+
if len(argv) != 3:
|
|
419
|
+
_die(5, "usage: agy-bridge-config.py uninstall <state_path>")
|
|
420
|
+
cmd_uninstall(argv[2])
|
|
421
|
+
elif sub == "clean-legacy":
|
|
422
|
+
if len(argv) != 3:
|
|
423
|
+
_die(5, "usage: agy-bridge-config.py clean-legacy <config_path>")
|
|
424
|
+
cmd_clean_legacy(argv[2])
|
|
425
|
+
elif sub == "doctor-static":
|
|
426
|
+
if len(argv) != 3:
|
|
427
|
+
_die(5, "usage: agy-bridge-config.py doctor-static <config_path>")
|
|
428
|
+
cmd_doctor_static(argv[2])
|
|
429
|
+
elif sub == "permission-install":
|
|
430
|
+
if len(argv) != 4:
|
|
431
|
+
_die(5, "usage: agy-bridge-config.py permission-install <settings_path> <state_path>")
|
|
432
|
+
cmd_permission_install(argv[2], argv[3])
|
|
433
|
+
elif sub == "permission-uninstall":
|
|
434
|
+
if len(argv) != 3:
|
|
435
|
+
_die(5, "usage: agy-bridge-config.py permission-uninstall <state_path>")
|
|
436
|
+
cmd_permission_uninstall(argv[2])
|
|
437
|
+
elif sub == "permission-doctor":
|
|
438
|
+
if len(argv) != 3:
|
|
439
|
+
_die(5, "usage: agy-bridge-config.py permission-doctor <settings_path>")
|
|
440
|
+
cmd_permission_doctor(argv[2])
|
|
441
|
+
else:
|
|
442
|
+
_die(5, f"agy-bridge-config.py: unknown subcommand {sub!r}")
|
|
443
|
+
|
|
444
|
+
|
|
445
|
+
if __name__ == "__main__":
|
|
446
|
+
main(sys.argv)
|