@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.
- package/AGENTS.md +85 -190
- package/BASELINE.md +6 -3
- package/CHANGELOG.md +473 -14
- package/CONTRIBUTING.md +1 -1
- package/DELIVERY.md +332 -60
- package/README.md +100 -22
- package/VERIFY.md +93 -7
- package/docs/acp-backend-rail.md +0 -1
- package/docs/external-mcp-host.md +64 -33
- package/docs/setup-clean-host.md +151 -17
- package/mcp/entwurf-bridge/dist/mcp/entwurf-bridge/src/index.js +130 -114
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/codex-caller-seat.js +174 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/codex-fresh-preflight.js +519 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-fact-provider.js +22 -2
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-peers-render.js +3 -1
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-contract.js +6 -7
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-native-push.js +30 -17
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-production.js +7 -1
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-runner.js +3 -2
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-send.js +8 -4
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-surface.js +7 -4
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/meta-mailbox-body.js +20 -11
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/meta-sender-identity.js +227 -1
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/mux-fresh-call.js +189 -19
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/native-push/adapter.js +10 -7
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/native-push/codex-ws-client.js +403 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/native-push/register.js +4 -4
- package/mcp/entwurf-bridge/dist/pi-extensions/meta-bridge-hook-codex.js +325 -0
- package/mcp/entwurf-bridge/dist/scripts/codex-socket-path.js +30 -0
- package/mcp/entwurf-bridge/dist/scripts/meta-bridge-fresh-cut.js +6 -1
- package/mcp/entwurf-bridge/src/index.ts +154 -116
- package/mcp/entwurf-bridge/tsconfig.build.json +10 -0
- package/package.json +13 -4
- package/pi-extensions/entwurf-control.ts +71 -19
- package/pi-extensions/lib/codex-caller-seat.ts +204 -0
- package/pi-extensions/lib/codex-fresh-preflight.ts +580 -0
- package/pi-extensions/lib/compaction-send-guard.ts +80 -0
- package/pi-extensions/lib/entwurf-fact-provider.ts +29 -3
- package/pi-extensions/lib/entwurf-peers-render.ts +4 -1
- package/pi-extensions/lib/entwurf-v2-contract.ts +6 -7
- package/pi-extensions/lib/entwurf-v2-native-push.ts +35 -18
- package/pi-extensions/lib/entwurf-v2-production.ts +10 -3
- package/pi-extensions/lib/entwurf-v2-runner.ts +3 -2
- package/pi-extensions/lib/entwurf-v2-send.ts +16 -11
- package/pi-extensions/lib/entwurf-v2-surface.ts +7 -4
- package/pi-extensions/lib/meta-mailbox-body.ts +22 -13
- package/pi-extensions/lib/meta-sender-identity.ts +305 -0
- package/pi-extensions/lib/mux-fresh-call.ts +233 -29
- package/pi-extensions/lib/native-push/adapter.ts +21 -24
- package/pi-extensions/lib/native-push/codex-ws-client.ts +506 -0
- package/pi-extensions/lib/native-push/register.ts +7 -9
- package/pi-extensions/meta-bridge-hook-codex.ts +371 -0
- package/run.sh +342 -28
- package/scripts/check-agy-sender-identity.ts +1 -1
- package/scripts/check-codex-app-server-launch.ts +445 -0
- package/scripts/check-codex-birth-hook.ts +264 -0
- package/scripts/check-codex-bridge-identity.ts +179 -0
- package/scripts/check-codex-native-push.ts +386 -0
- package/scripts/check-codex-sender-identity.ts +495 -0
- package/scripts/check-compaction-send-guard.ts +130 -0
- package/scripts/check-copilot-receive-arm.ts +4 -1
- package/scripts/check-entwurf-fact-provider.ts +38 -0
- package/scripts/check-entwurf-peers-surface.ts +13 -1
- package/scripts/check-entwurf-self-address.ts +15 -16
- package/scripts/check-entwurf-v2-contract.ts +4 -3
- package/scripts/check-entwurf-v2-decider.ts +7 -5
- package/scripts/check-entwurf-v2-native-push.ts +35 -7
- package/scripts/check-entwurf-v2-production.ts +245 -12
- package/scripts/check-entwurf-v2-runner.ts +1 -1
- package/scripts/check-entwurf-v2-send.ts +26 -7
- package/scripts/check-entwurf-v2-surface.ts +1 -1
- package/scripts/check-gate-qualification.ts +8 -3
- package/scripts/check-harness-admission-parity.ts +0 -1
- package/scripts/check-mux-launch-tmux.ts +345 -4
- package/scripts/check-native-push-adapter.ts +20 -16
- package/scripts/check-native-push-register.ts +5 -1
- package/scripts/check-release-gate-outcomes.ts +47 -1
- package/scripts/check-setup-qualification.sh +3 -1
- package/scripts/codex-app-server-launch.sh +275 -0
- package/scripts/codex-birth-doctor.sh +276 -0
- package/scripts/codex-birth-install.sh +414 -0
- package/scripts/codex-birth-uninstall.sh +170 -0
- package/scripts/codex-mcp-config.py +435 -0
- package/scripts/codex-socket-path.ts +33 -0
- package/scripts/codex-statusline-config.py +434 -0
- package/scripts/codex-terminal-title-config.py +500 -0
- package/scripts/codex_toml_io.py +653 -0
- package/scripts/lib/codex-fresh-live-protocol.ts +121 -0
- package/scripts/lib/codex-fresh-source-receipts.ts +426 -0
- package/scripts/lib/launch-receipt-windows.ts +46 -0
- package/scripts/lib/tmux-coordinate-row.ts +58 -0
- package/scripts/meta-bridge-fresh-cut.ts +6 -1
- package/scripts/mutants/codex-app-server-launch.json +157 -0
- package/scripts/mutants/codex-caller-seat.json +336 -0
- package/scripts/mutants/codex-native.json +838 -0
- package/scripts/mutants/compaction-send-guard.json +103 -0
- package/scripts/mutants/entwurf-peers.json +19 -0
- package/scripts/mutants/mux-fresh-call.json +93 -10
- package/scripts/mutants/omp-fresh.json +6 -4
- package/scripts/mutants/release-gate.json +13 -0
- package/scripts/mutants/v2-surface.json +75 -1
- package/scripts/raw-async-delivery/README.md +2 -1
- package/scripts/raw-codex-measure/README.md +114 -46
- package/scripts/smoke-agy-native-push-live.ts +3 -1
- package/scripts/smoke-codex-birth.sh +347 -0
- package/scripts/smoke-codex-config-state.sh +700 -0
- package/scripts/smoke-codex-fresh-live.ts +1426 -0
- package/scripts/smoke-codex-native-push-live.ts +75 -0
- package/scripts/smoke-entwurf-chain-live.ts +50 -0
- package/scripts/smoke-setup-verdict.sh +125 -10
- 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."
|