@junghanacs/entwurf 0.16.0 → 0.16.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +1 -1
- package/CHANGELOG.md +125 -0
- package/README.md +8 -11
- package/demo/README.md +1 -1
- package/docs/setup-clean-host.md +24 -10
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/backend-adapter.js +19 -10
- package/package.json +8 -8
- package/pi-extensions/lib/acp/backend-adapter.ts +19 -9
- package/pi-extensions/lib/acp/backend.ts +125 -7
- package/pi-extensions/lib/acp/claude-acp-launch.js +100 -0
- package/run.sh +108 -32
- package/scripts/check-acp-launch-namespace.ts +127 -0
- package/scripts/check-acp-prompt-lifecycle.ts +145 -2
- package/scripts/check-copilot-birth-hook.ts +28 -1
- package/scripts/check-gate-qualification.ts +3 -2
- package/scripts/check-omp-fresh-preflight.ts +27 -0
- package/scripts/check-setup-qualification.sh +40 -2
- package/scripts/copilot-bridge-oracle.sh +14 -6
- package/scripts/fake-copilot-vendor.sh +4 -2
- package/scripts/mutants/acp-launch-namespace.json +34 -0
- package/scripts/mutants/acp-prompt-lifecycle.json +67 -2
- package/scripts/mutants/copilot-birth.json +3 -5
- package/scripts/mutants/pack-install.json +2 -2
- package/scripts/mutants/setup-verdict.json +35 -0
- package/scripts/omp-config-xdev.py +310 -0
- package/scripts/omp-config-xdev.sh +76 -0
- package/scripts/omp-tool-surface.py +61 -10
- package/scripts/raw-acp-child-exit-measure/README.md +285 -0
- package/scripts/raw-acp-child-exit-measure/acp-turn-population.py +89 -0
- package/scripts/raw-acp-child-exit-measure/reaper-correlation.py +47 -0
- package/scripts/smoke-setup-verdict.sh +48 -3
|
@@ -54,8 +54,8 @@
|
|
|
54
54
|
"claim": "REUSE-CARRIES-CHILD-DIAGNOSTICS",
|
|
55
55
|
"title": "the reuse path drops the child's stderr tail again — exactly the live sonnet failure that showed only \"ACP connection closed\"",
|
|
56
56
|
"subject": "pi-extensions/lib/acp/backend.ts",
|
|
57
|
-
"find": ["\t\t\tfinishError(err, aborted, session.stderrTail, lifecycle);"],
|
|
58
|
-
"replace": ["\t\t\tfinishError(err, aborted, undefined, lifecycle);"],
|
|
57
|
+
"find": ["\t\t\tfinishError(err, aborted, session.stderrTail, lifecycle, session.launchObservation);"],
|
|
58
|
+
"replace": ["\t\t\tfinishError(err, aborted, undefined, lifecycle, session.launchObservation);"],
|
|
59
59
|
"gate": ["bash", "run.sh", "check-acp-prompt-lifecycle"],
|
|
60
60
|
"timeoutSeconds": 180,
|
|
61
61
|
"signature": "[QK:REUSE-CARRIES-CHILD-DIAGNOSTICS]",
|
|
@@ -117,6 +117,71 @@
|
|
|
117
117
|
"timeoutSeconds": 180,
|
|
118
118
|
"signature": "[QK:CHILD-END-SILENCE-BOUNDED]",
|
|
119
119
|
"signatureSource": "scripts/check-acp-prompt-lifecycle.ts"
|
|
120
|
+
},
|
|
121
|
+
{
|
|
122
|
+
"claim": "LAUNCH-SIGNAL-EVIDENCE-STRUCTURED",
|
|
123
|
+
"title": "the caught-signal observation is dropped from the report — an externally killed child again reads as a clean vendor exit, the exact ambiguity #72 cost three diagnosis passes",
|
|
124
|
+
"subject": "pi-extensions/lib/acp/backend.ts",
|
|
125
|
+
"find": ["\t\tconst diagnosed = observed ? `${withLifecycle}\\n${observed}` : withLifecycle;"],
|
|
126
|
+
"replace": ["\t\tconst diagnosed = withLifecycle;"],
|
|
127
|
+
"gate": ["bash", "run.sh", "check-acp-prompt-lifecycle"],
|
|
128
|
+
"timeoutSeconds": 180,
|
|
129
|
+
"signature": "[QK:LAUNCH-SIGNAL-EVIDENCE-STRUCTURED]",
|
|
130
|
+
"signatureSource": "scripts/check-acp-prompt-lifecycle.ts"
|
|
131
|
+
},
|
|
132
|
+
{
|
|
133
|
+
"claim": "LAUNCH-FRAME-NOT-IN-TAIL",
|
|
134
|
+
"title": "the launcher's control frame is echoed into the vendor stderr tail — entwurf's own fact is laundered back into vendor evidence",
|
|
135
|
+
"subject": "pi-extensions/lib/acp/backend.ts",
|
|
136
|
+
"find": ["\t\t\tspawned.stderr.on(\"data\", (c: Buffer) => consumeStderr.write(c.toString()));"],
|
|
137
|
+
"replace": [
|
|
138
|
+
"\t\t\tspawned.stderr.on(\"data\", (c: Buffer) => {",
|
|
139
|
+
"\t\t\t\tconsumeStderr.write(c.toString());",
|
|
140
|
+
"\t\t\t\tstderrTail.push(c.toString());",
|
|
141
|
+
"\t\t\t});"
|
|
142
|
+
],
|
|
143
|
+
"gate": ["bash", "run.sh", "check-acp-prompt-lifecycle"],
|
|
144
|
+
"timeoutSeconds": 180,
|
|
145
|
+
"signature": "[QK:LAUNCH-FRAME-NOT-IN-TAIL]",
|
|
146
|
+
"signatureSource": "scripts/check-acp-prompt-lifecycle.ts"
|
|
147
|
+
},
|
|
148
|
+
{
|
|
149
|
+
"claim": "LAUNCH-SIGNAL-EXACT-FRAME-ONLY",
|
|
150
|
+
"title": "the frame is matched by prefix without its fixed enum — vendor prose can forge an entwurf-owned observation",
|
|
151
|
+
"subject": "pi-extensions/lib/acp/backend.ts",
|
|
152
|
+
"find": [
|
|
153
|
+
"\t\t\t\tif (",
|
|
154
|
+
"\t\t\t\t\tline.startsWith(LAUNCH_SIGNAL_FRAME_PREFIX) &&",
|
|
155
|
+
"\t\t\t\t\tLAUNCH_SIGNAL_FRAME_VALUES.has(line.slice(LAUNCH_SIGNAL_FRAME_PREFIX.length))",
|
|
156
|
+
"\t\t\t\t) {"
|
|
157
|
+
],
|
|
158
|
+
"replace": ["\t\t\t\tif (line.includes(LAUNCH_SIGNAL_FRAME_PREFIX)) {"],
|
|
159
|
+
"gate": ["bash", "run.sh", "check-acp-prompt-lifecycle"],
|
|
160
|
+
"timeoutSeconds": 180,
|
|
161
|
+
"signature": "[QK:LAUNCH-SIGNAL-EXACT-FRAME-ONLY]",
|
|
162
|
+
"signatureSource": "scripts/check-acp-prompt-lifecycle.ts"
|
|
163
|
+
},
|
|
164
|
+
{
|
|
165
|
+
"claim": "STDERR-TAIL-FLUSHES-PARTIAL-LINE",
|
|
166
|
+
"title": "the line buffer never flushes its unterminated remainder — a child that dies mid-write loses the dying words the stderr tail exists to carry",
|
|
167
|
+
"subject": "pi-extensions/lib/acp/backend.ts",
|
|
168
|
+
"find": ["\t\t\tspawned.stderr.once?.(\"close\", () => consumeStderr.flush());"],
|
|
169
|
+
"replace": ["\t\t\tvoid consumeStderr;"],
|
|
170
|
+
"gate": ["bash", "run.sh", "check-acp-prompt-lifecycle"],
|
|
171
|
+
"timeoutSeconds": 180,
|
|
172
|
+
"signature": "[QK:STDERR-TAIL-FLUSHES-PARTIAL-LINE]",
|
|
173
|
+
"signatureSource": "scripts/check-acp-prompt-lifecycle.ts"
|
|
174
|
+
},
|
|
175
|
+
{
|
|
176
|
+
"claim": "LAUNCH-FRAME-SPANS-CHUNKS",
|
|
177
|
+
"title": "the frame is matched per read instead of per line — a frame split across a chunk boundary is missed, and the external kill goes unreported exactly as it did in the field",
|
|
178
|
+
"subject": "pi-extensions/lib/acp/backend.ts",
|
|
179
|
+
"find": ["\t\t\theld += chunk;"],
|
|
180
|
+
"replace": ["\t\t\theld = chunk;"],
|
|
181
|
+
"gate": ["bash", "run.sh", "check-acp-prompt-lifecycle"],
|
|
182
|
+
"timeoutSeconds": 180,
|
|
183
|
+
"signature": "[QK:LAUNCH-FRAME-SPANS-CHUNKS]",
|
|
184
|
+
"signatureSource": "scripts/check-acp-prompt-lifecycle.ts"
|
|
120
185
|
}
|
|
121
186
|
]
|
|
122
187
|
}
|
|
@@ -196,12 +196,10 @@
|
|
|
196
196
|
},
|
|
197
197
|
{
|
|
198
198
|
"claim": "COPILOT-MALFORMED-ROW-NOT-ABSENT",
|
|
199
|
-
"title": "a truncated/garbled exact plugin row falls through the parser and rounds to ABSENT, which the inverse reads as retry-safe absence and continues past — the exact fall-through B reproduced (`(v0.1` with no closing paren) against 47a5ae3 (#86 C3a corrective amendment, B defect 3)",
|
|
199
|
+
"title": "a truncated/garbled exact plugin row falls through the parser and rounds to ABSENT, which the inverse reads as retry-safe absence and continues past — the exact fall-through B reproduced (`(v0.1` with no closing paren) against 47a5ae3 (#86 C3a corrective amendment, B defect 3). The parse became a regex when the 1.0.81 state token was admitted; the fall-through must stay closed on either spelling.",
|
|
200
200
|
"subject": "scripts/copilot-bridge-oracle.sh",
|
|
201
|
-
"find": [
|
|
202
|
-
|
|
203
|
-
],
|
|
204
|
-
"replace": [" if not (rest.startswith(\"(v\") and rest.endswith(\")\")):\n continue"],
|
|
201
|
+
"find": [" match = ROW_TAIL.fullmatch(rest)\n if match is None:"],
|
|
202
|
+
"replace": [" match = ROW_TAIL.fullmatch(rest)\n if match is None:\n continue\n if False:"],
|
|
205
203
|
"gate": ["bash", "run.sh", "check-copilot-birth-hook"],
|
|
206
204
|
"timeoutSeconds": 300,
|
|
207
205
|
"signature": "[QK:COPILOT-MALFORMED-ROW-NOT-ABSENT]",
|
|
@@ -6,8 +6,8 @@
|
|
|
6
6
|
"claim": "PACK-INSTALL-PIN-MATCHER-BOUNDED",
|
|
7
7
|
"title": "the pin-leak matcher loses its version boundary and blesses substring lookalikes — @0.84.30 reads as the pinned 0.84.3 and the gate announces a verified tree it never verified (independent review, 2026-08-25)",
|
|
8
8
|
"subject": "run.sh",
|
|
9
|
-
"find": [" grep '^@earendil-works+pi-' | grep -Ev '@0\\.84\\.
|
|
10
|
-
"replace": [" grep '^@earendil-works+pi-' | grep -v '@0\\.84\\.
|
|
9
|
+
"find": [" grep '^@earendil-works+pi-' | grep -Ev '@0\\.84\\.4(_|$)' || true"],
|
|
10
|
+
"replace": [" grep '^@earendil-works+pi-' | grep -v '@0\\.84\\.4' || true"],
|
|
11
11
|
"gate": ["bash", "run.sh", "check-pack-pin-matcher"],
|
|
12
12
|
"timeoutSeconds": 120,
|
|
13
13
|
"signature": "[QK:PACK-INSTALL-PIN-MATCHER-BOUNDED]",
|
|
@@ -143,6 +143,41 @@
|
|
|
143
143
|
"timeoutSeconds": 600,
|
|
144
144
|
"signature": "[QK:SETUP-AGY-COSMETIC-RETURN]",
|
|
145
145
|
"signatureSource": "scripts/smoke-agy-install-state.sh"
|
|
146
|
+
},
|
|
147
|
+
{
|
|
148
|
+
"claim": "SETUP-OMP-ABSENT-SKIP",
|
|
149
|
+
"title": "an absent omp is composed anyway instead of taking the zero-state SKIP branch, so setup writes omp units onto a host that never installed omp",
|
|
150
|
+
"subject": "run.sh",
|
|
151
|
+
"find": [
|
|
152
|
+
" if ! command -v \"${OMP_BIN:-omp}\" >/dev/null 2>&1; then\n setup_result omp SKIP \"omp not on PATH — zero OMP wiring written\""
|
|
153
|
+
],
|
|
154
|
+
"replace": [" if false; then\n setup_result omp SKIP \"omp not on PATH — zero OMP wiring written\""],
|
|
155
|
+
"gate": ["bash", "scripts/check-setup-qualification.sh"],
|
|
156
|
+
"timeoutSeconds": 180,
|
|
157
|
+
"signature": "[QK:SETUP-OMP-ABSENT-SKIP]",
|
|
158
|
+
"signatureSource": "scripts/check-setup-qualification.sh"
|
|
159
|
+
},
|
|
160
|
+
{
|
|
161
|
+
"claim": "SETUP-OMP-INDEPENDENT",
|
|
162
|
+
"title": "the omp units stop being composed at all — the exact v0.16.0 shape: every installer exists, setup never reaches them, and the summary still computes green",
|
|
163
|
+
"subject": "run.sh",
|
|
164
|
+
"find": [" omp_rc=0; (cd \"$REPO_DIR\" && bash scripts/omp-bridge-install.sh) || omp_rc=$?"],
|
|
165
|
+
"replace": [" omp_rc=0"],
|
|
166
|
+
"gate": ["bash", "scripts/check-setup-qualification.sh"],
|
|
167
|
+
"timeoutSeconds": 180,
|
|
168
|
+
"signature": "[QK:SETUP-OMP-INDEPENDENT]",
|
|
169
|
+
"signatureSource": "scripts/check-setup-qualification.sh"
|
|
170
|
+
},
|
|
171
|
+
{
|
|
172
|
+
"claim": "SETUP-OMP-CONFIG-NO-OVERWRITE",
|
|
173
|
+
"title": "the tools.xdev writer overwrites an EXPLICIT operator `xdev: true` instead of refusing it by name, so entwurf silently reverses a decision the operator wrote down",
|
|
174
|
+
"subject": "scripts/omp-config-xdev.py",
|
|
175
|
+
"find": [" if existing is not None:"],
|
|
176
|
+
"replace": [" if False:"],
|
|
177
|
+
"gate": ["bash", "scripts/check-setup-qualification.sh"],
|
|
178
|
+
"timeoutSeconds": 180,
|
|
179
|
+
"signature": "[QK:SETUP-OMP-CONFIG-NO-OVERWRITE]",
|
|
180
|
+
"signatureSource": "scripts/check-setup-qualification.sh"
|
|
146
181
|
}
|
|
147
182
|
]
|
|
148
183
|
}
|
|
@@ -0,0 +1,310 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Write the ONE operator setting omp's tool hand requires: ``tools.xdev: false``.
|
|
3
|
+
|
|
4
|
+
stdlib only. This is the WRITER half of the runtime cell ``omp-tool-surface.py``
|
|
5
|
+
already reads and ``doctor-omp-mcp`` already judges. Until it existed the setting
|
|
6
|
+
was documentation — `docs/setup-clean-host.md` §4b told the operator to hand-edit
|
|
7
|
+
`config.yml`, and a host that skipped it looked green everywhere except the one
|
|
8
|
+
place it mattered: the doorbell announced ``mcp__entwurf_bridge_entwurf_v`` while
|
|
9
|
+
the vendor had wrapped every MCP tool behind ``xd://`` and the model could not
|
|
10
|
+
call it (measured, omp 18.0.0).
|
|
11
|
+
|
|
12
|
+
Ownership, in the same shape as every other entwurf writer:
|
|
13
|
+
|
|
14
|
+
* The config file is the VENDOR's. We add exactly one key and we record exactly
|
|
15
|
+
what we added, so the inverse can take back our bytes and nothing else.
|
|
16
|
+
* ``xdev: true`` written EXPLICITLY by the operator is a decision, not drift. We
|
|
17
|
+
refuse it by name instead of overwriting — setup reports a FAIL the operator
|
|
18
|
+
resolves, which is the honest branch when two authorities disagree.
|
|
19
|
+
* A config we cannot parse is refused, never rewritten. A writer that edits a
|
|
20
|
+
file it cannot read is how a config gets corrupted.
|
|
21
|
+
|
|
22
|
+
Exit codes: 0 ok · 2 nothing to do (uninstall, no state) · 3 refused (symlink) ·
|
|
23
|
+
4 unreadable/invalid · 5 refused (operator's explicit xdev: true) · 6 refused
|
|
24
|
+
(the file changed under us since install).
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
from __future__ import annotations
|
|
28
|
+
|
|
29
|
+
import hashlib
|
|
30
|
+
import json
|
|
31
|
+
import os
|
|
32
|
+
import sys
|
|
33
|
+
import time
|
|
34
|
+
|
|
35
|
+
STATE_SCHEMA = 1
|
|
36
|
+
TRUE_WORDS = {"true", "yes", "on", "y"}
|
|
37
|
+
FALSE_WORDS = {"false", "no", "off", "n"}
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def die(code: int, message: str) -> None:
|
|
41
|
+
sys.stderr.write(message.rstrip("\n") + "\n")
|
|
42
|
+
raise SystemExit(code)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def sha256(text: str) -> str:
|
|
46
|
+
return hashlib.sha256(text.encode("utf-8")).hexdigest()
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def strip_comment(raw: str) -> str:
|
|
50
|
+
"""Drop a trailing `#` comment outside quotes — the reader's rule, kept in sync."""
|
|
51
|
+
in_single = in_double = escaped = False
|
|
52
|
+
for index, char in enumerate(raw):
|
|
53
|
+
if escaped:
|
|
54
|
+
escaped = False
|
|
55
|
+
continue
|
|
56
|
+
if char == "\\" and in_double:
|
|
57
|
+
escaped = True
|
|
58
|
+
continue
|
|
59
|
+
if char == "'" and not in_double:
|
|
60
|
+
in_single = not in_single
|
|
61
|
+
continue
|
|
62
|
+
if char == '"' and not in_single:
|
|
63
|
+
in_double = not in_double
|
|
64
|
+
continue
|
|
65
|
+
if char == "#" and not in_single and not in_double:
|
|
66
|
+
return raw[:index].rstrip()
|
|
67
|
+
return raw.rstrip()
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def indent_of(line: str) -> int:
|
|
71
|
+
body = strip_comment(line)
|
|
72
|
+
return len(body) - len(body.lstrip(" "))
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def split_key(line: str) -> str | None:
|
|
76
|
+
"""The key of a `key: value` line, or None when the line is not a mapping entry."""
|
|
77
|
+
body = strip_comment(line).strip()
|
|
78
|
+
if not body or body.startswith("- ") or body.startswith("#"):
|
|
79
|
+
return None
|
|
80
|
+
in_single = in_double = False
|
|
81
|
+
for index, char in enumerate(body):
|
|
82
|
+
if char == "'" and not in_double:
|
|
83
|
+
in_single = not in_single
|
|
84
|
+
continue
|
|
85
|
+
if char == '"' and not in_single:
|
|
86
|
+
in_double = not in_double
|
|
87
|
+
continue
|
|
88
|
+
if char == ":" and not in_single and not in_double:
|
|
89
|
+
key = body[:index].strip()
|
|
90
|
+
if (len(key) >= 2) and (key[0] == key[-1] == '"' or key[0] == key[-1] == "'"):
|
|
91
|
+
key = key[1:-1]
|
|
92
|
+
return key or None
|
|
93
|
+
return None
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def value_of(line: str) -> str:
|
|
97
|
+
body = strip_comment(line).strip()
|
|
98
|
+
_, _, rest = body.partition(":")
|
|
99
|
+
return rest.strip()
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def scalar_bool(raw: str) -> bool | None:
|
|
103
|
+
text = raw.strip()
|
|
104
|
+
if (len(text) >= 2) and (text[0] == text[-1] == '"' or text[0] == text[-1] == "'"):
|
|
105
|
+
text = text[1:-1]
|
|
106
|
+
folded = text.casefold()
|
|
107
|
+
if folded in TRUE_WORDS:
|
|
108
|
+
return True
|
|
109
|
+
if folded in FALSE_WORDS:
|
|
110
|
+
return False
|
|
111
|
+
return None
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def find_tools_block(lines: list[str]) -> tuple[int, int, int] | None:
|
|
115
|
+
"""(header index, first child index, child indent) for the TOP-LEVEL `tools:` key."""
|
|
116
|
+
for index, line in enumerate(lines):
|
|
117
|
+
if not strip_comment(line).strip():
|
|
118
|
+
continue
|
|
119
|
+
if indent_of(line) != 0:
|
|
120
|
+
continue
|
|
121
|
+
if split_key(line) != "tools":
|
|
122
|
+
continue
|
|
123
|
+
end = index + 1
|
|
124
|
+
child_indent = -1
|
|
125
|
+
while end < len(lines):
|
|
126
|
+
if not strip_comment(lines[end]).strip():
|
|
127
|
+
end += 1
|
|
128
|
+
continue
|
|
129
|
+
if indent_of(lines[end]) == 0:
|
|
130
|
+
break
|
|
131
|
+
if child_indent < 0:
|
|
132
|
+
child_indent = indent_of(lines[end])
|
|
133
|
+
end += 1
|
|
134
|
+
return index, end, (child_indent if child_indent > 0 else 2)
|
|
135
|
+
return None
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def read_config(path: str) -> list[str]:
|
|
139
|
+
with open(path, "r", encoding="utf-8") as handle:
|
|
140
|
+
return handle.read().splitlines()
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def write_config(path: str, lines: list[str]) -> str:
|
|
144
|
+
text = "\n".join(lines) + "\n"
|
|
145
|
+
parent = os.path.dirname(path) or "."
|
|
146
|
+
os.makedirs(parent, exist_ok=True)
|
|
147
|
+
tmp = f"{path}.entwurf-tmp"
|
|
148
|
+
with open(tmp, "w", encoding="utf-8") as handle:
|
|
149
|
+
handle.write(text)
|
|
150
|
+
os.replace(tmp, path)
|
|
151
|
+
return text
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def write_state(state_file: str, payload: dict[str, object]) -> None:
|
|
155
|
+
os.makedirs(os.path.dirname(state_file) or ".", exist_ok=True)
|
|
156
|
+
tmp = f"{state_file}.tmp"
|
|
157
|
+
with open(tmp, "w", encoding="utf-8") as handle:
|
|
158
|
+
json.dump(payload, handle, indent=2, sort_keys=True)
|
|
159
|
+
handle.write("\n")
|
|
160
|
+
os.replace(tmp, state_file)
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def do_install(config_path: str, state_file: str) -> None:
|
|
164
|
+
if os.path.islink(config_path):
|
|
165
|
+
die(3, f"refusing: {config_path} is a symlink — entwurf never writes through a link")
|
|
166
|
+
existed = os.path.exists(config_path)
|
|
167
|
+
if existed and not os.path.isfile(config_path):
|
|
168
|
+
die(4, f"refusing: {config_path} exists and is not a regular file")
|
|
169
|
+
|
|
170
|
+
lines = read_config(config_path) if existed else []
|
|
171
|
+
block = find_tools_block(lines)
|
|
172
|
+
inserted: list[str] = []
|
|
173
|
+
action: str
|
|
174
|
+
|
|
175
|
+
if block is None:
|
|
176
|
+
# No `tools:` at all — append the whole two-line section. The vendor's own
|
|
177
|
+
# writer emits `key: ` with a trailing space, so the file stays uniform.
|
|
178
|
+
if lines and strip_comment(lines[-1]).strip() == "":
|
|
179
|
+
lines = lines[:-1]
|
|
180
|
+
inserted = ["tools: ", " xdev: false"]
|
|
181
|
+
lines.extend(inserted)
|
|
182
|
+
action = "created-file" if not existed else "appended-tools-block"
|
|
183
|
+
else:
|
|
184
|
+
header, end, child_indent = block
|
|
185
|
+
existing = None
|
|
186
|
+
for index in range(header + 1, end):
|
|
187
|
+
if indent_of(lines[index]) == child_indent and split_key(lines[index]) == "xdev":
|
|
188
|
+
existing = index
|
|
189
|
+
break
|
|
190
|
+
if existing is not None:
|
|
191
|
+
current = scalar_bool(value_of(lines[existing]))
|
|
192
|
+
if current is False:
|
|
193
|
+
write_state(
|
|
194
|
+
state_file,
|
|
195
|
+
{
|
|
196
|
+
"schemaVersion": STATE_SCHEMA,
|
|
197
|
+
"configPath": os.path.abspath(config_path),
|
|
198
|
+
"fileExistedBefore": True,
|
|
199
|
+
"action": "already-false",
|
|
200
|
+
"insertedLines": [],
|
|
201
|
+
"postimageSha256": sha256("\n".join(lines) + "\n"),
|
|
202
|
+
"installedAt": time.strftime("%Y-%m-%dT%H:%M:%S%z"),
|
|
203
|
+
},
|
|
204
|
+
)
|
|
205
|
+
print(f"already-set {config_path} (tools.xdev is false — nothing written)")
|
|
206
|
+
return
|
|
207
|
+
if current is True:
|
|
208
|
+
die(
|
|
209
|
+
5,
|
|
210
|
+
f"refusing: {config_path} sets tools.xdev: true EXPLICITLY (line {existing + 1}). "
|
|
211
|
+
"That is the operator's decision, not drift, so entwurf will not overwrite it. "
|
|
212
|
+
"Entwurf's MCP tools are unreachable from omp while xdev is on and no "
|
|
213
|
+
"xdevInlineDevices glob covers them — set it to false yourself, or accept that "
|
|
214
|
+
"the omp citizen cannot call entwurf tools.",
|
|
215
|
+
)
|
|
216
|
+
die(4, f"refusing: {config_path} line {existing + 1} sets tools.xdev to a value this reader cannot classify")
|
|
217
|
+
inserted = [" " * child_indent + "xdev: false"]
|
|
218
|
+
lines[end:end] = inserted
|
|
219
|
+
action = "inserted-xdev-key"
|
|
220
|
+
|
|
221
|
+
text = write_config(config_path, lines)
|
|
222
|
+
write_state(
|
|
223
|
+
state_file,
|
|
224
|
+
{
|
|
225
|
+
"schemaVersion": STATE_SCHEMA,
|
|
226
|
+
"configPath": os.path.abspath(config_path),
|
|
227
|
+
"fileExistedBefore": existed,
|
|
228
|
+
"action": action,
|
|
229
|
+
"insertedLines": inserted,
|
|
230
|
+
"postimageSha256": sha256(text),
|
|
231
|
+
"installedAt": time.strftime("%Y-%m-%dT%H:%M:%S%z"),
|
|
232
|
+
},
|
|
233
|
+
)
|
|
234
|
+
print(f"{action} {config_path} (tools.xdev: false)")
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def do_uninstall(state_file: str) -> None:
|
|
238
|
+
if not os.path.exists(state_file):
|
|
239
|
+
die(2, f"no install-state at {state_file} — entwurf never wrote this setting on this host")
|
|
240
|
+
try:
|
|
241
|
+
with open(state_file, "r", encoding="utf-8") as handle:
|
|
242
|
+
state = json.load(handle)
|
|
243
|
+
except (OSError, ValueError):
|
|
244
|
+
die(4, f"install-state at {state_file} is unreadable — refusing to guess what to take back")
|
|
245
|
+
if not isinstance(state, dict) or state.get("schemaVersion") != STATE_SCHEMA:
|
|
246
|
+
die(4, f"install-state at {state_file} is not schemaVersion {STATE_SCHEMA} — refusing")
|
|
247
|
+
|
|
248
|
+
config_path = state.get("configPath")
|
|
249
|
+
action = state.get("action")
|
|
250
|
+
inserted = state.get("insertedLines")
|
|
251
|
+
if not isinstance(config_path, str) or not isinstance(inserted, list):
|
|
252
|
+
die(4, f"install-state at {state_file} is malformed — refusing")
|
|
253
|
+
|
|
254
|
+
if action == "already-false":
|
|
255
|
+
os.remove(state_file)
|
|
256
|
+
print(f"nothing to take back ({config_path} already had tools.xdev: false); state cleared")
|
|
257
|
+
return
|
|
258
|
+
|
|
259
|
+
if os.path.islink(config_path):
|
|
260
|
+
die(3, f"refusing: {config_path} is now a symlink — entwurf never writes through a link")
|
|
261
|
+
if not os.path.exists(config_path):
|
|
262
|
+
os.remove(state_file)
|
|
263
|
+
print(f"{config_path} is already gone; state cleared")
|
|
264
|
+
return
|
|
265
|
+
|
|
266
|
+
with open(config_path, "r", encoding="utf-8") as handle:
|
|
267
|
+
current = handle.read()
|
|
268
|
+
if sha256(current) != state.get("postimageSha256"):
|
|
269
|
+
die(
|
|
270
|
+
6,
|
|
271
|
+
f"refusing: {config_path} changed since entwurf wrote it. Remove the tools.xdev line "
|
|
272
|
+
"yourself if you want it gone — a blind edit here would take back somebody else's bytes.",
|
|
273
|
+
)
|
|
274
|
+
|
|
275
|
+
if action == "created-file":
|
|
276
|
+
os.remove(config_path)
|
|
277
|
+
os.remove(state_file)
|
|
278
|
+
print(f"removed {config_path} (entwurf created it); state cleared")
|
|
279
|
+
return
|
|
280
|
+
|
|
281
|
+
lines = current.splitlines()
|
|
282
|
+
for line in reversed(inserted):
|
|
283
|
+
if line in lines:
|
|
284
|
+
lines.remove(line)
|
|
285
|
+
else:
|
|
286
|
+
die(6, f"refusing: the line {line!r} entwurf added is no longer in {config_path}")
|
|
287
|
+
write_config(config_path, lines)
|
|
288
|
+
os.remove(state_file)
|
|
289
|
+
print(f"took back {len(inserted)} line(s) from {config_path}; state cleared")
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
def main(argv: list[str]) -> None:
|
|
293
|
+
if len(argv) < 2:
|
|
294
|
+
die(5, "usage: omp-config-xdev.py install <config-path> <state-file> | uninstall <state-file>")
|
|
295
|
+
verb = argv[1]
|
|
296
|
+
if verb == "install":
|
|
297
|
+
if len(argv) != 4:
|
|
298
|
+
die(5, "usage: omp-config-xdev.py install <config-path> <state-file>")
|
|
299
|
+
do_install(argv[2], argv[3])
|
|
300
|
+
return
|
|
301
|
+
if verb == "uninstall":
|
|
302
|
+
if len(argv) != 3:
|
|
303
|
+
die(5, "usage: omp-config-xdev.py uninstall <state-file>")
|
|
304
|
+
do_uninstall(argv[2])
|
|
305
|
+
return
|
|
306
|
+
die(5, f"unknown verb {verb!r} — install | uninstall")
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
if __name__ == "__main__":
|
|
310
|
+
main(sys.argv)
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# OMP operator-setting adapter (`tools.xdev: false`) — the WRITER for the runtime cell
|
|
3
|
+
# `doctor-omp-mcp` already judges and `omp-tool-surface.py` already reads.
|
|
4
|
+
#
|
|
5
|
+
# Separate surface from the MCP hand on purpose: registering the server is TOOLS, and
|
|
6
|
+
# `tools.xdev` decides whether those registered tools are REACHABLE at all. omp's default
|
|
7
|
+
# (`xdev: true`, no inline allowlist) wraps every MCP tool behind `xd://`, so a host with a
|
|
8
|
+
# perfect mcp.json still announces a doorbell tool the model cannot call. Until this adapter
|
|
9
|
+
# existed the fix was a documented hand-edit, which is exactly the kind of step a one-command
|
|
10
|
+
# setup is supposed to end.
|
|
11
|
+
#
|
|
12
|
+
# Target confinement follows the MCP hand's rule (#87 D1): the file is exactly
|
|
13
|
+
# `<resolved omp agent dir>/config.yml`, never a configurable path. `ENTWURF_OMP_AGENT_DIR`
|
|
14
|
+
# moves the agent dir and the target follows by construction.
|
|
15
|
+
set -euo pipefail
|
|
16
|
+
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
17
|
+
CONFIG_PY="$HERE/omp-config-xdev.py"
|
|
18
|
+
STATE_DIR="${XDG_DATA_HOME:-$HOME/.local/share}/entwurf/omp-config"
|
|
19
|
+
STATE_FILE="$STATE_DIR/install-state.json"
|
|
20
|
+
|
|
21
|
+
log() { printf '%s\n' "$*"; }
|
|
22
|
+
fail() { printf 'FAIL: %s\n' "$*" >&2; exit 1; }
|
|
23
|
+
|
|
24
|
+
# shellcheck source=scripts/omp-bridge-oracle.sh
|
|
25
|
+
. "$HERE/omp-bridge-oracle.sh"
|
|
26
|
+
|
|
27
|
+
AGENT_DIR="$(omp_agent_dir)" || fail "the omp agent directory this host reads is ambiguous (see above) — refusing to write a config file omp may never read."
|
|
28
|
+
CONFIG="$AGENT_DIR/config.yml"
|
|
29
|
+
|
|
30
|
+
do_install() {
|
|
31
|
+
log "[omp-config install]"
|
|
32
|
+
log " target: $CONFIG"
|
|
33
|
+
log " setting: tools.xdev: false"
|
|
34
|
+
log " state: $STATE_FILE"
|
|
35
|
+
local out rc
|
|
36
|
+
set +e
|
|
37
|
+
out="$(python3 "$CONFIG_PY" install "$CONFIG" "$STATE_FILE" 2>&1)"
|
|
38
|
+
rc=$?
|
|
39
|
+
set -e
|
|
40
|
+
case "$rc" in
|
|
41
|
+
0) log " ok: $out" ;;
|
|
42
|
+
3) fail "refused (symlink) — $out" ;;
|
|
43
|
+
4) fail "unreadable config — $out" ;;
|
|
44
|
+
5) fail "refused (the operator set tools.xdev: true explicitly) — $out" ;;
|
|
45
|
+
*) fail "install error (rc=$rc) — $out" ;;
|
|
46
|
+
esac
|
|
47
|
+
log " installed. The runtime axis is verified by: ./run.sh doctor-omp-mcp"
|
|
48
|
+
log " NOTE: an omp session that is ALREADY OPEN keeps the tool surface it started with;"
|
|
49
|
+
log " restart it for the top-level tools to appear."
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
do_uninstall() {
|
|
53
|
+
log "[omp-config uninstall]"
|
|
54
|
+
local out rc
|
|
55
|
+
set +e
|
|
56
|
+
out="$(python3 "$CONFIG_PY" uninstall "$STATE_FILE" 2>&1)"
|
|
57
|
+
rc=$?
|
|
58
|
+
set -e
|
|
59
|
+
case "$rc" in
|
|
60
|
+
0) log " ok: $out" ;;
|
|
61
|
+
2) log " note: $out" ;;
|
|
62
|
+
3) fail "refused (symlink) — $out" ;;
|
|
63
|
+
4) fail "invalid state — $out" ;;
|
|
64
|
+
6) fail "refused (the config changed since install) — $out" ;;
|
|
65
|
+
*) fail "uninstall error (rc=$rc) — $out" ;;
|
|
66
|
+
esac
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
case "${1:-}" in
|
|
70
|
+
install) do_install ;;
|
|
71
|
+
uninstall) do_uninstall ;;
|
|
72
|
+
*)
|
|
73
|
+
echo "usage: omp-config-xdev.sh install | uninstall" >&2
|
|
74
|
+
exit 2
|
|
75
|
+
;;
|
|
76
|
+
esac
|
|
@@ -56,14 +56,9 @@ def strip_comment(raw: str) -> str:
|
|
|
56
56
|
return raw.rstrip()
|
|
57
57
|
|
|
58
58
|
|
|
59
|
-
def
|
|
60
|
-
body
|
|
61
|
-
|
|
62
|
-
return None
|
|
63
|
-
inner = body[1:-1].strip()
|
|
64
|
-
if inner == "":
|
|
65
|
-
return []
|
|
66
|
-
items: list[object] = []
|
|
59
|
+
def split_flow_items(inner: str) -> list[str] | None:
|
|
60
|
+
"""Split a flow collection's body on its TOP-LEVEL commas (quote/nest aware)."""
|
|
61
|
+
items: list[str] = []
|
|
67
62
|
buf: list[str] = []
|
|
68
63
|
in_single = False
|
|
69
64
|
in_double = False
|
|
@@ -83,16 +78,59 @@ def parse_flow_seq(text: str) -> list[object] | None:
|
|
|
83
78
|
elif char in "]}":
|
|
84
79
|
depth -= 1
|
|
85
80
|
elif char == "," and depth == 0:
|
|
86
|
-
items.append(
|
|
81
|
+
items.append("".join(buf).strip())
|
|
87
82
|
buf = []
|
|
88
83
|
continue
|
|
89
84
|
buf.append(char)
|
|
90
85
|
if in_single or in_double or depth != 0:
|
|
91
86
|
return None
|
|
92
|
-
items.append(
|
|
87
|
+
items.append("".join(buf).strip())
|
|
93
88
|
return items
|
|
94
89
|
|
|
95
90
|
|
|
91
|
+
def parse_flow_seq(text: str) -> list[object] | None:
|
|
92
|
+
body = text.strip()
|
|
93
|
+
if not (body.startswith("[") and body.endswith("]")):
|
|
94
|
+
return None
|
|
95
|
+
inner = body[1:-1].strip()
|
|
96
|
+
if inner == "":
|
|
97
|
+
return []
|
|
98
|
+
parts = split_flow_items(inner)
|
|
99
|
+
if parts is None:
|
|
100
|
+
return None
|
|
101
|
+
return [parse_scalar(part) for part in parts]
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def parse_flow_map(text: str) -> dict[str, object] | None:
|
|
105
|
+
"""The flow-mapping form the VENDOR itself writes.
|
|
106
|
+
|
|
107
|
+
omp's own settings writer emits `modelRoles:` followed by an indented `{}`
|
|
108
|
+
(measured on omp 18.0.0, an untouched operator config). The block-only reader
|
|
109
|
+
read that line as a malformed mapping and returned None for the WHOLE file, so
|
|
110
|
+
a perfectly ordinary vendor config classified as `unreadable` and the MCP
|
|
111
|
+
doctor went RED for a reason that had nothing to do with tools.xdev.
|
|
112
|
+
"""
|
|
113
|
+
body = text.strip()
|
|
114
|
+
if not (body.startswith("{") and body.endswith("}")):
|
|
115
|
+
return None
|
|
116
|
+
inner = body[1:-1].strip()
|
|
117
|
+
if inner == "":
|
|
118
|
+
return {}
|
|
119
|
+
parts = split_flow_items(inner)
|
|
120
|
+
if parts is None:
|
|
121
|
+
return None
|
|
122
|
+
mapping: dict[str, object] = {}
|
|
123
|
+
for part in parts:
|
|
124
|
+
if part == "":
|
|
125
|
+
return None
|
|
126
|
+
pair = split_key_value(part)
|
|
127
|
+
if pair is None:
|
|
128
|
+
return None
|
|
129
|
+
key, raw = pair
|
|
130
|
+
mapping[key] = parse_scalar(raw)
|
|
131
|
+
return mapping
|
|
132
|
+
|
|
133
|
+
|
|
96
134
|
def parse_scalar(raw: str) -> object:
|
|
97
135
|
text = raw.strip()
|
|
98
136
|
if text == "" or text in ("~", "null", "Null", "NULL"):
|
|
@@ -102,6 +140,9 @@ def parse_scalar(raw: str) -> object:
|
|
|
102
140
|
if text.startswith("[") and text.endswith("]"):
|
|
103
141
|
parsed = parse_flow_seq(text)
|
|
104
142
|
return parsed if parsed is not None else text
|
|
143
|
+
if text.startswith("{") and text.endswith("}"):
|
|
144
|
+
parsed_map = parse_flow_map(text)
|
|
145
|
+
return parsed_map if parsed_map is not None else text
|
|
105
146
|
folded = text.casefold()
|
|
106
147
|
if folded in TRUE_WORDS:
|
|
107
148
|
return True
|
|
@@ -137,6 +178,16 @@ def parse_block(lines: list[tuple[int, str]], start: int, indent: int) -> tuple[
|
|
|
137
178
|
first_indent, first_text = lines[start]
|
|
138
179
|
if first_indent <= indent:
|
|
139
180
|
return {}, start
|
|
181
|
+
if first_text.startswith("{") or first_text.startswith("["):
|
|
182
|
+
# `key:` on one line, a flow collection indented under it on the next — the
|
|
183
|
+
# shape the vendor's own writer produces for an empty map (`modelRoles:\n {}`).
|
|
184
|
+
flow = parse_flow_map(first_text) if first_text.startswith("{") else parse_flow_seq(first_text)
|
|
185
|
+
if flow is None:
|
|
186
|
+
return None, start
|
|
187
|
+
index = start + 1
|
|
188
|
+
if index < len(lines) and lines[index][0] >= first_indent:
|
|
189
|
+
return None, start # a flow collection is the WHOLE block or nothing
|
|
190
|
+
return flow, index
|
|
140
191
|
if first_text.startswith("- "):
|
|
141
192
|
items: list[object] = []
|
|
142
193
|
index = start
|