@junghanacs/entwurf 0.15.1 → 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 +354 -0
- package/DELIVERY.md +3 -2
- package/README.md +68 -88
- package/VERIFY.md +4 -1
- package/demo/README.md +1 -1
- package/docs/acp-backend-rail.md +1 -1
- package/docs/external-mcp-host.md +147 -9
- package/docs/setup-clean-host.md +125 -6
- package/mcp/entwurf-bridge/dist/mcp/entwurf-bridge/src/index.js +65 -20
- package/mcp/entwurf-bridge/dist/pi-extensions/entwurf-capabilities.json +1 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/backend-adapter.js +19 -10
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-self-address.js +17 -6
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/meta-sender-identity.js +1 -1
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/meta-session.js +172 -5
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/mux-fresh-call.js +152 -17
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/omp-fresh-preflight.js +271 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/meta-bridge-omp.js +988 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/meta-bridge-receive-omp.js +574 -0
- package/mcp/entwurf-bridge/dist/scripts/omp-receive-facts.js +84 -0
- package/mcp/entwurf-bridge/src/index.ts +65 -19
- package/mcp/entwurf-bridge/tsconfig.build.json +19 -3
- package/mcp/tsconfig.json +10 -0
- package/package.json +19 -9
- package/pi/entwurf-capabilities.json +1 -0
- package/pi/meta-bridge-omp/entwurf-meta-omp/package.json +7 -0
- package/pi/omp-receive/entwurf-receive-omp/package.json +7 -0
- package/pi-extensions/entwurf-control.ts +12 -9
- 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/pi-extensions/lib/entwurf-self-address.ts +18 -7
- package/pi-extensions/lib/meta-sender-identity.ts +1 -1
- package/pi-extensions/lib/meta-session.ts +219 -5
- package/pi-extensions/lib/mux-fresh-call.ts +171 -17
- package/pi-extensions/lib/omp-fresh-preflight.ts +275 -0
- package/pi-extensions/meta-bridge-omp.ts +1244 -0
- package/pi-extensions/meta-bridge-receive-omp.ts +666 -0
- package/run.sh +399 -31
- 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-entwurf-self-address.ts +31 -0
- package/scripts/check-gate-qualification.ts +7 -3
- package/scripts/check-harness-admission-parity.ts +143 -0
- package/scripts/check-meta-doctor-oracle.sh +20 -0
- package/scripts/check-omp-birth-hook.ts +1049 -0
- package/scripts/check-omp-fresh-preflight.ts +208 -0
- package/scripts/check-omp-receive-arm.ts +516 -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/inventory-verification-surface.ts +1 -1
- package/scripts/meta-bridge-hook-log.sh +9 -1
- package/scripts/mutants/acp-launch-namespace.json +34 -0
- package/scripts/mutants/acp-prompt-lifecycle.json +67 -2
- package/scripts/mutants/copilot-birth.json +10 -10
- package/scripts/mutants/mux-fresh-call.json +22 -22
- package/scripts/mutants/omp-birth.json +173 -0
- package/scripts/mutants/omp-fresh.json +300 -0
- package/scripts/mutants/omp-receive.json +135 -0
- package/scripts/mutants/pack-install.json +2 -2
- package/scripts/mutants/self-address.json +34 -0
- package/scripts/mutants/setup-verdict.json +35 -0
- package/scripts/omp-bridge-doctor.sh +315 -0
- package/scripts/omp-bridge-install.sh +221 -0
- package/scripts/omp-bridge-oracle.sh +154 -0
- package/scripts/omp-bridge-uninstall.sh +57 -0
- package/scripts/omp-config-xdev.py +310 -0
- package/scripts/omp-config-xdev.sh +76 -0
- package/scripts/omp-mcp-bridge.sh +320 -0
- package/scripts/omp-mcp-config.py +392 -0
- package/scripts/omp-receive-doctor.sh +246 -0
- package/scripts/omp-receive-facts.ts +106 -0
- package/scripts/omp-receive-install.sh +228 -0
- package/scripts/omp-receive-uninstall.sh +60 -0
- package/scripts/omp-tool-surface.py +400 -0
- 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/raw-omp-measure/README.md +420 -0
- package/scripts/raw-omp-measure/probe-extension.ts +76 -0
- package/scripts/raw-omp-measure/probe-receive-surface.ts +250 -0
- package/scripts/raw-omp-measure/source-audit.md +414 -0
- package/scripts/smoke-omp-bridge-state.sh +221 -0
- package/scripts/smoke-omp-fresh-live.ts +497 -0
- package/scripts/smoke-omp-mcp-state.sh +327 -0
- package/scripts/smoke-omp-receive-live.ts +340 -0
- package/scripts/smoke-omp-receive-state.sh +196 -0
- package/scripts/smoke-setup-verdict.sh +48 -3
- package/scripts/tsconfig.json +2 -0
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# omp-bridge-oracle.sh — the ONE structural oracle for the OMP birth unit (#87).
|
|
3
|
+
# Sourced by the installer, the inverse and the doctor so "valid assembly", "valid
|
|
4
|
+
# ownership state" and "which agent dir does omp actually read" cannot mean three
|
|
5
|
+
# different things in three files.
|
|
6
|
+
#
|
|
7
|
+
# omp_agent_dir
|
|
8
|
+
# Resolve the agent directory omp itself would read, or REFUSE. Prints the absolute
|
|
9
|
+
# path on rc 0; prints the reason on stderr and returns rc 1 when the environment is
|
|
10
|
+
# ambiguous. See the function header for why an inherited `PI_*` knob is a refusal
|
|
11
|
+
# rather than a guess.
|
|
12
|
+
#
|
|
13
|
+
# omp_assembly_valid <asm-root> <unit-name>
|
|
14
|
+
# rc 0 = the assembly is a complete unit (entry + lib + registry + package.json with
|
|
15
|
+
# `type: module`); rc 1 with the first failing reason on stderr.
|
|
16
|
+
#
|
|
17
|
+
# omp_state_read <state_file> <unit_dir> <asm>
|
|
18
|
+
# Fail-closed EXACT-schema reader for the ownership state — one validator for all
|
|
19
|
+
# three surfaces. rc 0 prints `<unitVersion> <entryName>`; any missing/unknown key,
|
|
20
|
+
# wrong type, empty string, relative path or constant drift is rc 1.
|
|
21
|
+
#
|
|
22
|
+
# Deliberately BOUNDED: environment, structure, one state schema. Records, markers and
|
|
23
|
+
# hook-log truth stay on the doctor's own axes.
|
|
24
|
+
|
|
25
|
+
# The vendor's own agent-dir resolution, reproduced only as far as this lane needs.
|
|
26
|
+
#
|
|
27
|
+
# `[source]` omp reads `PI_CONFIG_DIR` (default `.omp`) and `PI_CODING_AGENT_DIR`
|
|
28
|
+
# (`packages/utils/src/dirs.ts:23`, `:210`, `:358`), and selects a profile from
|
|
29
|
+
# `OMP_PROFILE` first, `PI_PROFILE` second (`dirs.ts:57`, `:85-91`), which moves the
|
|
30
|
+
# agent dir to `~/.omp/profiles/<name>/agent`.
|
|
31
|
+
#
|
|
32
|
+
# WHY A `PI_*` KNOB IS A REFUSAL AND NOT A LOOKUP (ledger M6). omp is a pi fork and kept
|
|
33
|
+
# pi's env vocabulary, so those three variables now steer TWO harnesses: entwurf's own pi
|
|
34
|
+
# rail sandboxes `PI_CODING_AGENT_DIR` (AGENTS.md Hard Rule 12) and omp reads the same
|
|
35
|
+
# name. A value in the installing shell therefore does not say WHICH harness it is
|
|
36
|
+
# addressing. Installing into a directory chosen by that guess is how an operator ends up
|
|
37
|
+
# with a unit no live omp reads, or a unit written into the pi rail's sandbox. `OMP_PROFILE`
|
|
38
|
+
# is omp's own name and is honoured; the pi-shaped names refuse, and `ENTWURF_OMP_AGENT_DIR`
|
|
39
|
+
# is the explicit override every gate and smoke passes.
|
|
40
|
+
omp_agent_dir() {
|
|
41
|
+
if [ -n "${ENTWURF_OMP_AGENT_DIR:-}" ]; then
|
|
42
|
+
python3 -c 'import os,sys;print(os.path.abspath(os.path.expanduser(sys.argv[1])))' "$ENTWURF_OMP_AGENT_DIR"
|
|
43
|
+
return 0
|
|
44
|
+
fi
|
|
45
|
+
if [ -n "${PI_CODING_AGENT_DIR:-}" ]; then
|
|
46
|
+
echo "PI_CODING_AGENT_DIR is set ($PI_CODING_AGENT_DIR). omp reads it too (utils/src/dirs.ts:358), so it does not say whether it is addressing omp or entwurf's pi rail — refusing to guess. Unset it, or pass ENTWURF_OMP_AGENT_DIR explicitly." >&2
|
|
47
|
+
return 1
|
|
48
|
+
fi
|
|
49
|
+
if [ -n "${PI_CONFIG_DIR:-}" ]; then
|
|
50
|
+
echo "PI_CONFIG_DIR is set ($PI_CONFIG_DIR). It renames omp's whole config root (utils/src/dirs.ts:23, :210) and is a pi-shaped name — refusing to guess. Unset it, or pass ENTWURF_OMP_AGENT_DIR explicitly." >&2
|
|
51
|
+
return 1
|
|
52
|
+
fi
|
|
53
|
+
if [ -n "${PI_PROFILE:-}" ] && [ -z "${OMP_PROFILE:-}" ]; then
|
|
54
|
+
echo "PI_PROFILE is set ($PI_PROFILE) with no OMP_PROFILE. omp falls back to it (utils/src/dirs.ts:85-91) but so does pi — refusing to guess which harness this profile addresses. Set OMP_PROFILE explicitly, or pass ENTWURF_OMP_AGENT_DIR." >&2
|
|
55
|
+
return 1
|
|
56
|
+
fi
|
|
57
|
+
local root="$HOME/.omp"
|
|
58
|
+
if [ -n "${OMP_PROFILE:-}" ]; then
|
|
59
|
+
case "$OMP_PROFILE" in
|
|
60
|
+
[a-z0-9]*)
|
|
61
|
+
case "$OMP_PROFILE" in
|
|
62
|
+
*[!a-z0-9._-]*)
|
|
63
|
+
echo "OMP_PROFILE '$OMP_PROFILE' is not a valid omp profile name (utils/src/dirs.ts PROFILE_NAME_RE)." >&2
|
|
64
|
+
return 1 ;;
|
|
65
|
+
esac
|
|
66
|
+
root="$HOME/.omp/profiles/$OMP_PROFILE" ;;
|
|
67
|
+
*)
|
|
68
|
+
echo "OMP_PROFILE '$OMP_PROFILE' is not a valid omp profile name (utils/src/dirs.ts PROFILE_NAME_RE)." >&2
|
|
69
|
+
return 1 ;;
|
|
70
|
+
esac
|
|
71
|
+
fi
|
|
72
|
+
printf '%s\n' "$root/agent"
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
omp_assembly_valid() {
|
|
76
|
+
local asm="$1" unit="$2"
|
|
77
|
+
local dir="$asm/$unit"
|
|
78
|
+
if [ ! -d "$dir" ]; then
|
|
79
|
+
echo "assembly has no unit directory: $dir" >&2
|
|
80
|
+
return 1
|
|
81
|
+
fi
|
|
82
|
+
local entry=""
|
|
83
|
+
if [ -f "$dir/index.ts" ]; then entry="index.ts"; elif [ -f "$dir/index.js" ]; then entry="index.js"; fi
|
|
84
|
+
if [ -z "$entry" ]; then
|
|
85
|
+
echo "assembly has no index.ts/index.js entry: $dir (omp discovers a subdirectory extension by that name, discovery/helpers.ts:700-710)" >&2
|
|
86
|
+
return 1
|
|
87
|
+
fi
|
|
88
|
+
local libext="ts"
|
|
89
|
+
[ "$entry" = "index.js" ] && libext="js"
|
|
90
|
+
for required in "package.json" "entwurf-capabilities.json" "lib/meta-session.$libext" "lib/session-id.js"; do
|
|
91
|
+
if [ ! -f "$dir/$required" ]; then
|
|
92
|
+
echo "assembly is missing $required: $dir/$required" >&2
|
|
93
|
+
return 1
|
|
94
|
+
fi
|
|
95
|
+
done
|
|
96
|
+
# `type: module` is not cosmetic on the installed-package shape: an ESM `index.js`
|
|
97
|
+
# under a directory with no nearest package.json type field is read as CommonJS.
|
|
98
|
+
if ! DIR_ENV="$dir" python3 - <<'PY'
|
|
99
|
+
import json, os, sys
|
|
100
|
+
pkg = json.load(open(os.path.join(os.environ["DIR_ENV"], "package.json")))
|
|
101
|
+
if pkg.get("type") != "module":
|
|
102
|
+
print(f"assembly package.json type is {pkg.get('type')!r}, want the literal \"module\"", file=sys.stderr)
|
|
103
|
+
sys.exit(1)
|
|
104
|
+
if not isinstance(pkg.get("version"), str) or not pkg["version"]:
|
|
105
|
+
print("assembly package.json has no non-empty version", file=sys.stderr)
|
|
106
|
+
sys.exit(1)
|
|
107
|
+
PY
|
|
108
|
+
then
|
|
109
|
+
return 1
|
|
110
|
+
fi
|
|
111
|
+
return 0
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
omp_state_read() {
|
|
115
|
+
local state_file="$1" unit_dir="$2" asm="$3"
|
|
116
|
+
STATE_FILE_ENV="$state_file" UNIT_DIR_ENV="$unit_dir" ASM_ENV="$asm" python3 - <<'PY'
|
|
117
|
+
import json, os, sys
|
|
118
|
+
try:
|
|
119
|
+
state = json.load(open(os.environ["STATE_FILE_ENV"]))
|
|
120
|
+
except (OSError, json.JSONDecodeError) as err:
|
|
121
|
+
print(f"state parse failed: {err}", file=sys.stderr)
|
|
122
|
+
sys.exit(1)
|
|
123
|
+
EXACT_KEYS = {"schemaVersion", "unitDir", "assemblyPath", "unitVersion", "entryName", "ownedUnitDir", "installedAt"}
|
|
124
|
+
if not isinstance(state, dict) or set(state) != EXACT_KEYS:
|
|
125
|
+
got = sorted(state) if isinstance(state, dict) else type(state).__name__
|
|
126
|
+
print(f"state keyset drifted: {got} != {sorted(EXACT_KEYS)}", file=sys.stderr)
|
|
127
|
+
sys.exit(1)
|
|
128
|
+
if state["schemaVersion"] != 1:
|
|
129
|
+
print(f"state schemaVersion is {state['schemaVersion']!r}, want the literal 1", file=sys.stderr)
|
|
130
|
+
sys.exit(1)
|
|
131
|
+
for key in ("unitDir", "assemblyPath", "unitVersion", "entryName", "installedAt"):
|
|
132
|
+
if not isinstance(state[key], str) or not state[key]:
|
|
133
|
+
print(f"state {key} must be a nonempty string, got {state[key]!r}", file=sys.stderr)
|
|
134
|
+
sys.exit(1)
|
|
135
|
+
if any(ord(c) < 0x21 or ord(c) == 0x7f for c in state["unitVersion"]):
|
|
136
|
+
print(f"state unitVersion contains whitespace/control characters: {state['unitVersion']!r} — "
|
|
137
|
+
"the space-separated fact transport would truncate it into a fabricated version", file=sys.stderr)
|
|
138
|
+
sys.exit(1)
|
|
139
|
+
if state["entryName"] not in ("index.ts", "index.js"):
|
|
140
|
+
print(f"state entryName is {state['entryName']!r}, want index.ts or index.js", file=sys.stderr)
|
|
141
|
+
sys.exit(1)
|
|
142
|
+
if not isinstance(state["ownedUnitDir"], bool):
|
|
143
|
+
print(f"state ownedUnitDir must be a boolean, got {state['ownedUnitDir']!r} — flags are never coerced", file=sys.stderr)
|
|
144
|
+
sys.exit(1)
|
|
145
|
+
for key, want in (("unitDir", os.environ["UNIT_DIR_ENV"]), ("assemblyPath", os.environ["ASM_ENV"])):
|
|
146
|
+
if not os.path.isabs(state[key]):
|
|
147
|
+
print(f"state {key} is not absolute: {state[key]!r}", file=sys.stderr)
|
|
148
|
+
sys.exit(1)
|
|
149
|
+
if os.path.abspath(state[key]) != os.path.abspath(want):
|
|
150
|
+
print(f"state {key} names {state[key]!r}, not this installation's {want!r}", file=sys.stderr)
|
|
151
|
+
sys.exit(1)
|
|
152
|
+
print(f"{state['unitVersion']} {state['entryName']}")
|
|
153
|
+
PY
|
|
154
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# omp-bridge-uninstall.sh — the honest inverse of install-omp-bridge (#87).
|
|
3
|
+
#
|
|
4
|
+
# HONEST means: remove exactly what the ownership state says this repo installed, and
|
|
5
|
+
# nothing else. No `--force`, no "clean up anything that looks like ours", no removal of
|
|
6
|
+
# a unit directory we cannot prove we placed. A no-state host is NOT an empty host: it is
|
|
7
|
+
# a host whose ownership we cannot establish, so the answer is a named refusal.
|
|
8
|
+
#
|
|
9
|
+
# The state is deleted LAST, so an interrupted inverse leaves the authority to retry.
|
|
10
|
+
set -euo pipefail
|
|
11
|
+
|
|
12
|
+
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
13
|
+
UNIT="entwurf-meta-omp"
|
|
14
|
+
ASM="${ENTWURF_OMP_ASM:-${XDG_DATA_HOME:-$HOME/.local/share}/entwurf/meta-bridge-omp/.assembled}"
|
|
15
|
+
STATE_DIR="${XDG_DATA_HOME:-$HOME/.local/share}/entwurf/omp-bridge"
|
|
16
|
+
STATE_FILE="$STATE_DIR/install-state.json"
|
|
17
|
+
|
|
18
|
+
die() { echo "[omp-bridge-uninstall] $*" >&2; exit 1; }
|
|
19
|
+
|
|
20
|
+
# shellcheck source=scripts/omp-bridge-oracle.sh
|
|
21
|
+
. "$HERE/omp-bridge-oracle.sh"
|
|
22
|
+
|
|
23
|
+
AGENT_DIR="$(omp_agent_dir)" || die "refusing: the omp agent directory this host reads is ambiguous (see above)."
|
|
24
|
+
UNIT_DIR="$AGENT_DIR/extensions/$UNIT"
|
|
25
|
+
|
|
26
|
+
[ -L "$STATE_FILE" ] && die "ownership state $STATE_FILE is a symlink — refusing to trust it."
|
|
27
|
+
if [ ! -f "$STATE_FILE" ]; then
|
|
28
|
+
die "no ownership state at $STATE_FILE, so nothing here is provably ours. Refusing with zero writes — if $UNIT_DIR is a stale hand-copied unit, remove it by hand."
|
|
29
|
+
fi
|
|
30
|
+
STATE_FACTS="$(omp_state_read "$STATE_FILE" "$UNIT_DIR" "$ASM")" \
|
|
31
|
+
|| die "ownership state $STATE_FILE is corrupt or names a different installation (see above) — refusing with zero writes."
|
|
32
|
+
STATE_ENTRY="${STATE_FACTS#* }"
|
|
33
|
+
|
|
34
|
+
if [ -e "$UNIT_DIR" ]; then
|
|
35
|
+
if [ -L "$UNIT_DIR" ]; then
|
|
36
|
+
die "$UNIT_DIR is a SYMLINK — this installer never creates one, so it is not ours. Refusing; remove it by hand."
|
|
37
|
+
fi
|
|
38
|
+
if [ ! -f "$UNIT_DIR/$STATE_ENTRY" ]; then
|
|
39
|
+
die "$UNIT_DIR exists but does not carry the entry this install recorded ($STATE_ENTRY) — it is not provably the unit we placed. Refusing with zero writes."
|
|
40
|
+
fi
|
|
41
|
+
rm -rf "$UNIT_DIR"
|
|
42
|
+
echo "[omp-bridge-uninstall] removed the installed unit: $UNIT_DIR"
|
|
43
|
+
else
|
|
44
|
+
echo "[omp-bridge-uninstall] the installed unit was already absent: $UNIT_DIR"
|
|
45
|
+
fi
|
|
46
|
+
|
|
47
|
+
if [ -d "$ASM" ]; then
|
|
48
|
+
rm -rf "$ASM"
|
|
49
|
+
echo "[omp-bridge-uninstall] removed the assembly: $ASM"
|
|
50
|
+
fi
|
|
51
|
+
|
|
52
|
+
rm -f "$STATE_FILE"
|
|
53
|
+
rmdir "$STATE_DIR" 2>/dev/null || true
|
|
54
|
+
echo "[omp-bridge-uninstall] ownership state removed: $STATE_FILE"
|
|
55
|
+
echo "[omp-bridge-uninstall] DONE. Existing omp sessions keep the record they already"
|
|
56
|
+
echo "minted — records are preserved, never deleted (AGENTS.md Hard Rule 8); what stops"
|
|
57
|
+
echo "is any FUTURE birth. The MCP hand is a separate inverse: ./run.sh uninstall-omp-mcp"
|
|
@@ -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
|