@junghanacs/entwurf 0.20.1 → 0.22.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (111) hide show
  1. package/AGENTS.md +85 -190
  2. package/BASELINE.md +6 -3
  3. package/CHANGELOG.md +473 -14
  4. package/CONTRIBUTING.md +1 -1
  5. package/DELIVERY.md +332 -60
  6. package/README.md +100 -22
  7. package/VERIFY.md +93 -7
  8. package/docs/acp-backend-rail.md +0 -1
  9. package/docs/external-mcp-host.md +64 -33
  10. package/docs/setup-clean-host.md +151 -17
  11. package/mcp/entwurf-bridge/dist/mcp/entwurf-bridge/src/index.js +130 -114
  12. package/mcp/entwurf-bridge/dist/pi-extensions/lib/codex-caller-seat.js +174 -0
  13. package/mcp/entwurf-bridge/dist/pi-extensions/lib/codex-fresh-preflight.js +519 -0
  14. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-fact-provider.js +22 -2
  15. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-peers-render.js +3 -1
  16. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-contract.js +6 -7
  17. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-native-push.js +30 -17
  18. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-production.js +7 -1
  19. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-runner.js +3 -2
  20. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-send.js +8 -4
  21. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-surface.js +7 -4
  22. package/mcp/entwurf-bridge/dist/pi-extensions/lib/meta-mailbox-body.js +20 -11
  23. package/mcp/entwurf-bridge/dist/pi-extensions/lib/meta-sender-identity.js +227 -1
  24. package/mcp/entwurf-bridge/dist/pi-extensions/lib/mux-fresh-call.js +189 -19
  25. package/mcp/entwurf-bridge/dist/pi-extensions/lib/native-push/adapter.js +10 -7
  26. package/mcp/entwurf-bridge/dist/pi-extensions/lib/native-push/codex-ws-client.js +403 -0
  27. package/mcp/entwurf-bridge/dist/pi-extensions/lib/native-push/register.js +4 -4
  28. package/mcp/entwurf-bridge/dist/pi-extensions/meta-bridge-hook-codex.js +325 -0
  29. package/mcp/entwurf-bridge/dist/scripts/codex-socket-path.js +30 -0
  30. package/mcp/entwurf-bridge/dist/scripts/meta-bridge-fresh-cut.js +6 -1
  31. package/mcp/entwurf-bridge/src/index.ts +154 -116
  32. package/mcp/entwurf-bridge/tsconfig.build.json +10 -0
  33. package/package.json +13 -4
  34. package/pi-extensions/entwurf-control.ts +71 -19
  35. package/pi-extensions/lib/codex-caller-seat.ts +204 -0
  36. package/pi-extensions/lib/codex-fresh-preflight.ts +580 -0
  37. package/pi-extensions/lib/compaction-send-guard.ts +80 -0
  38. package/pi-extensions/lib/entwurf-fact-provider.ts +29 -3
  39. package/pi-extensions/lib/entwurf-peers-render.ts +4 -1
  40. package/pi-extensions/lib/entwurf-v2-contract.ts +6 -7
  41. package/pi-extensions/lib/entwurf-v2-native-push.ts +35 -18
  42. package/pi-extensions/lib/entwurf-v2-production.ts +10 -3
  43. package/pi-extensions/lib/entwurf-v2-runner.ts +3 -2
  44. package/pi-extensions/lib/entwurf-v2-send.ts +16 -11
  45. package/pi-extensions/lib/entwurf-v2-surface.ts +7 -4
  46. package/pi-extensions/lib/meta-mailbox-body.ts +22 -13
  47. package/pi-extensions/lib/meta-sender-identity.ts +305 -0
  48. package/pi-extensions/lib/mux-fresh-call.ts +233 -29
  49. package/pi-extensions/lib/native-push/adapter.ts +21 -24
  50. package/pi-extensions/lib/native-push/codex-ws-client.ts +506 -0
  51. package/pi-extensions/lib/native-push/register.ts +7 -9
  52. package/pi-extensions/meta-bridge-hook-codex.ts +371 -0
  53. package/run.sh +342 -28
  54. package/scripts/check-agy-sender-identity.ts +1 -1
  55. package/scripts/check-codex-app-server-launch.ts +445 -0
  56. package/scripts/check-codex-birth-hook.ts +264 -0
  57. package/scripts/check-codex-bridge-identity.ts +179 -0
  58. package/scripts/check-codex-native-push.ts +386 -0
  59. package/scripts/check-codex-sender-identity.ts +495 -0
  60. package/scripts/check-compaction-send-guard.ts +130 -0
  61. package/scripts/check-copilot-receive-arm.ts +4 -1
  62. package/scripts/check-entwurf-fact-provider.ts +38 -0
  63. package/scripts/check-entwurf-peers-surface.ts +13 -1
  64. package/scripts/check-entwurf-self-address.ts +15 -16
  65. package/scripts/check-entwurf-v2-contract.ts +4 -3
  66. package/scripts/check-entwurf-v2-decider.ts +7 -5
  67. package/scripts/check-entwurf-v2-native-push.ts +35 -7
  68. package/scripts/check-entwurf-v2-production.ts +245 -12
  69. package/scripts/check-entwurf-v2-runner.ts +1 -1
  70. package/scripts/check-entwurf-v2-send.ts +26 -7
  71. package/scripts/check-entwurf-v2-surface.ts +1 -1
  72. package/scripts/check-gate-qualification.ts +8 -3
  73. package/scripts/check-harness-admission-parity.ts +0 -1
  74. package/scripts/check-mux-launch-tmux.ts +345 -4
  75. package/scripts/check-native-push-adapter.ts +20 -16
  76. package/scripts/check-native-push-register.ts +5 -1
  77. package/scripts/check-release-gate-outcomes.ts +47 -1
  78. package/scripts/check-setup-qualification.sh +3 -1
  79. package/scripts/codex-app-server-launch.sh +275 -0
  80. package/scripts/codex-birth-doctor.sh +276 -0
  81. package/scripts/codex-birth-install.sh +414 -0
  82. package/scripts/codex-birth-uninstall.sh +170 -0
  83. package/scripts/codex-mcp-config.py +435 -0
  84. package/scripts/codex-socket-path.ts +33 -0
  85. package/scripts/codex-statusline-config.py +434 -0
  86. package/scripts/codex-terminal-title-config.py +500 -0
  87. package/scripts/codex_toml_io.py +653 -0
  88. package/scripts/lib/codex-fresh-live-protocol.ts +121 -0
  89. package/scripts/lib/codex-fresh-source-receipts.ts +426 -0
  90. package/scripts/lib/launch-receipt-windows.ts +46 -0
  91. package/scripts/lib/tmux-coordinate-row.ts +58 -0
  92. package/scripts/meta-bridge-fresh-cut.ts +6 -1
  93. package/scripts/mutants/codex-app-server-launch.json +157 -0
  94. package/scripts/mutants/codex-caller-seat.json +336 -0
  95. package/scripts/mutants/codex-native.json +838 -0
  96. package/scripts/mutants/compaction-send-guard.json +103 -0
  97. package/scripts/mutants/entwurf-peers.json +19 -0
  98. package/scripts/mutants/mux-fresh-call.json +93 -10
  99. package/scripts/mutants/omp-fresh.json +6 -4
  100. package/scripts/mutants/release-gate.json +13 -0
  101. package/scripts/mutants/v2-surface.json +75 -1
  102. package/scripts/raw-async-delivery/README.md +2 -1
  103. package/scripts/raw-codex-measure/README.md +114 -46
  104. package/scripts/smoke-agy-native-push-live.ts +3 -1
  105. package/scripts/smoke-codex-birth.sh +347 -0
  106. package/scripts/smoke-codex-config-state.sh +700 -0
  107. package/scripts/smoke-codex-fresh-live.ts +1426 -0
  108. package/scripts/smoke-codex-native-push-live.ts +75 -0
  109. package/scripts/smoke-entwurf-chain-live.ts +50 -0
  110. package/scripts/smoke-setup-verdict.sh +125 -10
  111. package/scripts/tsconfig.json +1 -0
@@ -0,0 +1,414 @@
1
+ #!/usr/bin/env bash
2
+ # codex-birth-install — publish the Codex SessionStart birth unit. No root, ever.
3
+ #
4
+ # Three paths, all the operator's own:
5
+ #
6
+ # $CODEX_HOME/hooks.json ONE complete file, ours or absent
7
+ # ${XDG_DATA_HOME:-~/.local/share}/entwurf/codex-birth/helper/
8
+ # launcher + the TS payload closure
9
+ # .../entwurf/codex-birth/install-state.json the digest inventory that licenses removal
10
+ #
11
+ # WHY THIS IS NOT A SYSTEM UNIT. An earlier candidate declared the same hook in the MANAGED
12
+ # `/etc` config layer, where the vendor skips its trust check entirely (`[source]`
13
+ # hooks/src/engine/discovery.rs:794-815). That bought a prompt-free install and charged
14
+ # root for it — and on a user-installed Codex (NixOS, pnpm-global) root is not a thing the
15
+ # operator agreed to spend. A user declaration is not rejected; it is TRUST-GATED. It runs
16
+ # after the operator answers "Trust all" ONCE, in their own visible Codex, and the vendor
17
+ # records that decision itself. Measured on this host: after that single answer, later
18
+ # plain sessions raise no prompt and are born automatically.
19
+ #
20
+ # THIS UNIT NEVER TOUCHES THAT DECISION. It does not compute, write, pre-seed or read a
21
+ # `trusted_hash`, and it never reads or writes config.toml — the file where the vendor keeps
22
+ # `[hooks.state]` (and where entwurf's two other user atoms live). Trust is the operator's to
23
+ # give; forging it in a file we already own would be the one shortcut that turns a security
24
+ # prompt into a silent install.
25
+ #
26
+ # THE TRUST IDENTITY IS THE LAUNCHER PATH. `[source]` discovery.rs:775-792 hashes a
27
+ # normalized identity of (event name + matcher group + the single handler) — so `type`, the
28
+ # quoted absolute `command`, `timeout` and the absent `matcher` ARE the operator's approval,
29
+ # while the launcher's CONTENTS, the payload closure, this unit's version and the hooks.json
30
+ # `description` are not. That asymmetry is the whole reason a user unit is affordable: the
31
+ # closure can be upgraded on every release without ever asking the operator again, as long as
32
+ # the launcher path and those three declaration fields never move.
33
+ set -euo pipefail
34
+
35
+ # A hostile inherited umask must not decide the mode of a directory this unit creates. The
36
+ # ownership preflight below refuses an EXISTING group/world-writable path, but a directory we
37
+ # make ourselves never passes through that check — measured: `umask 000` published the unit
38
+ # root, helper and lib directories 0777, i.e. a closure anyone on the host could rewrite
39
+ # between the digest we record and the exec codex performs. Both halves are fixed: this umask
40
+ # floors what the process can create, and every directory is chmod'ed to its exact mode after
41
+ # creation so the result does not depend on inheriting anything.
42
+ umask 022
43
+
44
+ die() { printf '[codex-birth-install] %s\n' "$1" >&2; exit 1; }
45
+ note() { printf '[codex-birth-install] %s\n' "$1"; }
46
+
47
+ for arg in "$@"; do
48
+ case "$arg" in
49
+ *) die "unknown argument '$arg' — this installer takes no options; its paths are fixed so the trust identity cannot drift." ;;
50
+ esac
51
+ done
52
+
53
+ [ "$(uname -s)" = "Linux" ] || die "Linux only: this unit is measured against a Linux Codex CLI and publishes Linux paths."
54
+ [ "$(id -u)" != "0" ] || die "refusing to run as root: every path this unit owns belongs to the operator, and publishing them as root would leave bytes they cannot replace or remove."
55
+
56
+ REPO="$(cd -P -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)"
57
+ CODEX_HOME="${CODEX_HOME:-$HOME/.codex}"
58
+ HOOKS_FILE="$CODEX_HOME/hooks.json"
59
+ PACKAGE_STATE_ROOT="${XDG_DATA_HOME:-$HOME/.local/share}/entwurf"
60
+ UNIT_ROOT="$PACKAGE_STATE_ROOT/codex-birth"
61
+ HELPER_DIR="$UNIT_ROOT/helper"
62
+ LAUNCHER_NAME="codex-birth-launch.sh"
63
+ LAUNCHER="$HELPER_DIR/$LAUNCHER_NAME"
64
+ STATE_FILE="$UNIT_ROOT/install-state.json"
65
+ PAYLOAD_NAME="meta-bridge-hook-codex.ts"
66
+
67
+ # Every path below is derived from the operator's environment, so a relative CODEX_HOME or
68
+ # XDG_DATA_HOME would make "the fixed launcher path" depend on the caller's cwd — and that
69
+ # path IS the identity the vendor trust receipt is keyed to. A declaration whose meaning
70
+ # changes with `cd` is not an identity; refuse before anything is read or written.
71
+ case "$CODEX_HOME" in /*) : ;; *) die "CODEX_HOME resolves to a relative path ('$CODEX_HOME'); the declaration records an absolute launcher path and the vendor keys its trust receipt to it. Nothing written." ;; esac
72
+ case "$UNIT_ROOT" in /*) : ;; *) die "the unit root resolves to a relative path ('$UNIT_ROOT') — set an absolute XDG_DATA_HOME or HOME. Nothing written." ;; esac
73
+
74
+ command -v sha256sum >/dev/null 2>&1 || die "sha256sum is required (every published byte is recorded by digest) and is not on PATH."
75
+ NODE_BIN="$(command -v node)" || die "node is not on PATH — the hook payload runs under it."
76
+ NODE_BIN="$(cd -P -- "$(dirname -- "$NODE_BIN")" && pwd)/$(basename -- "$NODE_BIN")"
77
+ [ -x "$NODE_BIN" ] || die "resolved node is not executable: $NODE_BIN"
78
+ case "$NODE_BIN" in /*) : ;; *) die "resolved node path is not absolute: $NODE_BIN" ;; esac
79
+ NODE_MAJOR="$("$NODE_BIN" -p 'process.versions.node.split(".")[0]' 2>/dev/null || echo 0)"
80
+ [ "$NODE_MAJOR" -ge 24 ] 2>/dev/null || die "node >= 24 is required (the payload is stripped, not compiled); $NODE_BIN reports major '$NODE_MAJOR'."
81
+
82
+ PAYLOAD_SRC="$REPO/pi-extensions/$PAYLOAD_NAME"
83
+ LIB_SRC="$REPO/pi-extensions/lib/meta-session.ts"
84
+ CODEX_CLIENT_SRC="$REPO/pi-extensions/lib/native-push/codex-ws-client.ts"
85
+ SESSION_ID_SRC="$REPO/pi-extensions/lib/session-id.js"
86
+ REGISTRY_SRC="$REPO/pi/entwurf-capabilities.json"
87
+ for f in "$PAYLOAD_SRC" "$LIB_SRC" "$CODEX_CLIENT_SRC" "$SESSION_ID_SRC" "$REGISTRY_SRC"; do
88
+ [ -f "$f" ] || die "closure member missing: $f (reinstall the package, or check out the repo completely)."
89
+ done
90
+
91
+ UNIT_VERSION="$("$NODE_BIN" -p 'require("'"$REPO"'/package.json").version' 2>/dev/null || true)"
92
+ case "$UNIT_VERSION" in
93
+ ""|*[![:graph:]]*) die "could not read a single printable version token from $REPO/package.json (got '${UNIT_VERSION:-<empty>}') — it travels into the ownership state." ;;
94
+ esac
95
+
96
+ sha_of() { sha256sum -- "$1" | cut -d' ' -f1; }
97
+ ME="$(id -u)"
98
+
99
+ # A path we are allowed to own: present, not a link, ours, and not writable by anyone else.
100
+ # The subject is the operator: nothing here is licensed by root, and nothing asks for it.
101
+ safe_own_dir() { # $1 = dir, $2 = label
102
+ local st mode uid
103
+ st="$(stat -c '%f %a %u' -- "$1" 2>/dev/null)" || die "$2 ($1) cannot be stat'ed. Nothing written."
104
+ mode="$(printf '%s' "$st" | cut -d' ' -f2)"; uid="$(printf '%s' "$st" | cut -d' ' -f3)"
105
+ [ -L "$1" ] && die "$2 ($1) is a SYMLINK — this unit publishes through no link. Nothing written."
106
+ [ -d "$1" ] || die "$2 ($1) is not a directory. Nothing written."
107
+ [ "$uid" = "$ME" ] || die "$2 ($1) is owned by uid $uid, not you ($ME). Nothing written."
108
+ [ $((8#$mode & 0022)) -eq 0 ] || die "$2 ($1) is group/world-writable (mode $mode), so its contents are not provably ours. Nothing written."
109
+ }
110
+
111
+ # ── ownership preflight — READ-ONLY, and every refusal is zero-write ─────────
112
+ # Three different facts, three different refusals, none of them silent:
113
+ # a foreign hooks.json (no state behind it), a hand-edited one (state disagrees), and a
114
+ # symlink (whose target could be any file on the host).
115
+ # The directory holding the ownership state is part of that state's authority: anyone who can
116
+ # write it can replace the receipt that licenses every removal below. So it is judged BEFORE
117
+ # the state is read, and it is NEVER repaired on the way past — silently chmod-ing an unsafe
118
+ # directory we found would erase the evidence that it was unsafe and then trust it.
119
+ [ -e "$PACKAGE_STATE_ROOT" ] && safe_own_dir "$PACKAGE_STATE_ROOT" "the package state root"
120
+ [ -e "$UNIT_ROOT" ] && safe_own_dir "$UNIT_ROOT" "the unit root (it holds the ownership state)"
121
+
122
+ STATE_PRESENT=0
123
+ RECORDED_HOOKS_SHA=""
124
+ if [ -e "$STATE_FILE" ] || [ -L "$STATE_FILE" ]; then
125
+ [ -L "$STATE_FILE" ] && die "$STATE_FILE is a symlink — refusing to take replacement authority from a link. Nothing written."
126
+ [ -f "$STATE_FILE" ] || die "$STATE_FILE is not a regular file. Nothing written."
127
+ STATE_ST="$(stat -c '%a %u' -- "$STATE_FILE")"
128
+ STATE_MODE="$(printf '%s' "$STATE_ST" | cut -d' ' -f1)"; STATE_UID="$(printf '%s' "$STATE_ST" | cut -d' ' -f2)"
129
+ [ "$STATE_UID" = "$ME" ] || die "$STATE_FILE is owned by uid $STATE_UID, not you ($ME) — it licenses deletions, so a foreign owner means someone else chooses what this removes. Nothing written."
130
+ [ $((8#$STATE_MODE & 0022)) -eq 0 ] || die "$STATE_FILE is group/world-writable (mode $STATE_MODE) — the same authority problem. Nothing written."
131
+ STATE_READ="$("$NODE_BIN" -e '
132
+ const s = JSON.parse(require("node:fs").readFileSync(process.argv[1], "utf8"));
133
+ const path = require("node:path");
134
+ if (s.schema !== "codex-birth-install-state/v1") throw new Error("foreign state schema " + JSON.stringify(s.schema));
135
+ // A state this unit never wrote — or one left in a status it does not define — is not a
136
+ // receipt, however well its digests happen to match. `publishing` is the one interrupted
137
+ // generation this installer knows how to finish.
138
+ if (s.status !== "installed" && s.status !== "publishing") throw new Error("unknown state status " + JSON.stringify(s.status));
139
+ if (s.hooksFile !== process.argv[2]) throw new Error("state is bound to " + s.hooksFile + ", not " + process.argv[2]);
140
+ if (s.helperDir !== process.argv[3]) throw new Error("state is bound to helper dir " + s.helperDir);
141
+ if (!/^[0-9a-f]{64}$/.test(s.hooksSha256)) throw new Error("hooksSha256 is not a digest");
142
+ // The inventory is the removal/replacement authority, so it must be EXACTLY the closure
143
+ // this unit publishes: a state naming fewer members would leave an unlisted file in the
144
+ // helper directory that nothing can reclaim, and one naming more would license deleting
145
+ // something we never wrote.
146
+ const expected = [
147
+ "codex-birth-launch.sh",
148
+ "meta-bridge-hook-codex.ts",
149
+ "lib/meta-session.ts",
150
+ "lib/native-push/codex-ws-client.ts",
151
+ "lib/session-id.js",
152
+ "entwurf-capabilities.json",
153
+ ];
154
+ if (!Array.isArray(s.helperFiles)) throw new Error("helperFiles is not an array");
155
+ const named = s.helperFiles.map((f) => (f == null ? "" : f.path));
156
+ const missing = expected.filter((name) => !named.includes(name));
157
+ const extra = named.filter((name) => !expected.includes(name));
158
+ const duplicate = named.filter((name, i) => named.indexOf(name) !== i);
159
+ if (missing.length || extra.length || duplicate.length) {
160
+ throw new Error(
161
+ "the recorded closure inventory is not the exact six members this unit publishes" +
162
+ (missing.length ? "; missing " + missing.join(", ") : "") +
163
+ (extra.length ? "; unexpected " + extra.join(", ") : "") +
164
+ (duplicate.length ? "; duplicated " + duplicate.join(", ") : ""),
165
+ );
166
+ }
167
+ const lines = ["HOOKSSHA\t" + s.hooksSha256];
168
+ for (const f of s.helperFiles) {
169
+ if (typeof f?.path !== "string" || f.path.length === 0 || f.path.startsWith("/") || f.path.split("/").includes("..")) {
170
+ throw new Error("unsafe helper path " + JSON.stringify(f?.path));
171
+ }
172
+ if (!/^[0-9a-f]{64}$/.test(f.sha256)) throw new Error("bad helper digest for " + f.path);
173
+ lines.push(["HELPER", f.sha256, path.join(s.helperDir, f.path)].join("\t"));
174
+ }
175
+ process.stdout.write(lines.join("\n"));
176
+ ' "$STATE_FILE" "$HOOKS_FILE" "$HELPER_DIR" 2>&1)" || die "the ownership state is malformed or bound elsewhere, so replacement authority is UNKNOWN: $STATE_READ
177
+ Move $STATE_FILE aside deliberately and re-run. Nothing written."
178
+ RECORDED_HOOKS_SHA="$(printf '%s\n' "$STATE_READ" | sed -n 's/^HOOKSSHA\t//p')"
179
+ STATE_PRESENT=1
180
+
181
+ # The closure is licensed by the SAME rule as the declaration, and for the same reason.
182
+ # A reinstall publishes a new generation over these exact paths, so a member that no
183
+ # longer matches the digest the old state recorded is somebody's edit — and a `cp` over
184
+ # it would destroy the one copy of a change nobody can read back. This unit repairs
185
+ # nothing it cannot prove it wrote: present-and-different is a REFUSAL here, not a fix.
186
+ #
187
+ # ABSENT is deliberately not drift, mirroring the inverse's own vocabulary ("already
188
+ # absent" is done, not DRIFT): a publish interrupted between the state write and the
189
+ # closure copy leaves members missing, and refusing those would make a crash
190
+ # unrecoverable by the very command that repairs it.
191
+ while IFS="$(printf '\t')" read -r kind want target; do
192
+ [ "$kind" = "HELPER" ] || continue
193
+ if [ -L "$target" ]; then
194
+ die "$target is a SYMLINK now — this unit published a regular file there and refuses to write through a link. Refusing; nothing written."
195
+ fi
196
+ if [ ! -e "$target" ]; then
197
+ note "closure member absent, will be republished: $target"
198
+ continue
199
+ fi
200
+ [ -f "$target" ] || die "$target is no longer a regular file. Refusing; nothing written."
201
+ MEMBER_ST="$(stat -c '%a %u' -- "$target")"
202
+ MEMBER_MODE="$(printf '%s' "$MEMBER_ST" | cut -d' ' -f1)"
203
+ MEMBER_UID="$(printf '%s' "$MEMBER_ST" | cut -d' ' -f2)"
204
+ [ "$MEMBER_UID" = "$ME" ] || die "$target is owned by uid $MEMBER_UID, not you ($ME) — it is not ours to replace. Refusing; nothing written."
205
+ [ $((8#$MEMBER_MODE & 0022)) -eq 0 ] || die "$target is group/world-writable (mode $MEMBER_MODE), so its current bytes are not provably ours. Refusing; nothing written."
206
+ MEMBER_LIVE="$(sha_of "$target")"
207
+ if [ "$MEMBER_LIVE" != "$want" ]; then
208
+ die "$target was edited after install (live $MEMBER_LIVE, recorded $want).
209
+ Republishing would throw away an edit this unit cannot read back. Refusing; nothing written.
210
+ If the edit was yours, keep it and stop using this unit; otherwise run the inverse first."
211
+ fi
212
+ done <<EOF
213
+ $STATE_READ
214
+ EOF
215
+ fi
216
+
217
+ if [ -L "$HOOKS_FILE" ]; then
218
+ die "$HOOKS_FILE is a SYMLINK. This unit owns that path as ONE regular file and never publishes through a link. Refusing; nothing written."
219
+ fi
220
+ if [ -e "$HOOKS_FILE" ]; then
221
+ [ -f "$HOOKS_FILE" ] || die "$HOOKS_FILE exists and is not a regular file. Refusing; nothing written."
222
+ LIVE_HOOKS_SHA="$(sha_of "$HOOKS_FILE")"
223
+ if [ "$STATE_PRESENT" = "0" ]; then
224
+ die "$HOOKS_FILE already exists and this host has NO entwurf ownership state ($STATE_FILE).
225
+ This unit owns hooks.json as ONE COMPLETE FILE, so adopting it would silently delete hook
226
+ declarations somebody else wrote. Refusing; nothing written.
227
+ Move it aside deliberately and re-run — or declare your own hooks in config.toml, which the
228
+ vendor merges with this file (both sources load per layer)."
229
+ fi
230
+ if [ "$LIVE_HOOKS_SHA" != "$RECORDED_HOOKS_SHA" ]; then
231
+ die "$HOOKS_FILE was edited after install (live $LIVE_HOOKS_SHA, recorded $RECORDED_HOOKS_SHA).
232
+ Republishing would throw away an edit this unit cannot read back. Refusing; nothing written.
233
+ If the edit was yours, keep it and stop using this unit; otherwise run the inverse first."
234
+ fi
235
+ note "adopting the hooks.json this unit published (sha256 $LIVE_HOOKS_SHA) — it will be republished."
236
+ fi
237
+
238
+ if [ -e "$HELPER_DIR" ] || [ -L "$HELPER_DIR" ]; then
239
+ [ "$STATE_PRESENT" = "1" ] || die "$HELPER_DIR already exists and this host has NO entwurf ownership state ($STATE_FILE) — its contents are not ours to replace. Refusing; nothing written.
240
+ Move it aside deliberately and re-run."
241
+ safe_own_dir "$HELPER_DIR" "the helper directory"
242
+ fi
243
+ [ -e "$CODEX_HOME" ] && safe_own_dir "$CODEX_HOME" "the Codex config folder"
244
+
245
+ # ── stage — everything is built and digested before ANY published path moves ─
246
+ STAGE="$(mktemp -d)"
247
+ HOOKS_TMP="$(mktemp)"
248
+ STATE_TMP="$(mktemp)"
249
+ cleanup() { rm -rf -- "$STAGE" "$HOOKS_TMP" "$STATE_TMP"; }
250
+ trap cleanup EXIT
251
+
252
+ mkdir -p -- "$STAGE/lib/native-push"
253
+ cp -- "$PAYLOAD_SRC" "$STAGE/$PAYLOAD_NAME"
254
+ cp -- "$LIB_SRC" "$STAGE/lib/meta-session.ts"
255
+ cp -- "$CODEX_CLIENT_SRC" "$STAGE/lib/native-push/codex-ws-client.ts"
256
+ cp -- "$SESSION_ID_SRC" "$STAGE/lib/session-id.js"
257
+ cp -- "$REGISTRY_SRC" "$STAGE/entwurf-capabilities.json"
258
+
259
+ # The launcher is the indirection the trust identity points at. Its bytes are NOT hashed by
260
+ # the vendor, which is exactly why the node path and the payload location live in here and
261
+ # not in the declaration: this file may be rewritten on every upgrade, the declaration may
262
+ # not. `exec`, not call — the payload keeps this pid.
263
+ cat > "$STAGE/$LAUNCHER_NAME" <<LAUNCHER
264
+ #!/usr/bin/env bash
265
+ # GENERATED by codex-birth-install.sh (entwurf $UNIT_VERSION) — DO NOT EDIT.
266
+ # Its exact bytes are recorded in $STATE_FILE; an edit makes the doctor red and makes the
267
+ # inverse refuse to remove this file.
268
+ set -uo pipefail
269
+
270
+ NODE_BIN="$NODE_BIN"
271
+ PAYLOAD="$HELPER_DIR/$PAYLOAD_NAME"
272
+
273
+ if [ ! -x "\$NODE_BIN" ]; then
274
+ echo "entwurf codex-birth: the recorded node is missing or not executable: \$NODE_BIN" >&2
275
+ echo "entwurf codex-birth: re-run './run.sh install-codex-birth' after a node change." >&2
276
+ exit 1
277
+ fi
278
+ if [ ! -f "\$PAYLOAD" ]; then
279
+ echo "entwurf codex-birth: hook payload missing: \$PAYLOAD (half-removed install?)" >&2
280
+ exit 1
281
+ fi
282
+
283
+ exec "\$NODE_BIN" --experimental-strip-types --disable-warning=ExperimentalWarning "\$PAYLOAD"
284
+ LAUNCHER
285
+ chmod 0755 -- "$STAGE/$LAUNCHER_NAME"
286
+ chmod 0644 -- "$STAGE/$PAYLOAD_NAME" "$STAGE/lib/meta-session.ts" "$STAGE/lib/native-push/codex-ws-client.ts" "$STAGE/lib/session-id.js" "$STAGE/entwurf-capabilities.json"
287
+
288
+ LAUNCHER_SHA="$(sha_of "$STAGE/$LAUNCHER_NAME")"
289
+ PAYLOAD_SHA="$(sha_of "$STAGE/$PAYLOAD_NAME")"
290
+ LIB_SHA="$(sha_of "$STAGE/lib/meta-session.ts")"
291
+ CODEX_CLIENT_SHA="$(sha_of "$STAGE/lib/native-push/codex-ws-client.ts")"
292
+ SESSION_ID_SHA="$(sha_of "$STAGE/lib/session-id.js")"
293
+ REGISTRY_SHA="$(sha_of "$STAGE/entwurf-capabilities.json")"
294
+
295
+ # ── the declaration — the ONLY bytes the operator's trust decision covers ────
296
+ # `[source]` hooks.json is `HooksFile { description?, hooks }` (config/src/hook_config.rs:12-17),
297
+ # each event a list of `MatcherGroup { matcher?, hooks }` (:36-61, :153-159), each handler a
298
+ # `#[serde(tag="type")]` variant (:161-200). Three omissions carry meaning:
299
+ # matcher omitted, so this fires for all four SessionStart sources; "" is NOT the same.
300
+ # async omitted: birth must be SYNCHRONOUS — the MCP child of the same turn resolves its
301
+ # identity from the record this hook writes.
302
+ # state not a hooks.json key at all; `[hooks.state]` is the vendor's, in config.toml, and
303
+ # this unit neither reads nor writes that file.
304
+ cat > "$HOOKS_TMP" <<HOOKSJSON
305
+ {
306
+ "description": "entwurf codex-birth $UNIT_VERSION — mints one garden meta-record per top-level Codex thread on its first turn. Managed by scripts/codex-birth-install.sh; inverse: scripts/codex-birth-uninstall.sh. Do not edit: the exact bytes are recorded in $STATE_FILE. The vendor's trust decision for this declaration lives in config.toml and is the operator's alone.",
307
+ "hooks": {
308
+ "SessionStart": [
309
+ {
310
+ "hooks": [
311
+ {
312
+ "type": "command",
313
+ "command": "'$LAUNCHER'",
314
+ "timeout": 30
315
+ }
316
+ ]
317
+ }
318
+ ]
319
+ }
320
+ }
321
+ HOOKSJSON
322
+ chmod 0644 -- "$HOOKS_TMP"
323
+
324
+ # Prove the bytes are the grammar we think they are BEFORE they are published: a broken
325
+ # hooks.json is a hook nobody declared and nobody can see failing.
326
+ "$NODE_BIN" -e '
327
+ const fs = require("node:fs");
328
+ const f = JSON.parse(fs.readFileSync(process.argv[1], "utf8"));
329
+ if (Object.keys(f).sort().join(",") !== "description,hooks") throw new Error("hooks.json top level must be exactly description+hooks");
330
+ const groups = f.hooks?.SessionStart;
331
+ if (Object.keys(f.hooks).join(",") !== "SessionStart") throw new Error("this unit declares SessionStart and nothing else");
332
+ if (!Array.isArray(groups) || groups.length !== 1) throw new Error("hooks.SessionStart must be a single matcher group");
333
+ if ("matcher" in groups[0]) throw new Error("the matcher group must carry no matcher key");
334
+ if (Object.keys(groups[0]).join(",") !== "hooks") throw new Error("the matcher group must carry no key other than hooks");
335
+ const hs = groups[0].hooks;
336
+ if (!Array.isArray(hs) || hs.length !== 1) throw new Error("the matcher group must hold exactly one handler");
337
+ if (Object.keys(hs[0]).sort().join(",") !== "command,timeout,type") throw new Error("the handler must carry exactly type+command+timeout — every extra key changes the trust identity");
338
+ if (hs[0].type !== "command") throw new Error("the handler type must be command");
339
+ if (hs[0].timeout !== 30) throw new Error("the handler timeout must be 30 — it is part of the hash the operator trusts");
340
+ if (hs[0].command !== "'"'"'" + process.argv[2] + "'"'"'") throw new Error("the command must be the single-quoted fixed launcher path, got " + hs[0].command);
341
+ ' "$HOOKS_TMP" "$LAUNCHER" || die "the staged hooks.json is not the exact declaration this unit publishes. Nothing written."
342
+ HOOKS_SHA="$(sha_of "$HOOKS_TMP")"
343
+
344
+ write_state() { # $1 = status
345
+ # Created here only when absent — the safe umask floors it and the explicit mode removes any
346
+ # doubt. An EXISTING root was judged above and is left exactly as it was found.
347
+ if [ ! -e "$UNIT_ROOT" ]; then mkdir -p -- "$UNIT_ROOT" && chmod 0755 -- "$UNIT_ROOT"; fi
348
+ cat > "$STATE_TMP" <<STATE
349
+ {
350
+ "schema": "codex-birth-install-state/v1",
351
+ "status": "$1",
352
+ "unitVersion": "$UNIT_VERSION",
353
+ "writtenAt": "$(date -u +%Y-%m-%dT%H:%M:%SZ)",
354
+ "nodeBin": "$NODE_BIN",
355
+ "hooksFile": "$HOOKS_FILE",
356
+ "hooksSha256": "$HOOKS_SHA",
357
+ "hooksExistedBefore": $HOOKS_EXISTED,
358
+ "helperDir": "$HELPER_DIR",
359
+ "helperFiles": [
360
+ { "path": "$LAUNCHER_NAME", "sha256": "$LAUNCHER_SHA", "mode": "0755" },
361
+ { "path": "$PAYLOAD_NAME", "sha256": "$PAYLOAD_SHA", "mode": "0644" },
362
+ { "path": "lib/meta-session.ts", "sha256": "$LIB_SHA", "mode": "0644" },
363
+ { "path": "lib/native-push/codex-ws-client.ts", "sha256": "$CODEX_CLIENT_SHA", "mode": "0644" },
364
+ { "path": "lib/session-id.js", "sha256": "$SESSION_ID_SHA", "mode": "0644" },
365
+ { "path": "entwurf-capabilities.json", "sha256": "$REGISTRY_SHA", "mode": "0644" }
366
+ ]
367
+ }
368
+ STATE
369
+ chmod 0644 -- "$STATE_TMP"
370
+ cp -- "$STATE_TMP" "$STATE_TMP.pub"
371
+ mv -f -- "$STATE_TMP.pub" "$STATE_FILE"
372
+ }
373
+
374
+ HOOKS_EXISTED=false
375
+ [ -f "$HOOKS_FILE" ] && HOOKS_EXISTED=true
376
+
377
+ # ── publish — state first, closure second, declaration last ─────────────────
378
+ # ORDER IS LOAD-BEARING twice over. The state goes first so a crash anywhere below still
379
+ # leaves removal authority for whatever reached disk. The launcher goes before hooks.json
380
+ # because the declaration NAMES it: the reverse order leaves a window where every new Codex
381
+ # session runs a hook command that is not there.
382
+ write_state publishing
383
+
384
+ # Exact modes for what we create, never a repair of what we found (see the umask note at the
385
+ # top and the parent-authority check above).
386
+ for dir in "$HELPER_DIR" "$HELPER_DIR/lib" "$HELPER_DIR/lib/native-push"; do
387
+ if [ ! -e "$dir" ]; then mkdir -p -- "$dir" && chmod 0755 -- "$dir"; fi
388
+ done
389
+ cp -- "$STAGE/$LAUNCHER_NAME" "$HELPER_DIR/$LAUNCHER_NAME.tmp" && mv -f -- "$HELPER_DIR/$LAUNCHER_NAME.tmp" "$LAUNCHER"
390
+ cp -- "$STAGE/$PAYLOAD_NAME" "$HELPER_DIR/$PAYLOAD_NAME"
391
+ cp -- "$STAGE/lib/meta-session.ts" "$HELPER_DIR/lib/meta-session.ts"
392
+ cp -- "$STAGE/lib/native-push/codex-ws-client.ts" "$HELPER_DIR/lib/native-push/codex-ws-client.ts"
393
+ cp -- "$STAGE/lib/session-id.js" "$HELPER_DIR/lib/session-id.js"
394
+ cp -- "$STAGE/entwurf-capabilities.json" "$HELPER_DIR/entwurf-capabilities.json"
395
+ chmod 0755 -- "$LAUNCHER"
396
+ chmod 0644 -- "$HELPER_DIR/$PAYLOAD_NAME" "$HELPER_DIR/lib/meta-session.ts" "$HELPER_DIR/lib/native-push/codex-ws-client.ts" "$HELPER_DIR/lib/session-id.js" "$HELPER_DIR/entwurf-capabilities.json"
397
+
398
+ if [ ! -e "$CODEX_HOME" ]; then mkdir -p -- "$CODEX_HOME" && chmod 0755 -- "$CODEX_HOME"; fi
399
+ # Strongest idempotence is an untouched file: identical bytes are not rewritten, so a
400
+ # re-run leaves mtime — and any vendor state keyed off it — alone.
401
+ if [ -f "$HOOKS_FILE" ] && [ "$(sha_of "$HOOKS_FILE")" = "$HOOKS_SHA" ]; then
402
+ note "hooks.json is already the exact declaration — not rewritten"
403
+ else
404
+ cp -- "$HOOKS_TMP" "$HOOKS_FILE.tmp" && mv -f -- "$HOOKS_FILE.tmp" "$HOOKS_FILE"
405
+ fi
406
+
407
+ write_state installed
408
+
409
+ note "installed"
410
+ note " declaration : $HOOKS_FILE (sha256 $HOOKS_SHA)"
411
+ note " launcher : $LAUNCHER"
412
+ note " state : $STATE_FILE"
413
+ note "The vendor will ask the operator to trust this declaration ONCE, in the Codex TUI."
414
+ note "This unit does not answer that prompt and records nothing about it."
@@ -0,0 +1,170 @@
1
+ #!/usr/bin/env bash
2
+ # codex-birth-uninstall — the inverse of codex-birth-install.sh.
3
+ #
4
+ # The ownership state is the ONLY input. Every removal is licensed per-byte by a digest it
5
+ # recorded, so this inverse can say three different things about every path it published and
6
+ # never guesses at a fourth:
7
+ #
8
+ # * already absent -> done, not drift.
9
+ # * bytes match the state -> removed.
10
+ # * bytes DRIFTED -> refuse THAT path, name it, leave it, exit non-zero. Somebody
11
+ # edited it, and deleting an edit nobody can read back is worse
12
+ # than leaving a file behind.
13
+ #
14
+ # It never reads or writes config.toml, so the vendor's `[hooks.state]` trust record survives
15
+ # this inverse untouched. Removing the declaration does not un-trust it; re-publishing the
16
+ # SAME declaration later reuses the operator's original approval, because the trust identity
17
+ # is the declaration, not the file's history.
18
+ set -euo pipefail
19
+
20
+ die() { printf '[codex-birth-uninstall] %s\n' "$1" >&2; exit 1; }
21
+ note() { printf '[codex-birth-uninstall] %s\n' "$1"; }
22
+
23
+ for arg in "$@"; do
24
+ case "$arg" in
25
+ *) die "unknown argument '$arg' — this inverse takes no options; the ownership state is its only input." ;;
26
+ esac
27
+ done
28
+
29
+ [ "$(uname -s)" = "Linux" ] || die "Linux only, like the installer."
30
+ [ "$(id -u)" != "0" ] || die "refusing to run as root: every path this inverse removes belongs to the operator, and root would remove them under the wrong authority."
31
+
32
+ PACKAGE_STATE_ROOT="${XDG_DATA_HOME:-$HOME/.local/share}/entwurf"
33
+ UNIT_ROOT="$PACKAGE_STATE_ROOT/codex-birth"
34
+ STATE_FILE="$UNIT_ROOT/install-state.json"
35
+ ME="$(id -u)"
36
+
37
+ # The state names absolute paths and this inverse deletes what it names, so a relative unit
38
+ # root would point the removal at whatever the caller's cwd happens to be.
39
+ case "$UNIT_ROOT" in /*) : ;; *) die "the unit root resolves to a relative path ('$UNIT_ROOT') — set an absolute XDG_DATA_HOME or HOME. Nothing removed." ;; esac
40
+
41
+ command -v sha256sum >/dev/null 2>&1 || die "sha256sum is required (removal is licensed per-byte by digest) and is not on PATH."
42
+ NODE_BIN="$(command -v node)" || die "node is not on PATH — it parses the ownership state."
43
+ sha_of() { sha256sum -- "$1" | cut -d' ' -f1; }
44
+
45
+ # Judged BEFORE anything is removed: a state sitting in a directory somebody else can write
46
+ # is a removal licence somebody else can forge.
47
+ safe_parent() { # $1 = dir, $2 = label
48
+ [ -e "$1" ] || return 0
49
+ [ -L "$1" ] && die "$2 ($1) is a SYMLINK — refusing to take removal authority through a link. Nothing removed."
50
+ [ -d "$1" ] || die "$2 ($1) is not a directory. Nothing removed."
51
+ local st mode uid
52
+ st="$(stat -c '%a %u' -- "$1")"; mode="$(printf '%s' "$st" | cut -d' ' -f1)"; uid="$(printf '%s' "$st" | cut -d' ' -f2)"
53
+ [ "$uid" = "$ME" ] || die "$2 ($1) is owned by uid $uid, not you ($ME) — it holds the state that licenses these removals. Nothing removed."
54
+ [ $((8#$mode & 0022)) -eq 0 ] || die "$2 ($1) is group/world-writable (mode $mode), so the state inside it cannot authorize deleting anything. Nothing removed."
55
+ }
56
+ safe_parent "$PACKAGE_STATE_ROOT" "the package state root"
57
+ safe_parent "$UNIT_ROOT" "the unit root"
58
+
59
+ [ -e "$STATE_FILE" ] || [ -L "$STATE_FILE" ] || die "no ownership state at $STATE_FILE, so nothing on this host is provably ours.
60
+ This inverse removes only what that state vouches for. If you believe a declaration is left
61
+ over, inspect it and remove it deliberately."
62
+ [ -L "$STATE_FILE" ] && die "$STATE_FILE is a symlink — refusing to take removal authority from a link. Nothing removed."
63
+ [ -f "$STATE_FILE" ] || die "$STATE_FILE is not a regular file. Nothing removed."
64
+ STATE_ST="$(stat -c '%a %u' -- "$STATE_FILE")"
65
+ STATE_MODE="$(printf '%s' "$STATE_ST" | cut -d' ' -f1)"
66
+ STATE_UID="$(printf '%s' "$STATE_ST" | cut -d' ' -f2)"
67
+ [ "$STATE_UID" = "$ME" ] || die "$STATE_FILE is owned by uid $STATE_UID, not you ($ME) — it authorizes removals, so a foreign owner means someone else chose what this deletes. Nothing removed."
68
+ [ $((8#$STATE_MODE & 0022)) -eq 0 ] || die "$STATE_FILE is group/world-writable (mode $STATE_MODE), so it cannot authorize deleting anything. Nothing removed."
69
+
70
+ PLAN="$("$NODE_BIN" -e '
71
+ const fs = require("node:fs");
72
+ const path = require("node:path");
73
+ const s = JSON.parse(fs.readFileSync(process.argv[1], "utf8"));
74
+ const hex = (v) => typeof v === "string" && /^[0-9a-f]{64}$/.test(v);
75
+ if (s.schema !== "codex-birth-install-state/v1") throw new Error("foreign state schema " + JSON.stringify(s.schema));
76
+ if (s.status !== "installed" && s.status !== "publishing") throw new Error("unknown status " + JSON.stringify(s.status));
77
+ if (typeof s.hooksFile !== "string" || !s.hooksFile.startsWith("/")) throw new Error("hooksFile is not an absolute path");
78
+ if (typeof s.helperDir !== "string" || !s.helperDir.startsWith("/")) throw new Error("helperDir is not an absolute path");
79
+ if (!hex(s.hooksSha256)) throw new Error("hooksSha256 is not a sha256 hex digest");
80
+ if (!Array.isArray(s.helperFiles) || s.helperFiles.length === 0) throw new Error("helperFiles is empty");
81
+ // Exactly the closure this unit publishes. Removing on a SHORT inventory would leave a file
82
+ // behind and then delete the directory that held it; a LONG one would license deleting
83
+ // something this unit never wrote.
84
+ const expected = [
85
+ "codex-birth-launch.sh",
86
+ "meta-bridge-hook-codex.ts",
87
+ "lib/meta-session.ts",
88
+ "lib/native-push/codex-ws-client.ts",
89
+ "lib/session-id.js",
90
+ "entwurf-capabilities.json",
91
+ ];
92
+ const named = s.helperFiles.map((f) => (f == null ? "" : f.path));
93
+ const missing = expected.filter((name) => !named.includes(name));
94
+ const extra = named.filter((name) => !expected.includes(name));
95
+ const duplicate = named.filter((name, i) => named.indexOf(name) !== i);
96
+ if (missing.length || extra.length || duplicate.length) {
97
+ throw new Error(
98
+ "the recorded closure inventory is not the exact six members this unit publishes" +
99
+ (missing.length ? "; missing " + missing.join(", ") : "") +
100
+ (extra.length ? "; unexpected " + extra.join(", ") : "") +
101
+ (duplicate.length ? "; duplicated " + duplicate.join(", ") : ""),
102
+ );
103
+ }
104
+ const lines = [["STATUS", s.status, s.unitVersion ?? ""].join("\t"), ["HOOKS", s.hooksSha256, s.hooksFile].join("\t")];
105
+ const seen = new Set();
106
+ for (const f of s.helperFiles) {
107
+ if (typeof f?.path !== "string" || f.path.length === 0 || f.path.startsWith("/") || f.path.split("/").includes("..")) {
108
+ throw new Error("unsafe helper path " + JSON.stringify(f?.path));
109
+ }
110
+ if (!hex(f.sha256)) throw new Error("bad helper digest for " + f.path);
111
+ if (seen.has(f.path)) throw new Error("duplicate helper path " + f.path);
112
+ seen.add(f.path);
113
+ lines.push(["HELPER", f.sha256, path.join(s.helperDir, f.path)].join("\t"));
114
+ }
115
+ lines.push(["HELPERDIR", "-", s.helperDir].join("\t"));
116
+ process.stdout.write(lines.join("\n") + "\n");
117
+ ' "$STATE_FILE" 2>&1)" || die "the ownership state is malformed, so what to remove is UNKNOWN: $PLAN
118
+ Inspect it and remove the unit deliberately. Nothing removed."
119
+
120
+ DRIFTED=0
121
+ REMOVE_ONE() { # $1 = recorded digest, $2 = absolute path, $3 = label
122
+ local want="$1" target="$2" label="$3" live
123
+ if [ -L "$target" ]; then
124
+ printf '[codex-birth-uninstall] DRIFT: %s is a SYMLINK now (%s) — refusing to remove through a link.\n' "$label" "$target" >&2
125
+ DRIFTED=$((DRIFTED + 1)); return
126
+ fi
127
+ if [ ! -e "$target" ]; then
128
+ note "already absent: $target"; return
129
+ fi
130
+ if [ ! -f "$target" ]; then
131
+ printf '[codex-birth-uninstall] DRIFT: %s is no longer a regular file (%s) — leaving it.\n' "$label" "$target" >&2
132
+ DRIFTED=$((DRIFTED + 1)); return
133
+ fi
134
+ live="$(sha_of "$target")"
135
+ if [ "$live" != "$want" ]; then
136
+ printf '[codex-birth-uninstall] DRIFT: %s was edited after install (%s: live %s, recorded %s) — LEFT IN PLACE.\n' "$label" "$target" "$live" "$want" >&2
137
+ DRIFTED=$((DRIFTED + 1)); return
138
+ fi
139
+ rm -f -- "$target"
140
+ note "removed $target"
141
+ }
142
+
143
+ HELPER_DIR=""
144
+ while IFS="$(printf '\t')" read -r kind digest target; do
145
+ case "$kind" in
146
+ STATUS) note "state: status=$digest unitVersion=$target" ;;
147
+ HOOKS) REMOVE_ONE "$digest" "$target" "the declaration" ;;
148
+ HELPER) REMOVE_ONE "$digest" "$target" "helper member" ;;
149
+ HELPERDIR) HELPER_DIR="$target" ;;
150
+ esac
151
+ done <<EOF
152
+ $PLAN
153
+ EOF
154
+
155
+ # Directories are removed only when EMPTY: a leftover file in there is either drift we just
156
+ # refused or something else's, and rmdir failing is the honest answer to both.
157
+ if [ -n "$HELPER_DIR" ] && [ -d "$HELPER_DIR" ] && [ ! -L "$HELPER_DIR" ]; then
158
+ rmdir -- "$HELPER_DIR/lib/native-push" 2>/dev/null || true
159
+ rmdir -- "$HELPER_DIR/lib" 2>/dev/null || true
160
+ if rmdir -- "$HELPER_DIR" 2>/dev/null; then note "removed $HELPER_DIR"; else note "kept $HELPER_DIR (not empty)"; fi
161
+ fi
162
+
163
+ if [ "$DRIFTED" -ne 0 ]; then
164
+ die "$DRIFTED path(s) DRIFTED and were left in place; the ownership state is KEPT so a later run can still license them. Nothing else to do automatically."
165
+ fi
166
+
167
+ rm -f -- "$STATE_FILE"
168
+ note "removed $STATE_FILE"
169
+ rmdir -- "$UNIT_ROOT" 2>/dev/null || true
170
+ note "uninstalled. config.toml was neither read nor written, so the vendor's own hook-trust record is untouched."