@junghanacs/entwurf 0.21.0 → 0.23.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 (122) hide show
  1. package/AGENTS.md +3 -2
  2. package/BASELINE.md +3 -1
  3. package/CHANGELOG.md +658 -0
  4. package/DELIVERY.md +167 -29
  5. package/README.md +104 -14
  6. package/VERIFY.md +73 -11
  7. package/docs/external-mcp-host.md +16 -6
  8. package/docs/setup-clean-host.md +73 -22
  9. package/mcp/entwurf-bridge/dist/mcp/entwurf-bridge/src/index.js +54 -31
  10. package/mcp/entwurf-bridge/dist/pi-extensions/lib/codex-caller-seat.js +174 -0
  11. package/mcp/entwurf-bridge/dist/pi-extensions/lib/codex-declaration.js +609 -0
  12. package/mcp/entwurf-bridge/dist/pi-extensions/lib/codex-fresh-preflight.js +289 -57
  13. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-control-rpc.js +40 -0
  14. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-fact-provider.js +6 -2
  15. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-facts.js +7 -1
  16. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-peer-observe.js +67 -2
  17. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-peers-render.js +9 -1
  18. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-self-address.js +24 -0
  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/fresh-call-composition.js +423 -0
  22. package/mcp/entwurf-bridge/dist/pi-extensions/lib/fresh-call-dispatch.js +98 -0
  23. package/mcp/entwurf-bridge/dist/pi-extensions/lib/herdr-fresh-call.js +1018 -0
  24. package/mcp/entwurf-bridge/dist/pi-extensions/lib/herdr-placement.js +213 -0
  25. package/mcp/entwurf-bridge/dist/pi-extensions/lib/mux-fresh-call.js +171 -263
  26. package/mcp/entwurf-bridge/dist/scripts/codex-socket-path.js +30 -0
  27. package/mcp/entwurf-bridge/dist/scripts/peer-facts.js +109 -0
  28. package/mcp/entwurf-bridge/src/index.ts +65 -31
  29. package/mcp/entwurf-bridge/tsconfig.build.json +10 -0
  30. package/package.json +4 -4
  31. package/pi-extensions/entwurf-control.ts +91 -52
  32. package/pi-extensions/lib/codex-caller-seat.ts +204 -0
  33. package/pi-extensions/lib/codex-declaration.js +612 -0
  34. package/pi-extensions/lib/codex-fresh-preflight.ts +325 -52
  35. package/pi-extensions/lib/entwurf-control-rpc.ts +46 -0
  36. package/pi-extensions/lib/entwurf-fact-provider.ts +13 -2
  37. package/pi-extensions/lib/entwurf-facts.ts +14 -1
  38. package/pi-extensions/lib/entwurf-peer-observe.ts +72 -2
  39. package/pi-extensions/lib/entwurf-peers-render.ts +9 -1
  40. package/pi-extensions/lib/entwurf-self-address.ts +33 -0
  41. package/pi-extensions/lib/entwurf-v2-runner.ts +3 -2
  42. package/pi-extensions/lib/entwurf-v2-send.ts +16 -11
  43. package/pi-extensions/lib/fresh-call-composition.ts +493 -0
  44. package/pi-extensions/lib/fresh-call-dispatch.ts +142 -0
  45. package/pi-extensions/lib/herdr-fresh-call.ts +1378 -0
  46. package/pi-extensions/lib/herdr-placement.ts +276 -0
  47. package/pi-extensions/lib/mux-fresh-call.ts +257 -304
  48. package/run.sh +522 -10
  49. package/scripts/check-codex-app-server-launch.ts +445 -0
  50. package/scripts/check-codex-birth-hook.ts +187 -0
  51. package/scripts/check-entwurf-control-rpc.ts +138 -0
  52. package/scripts/check-entwurf-fact-provider.ts +4 -2
  53. package/scripts/check-entwurf-facts.ts +12 -6
  54. package/scripts/check-entwurf-peers-surface.ts +3 -1
  55. package/scripts/check-entwurf-self-address.ts +68 -0
  56. package/scripts/check-entwurf-v2-production.ts +42 -1
  57. package/scripts/check-entwurf-v2-send.ts +26 -7
  58. package/scripts/check-fresh-call-dispatch.ts +301 -0
  59. package/scripts/check-gate-qualification.ts +19 -5
  60. package/scripts/check-herdr-activation.ts +1293 -0
  61. package/scripts/check-herdr-fresh-call.ts +1250 -0
  62. package/scripts/check-herdr-placement.ts +383 -0
  63. package/scripts/check-herdr-plugin-build.ts +842 -0
  64. package/scripts/check-herdr-plugin-profile.ts +371 -0
  65. package/scripts/check-herdr-plugin.ts +523 -0
  66. package/scripts/check-herdr-runtime-bootstrap.ts +1264 -0
  67. package/scripts/check-herdr-sandbox.ts +456 -0
  68. package/scripts/check-herdr-supply.ts +184 -0
  69. package/scripts/check-mux-launch-tmux.ts +331 -35
  70. package/scripts/check-mux-launcher-fence.ts +138 -0
  71. package/scripts/check-peer-facts.ts +331 -0
  72. package/scripts/check-release-gate-outcomes.ts +12 -0
  73. package/scripts/check-typing-call-fence.ts +296 -0
  74. package/scripts/codex-app-server-launch.sh +275 -0
  75. package/scripts/codex-birth-doctor.sh +159 -16
  76. package/scripts/codex-birth-install.sh +178 -72
  77. package/scripts/codex-birth-uninstall.sh +162 -7
  78. package/scripts/codex-socket-path.ts +33 -0
  79. package/scripts/codex-terminal-title-config.py +500 -0
  80. package/scripts/codex_toml_io.py +121 -0
  81. package/scripts/fixtures/herdr-supply.json +29 -0
  82. package/scripts/herdr-activation.mjs +536 -0
  83. package/scripts/herdr-plugin-activate.mjs +270 -0
  84. package/scripts/herdr-plugin-deactivate.mjs +193 -0
  85. package/scripts/herdr-runtime.mjs +1203 -0
  86. package/scripts/install-herdr-ci.sh +96 -0
  87. package/scripts/lib/codex-fresh-live-protocol.ts +13 -5
  88. package/scripts/lib/codex-fresh-source-receipts.ts +29 -2
  89. package/scripts/lib/tree-digest.ts +97 -0
  90. package/scripts/meta-bridge-install.sh +19 -2
  91. package/scripts/meta-bridge-state.py +167 -11
  92. package/scripts/mutants/codex-app-server-launch.json +157 -0
  93. package/scripts/mutants/codex-caller-seat.json +334 -0
  94. package/scripts/mutants/codex-native.json +195 -29
  95. package/scripts/mutants/control-socket-disconnect.json +57 -0
  96. package/scripts/mutants/fresh-call-dispatch.json +154 -0
  97. package/scripts/mutants/herdr-activation.json +305 -0
  98. package/scripts/mutants/herdr-fresh-call.json +448 -0
  99. package/scripts/mutants/herdr-placement.json +161 -0
  100. package/scripts/mutants/herdr-plugin-build.json +160 -0
  101. package/scripts/mutants/herdr-plugin-profile.json +202 -0
  102. package/scripts/mutants/herdr-plugin.json +133 -0
  103. package/scripts/mutants/herdr-runtime-bootstrap.json +411 -0
  104. package/scripts/mutants/herdr-supply.json +133 -0
  105. package/scripts/mutants/meta-hook-session-switch.json +2 -2
  106. package/scripts/mutants/mux-fresh-call.json +184 -25
  107. package/scripts/mutants/mux-launcher-fence.json +13 -0
  108. package/scripts/mutants/omp-fresh.json +4 -4
  109. package/scripts/mutants/peer-facts.json +98 -0
  110. package/scripts/mutants/self-address.json +33 -0
  111. package/scripts/mutants/typing-call-fence.json +17 -0
  112. package/scripts/mutants/v2-surface.json +22 -0
  113. package/scripts/peer-facts.ts +120 -0
  114. package/scripts/register-pi-package.py +8 -0
  115. package/scripts/register-pi-provider.py +88 -12
  116. package/scripts/smoke-codex-birth.sh +281 -12
  117. package/scripts/smoke-codex-config-state.sh +192 -3
  118. package/scripts/smoke-codex-fresh-live.ts +277 -37
  119. package/scripts/smoke-entwurf-chain-live.ts +50 -0
  120. package/scripts/smoke-herdr-fresh-call-live.ts +1027 -0
  121. package/scripts/smoke-herdr-plugin-build-live.ts +543 -0
  122. package/scripts/smoke-setup-verdict.sh +13 -11
@@ -2,13 +2,21 @@ import * as crypto from "node:crypto";
2
2
  import * as fs from "node:fs";
3
3
  import * as path from "node:path";
4
4
  import { parse } from "smol-toml";
5
+ import { CODEX_BIRTH_EVENT, classifyOwnedPath, entwurfDeclarationCommand, selectEntwurfDeclaration, statOwnedPath, trustReceiptKey, } from "./codex-declaration.js";
5
6
  import { checkCodexSocketFile, realCodexProtocolOpener, resolveCodexDefaultSocketPath, } from "./native-push/codex-ws-client.js";
6
7
  export const CODEX_PREFLIGHT_HINT = {
7
8
  "codex-birth-unit-missing": "the Codex SessionStart birth unit is absent, drifted from its recorded digests, or unsafely owned; run `entwurf install-codex-birth`, then `entwurf doctor-codex-birth`",
8
9
  "codex-birth-trust-missing": "the vendor has recorded no trust receipt for this birth declaration, so the hook is declared and never runs; open a visible plain Codex, answer its prompt with `Trust all and continue`, send one first turn, then `entwurf doctor-codex-birth`",
9
10
  "codex-mcp-hand-missing": "the Codex user config does not expose the managed entwurf bridge; run `entwurf install-codex-mcp`, then `entwurf doctor-codex-mcp`",
10
11
  "codex-visible-identity-missing": "Codex tui.status_line does not include thread-title; run `entwurf install-codex-statusline`, then `entwurf doctor-codex-statusline`",
11
- "codex-app-server-unavailable": "the operator-owned Codex app-server default socket is absent or unsafe; start `codex app-server --listen unix://$CODEX_HOME/app-server-control/app-server-control.sock` and retry",
12
+ "codex-app-server-unavailable": "the operator-owned Codex app-server default socket is absent or unsafe; start it with `entwurf codex-app-server` from a detached tmux session (one that is NOT where you work), then retry",
13
+ };
14
+ export const CODEX_LAUNCH_CWD_PREFLIGHT_HINT = {
15
+ "codex-launch-cwd-undecided": "Codex has recorded NO decision for the directory this sibling would start in, so the window would open on the vendor's folder-consent screen and WAIT for a human instead of running its first turn — no rollout, no callback, and nothing to address; either launch in a directory this Codex has already been answered for, or open a plain `codex -C <dir>` there once and answer it, then retry",
16
+ "codex-launch-cwd-untrusted-ancestor": "this directory sits inside a project the operator marked `untrusted`, and on this rail the vendor does not offer a consent screen for that — it refuses the startup outright with `pass the repository root explicitly with --cd`, so answering a prompt at this directory would only reproduce that error; launch at that untrusted project's own root instead, or change that project's recorded decision",
17
+ };
18
+ export const CODEX_CALLER_PREFLIGHT_HINT = {
19
+ "codex-caller-title-missing": "this Codex caller's tui.terminal_title does not include thread-id, so the multiplexer reports no pane title naming this thread and there is no caller seat to open the sibling beside; run `entwurf install-codex-terminal-title`, then `entwurf doctor-codex-terminal-title` (an explicit placement.tmuxSession skips this check entirely, because it never needs the seat)",
12
20
  };
13
21
  /**
14
22
  * The unit's paths are the operator's own, derived from the SAME environment the installer
@@ -37,54 +45,69 @@ const CODEX_MCP_ENV_VARS = [
37
45
  "TMUX",
38
46
  "TMUX_PANE",
39
47
  ];
48
+ /**
49
+ * ONE POLICY, FOUR SURFACES. The predicate itself lives in `codex-declaration.js` (sol B3,
50
+ * 2026-09-18) because the installer, the inverse and the doctor decide about the SAME shared
51
+ * hooks.json and used to decide differently: they checked symlink-and-regular, this checked owner
52
+ * and mode as well, and the doctor checked neither. That split let an install and a doctor say yes
53
+ * about bytes every fresh call then refused. The executable bit stays HERE — it is this rail's
54
+ * question about the launcher codex is going to exec, not a fact about shared ownership.
55
+ */
40
56
  function isSafeOwnedFile(file, expectedUid, executable = false) {
41
- try {
42
- const stat = fs.lstatSync(file);
43
- return (stat.isFile() &&
44
- !stat.isSymbolicLink() &&
45
- stat.uid === expectedUid &&
46
- (stat.mode & 0o022) === 0 &&
47
- (!executable || (stat.mode & 0o111) !== 0));
48
- }
49
- catch {
57
+ const stat = statOwnedPath(fs, file);
58
+ if (classifyOwnedPath(stat, expectedUid) !== "ok")
50
59
  return false;
51
- }
60
+ return !executable || (stat.mode & 0o111) !== 0;
52
61
  }
53
62
  function isSafeOwnedDir(dir, expectedUid) {
54
- try {
55
- const stat = fs.lstatSync(dir);
56
- return stat.isDirectory() && !stat.isSymbolicLink() && stat.uid === expectedUid && (stat.mode & 0o022) === 0;
57
- }
58
- catch {
59
- return false;
60
- }
63
+ return classifyOwnedPath(statOwnedPath(fs, dir), expectedUid, { kind: "directory" }) === "ok";
61
64
  }
62
65
  /**
63
66
  * The unit's own ownership state, read as the digest inventory it is. Bytes that merely
64
67
  * EXIST prove nothing: the launcher codex is about to exec must be the one this unit
65
68
  * published, so every recorded member is compared to its recorded digest before a sibling
66
69
  * is opened. No digest is computed for the VENDOR here — that is a different axis below.
70
+ *
71
+ * WHAT THIS NO LONGER READS (#117): a WHOLE-FILE `hooksSha256`. `codex-birth-install-state/v2`
72
+ * records the normalized digest of entwurf's own declaration instead, because hooks.json is a
73
+ * file this unit SHARES — a neighbouring integration appending its own `SessionStart` group
74
+ * changes every byte of that file and none of our declaration's meaning. The declaration axis
75
+ * lives in `birthDeclaration` below, which needs the live document anyway to find where our group
76
+ * currently sits.
67
77
  */
68
- function closureDriftedFromState(paths, expectedUid) {
78
+ function certifiedState(paths, expectedUid) {
69
79
  // The directory holding the state carries the state's authority: anyone who can write it
70
80
  // can replace the inventory every digest below is compared against.
71
81
  if (!isSafeOwnedDir(path.dirname(paths.stateFile), expectedUid))
72
- return true;
82
+ return null;
73
83
  if (!isSafeOwnedDir(path.dirname(path.dirname(paths.stateFile)), expectedUid))
74
- return true;
84
+ return null;
75
85
  if (!isSafeOwnedFile(paths.stateFile, expectedUid))
76
- return true;
86
+ return null;
77
87
  let state;
78
88
  try {
79
89
  state = JSON.parse(fs.readFileSync(paths.stateFile, "utf8"));
80
90
  }
81
91
  catch {
82
- return true;
92
+ return null;
83
93
  }
84
- if (state.schema !== "codex-birth-install-state/v1" || state.status !== "installed")
85
- return true;
94
+ // v2 ONLY, and a v1 state is refused rather than read leniently: v1 recorded a whole-file
95
+ // digest, so a reader that tolerated it would be certifying an authority nobody holds any
96
+ // more. `entwurf install-codex-birth` supersedes it — that is the one forward path.
97
+ if (state.schema !== "codex-birth-install-state/v2" || state.status !== "installed")
98
+ return null;
86
99
  if (state.hooksFile !== paths.hooksFile || state.helperDir !== paths.helperDir)
87
- return true;
100
+ return null;
101
+ const declaration = state.declaration;
102
+ if (declaration === null || typeof declaration !== "object" || Array.isArray(declaration))
103
+ return null;
104
+ const recorded = declaration;
105
+ if (recorded.event !== CODEX_BIRTH_EVENT)
106
+ return null;
107
+ if (typeof recorded.command !== "string" || typeof recorded.sha256 !== "string")
108
+ return null;
109
+ if (!/^[0-9a-f]{64}$/.test(recorded.sha256))
110
+ return null;
88
111
  const digest = (file) => {
89
112
  try {
90
113
  return crypto.createHash("sha256").update(fs.readFileSync(file)).digest("hex");
@@ -93,11 +116,9 @@ function closureDriftedFromState(paths, expectedUid) {
93
116
  return null;
94
117
  }
95
118
  };
96
- if (typeof state.hooksSha256 !== "string" || digest(paths.hooksFile) !== state.hooksSha256)
97
- return true;
98
119
  const members = state.helperFiles;
99
120
  if (!Array.isArray(members))
100
- return true;
121
+ return null;
101
122
  // Exactly the closure the installer publishes: a short inventory would leave a member
102
123
  // nothing compares, which is the same hole as not checking digests at all.
103
124
  const named = members.map((raw) => raw?.path);
@@ -110,23 +131,37 @@ function closureDriftedFromState(paths, expectedUid) {
110
131
  "entwurf-capabilities.json",
111
132
  ];
112
133
  if (named.length !== expected.length)
113
- return true;
134
+ return null;
114
135
  if (expected.some((name) => !named.includes(name)))
115
- return true;
136
+ return null;
116
137
  if (named.some((name, index) => named.indexOf(name) !== index))
117
- return true;
138
+ return null;
118
139
  for (const raw of members) {
119
140
  const member = raw;
120
141
  const rel = member.path;
121
142
  if (typeof rel !== "string" || rel.length === 0 || rel.startsWith("/") || rel.split("/").includes("..")) {
122
- return true;
143
+ return null;
123
144
  }
124
145
  if (typeof member.sha256 !== "string" || digest(path.join(paths.helperDir, rel)) !== member.sha256)
125
- return true;
146
+ return null;
126
147
  }
127
- return false;
148
+ return { command: recorded.command, sha256: recorded.sha256 };
128
149
  }
129
- function birthMissing(paths, expectedUid) {
150
+ /**
151
+ * WHERE ENTWURF'S DECLARATION CURRENTLY SITS, or nothing.
152
+ *
153
+ * `null` here means the SAME reject as before — `codex-birth-unit-missing` — but the question
154
+ * it answers is narrower than it used to be (#117). It no longer asks "is hooks.json exactly the
155
+ * file we wrote"; it asks "is entwurf's own `SessionStart` group present exactly once, shaped the
156
+ * way the operator approved, and still the declaration this unit recorded". A Herdr (or any
157
+ * other) integration declaring its own group beside ours changes neither answer.
158
+ *
159
+ * The POSITION is the return value rather than a side note because the vendor keys its trust
160
+ * receipt by index: a declaration that has been renumbered by a neighbour's install needs its
161
+ * receipt read at the new index, and reading the old one would report a NEIGHBOUR's approval as
162
+ * our own.
163
+ */
164
+ function birthDeclaration(paths, expectedUid) {
130
165
  const launcher = path.join(paths.helperDir, "codex-birth-launch.sh");
131
166
  const directories = [
132
167
  path.dirname(paths.hooksFile),
@@ -135,9 +170,9 @@ function birthMissing(paths, expectedUid) {
135
170
  path.join(paths.helperDir, "lib", "native-push"),
136
171
  ];
137
172
  if (directories.some((dir) => !isSafeOwnedDir(dir, expectedUid)))
138
- return true;
173
+ return null;
139
174
  if (!isSafeOwnedFile(paths.hooksFile, expectedUid))
140
- return true;
175
+ return null;
141
176
  const closure = [
142
177
  [launcher, true],
143
178
  [path.join(paths.helperDir, "meta-bridge-hook-codex.ts"), false],
@@ -147,25 +182,28 @@ function birthMissing(paths, expectedUid) {
147
182
  [path.join(paths.helperDir, "entwurf-capabilities.json"), false],
148
183
  ];
149
184
  if (closure.some(([file, executable]) => !isSafeOwnedFile(file, expectedUid, executable)))
150
- return true;
185
+ return null;
186
+ const recorded = certifiedState(paths, expectedUid);
187
+ if (recorded === null)
188
+ return null;
189
+ // The state's own account of what it certified must be the declaration these paths produce;
190
+ // a receipt bound to a different launcher is a receipt for a different unit.
191
+ if (recorded.command !== entwurfDeclarationCommand(launcher))
192
+ return null;
193
+ let selected;
151
194
  try {
152
- const parsed = JSON.parse(fs.readFileSync(paths.hooksFile, "utf8"));
153
- const events = parsed.hooks?.SessionStart;
154
- if (!Array.isArray(events) || events.length !== 1)
155
- return true;
156
- const group = events[0];
157
- if ("matcher" in group || !Array.isArray(group.hooks) || group.hooks.length !== 1)
158
- return true;
159
- const hook = group.hooks[0];
160
- if (Object.keys(hook).sort().join(",") !== "command,timeout,type")
161
- return true;
162
- if (hook.type !== "command" || hook.command !== `'${launcher}'` || hook.timeout !== 30)
163
- return true;
195
+ selected = selectEntwurfDeclaration(JSON.parse(fs.readFileSync(paths.hooksFile, "utf8")), launcher);
164
196
  }
165
197
  catch {
166
- return true;
198
+ return null;
167
199
  }
168
- return closureDriftedFromState(paths, expectedUid);
200
+ if (!selected.ok)
201
+ return null;
202
+ // The NORMALIZED digest, so a neighbour re-serializing the document (measured: Herdr does)
203
+ // cannot make our intact declaration look edited — and an edit to our own handler still can.
204
+ if (selected.digest !== recorded.sha256)
205
+ return null;
206
+ return { groupIndex: selected.groupIndex, handlerIndex: selected.handlerIndex };
169
207
  }
170
208
  /**
171
209
  * The vendor's own receipt for THIS declaration, read and never written. The key the vendor
@@ -173,15 +211,21 @@ function birthMissing(paths, expectedUid) {
173
211
  * `trusted_hash` of the shape `sha256:<64 hex>`. What this asserts is that a receipt EXISTS
174
212
  * for our declaration identity — never that the hash is correct, which only the vendor can
175
213
  * say, and never by launching Codex to find out.
214
+ *
215
+ * THE KEY IS MEASURED, NOT ASSUMED (#117). It used to be spelled `…:session_start:0:0`, which
216
+ * was true only while entwurf was the sole declaration in the file. With a neighbour at index 0
217
+ * that constant reads THEIR receipt and calls it ours — a false green for a hook the vendor has
218
+ * never been asked to run. The caller passes the key built from the position our declaration was
219
+ * just found at.
176
220
  */
177
- function trustReceiptMissing(config, hooksFile) {
221
+ function trustReceiptMissing(config, key) {
178
222
  const hooks = config.hooks;
179
223
  if (hooks == null || typeof hooks !== "object" || Array.isArray(hooks))
180
224
  return true;
181
225
  const state = hooks.state;
182
226
  if (state == null || typeof state !== "object" || Array.isArray(state))
183
227
  return true;
184
- const entry = state[`${hooksFile}:session_start:0:0`];
228
+ const entry = state[key];
185
229
  if (entry == null || typeof entry !== "object" || Array.isArray(entry))
186
230
  return true;
187
231
  const digest = entry.trusted_hash;
@@ -227,6 +271,192 @@ function visibleIdentityMissing(config) {
227
271
  const statusLine = tui.status_line;
228
272
  return !Array.isArray(statusLine) || !statusLine.includes("thread-title");
229
273
  }
274
+ /** `[tui].terminal_title` membership, the exact axis `entwurf doctor-codex-terminal-title`
275
+ * judges. Same shape as `visibleIdentityMissing` and a DIFFERENT key: `status_line` is what a
276
+ * human reads inside the TUI, `terminal_title` is what the multiplexer reports back as
277
+ * `#{pane_title}`. Neither substitutes for the other. */
278
+ function callerTitleMissing(config) {
279
+ const tui = config.tui;
280
+ if (tui == null || typeof tui !== "object" || Array.isArray(tui))
281
+ return true;
282
+ const terminalTitle = tui.terminal_title;
283
+ return !Array.isArray(terminalTitle) || !terminalTitle.includes("thread-id");
284
+ }
285
+ /**
286
+ * The CALLER-side capability, pre-mutation and synchronous.
287
+ *
288
+ * Synchronous because it reads one config file and nothing else: there is no app-server axis
289
+ * here and there must not be one — whether the operator's app-server is up says nothing about
290
+ * whether a caller's pane can be found, and asking would make a placement check fail for a
291
+ * delivery reason.
292
+ *
293
+ * Call it only when the anchor will actually be USED — a codex caller that named an explicit
294
+ * `placement` never consults the title, so refusing it for a missing `thread-id` would be a
295
+ * refusal for an unused capability.
296
+ */
297
+ export function codexCallerFreshPreflight(env) {
298
+ const config = readConfig(env);
299
+ if (config === null || callerTitleMissing(config))
300
+ return "codex-caller-title-missing";
301
+ return null;
302
+ }
303
+ /**
304
+ * The launch directory's own capability, pre-mutation and synchronous — and DELIBERATELY NARROWER
305
+ * than the vendor's own decision, because the leaf that decides here reads one TOML file while the
306
+ * vendor asks its app-server. Read this whole comment as the scope statement it is: what follows
307
+ * says what this answers, and then says what it refuses to claim.
308
+ *
309
+ * WHY THE AXIS EXISTS. `[source rust-v0.153.4]` a fresh call always passes `--remote`, so the TUI
310
+ * resolves `AppServerTarget::Remote` and startup ALWAYS runs `check_directory_trust` on the `-C`
311
+ * value (`tui/src/lib.rs:1699-1725`). Nothing on that path consults the approval or sandbox policy
312
+ * (`tui/src/onboarding/directory_trust.rs:33-130`), which is why the
313
+ * `--dangerously-bypass-approvals-and-sandbox` token this argv already carries does NOT cover it:
314
+ * approvals and folder consent are two different gates. A directory with no recorded decision
315
+ * renders the consent screen and blocks, and a blocked TUI has started no turn — no rollout, no
316
+ * birth, no callback, and the caller waits out its whole timeout on a window that is merely
317
+ * waiting to be answered. `[측정 2026-09-16]` the two release-gate runs that failed launched in
318
+ * `…-2zznHl` and `…-kSsoAn`, neither of which had an entry; the two that passed launched in
319
+ * `…-db65N2` and `…-pBXxOJ`, both of which did. Unattended, the failure is silent.
320
+ *
321
+ * THE THREE VENDOR OUTCOMES THIS LEAF MODELS, in the vendor's own order
322
+ * (`tui/src/config_update.rs:290-371`, `ProjectTrustHost::Remote`):
323
+ *
324
+ * 1. A DIRECT decision on the exact cwd starts a turn, and BOTH recorded answers do. `trusted`
325
+ * returns before any screen (`:346-354`); a saved `untrusted` is explicitly skipped for a
326
+ * remote target — `if target.uses_remote_workspace() && trust_level == Some(Untrusted) {
327
+ * continue; }` (`onboarding/directory_trust.rs:94-96`), and `uses_remote_workspace()` is
328
+ * exactly `matches!(self, Self::Remote { .. })` (`tui/src/lib.rs:307-309`). Refusing a
329
+ * deliberate `untrusted` would be entwurf inventing a policy the vendor does not have.
330
+ * The key is the exact cwd — `cwd_keys = vec![cwd_key]` (`:290-296`), no root marker, no git
331
+ * root, no parent, all three of which exist only on the Local host. `[측정 2026-09-16]`
332
+ * `~/.codex/config.toml` carried a trusted `/tmp` and still recorded two separate
333
+ * `/tmp/entwurf-codex-fresh-live-*` entries, one per run a human answered.
334
+ * 2. With NO direct decision, an enabled PROJECT LAYER consents on the directory's behalf
335
+ * (`trust_level.is_none() && disabled_project.is_none() && project_layers.any(no
336
+ * disabledReason)` → `Ok(None)`, `:346-354`). Those layers come from the app-server's
337
+ * `ConfigRead { include_layers: true }` answer, which this leaf does not have. A layer that
338
+ * is present but DISABLED is a fourth outcome — the vendor preserves an unknown or untrusted
339
+ * project layer as disabled rather than dropping it, and that path falls through to the
340
+ * screen — and this leaf cannot tell the two apart either. So wherever a layer could exist at
341
+ * all it answers `null`, which folds both outcomes into "proceed".
342
+ * 3. With no direct decision and NO layers, a cwd inside an explicitly `untrusted` ancestor is
343
+ * not a consent screen at all: the remote branch returns an ERROR
344
+ * (`"remote project directory is inside an explicitly untrusted project; pass the repository
345
+ * root explicitly with --cd"`, `:357-371`). That is a different failure with a different
346
+ * repair, so it gets its own reason — answering `Trust` at the child would only reproduce the
347
+ * same vendor error. Note the vendor's own precondition there is `project_layers.is_empty()`,
348
+ * which is why this reason is only reachable after step 2 has found no layer anywhere.
349
+ *
350
+ * WHAT THIS LEAF IS FOR, AND WHAT IT IS NOT. It is NOT equivalent to the vendor's judgment and
351
+ * must not be described as asking "the same question": it reads the operator's own `config.toml`,
352
+ * while the vendor reads an EFFECTIVE config — system, managed and cloud layers merged around that
353
+ * user layer (`config/src/loader/mod.rs:258-290`, `:430-460`) — through its app-server.
354
+ *
355
+ * That gap is affordable because NOTHING HERE REFUSES A LAUNCH. `freshCall` prints what this leaf
356
+ * saw and opens the window anyway: the consent screen is self-repairing when a human is there, and
357
+ * one answer teaches the vendor the directory for good. The one caller that treats the answer as a
358
+ * precondition is `smoke-codex-fresh-live`, where nobody is at the keyboard and a named
359
+ * precondition is worth more than a callback timeout — that gate owns one directory, answered
360
+ * once.
361
+ *
362
+ * So read a non-null answer as "the vendor will probably stop here, and this is the repair", never
363
+ * as a verdict. Absence is not evidence either way, and every case this leaf cannot see resolves
364
+ * to `null`.
365
+ */
366
+ export function codexLaunchCwdFreshPreflight(env, launchCwd) {
367
+ // THREE NON-ANSWERS, AND EVERY ONE OF THEM PROCEEDS. Each is a case where this leaf holds no
368
+ // evidence about what the vendor will do, and a refusal without evidence is exactly the false
369
+ // refusal this axis promises not to produce:
370
+ //
371
+ // - A RELATIVE directory. It cannot match a stored key here, but the vendor does not give up
372
+ // on one — it asks its app-server for a cwd and joins (`config_update.rs:203-224`), so the
373
+ // joined path may well be answered. In production this branch is unreachable anyway: the
374
+ // shared cwd leaf already refuses a non-absolute request as `cwd-not-absolute`, which is a
375
+ // better reason than anything this axis could give.
376
+ // - NO READABLE USER CONFIG. A missing `config.toml` is not "no decisions": the vendor loads
377
+ // an empty user table and merges system, managed and cloud layers around it
378
+ // (`config/src/loader/mod.rs:258-290`, `:430-460`, `:520-610`), any of which can carry the
379
+ // decision or the layer that starts the turn.
380
+ // - NO `projects` TABLE. Same reason: it says the USER layer records nothing, not that the
381
+ // effective config does.
382
+ if (!path.isAbsolute(launchCwd))
383
+ return null;
384
+ const config = readConfig(env);
385
+ if (config === null)
386
+ return null;
387
+ const projects = config.projects;
388
+ if (projects == null || typeof projects !== "object" || Array.isArray(projects)) {
389
+ return null;
390
+ }
391
+ const table = projects;
392
+ const levelOf = (key) => {
393
+ const entry = table[key];
394
+ if (entry == null || typeof entry !== "object" || Array.isArray(entry))
395
+ return null;
396
+ const level = entry.trust_level;
397
+ // An unrecognised value leaves `trust_level` as `None` on the vendor side too, and `None`
398
+ // with no project layer is precisely the case that renders the screen.
399
+ return level === "trusted" || level === "untrusted" ? level : null;
400
+ };
401
+ // Outcome 1: a direct decision, either answer.
402
+ if (levelOf(launchCwd) !== null)
403
+ return null;
404
+ // Outcome 2: a PROJECT LAYER may consent with no entry at all, and this leaf cannot enumerate
405
+ // layers — they come from the app-server's `ConfigRead { include_layers: true }` answer. What
406
+ // it CAN decide is the negative: a `.codex` anywhere from the directory upward is the only
407
+ // place such a layer comes from, so when none exists the vendor's `project_layers` really is
408
+ // empty and the two remaining outcomes below are computable. Where one could exist, answer
409
+ // `null`.
410
+ //
411
+ // EXISTENCE, not ownership, and the weaker predicate is the load-bearing one. The vendor
412
+ // admits a layer on its own terms — an unknown or untrusted one is preserved as a DISABLED
413
+ // layer, not dropped — and none of that consults owner/mode/symlink safety. Asking for a
414
+ // safely-owned directory here would let a layer the vendor sees go unseen by this leaf, which
415
+ // would then synthesise an `untrusted-ancestor` refusal for a launch the vendor was going to
416
+ // run. That is the false refusal this axis must never produce, so the check is the widest
417
+ // thing that still means "a layer could live here".
418
+ //
419
+ // The operator's own CODEX HOME is excluded, and excluding it is what keeps this axis from
420
+ // being a no-op: `~/.codex` is an ancestor of nearly every directory anyone launches a sibling
421
+ // in, and it is the USER config root rather than a project layer — the vendor names those
422
+ // apart (`layer.name.dotCodexFolder` for a project, the home for the user layer). Counting it
423
+ // would answer `null` for every path under `$HOME` and the check would never fire in real use.
424
+ const codexHome = path.resolve(env.CODEX_HOME?.trim() || path.join(env.HOME ?? "", ".codex"));
425
+ for (let dir = launchCwd;;) {
426
+ const candidate = path.join(dir, ".codex");
427
+ if (candidate !== codexHome && fs.existsSync(candidate))
428
+ return null;
429
+ const parent = path.dirname(dir);
430
+ if (parent === dir)
431
+ break;
432
+ dir = parent;
433
+ }
434
+ // Outcome 3: no layer anywhere and an explicitly untrusted ancestor — the vendor's own
435
+ // precondition for that branch is exactly `project_layers.is_empty()`, which the loop above
436
+ // has now established. It is reported as its own failure because its repair is to launch at
437
+ // that root, never to answer a prompt at the child.
438
+ //
439
+ // The MATCH IS NOT THE VENDOR'S and is not claimed to be. The vendor compares path URIs
440
+ // (`LegacyAppPathString` → `PathUri::starts_with`, segment-aware, fail-closed on encoded
441
+ // separators); this compares plain strings on a separator boundary. Where the two could
442
+ // disagree — a key or a cwd that is not a plain POSIX path — this answers `undecided` rather
443
+ // than `untrusted-ancestor`. Be exact about what that buys: BOTH are refusals, so this is NOT
444
+ // the launch-permissive direction. What it weakens is the SPECIFIC-ERROR claim — the ancestor
445
+ // reason names another directory as the repair, and naming the wrong one is worse than saying
446
+ // "no decision here". Neither vendor outcome on this branch starts a turn, so the guarantee
447
+ // above is untouched either way.
448
+ const plainPosixPath = (value) => !value.includes("%") && !value.includes("\\") && !value.split("/").some((seg) => seg === "." || seg === "..");
449
+ if (plainPosixPath(launchCwd)) {
450
+ for (const key of Object.keys(table)) {
451
+ if (levelOf(key) !== "untrusted" || !plainPosixPath(key))
452
+ continue;
453
+ if (launchCwd === key || launchCwd.startsWith(key.endsWith("/") ? key : `${key}/`)) {
454
+ return "codex-launch-cwd-untrusted-ancestor";
455
+ }
456
+ }
457
+ }
458
+ return "codex-launch-cwd-undecided";
459
+ }
230
460
  const DEFAULT_APP_SERVER_TIMEOUT_MS = 5_000;
231
461
  function withTimeout(promise, timeoutMs) {
232
462
  return new Promise((resolve, reject) => {
@@ -304,14 +534,16 @@ export async function codexFreshPreflight(env, deps = {}) {
304
534
  if (!path.isAbsolute(paths.hooksFile) || !path.isAbsolute(paths.helperDir) || !path.isAbsolute(paths.stateFile)) {
305
535
  return "codex-birth-unit-missing";
306
536
  }
307
- if (birthMissing(paths, deps.operatorUid ?? process.getuid?.() ?? -1)) {
537
+ const declaration = birthDeclaration(paths, deps.operatorUid ?? process.getuid?.() ?? -1);
538
+ if (declaration === null) {
308
539
  return "codex-birth-unit-missing";
309
540
  }
310
541
  const config = readConfig(env);
311
542
  // The vendor receipt is a SEPARATE reject from our bytes: perfect bytes the vendor will
312
543
  // not run and absent bytes are two different repairs, and folding them would send the
313
544
  // operator to the installer for something only they can answer in their own Codex.
314
- if (config === null || trustReceiptMissing(config, paths.hooksFile))
545
+ const key = trustReceiptKey(paths.hooksFile, declaration.groupIndex, declaration.handlerIndex);
546
+ if (config === null || trustReceiptMissing(config, key))
315
547
  return "codex-birth-trust-missing";
316
548
  if (mcpMissing(config))
317
549
  return "codex-mcp-hand-missing";
@@ -38,6 +38,46 @@ export function formatSenderInfoBlock(sender, wantsReply = false) {
38
38
  ...(wantsReply ? { wants_reply: true } : {}),
39
39
  })}</sender_info>`;
40
40
  }
41
+ // ============================================================================
42
+ // Accepted-connection disconnect policy (server half of the same wire)
43
+ // ============================================================================
44
+ /** The two codes that mean THE PEER WENT AWAY, and nothing else. `EPIPE` is a write to a pipe the
45
+ * far side already closed; `ECONNRESET` is the far side resetting it. Both describe the client, not
46
+ * this process's state, which is why they are absorbed rather than diagnosed. */
47
+ const PEER_DISCONNECT_CODES = new Set(["EPIPE", "ECONNRESET"]);
48
+ export function isPeerDisconnect(error) {
49
+ return typeof error.code === "string" && PEER_DISCONNECT_CODES.has(error.code);
50
+ }
51
+ /**
52
+ * Install the disconnect policy on a connection this process ACCEPTED. Must run before any data
53
+ * handler and before any response can be written back.
54
+ *
55
+ * `[측정 2026-09-14, .agent-reports/116-c4-live-blocker-20260914.md]` a resident pi DIED without
56
+ * this. A sibling's control-socket send timed out while the receiving session was mid-turn, the
57
+ * sender closed its end, and the server then wrote its late response to a socket whose peer was
58
+ * gone. That EPIPE does NOT arrive as a throw — `writeResponse`'s synchronous try/catch cannot see
59
+ * it — it arrives asynchronously as an `error` event, and an `error` event with no listener is an
60
+ * uncaught exception, so Node terminated the whole session. The citizen then simply read as `dead`
61
+ * and the stale socket it left behind was the only trace.
62
+ *
63
+ * A client that went away is a BOUNDED environment condition, not invalid state: Rule 15's "crash,
64
+ * don't warn" governs states we cannot reason about, and losing an entire resident session because
65
+ * someone hung up is itself the silent failure. Same policy the MCP probe already carries for an
66
+ * async EPIPE on a child's stdin (`scripts/probe-bridge-command.ts:88-97`).
67
+ *
68
+ * Anything that is NOT a peer hanging up is diagnosed exactly once with its code and message and
69
+ * then served on. Swallowing every error would hide the class this listener is not here to absorb;
70
+ * rethrowing from an event callback would be the crash this whole function exists to prevent.
71
+ *
72
+ * `diagnose` is injected so a gate can count diagnostics without capturing global stderr.
73
+ */
74
+ export function attachAcceptedSocketDisconnectPolicy(socket, diagnose = (line) => console.error(line)) {
75
+ socket.on("error", (error) => {
76
+ if (isPeerDisconnect(error))
77
+ return;
78
+ diagnose(`[entwurf-control] control socket error (${error.code ?? "no code"}): ${error.message}`);
79
+ });
80
+ }
41
81
  export function parseGetInfoResponseData(data) {
42
82
  const value = data;
43
83
  return {
@@ -27,7 +27,7 @@
27
27
  * one diagnostic carries the fact. (pi + same-gid socket = the normal merge.)
28
28
  */
29
29
  import { isOutOfSocketDomainGardenIdConflict, resolveFactList, UNOBSERVED_PEER, } from "./entwurf-facts.js";
30
- import { observePeerFacts } from "./entwurf-peer-observe.js";
30
+ import { makeObservePeerFacts, readHerdrPlacementIndex } from "./entwurf-peer-observe.js";
31
31
  import { isLivenessSupported } from "./entwurf-v2-contract.js";
32
32
  import { FRESH_CUT_PRESCRIPTION, listAllMetaIdentities, } from "./meta-session.js";
33
33
  import { scanSocketProbes } from "./socket-discovery.js";
@@ -140,7 +140,11 @@ export async function listEntwurfFacts(deps) {
140
140
  // not performed becomes `unobserved`.
141
141
  const cleanIdentities = identities.filter((i) => !conflictGids.has(i.gardenId));
142
142
  const cleanProbes = probes.filter((p) => !conflictGids.has(p.gardenId));
143
- const observe = deps.observe ?? observePeerFacts;
143
+ // The placement owner is read EXACTLY ONCE per listing, before any row is shaped, and
144
+ // never again for this call. That single read is the anti-watcher rule in code: herdr
145
+ // publishes no "the session reference landed" event, and a retry loop around that gap
146
+ // is the discovery watcher `docs/mux-launch-rail.md` §7 refuses by name.
147
+ const observe = deps.observe ?? makeObservePeerFacts((deps.readPlacementIndex ?? readHerdrPlacementIndex)());
144
148
  let selectedObserve = observe;
145
149
  if (deps.observationLimit !== undefined) {
146
150
  if (!Number.isSafeInteger(deps.observationLimit) || deps.observationLimit < 0) {
@@ -30,9 +30,14 @@
30
30
  * the surrounding identity facts around it.
31
31
  */
32
32
  import { factLivenessOf, isLivenessSupported } from "./entwurf-v2-contract.js";
33
+ import { UNOBSERVED_PLACEMENT } from "./herdr-placement.js";
33
34
  /** What a caller that measured nothing must say. Explicit and greppable — a row that
34
35
  * silently defaulted to `none`/`absent` would be a fabricated fact. */
35
- export const UNOBSERVED_PEER = { receiver: "unobserved", transcript: "unobserved" };
36
+ export const UNOBSERVED_PEER = {
37
+ receiver: "unobserved",
38
+ transcript: "unobserved",
39
+ placement: UNOBSERVED_PLACEMENT,
40
+ };
36
41
  /**
37
42
  * Compose a `PeerFact` from a citizen's identity and an optional socket probe.
38
43
  *
@@ -57,6 +62,7 @@ export function resolvePeerFact(identity, socket, observations = UNOBSERVED_PEER
57
62
  liveness: factLivenessOf(identity.backend, socket),
58
63
  receiver: observations.receiver,
59
64
  transcript: observations.transcript,
65
+ placement: observations.placement,
60
66
  };
61
67
  }
62
68
  /**
@@ -13,14 +13,23 @@
13
13
  * about it: no `sendable`, no `deliverable`, no transport. The listing stays a listing —
14
14
  * dispatch still computes routing at call time from the same underlying markers.
15
15
  *
16
+ * THE PLACEMENT AXIS IS MEASURED ONCE, NOT PER CITIZEN (#116 S1). `receiver` and
17
+ * `transcript` are per-citizen filesystem questions; placement is one question asked of
18
+ * one placement owner about every pane it has. So the herdr read happens ABOVE this
19
+ * module (the provider does it once) and arrives here as an already-built index. That
20
+ * is also the anti-watcher shape: one read per listing, no retry, no wait for a pane
21
+ * whose session reference has not landed yet.
22
+ *
16
23
  * ONE MEASUREMENT, TWO WORDS. `receiver` is derived from the SAME
17
24
  * `resolveMailboxReceiverFacts` composition the v2 dispatch seam and `entwurf_self` use.
18
25
  * The surfaces are allowed to phrase it differently — a listing wants an enum, a reject
19
26
  * wants a sentence — but they may not disagree, so the enum is a projection of that atom
20
27
  * rather than a second opinion about the same markers.
21
28
  */
29
+ import { execFileSync } from "node:child_process";
22
30
  import * as fs from "node:fs";
23
31
  import { resolveMailboxReceiverFacts } from "./entwurf-deliverability.js";
32
+ import { buildPlacementIndex, parseHerdrPaneList, resolvePlacement, } from "./herdr-placement.js";
24
33
  import { metaCapabilityFor, metaReceiverMarkerPath, readMetaReceiverMarker, readMetaSenderMarker, requireBackend, } from "./meta-session.js";
25
34
  /**
26
35
  * The mailbox receiver axis for one citizen.
@@ -60,7 +69,63 @@ function observeTranscript(identity) {
60
69
  return "absent";
61
70
  }
62
71
  }
63
- /** The production observer: both axes, measured for one citizen. */
72
+ /** How long the one placement read may take before we stop waiting for it. A listing
73
+ * must not hang on a placement owner: the column is a convenience and `unobserved` is
74
+ * a complete answer. */
75
+ const HERDR_READ_TIMEOUT_MS = 2000;
76
+ /**
77
+ * Read the placement owner ONCE, or decline to.
78
+ *
79
+ * TWO ENV FACTS DECIDE, AND BOTH ARE HERDR'S OWN (measured, herdr 0.9.0). `HERDR_ENV=1`
80
+ * is how herdr tells a process it is running inside herdr, and `HERDR_BIN_PATH` is the
81
+ * invocation path herdr's own plugin contract tells callers to use. Neither is
82
+ * discovered: if herdr did not put them in this process's environment we are not inside
83
+ * herdr and we make no claim. There is no path guess, no socket scan, and no PATH
84
+ * lookup — this must stay as explicit as Hard Rule 6 wants configuration to be.
85
+ *
86
+ * EVERY FAILURE IS `null`, NEVER AN EMPTY INDEX. A missing binary, a timeout, a nonzero
87
+ * exit or a payload we cannot parse all mean nobody measured. An empty index would say
88
+ * something much stronger — "herdr was read and has none of your citizens" — about a
89
+ * read that did not happen.
90
+ */
91
+ export function readHerdrPlacementIndex(env = process.env) {
92
+ if (env.HERDR_ENV !== "1")
93
+ return null;
94
+ const bin = env.HERDR_BIN_PATH;
95
+ if (typeof bin !== "string" || bin.length === 0)
96
+ return null;
97
+ let stdout;
98
+ try {
99
+ stdout = execFileSync(bin, ["pane", "list"], {
100
+ encoding: "utf8",
101
+ timeout: HERDR_READ_TIMEOUT_MS,
102
+ stdio: ["ignore", "pipe", "ignore"],
103
+ });
104
+ }
105
+ catch {
106
+ // Bounded environment probe: a placement owner that did not answer is a citizen
107
+ // column that reads `unobserved`, not a listing that fails.
108
+ return null;
109
+ }
110
+ const rows = parseHerdrPaneList(stdout);
111
+ return rows === null ? null : buildPlacementIndex(rows);
112
+ }
113
+ /**
114
+ * Build the production observer over an already-resolved placement index.
115
+ *
116
+ * `null` means no placement owner was read on this host — every citizen then reads
117
+ * `unobserved`, which is the only honest answer when nobody looked. It is NOT `none`:
118
+ * that word is reserved for a herdr that WAS read and does not have this citizen.
119
+ */
120
+ export function makeObservePeerFacts(placementIndex) {
121
+ return (identity) => ({
122
+ receiver: observeReceiver(identity),
123
+ transcript: observeTranscript(identity),
124
+ placement: resolvePlacement(placementIndex, identity),
125
+ });
126
+ }
127
+ /** The production observer with no placement owner read — the shape every caller that
128
+ * has not resolved an index gets, and the default on a host with no herdr. */
64
129
  export function observePeerFacts(identity) {
65
- return { receiver: observeReceiver(identity), transcript: observeTranscript(identity) };
130
+ return makeObservePeerFacts(null)(identity);
66
131
  }