@junghanacs/entwurf 0.13.1 → 0.14.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +59 -15
- package/BASELINE.md +3 -3
- package/CHANGELOG.md +74 -0
- package/CONTRIBUTING.md +13 -9
- package/DELIVERY.md +7 -6
- package/README.md +37 -28
- package/VERIFY.md +22 -14
- package/demo/README.md +1 -1
- package/demo/demo-baseline.sh +1 -3
- package/demo/demo.sh +2 -5
- package/docs/acp-backend-rail.md +9 -4
- package/docs/external-mcp-host.md +4 -5
- package/docs/setup-clean-host.md +8 -7
- package/mcp/entwurf-bridge/dist/mcp/entwurf-bridge/src/index.js +155 -28
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/overlay.js +8 -6
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/classify-tmux-cwd.js +47 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-control-rpc.js +7 -5
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-core.js +13 -14
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-resume-args.js +45 -40
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-contract.js +117 -95
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-decider.js +23 -57
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-lock.js +16 -7
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-production.js +5 -53
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-release.js +21 -36
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-runner.js +3 -15
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-send-fallback.js +12 -11
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-send.js +2 -7
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-surface.js +30 -67
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-visible-resume.js +256 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/meta-session.js +91 -5
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/mux-fresh-call.js +300 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/mux-launch.js +202 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/mux-placement.js +289 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/mux-resume-call.js +141 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/resume-launch-identity.js +136 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/session-id.js +8 -5
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/socket-discovery.js +3 -3
- package/mcp/entwurf-bridge/dist/scripts/meta-facts.js +51 -0
- package/mcp/entwurf-bridge/dist/scripts/new-session-id.js +9 -4
- package/mcp/entwurf-bridge/src/index.ts +173 -28
- package/mcp/entwurf-bridge/start.sh +2 -2
- package/mcp/entwurf-bridge/test.sh +23 -9
- package/mcp/entwurf-bridge/tsconfig.build.json +11 -2
- package/package.json +22 -11
- package/pi-extensions/entwurf-control.ts +227 -40
- package/pi-extensions/lib/acp/backend.ts +71 -12
- package/pi-extensions/lib/acp/overlay.ts +8 -6
- package/pi-extensions/lib/classify-tmux-cwd.ts +50 -0
- package/pi-extensions/lib/entwurf-control-rpc.ts +7 -5
- package/pi-extensions/lib/entwurf-core.ts +15 -15
- package/pi-extensions/lib/entwurf-resume-args.ts +41 -52
- package/pi-extensions/lib/entwurf-v2-contract-schema.ts +1 -1
- package/pi-extensions/lib/entwurf-v2-contract.ts +120 -99
- package/pi-extensions/lib/entwurf-v2-decider.ts +30 -91
- package/pi-extensions/lib/entwurf-v2-lock.ts +16 -7
- package/pi-extensions/lib/entwurf-v2-production.ts +4 -78
- package/pi-extensions/lib/entwurf-v2-release.ts +25 -49
- package/pi-extensions/lib/entwurf-v2-runner.ts +6 -21
- package/pi-extensions/lib/entwurf-v2-send-fallback.ts +12 -11
- package/pi-extensions/lib/entwurf-v2-send.ts +2 -7
- package/pi-extensions/lib/entwurf-v2-surface.ts +36 -76
- package/pi-extensions/lib/entwurf-v2-visible-resume.ts +370 -0
- package/pi-extensions/lib/meta-session.ts +93 -5
- package/pi-extensions/lib/mux-fresh-call.ts +381 -0
- package/pi-extensions/lib/mux-launch.ts +267 -0
- package/pi-extensions/lib/mux-placement.ts +387 -0
- package/pi-extensions/lib/mux-resume-call.ts +189 -0
- package/pi-extensions/lib/resume-launch-identity.ts +162 -0
- package/pi-extensions/lib/session-id.js +8 -5
- package/pi-extensions/lib/socket-discovery.ts +3 -3
- package/run.sh +471 -201
- package/scripts/agy-bridge-config.py +5 -1
- package/scripts/check-acp-backend-preflight.ts +1 -1
- package/scripts/check-acp-overlay.ts +13 -3
- package/scripts/check-acp-stream-hooks.ts +504 -0
- package/scripts/check-elapsed.sh +25 -0
- package/scripts/check-entwurf-bridge-boot.ts +51 -4
- package/scripts/check-entwurf-bridge-pi-free.ts +6 -5
- package/scripts/check-entwurf-control-rpc.ts +4 -3
- package/scripts/check-entwurf-resume-args.ts +72 -70
- package/scripts/check-entwurf-session-identity.ts +14 -10
- package/scripts/check-entwurf-v2-contract.ts +34 -59
- package/scripts/check-entwurf-v2-decider.ts +17 -177
- package/scripts/check-entwurf-v2-lock.ts +5 -2
- package/scripts/check-entwurf-v2-matrix.ts +3 -53
- package/scripts/check-entwurf-v2-production.ts +2 -91
- package/scripts/check-entwurf-v2-release.ts +10 -105
- package/scripts/check-entwurf-v2-runner.ts +4 -85
- package/scripts/check-entwurf-v2-send-fallback.ts +5 -6
- package/scripts/check-entwurf-v2-send.ts +0 -28
- package/scripts/check-entwurf-v2-surface.ts +157 -128
- package/scripts/check-entwurf-v2-visible-resume.ts +445 -0
- package/scripts/check-fresh-cut-gate.sh +1 -1
- package/scripts/check-gate-qualification.ts +99 -7
- package/scripts/check-install-container.sh +10 -2
- package/scripts/check-install-surface.ts +1 -1
- package/scripts/check-keyset-overlap.py +1 -1
- package/scripts/check-meta-facts.ts +249 -0
- package/scripts/check-meta-identity-consumers.ts +1 -1
- package/scripts/check-meta-session.ts +169 -0
- package/scripts/check-mux-launch-tmux.ts +316 -0
- package/scripts/check-mux-launch.ts +288 -0
- package/scripts/check-mux-launcher-fence.ts +264 -0
- package/scripts/check-mux-parent-artifact.ts +195 -0
- package/scripts/check-mux-placement-tmux.ts +322 -0
- package/scripts/check-mux-placement.ts +323 -0
- package/scripts/check-mux-resume-call.ts +283 -0
- package/scripts/check-probe-cli-shim.ts +25 -22
- package/scripts/check-probe-ordering.ts +84 -76
- package/scripts/check-release-gate-outcomes.ts +127 -7
- package/scripts/check-resume-launch-identity.ts +244 -0
- package/scripts/check-socket-discovery.ts +1 -1
- package/scripts/fixtures/mux-parent-transcript.scrubbed.jsonl +3 -0
- package/scripts/inventory-verification-surface.ts +349 -0
- package/scripts/lib/claude-launcher-fence.ts +322 -0
- package/scripts/lib/mutation-qualify.ts +109 -3
- package/scripts/meta-bridge-doctor.sh +6 -8
- package/scripts/meta-bridge-state.py +75 -1
- package/scripts/meta-facts.ts +60 -0
- package/scripts/mutants/acp-overlay.json +17 -0
- package/scripts/mutants/acp-stream-hooks.json +158 -0
- package/scripts/mutants/bridge-boot-resume.json +45 -0
- package/scripts/mutants/meta-facts.json +50 -0
- package/scripts/mutants/meta-identity.json +36 -0
- package/scripts/mutants/meta-retire.json +47 -0
- package/scripts/mutants/mux-boundary.json +196 -0
- package/scripts/mutants/mux-fresh-call.json +229 -0
- package/scripts/mutants/mux-launcher-fence.json +123 -0
- package/scripts/mutants/mux-parent-artifact.json +39 -0
- package/scripts/mutants/mux-resume-call.json +148 -0
- package/scripts/mutants/probe-ordering.json +0 -1037
- package/scripts/mutants/release-gate.json +35 -0
- package/scripts/mutants/resume-args.json +76 -0
- package/scripts/mutants/resume-launch-identity.json +96 -0
- package/scripts/mutants/v2-surface.json +58 -18
- package/scripts/mutants/v2-visible-resume.json +215 -0
- package/scripts/new-session-id.ts +9 -4
- package/scripts/smoke-acp-raw-turn-live.ts +1 -1
- package/scripts/smoke-agy-native-push-live.ts +6 -17
- package/scripts/smoke-entwurf-chain-live.ts +11 -3
- package/scripts/smoke-entwurf-v2-matrix-live.ts +1 -1
- package/scripts/smoke-meta-honesty.sh +1 -1
- package/scripts/smoke-meta-install-state.sh +169 -3
- package/scripts/smoke-mux-fresh-call-live.ts +365 -0
- package/scripts/smoke-mux-lifecycle-live.ts +1136 -0
- package/scripts/smoke-pi-attach.ts +1 -1
- package/scripts/smoke-user-scope-citizen.sh +1 -1
- package/scripts/tsconfig.json +1 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-preflight.js +0 -160
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-spawn-production.js +0 -273
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-spawn.js +0 -216
- package/pi-extensions/lib/entwurf-v2-spawn-production.ts +0 -373
- package/pi-extensions/lib/entwurf-v2-spawn.ts +0 -323
- package/scripts/check-acp-sdk-surface.ts +0 -275
- package/scripts/check-entwurf-v2-spawn-production.ts +0 -551
- package/scripts/check-entwurf-v2-spawn.ts +0 -399
- package/scripts/smoke-entwurf-v2-spawn-live.ts +0 -188
- package/scripts/smoke-entwurf-v2-spawn-resume-live.ts +0 -467
|
@@ -161,6 +161,14 @@ async function main(): Promise<void> {
|
|
|
161
161
|
ENTWURF_META_MAILBOX_DIR: mailboxDir,
|
|
162
162
|
ENTWURF_META_RECEIVERS_DIR: receiversDir,
|
|
163
163
|
};
|
|
164
|
+
// The MCP bridge prefers PI_SESSION_ID+PI_AGENT_ID over a meta-sender marker when both
|
|
165
|
+
// are present. A runner that is itself a pi --entwurf-control session (release-gate,
|
|
166
|
+
// an agent preparing a cut) would otherwise stamp every Claude hop with the RUNNER's
|
|
167
|
+
// garden id — hop 1 then fails "B saw A's real garden id" while the payload still
|
|
168
|
+
// traverses. Strip the carriers so A's SessionStart marker is the only authority.
|
|
169
|
+
const childEnv: NodeJS.ProcessEnv = { ...process.env, ...worldEnv };
|
|
170
|
+
delete childEnv.PI_SESSION_ID;
|
|
171
|
+
delete childEnv.PI_AGENT_ID;
|
|
164
172
|
|
|
165
173
|
const nonce = `ENTWURF-CHAIN-${crypto.randomBytes(5).toString("hex").toUpperCase()}`;
|
|
166
174
|
console.error(`[smoke-entwurf-chain-live] repo: ${REPO_ROOT}`);
|
|
@@ -194,7 +202,7 @@ async function main(): Promise<void> {
|
|
|
194
202
|
const c = spawn(
|
|
195
203
|
"pi",
|
|
196
204
|
[...REPO_EXTENSION_ARGS, "--entwurf-control", "--provider", acpProvider, "--model", acpModel, "--mode", "rpc"],
|
|
197
|
-
{ cwd: world, stdio: ["pipe", "pipe", "pipe"], env:
|
|
205
|
+
{ cwd: world, stdio: ["pipe", "pipe", "pipe"], env: childEnv },
|
|
198
206
|
);
|
|
199
207
|
children.push(c);
|
|
200
208
|
c.stdout?.on("data", (b: Buffer) => {
|
|
@@ -215,7 +223,7 @@ async function main(): Promise<void> {
|
|
|
215
223
|
const b = spawn(
|
|
216
224
|
"pi",
|
|
217
225
|
[...REPO_EXTENSION_ARGS, "--entwurf-control", "--provider", gptProvider, "--model", gptModel, "--mode", "rpc"],
|
|
218
|
-
{ cwd: world, stdio: ["pipe", "pipe", "pipe"], env:
|
|
226
|
+
{ cwd: world, stdio: ["pipe", "pipe", "pipe"], env: childEnv },
|
|
219
227
|
);
|
|
220
228
|
children.push(b);
|
|
221
229
|
b.stdout?.on("data", (x: Buffer) => {
|
|
@@ -265,7 +273,7 @@ async function main(): Promise<void> {
|
|
|
265
273
|
cwd: world,
|
|
266
274
|
encoding: "utf8",
|
|
267
275
|
timeout: CLAUDE_TURN_TIMEOUT_MS,
|
|
268
|
-
env:
|
|
276
|
+
env: childEnv,
|
|
269
277
|
});
|
|
270
278
|
const claudeOut = `${claudeRun.stdout ?? ""}\n${claudeRun.stderr ?? ""}`;
|
|
271
279
|
ok("A's native Claude Code turn ran", claudeRun.status === 0);
|
|
@@ -260,7 +260,7 @@ async function main(): Promise<void> {
|
|
|
260
260
|
const up = await waitForSocket(sockPath, BOOT_TIMEOUT_MS);
|
|
261
261
|
ok("C1b real record-less `pi --entwurf-control` stood up a control socket", up);
|
|
262
262
|
|
|
263
|
-
for (const intent of ["fire-and-forget"
|
|
263
|
+
for (const intent of ["fire-and-forget"] as const) {
|
|
264
264
|
const result: EntwurfV2RunResult = await runEntwurfV2(
|
|
265
265
|
{ target: c1bGid, intent, message: `matrix-live C1b record-less probe (${intent})` },
|
|
266
266
|
prodDeps(smokeSender(c1bGid, tmp)),
|
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
# alarm), and must log sender-marker evidence so send-side replyable identity
|
|
18
18
|
# is not silently lost.
|
|
19
19
|
#
|
|
20
|
-
# Deterministic + offline (no `claude -p`, no network). Safe for pnpm check /
|
|
20
|
+
# Deterministic + offline (no `claude -p`, no network). Safe for pnpm run check:full /
|
|
21
21
|
# pre-commit. Isolates its store via PI_CODING_AGENT_DIR in a temp dir.
|
|
22
22
|
#
|
|
23
23
|
# RUNTIME DEPS: bash + node (the hook runs under `node --experimental-strip-types`)
|
|
@@ -6,6 +6,7 @@ set -euo pipefail
|
|
|
6
6
|
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
7
7
|
REPO="$(cd "$HERE/.." && pwd)"
|
|
8
8
|
export REPO
|
|
9
|
+
export PYTHONDONTWRITEBYTECODE=1
|
|
9
10
|
STATE="$REPO/scripts/meta-bridge-state.py"
|
|
10
11
|
STORE_DOCTOR="$REPO/scripts/meta-bridge-store-doctor.ts"
|
|
11
12
|
# shellcheck source=scripts/meta-bridge-hook-log.sh
|
|
@@ -192,6 +193,136 @@ JSON
|
|
|
192
193
|
|
|
193
194
|
py() { python3 "$STATE" "$@" --repo "$REPO" --asm "$ASM"; }
|
|
194
195
|
|
|
196
|
+
# #71 retirement cells: drive the production leaf directly so JSON scalar type,
|
|
197
|
+
# malformed provenance and one-shot convergence cannot hide inside the larger
|
|
198
|
+
# install fixture below. Three cells carry [QK:] claims backed by exact-once
|
|
199
|
+
# mutants in scripts/mutants/meta-retire.json; each prints its token on the
|
|
200
|
+
# FAILURE line, because signatureOnFailureLine ignores any line starting with
|
|
201
|
+
# `ok`. Cells keep running after a failure so one planted defect reddens exactly
|
|
202
|
+
# its own claim instead of masking the ones behind it.
|
|
203
|
+
if python3 - "$STATE" <<'PY'
|
|
204
|
+
import copy, importlib.util, sys
|
|
205
|
+
|
|
206
|
+
spec = importlib.util.spec_from_file_location("meta_bridge_state", sys.argv[1])
|
|
207
|
+
assert spec and spec.loader
|
|
208
|
+
m = importlib.util.module_from_spec(spec)
|
|
209
|
+
spec.loader.exec_module(m)
|
|
210
|
+
NAME = "skipDangerousModePermissionPrompt"
|
|
211
|
+
PATH = [NAME]
|
|
212
|
+
MISSING = object()
|
|
213
|
+
|
|
214
|
+
def entry(existed, value):
|
|
215
|
+
return {"kind": "scalar", "path": PATH, "original": {"existed": existed, "value": value}}
|
|
216
|
+
|
|
217
|
+
def fixture(current=MISSING, old_entry=MISSING):
|
|
218
|
+
settings = {} if current is MISSING else {NAME: current}
|
|
219
|
+
keys = {} if old_entry is MISSING else {NAME: copy.deepcopy(old_entry)}
|
|
220
|
+
state = {"files": {"settings": {"keys": keys}}}
|
|
221
|
+
return state, settings
|
|
222
|
+
|
|
223
|
+
def run(current=MISSING, old_entry=MISSING):
|
|
224
|
+
state, settings = fixture(current, old_entry)
|
|
225
|
+
m.relinquish_retired_scalar(state, settings, NAME, PATH, True)
|
|
226
|
+
return state, settings
|
|
227
|
+
|
|
228
|
+
failed = 0
|
|
229
|
+
|
|
230
|
+
def cell(sig, label, fn):
|
|
231
|
+
global failed
|
|
232
|
+
text = f"{sig} {label}".strip()
|
|
233
|
+
try:
|
|
234
|
+
fn()
|
|
235
|
+
except Exception as exc:
|
|
236
|
+
print(f" FAIL {text}: {exc!r}")
|
|
237
|
+
failed = 1
|
|
238
|
+
return
|
|
239
|
+
print(f" ok {text}")
|
|
240
|
+
|
|
241
|
+
def compare_type_cells():
|
|
242
|
+
assert NAME not in run(True, entry(False, None))[1]
|
|
243
|
+
assert run(True, entry(True, False))[1][NAME] is False
|
|
244
|
+
assert run(True, entry(True, True))[1][NAME] is True
|
|
245
|
+
assert run(False, entry(True, False))[1][NAME] is False
|
|
246
|
+
assert NAME not in run(MISSING, entry(True, False))[1]
|
|
247
|
+
value = run(1, entry(True, False))[1][NAME]
|
|
248
|
+
assert type(value) is int and value == 1
|
|
249
|
+
|
|
250
|
+
cell(
|
|
251
|
+
"[QK:META-RETIRE-COMPARE-TYPE]",
|
|
252
|
+
"only an exact bool true with proven provenance is restored; 1 is not true",
|
|
253
|
+
compare_type_cells,
|
|
254
|
+
)
|
|
255
|
+
|
|
256
|
+
def malformed_cells():
|
|
257
|
+
for bad_entry in [
|
|
258
|
+
{"kind": "map-entry", "path": PATH, "original": {"existed": False, "value": None}},
|
|
259
|
+
{"kind": "scalar", "path": PATH, "original": {"existed": "no", "value": None}},
|
|
260
|
+
]:
|
|
261
|
+
state, settings = fixture(True, bad_entry)
|
|
262
|
+
before = copy.deepcopy((state, settings))
|
|
263
|
+
try:
|
|
264
|
+
m.relinquish_retired_scalar(state, settings, NAME, PATH, True)
|
|
265
|
+
except m.StateError:
|
|
266
|
+
pass
|
|
267
|
+
else:
|
|
268
|
+
raise AssertionError("malformed retired provenance was accepted")
|
|
269
|
+
assert (state, settings) == before
|
|
270
|
+
|
|
271
|
+
cell(
|
|
272
|
+
"[QK:META-RETIRE-MALFORMED-SILENT]",
|
|
273
|
+
"malformed provenance fails loud and mutates neither settings nor state",
|
|
274
|
+
malformed_cells,
|
|
275
|
+
)
|
|
276
|
+
|
|
277
|
+
def fresh_cells():
|
|
278
|
+
for current in [True, False, 1, MISSING]:
|
|
279
|
+
state, settings = run(current)
|
|
280
|
+
expected = {} if current is MISSING else {NAME: current}
|
|
281
|
+
assert settings == expected and NAME not in state["files"]["settings"]["keys"]
|
|
282
|
+
|
|
283
|
+
cell(
|
|
284
|
+
"[QK:META-RETIRE-FRESH-TOUCH]",
|
|
285
|
+
"a provenance-less value is the operator's and is never touched",
|
|
286
|
+
fresh_cells,
|
|
287
|
+
)
|
|
288
|
+
|
|
289
|
+
def one_shot_cells():
|
|
290
|
+
state, settings = fixture(True, entry(True, False))
|
|
291
|
+
m.relinquish_retired_scalar(state, settings, NAME, PATH, True)
|
|
292
|
+
assert settings[NAME] is False and NAME not in state["files"]["settings"]["keys"]
|
|
293
|
+
before = copy.deepcopy((state, settings))
|
|
294
|
+
m.relinquish_retired_scalar(state, settings, NAME, PATH, True)
|
|
295
|
+
assert (state, settings) == before
|
|
296
|
+
|
|
297
|
+
# Recommended, not claimed: one-shot convergence and the disjoint-set guard ride
|
|
298
|
+
# along as ordinary cells so the proof budget stays at the three claims above.
|
|
299
|
+
cell("", "relinquishment consumes the entry once, so a re-run cannot restore again", one_shot_cells)
|
|
300
|
+
|
|
301
|
+
def structural_cells():
|
|
302
|
+
assert not m._managed_scalar_names & m._retired_scalar_names
|
|
303
|
+
# settings_state_keys is the sole reader of the ownership ledger, and
|
|
304
|
+
# load_state validates only the envelope. A corrupt files/settings/keys shape
|
|
305
|
+
# must stop with an operator diagnostic BEFORE any write — never a bare
|
|
306
|
+
# KeyError raised from inside apply(), and never a silent pass that would let
|
|
307
|
+
# relinquishment reason about ownership it cannot actually read.
|
|
308
|
+
for broken in [{}, {"files": {}}, {"files": {"settings": {}}}, {"files": {"settings": {"keys": []}}}]:
|
|
309
|
+
settings = {NAME: True}
|
|
310
|
+
before = copy.deepcopy((broken, settings))
|
|
311
|
+
try:
|
|
312
|
+
m.relinquish_retired_scalar(broken, settings, NAME, PATH, True)
|
|
313
|
+
except m.StateError:
|
|
314
|
+
pass
|
|
315
|
+
else:
|
|
316
|
+
raise AssertionError(f"corrupt state ledger accepted: {broken!r}")
|
|
317
|
+
assert (broken, settings) == before
|
|
318
|
+
|
|
319
|
+
cell("", "retired/managed sets stay disjoint and a corrupt key ledger fails closed", structural_cells)
|
|
320
|
+
sys.exit(failed)
|
|
321
|
+
PY
|
|
322
|
+
then ok "#71 retirement leaf: provenance, bool strictness, malformed refusal, one-shot convergence"
|
|
323
|
+
else bad "#71 retirement leaf cells failed (traceback above)"
|
|
324
|
+
fi
|
|
325
|
+
|
|
195
326
|
DEV_STATUSLINE="$(python3 "$STATE" desired-statusline --repo "$REPO" | python3 -c 'import json,sys; print(json.load(sys.stdin)["command"])')"
|
|
196
327
|
if [ "$DEV_STATUSLINE" = "$REPO/scripts/meta-bridge-statusline.sh" ]; then ok "dev statusLine pins the checkout script"; else bad "dev statusLine command drifted: $DEV_STATUSLINE"; fi
|
|
197
328
|
FAKE_INSTALLED_REPO="$TMP/npmroot/node_modules/@junghanacs/entwurf"
|
|
@@ -212,10 +343,26 @@ assert s['files']['settings']['keys']['env.DISABLE_AUTOCOMPACT']['original']['va
|
|
|
212
343
|
assert s['files']['settings']['keys']['statusLine']['original']['value']['command'] == '/old/user/statusline.sh'
|
|
213
344
|
assert s['files']['settings']['keys']['promptSuggestionEnabled']['original']['value'] is True
|
|
214
345
|
assert s['files']['settings']['keys']['autoCompactEnabled']['original']['existed'] is False
|
|
346
|
+
assert 'skipDangerousModePermissionPrompt' not in s['files']['settings']['keys']
|
|
215
347
|
assert s['files']['claudeRoot']['keys']['mcpServers.entwurf-bridge']['original']['value']['command'] == 'old'
|
|
216
348
|
PY
|
|
217
|
-
then ok "state captures original scalar/map values and is mode 0600"; else bad "state did not capture original values / mode 0600"; fi
|
|
349
|
+
then ok "state captures original scalar/map values, excludes the retired scalar, and is mode 0600"; else bad "state did not capture original values / retirement / mode 0600"; fi
|
|
218
350
|
|
|
351
|
+
# Simulate an upgrade from 0.14.0: the old state proves entwurf changed false→true.
|
|
352
|
+
# New apply must restore false and consume that ownership exactly once.
|
|
353
|
+
python3 - <<'PY'
|
|
354
|
+
import json, os
|
|
355
|
+
cfg=os.environ['CLAUDE_CONFIG_DIR']
|
|
356
|
+
sp=cfg + '/settings.json'; stp=cfg + '/entwurf.install-state.json'
|
|
357
|
+
settings=json.load(open(sp)); state=json.load(open(stp))
|
|
358
|
+
settings['skipDangerousModePermissionPrompt']=True
|
|
359
|
+
state['files']['settings']['keys']['skipDangerousModePermissionPrompt']={
|
|
360
|
+
'kind':'scalar', 'path':['skipDangerousModePermissionPrompt'],
|
|
361
|
+
'original':{'existed':True, 'value':False}
|
|
362
|
+
}
|
|
363
|
+
json.dump(settings, open(sp,'w'), indent=2); open(sp,'a').write('\n')
|
|
364
|
+
json.dump(state, open(stp,'w'), indent=2); open(stp,'a').write('\n')
|
|
365
|
+
PY
|
|
219
366
|
py apply >/dev/null
|
|
220
367
|
if python3 - <<'PY'
|
|
221
368
|
import json, os
|
|
@@ -231,7 +378,9 @@ assert settings['env']['KEEP_ME'] == 'yes'
|
|
|
231
378
|
assert settings['statusLine']['command'] == os.environ['REPO'] + '/scripts/meta-bridge-statusline.sh'
|
|
232
379
|
for key in ['promptSuggestionEnabled','awaySummaryEnabled','autoMemoryEnabled','verbose','autoCompactEnabled','showTurnDuration','terminalProgressBarEnabled','useAutoModeDuringPlan','enableWorkflows','workflowKeywordTriggerEnabled']:
|
|
233
380
|
assert settings[key] is False, key
|
|
234
|
-
assert settings['skipDangerousModePermissionPrompt'] is
|
|
381
|
+
assert settings['skipDangerousModePermissionPrompt'] is False
|
|
382
|
+
state=json.load(open(cfg + '/entwurf.install-state.json'))
|
|
383
|
+
assert 'skipDangerousModePermissionPrompt' not in state['files']['settings']['keys']
|
|
235
384
|
for item in ['Bash','Read','Write','Edit','Grep','Glob','WebFetch','WebSearch','Skill','mcp__entwurf-bridge__*']:
|
|
236
385
|
assert item in settings['permissions']['allow'], item
|
|
237
386
|
assert settings['permissions']['allow'].count('Read') == 1
|
|
@@ -249,7 +398,24 @@ then ok "apply installs managed keyset without clobbering unrelated keys"; else
|
|
|
249
398
|
# (agent-config merge, hand edit) overwrites a pi-owned key, check must fail loud
|
|
250
399
|
# AND name the drifted key so doctor can surface which one. Adversarial flips
|
|
251
400
|
# below; restore with apply afterward so the later cases see a clean keyset.
|
|
252
|
-
if py check >/dev/null 2>&1; then ok "survival check passes
|
|
401
|
+
if py check >/dev/null 2>&1; then ok "survival check passes after relinquishing the retired scalar"; else bad "survival check failed right after retirement apply"; fi
|
|
402
|
+
MANAGED_JSON="$(py managed-keys)"
|
|
403
|
+
if printf '%s' "$MANAGED_JSON" | grep -q 'skipDangerousModePermissionPrompt'; then bad "managed-keys still claims the retired operator key"; else ok "managed-keys returns skipDangerousModePermissionPrompt to the operator"; fi
|
|
404
|
+
# A provenance-less true cannot be changed safely, but doctor/check must make it visible.
|
|
405
|
+
python3 - <<'PY'
|
|
406
|
+
import json, os
|
|
407
|
+
p=os.environ['CLAUDE_CONFIG_DIR'] + '/settings.json'
|
|
408
|
+
d=json.load(open(p)); d['skipDangerousModePermissionPrompt']=True
|
|
409
|
+
json.dump(d, open(p,'w'), indent=2); open(p,'a').write('\n')
|
|
410
|
+
PY
|
|
411
|
+
RETIRED_NOTE="$(py check)"
|
|
412
|
+
if printf '%s' "$RETIRED_NOTE" | grep -q 'operator-owned'; then ok "check advises on a provenance-less true without claiming ownership"; else bad "check hid the provenance-less retired true"; fi
|
|
413
|
+
python3 - <<'PY'
|
|
414
|
+
import json, os
|
|
415
|
+
p=os.environ['CLAUDE_CONFIG_DIR'] + '/settings.json'
|
|
416
|
+
d=json.load(open(p)); d['skipDangerousModePermissionPrompt']=False
|
|
417
|
+
json.dump(d, open(p,'w'), indent=2); open(p,'a').write('\n')
|
|
418
|
+
PY
|
|
253
419
|
SURVIVAL_SNAP="$TMP/settings-survival-snapshot.json"
|
|
254
420
|
cp "$CLAUDE_CONFIG_DIR/settings.json" "$SURVIVAL_SNAP" # exact restore point (array-replace below would drop user items)
|
|
255
421
|
python3 - <<'PY'
|
|
@@ -0,0 +1,365 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* smoke-mux-fresh-call-live — the ONE axis a deterministic gate cannot reach: a real window, a
|
|
3
|
+
* real runtime, a real first turn, and a real callback arriving on a real inbound surface.
|
|
4
|
+
*
|
|
5
|
+
* OUT of `pnpm check`. Needs `LIVE=1`. Costs two model turns (one pi, one Claude Code).
|
|
6
|
+
*
|
|
7
|
+
* ── Isolate the WRITES, keep the runtimes real ──
|
|
8
|
+
*
|
|
9
|
+
* The obvious fence — redirect HOME and everything under it — is wrong here, and wrongly GREEN
|
|
10
|
+
* is worse than red: a pi with no `PI_CODING_AGENT_DIR` and a Claude with no config dir are
|
|
11
|
+
* unauthenticated runtimes that fail for a reason this smoke is not testing. So the split is:
|
|
12
|
+
*
|
|
13
|
+
* REAL (runtime-owned) the authenticated runtime config — the real pi agent dir
|
|
14
|
+
* for Pi, and canonical HOME/optional CLAUDE_CONFIG_DIR for
|
|
15
|
+
* Claude. Native session transcripts remain there as evidence.
|
|
16
|
+
* FIXTURE (entwurf-owned writes) XDG roots, the four meta roots, the v2 lock dir, and the
|
|
17
|
+
* working directory. Pi also receives fixture HOME so its
|
|
18
|
+
* control socket cannot touch the operator's directory.
|
|
19
|
+
*
|
|
20
|
+
* Be honest about what that means: this smoke READS the operator's real runtime config, and the
|
|
21
|
+
* two siblings it opens will write their own session transcripts into the real pi agent dir the
|
|
22
|
+
* same way any pi session does. What it must never do is mint a garden record, a control socket,
|
|
23
|
+
* a mailbox or a lock outside the fixture — that is what the proof at the end checks.
|
|
24
|
+
*
|
|
25
|
+
* The siblings inherit the fixture through the private tmux server we start inside it, so their
|
|
26
|
+
* records land in the fixture store too: born, observed, discarded, and the garden never sees
|
|
27
|
+
* them.
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
import { spawnSync } from "node:child_process";
|
|
31
|
+
import fs from "node:fs";
|
|
32
|
+
import os from "node:os";
|
|
33
|
+
import path from "node:path";
|
|
34
|
+
import {
|
|
35
|
+
assessLauncherCleanup,
|
|
36
|
+
restoreOriginalXdg,
|
|
37
|
+
snapshotClaudeLauncher,
|
|
38
|
+
snapshotOriginalXdg,
|
|
39
|
+
verifyClaudeLauncher,
|
|
40
|
+
} from "./lib/claude-launcher-fence.ts";
|
|
41
|
+
import { skipLive } from "./lib/live-skip.ts";
|
|
42
|
+
|
|
43
|
+
const LABEL = "smoke-mux-fresh-call-live";
|
|
44
|
+
const CALLBACK_WAIT_MS = 180_000;
|
|
45
|
+
const LIVE_MODEL = {
|
|
46
|
+
pi: "openai-codex/gpt-5.6-terra",
|
|
47
|
+
"claude-code": "claude-sonnet-5",
|
|
48
|
+
} as const;
|
|
49
|
+
|
|
50
|
+
// Captured BEFORE any redirect: these name the operator's world and must stay untouched.
|
|
51
|
+
const REAL_HOME = os.homedir();
|
|
52
|
+
const REAL_PI_AGENT_DIR = process.env.PI_CODING_AGENT_DIR?.trim() || path.join(REAL_HOME, ".pi", "agent");
|
|
53
|
+
const ORIGINAL_CLAUDE_CONFIG_DIR = process.env.CLAUDE_CONFIG_DIR?.trim() || null;
|
|
54
|
+
const REAL_CLAUDE_CONFIG_DIR = ORIGINAL_CLAUDE_CONFIG_DIR || path.join(REAL_HOME, ".claude");
|
|
55
|
+
const REAL_META_SESSIONS =
|
|
56
|
+
process.env.ENTWURF_META_SESSIONS_DIR?.trim() || path.join(REAL_PI_AGENT_DIR, "meta-sessions");
|
|
57
|
+
const REAL_CONTROL_DIR = path.join(REAL_HOME, ".pi", "entwurf-control");
|
|
58
|
+
// The four XDG roots as the OPERATOR has them — presence and value, captured before any redirect,
|
|
59
|
+
// so the real-HOME Claude cell can be given exact operator-env parity (issue #67).
|
|
60
|
+
const ORIGINAL_XDG = snapshotOriginalXdg(process.env);
|
|
61
|
+
|
|
62
|
+
let passed = 0;
|
|
63
|
+
function ok(label: string, cond: boolean): void {
|
|
64
|
+
if (!cond) throw new Error(`${LABEL}: FAILED — ${label}`);
|
|
65
|
+
console.log(` ok ${label}`);
|
|
66
|
+
passed++;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* The entry SET of a directory we must not write to. A missing directory is a valid answer
|
|
71
|
+
* (ENOENT); anything else — EACCES above all — is a real failure and must not be laundered into
|
|
72
|
+
* "absent", which would turn an unreadable store into a passing proof.
|
|
73
|
+
*
|
|
74
|
+
* This is an ENTRY-SET comparison, not a byte comparison: it detects a record/socket/lock that
|
|
75
|
+
* appeared or vanished, which is exactly the residue class this smoke can create. It does not
|
|
76
|
+
* claim file contents are unchanged.
|
|
77
|
+
*/
|
|
78
|
+
function entrySet(dir: string): string {
|
|
79
|
+
try {
|
|
80
|
+
return fs.readdirSync(dir).sort().join("\n");
|
|
81
|
+
} catch (err) {
|
|
82
|
+
if ((err as NodeJS.ErrnoException).code === "ENOENT") return "<absent>";
|
|
83
|
+
throw err;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function tmux(socket: string, args: string[], env: NodeJS.ProcessEnv): { status: number | null; stdout: string } {
|
|
88
|
+
const r = spawnSync("tmux", ["-S", socket, ...args], { env, encoding: "utf8" });
|
|
89
|
+
return { status: r.status, stdout: (r.stdout ?? "").trim() };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function pidIsAlive(pid: number): boolean {
|
|
93
|
+
try {
|
|
94
|
+
process.kill(pid, 0);
|
|
95
|
+
return true;
|
|
96
|
+
} catch (err) {
|
|
97
|
+
if ((err as NodeJS.ErrnoException).code === "ESRCH") return false;
|
|
98
|
+
throw err;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function waitForPidsGone(pids: ReadonlySet<number>, timeoutMs = 10_000): boolean {
|
|
103
|
+
const deadline = Date.now() + timeoutMs;
|
|
104
|
+
while (Date.now() < deadline) {
|
|
105
|
+
if ([...pids].every((pid) => !pidIsAlive(pid))) return true;
|
|
106
|
+
spawnSync("sleep", ["0.1"]);
|
|
107
|
+
}
|
|
108
|
+
return [...pids].every((pid) => !pidIsAlive(pid));
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
async function main(): Promise<void> {
|
|
112
|
+
if (process.env.LIVE !== "1")
|
|
113
|
+
skipLive(LABEL, "LIVE=1 not set — this smoke opens real windows and spends model turns");
|
|
114
|
+
if (spawnSync("tmux", ["-V"], { encoding: "utf8" }).status !== 0) skipLive(LABEL, "tmux is not installed on PATH");
|
|
115
|
+
if (spawnSync("sh", ["-c", "command -v claude"], { encoding: "utf8" }).status !== 0)
|
|
116
|
+
skipLive(LABEL, "the claude runtime is not on PATH — the fixed claude-code backend cannot be opened");
|
|
117
|
+
if (!fs.existsSync(REAL_PI_AGENT_DIR))
|
|
118
|
+
skipLive(LABEL, `no pi agent dir at ${REAL_PI_AGENT_DIR} — an unauthenticated pi would fail for the wrong reason`);
|
|
119
|
+
if (!fs.existsSync(REAL_CLAUDE_CONFIG_DIR))
|
|
120
|
+
skipLive(
|
|
121
|
+
LABEL,
|
|
122
|
+
`no Claude config dir at ${REAL_CLAUDE_CONFIG_DIR} — an unauthenticated claude would fail likewise`,
|
|
123
|
+
);
|
|
124
|
+
|
|
125
|
+
const root = fs.mkdtempSync(path.join(os.tmpdir(), "mux-fresh-call-live-"));
|
|
126
|
+
const scratch = path.join(root, "cwd");
|
|
127
|
+
const beforeRealStore = entrySet(REAL_META_SESSIONS);
|
|
128
|
+
const beforeRealSockets = entrySet(REAL_CONTROL_DIR);
|
|
129
|
+
const originalCwd = process.cwd();
|
|
130
|
+
// FAIL-CLOSED launcher preflight (issue #67): pin the real claude launcher — path, kind, link,
|
|
131
|
+
// resolved target and content — before ANY Claude-capable child starts. Throws if it cannot.
|
|
132
|
+
const launcherSnapshot = snapshotClaudeLauncher({ env: process.env, fixtureRoot: root });
|
|
133
|
+
|
|
134
|
+
// ── Every WRITE axis into the fixture; the two auth roots stay real ──────
|
|
135
|
+
const fenced: Record<string, string> = {
|
|
136
|
+
HOME: path.join(root, "home"),
|
|
137
|
+
XDG_CONFIG_HOME: path.join(root, "xdg-config"),
|
|
138
|
+
XDG_DATA_HOME: path.join(root, "xdg-data"),
|
|
139
|
+
XDG_STATE_HOME: path.join(root, "xdg-state"),
|
|
140
|
+
XDG_CACHE_HOME: path.join(root, "xdg-cache"),
|
|
141
|
+
XDG_RUNTIME_DIR: path.join(root, "xdg-runtime"),
|
|
142
|
+
ENTWURF_META_SESSIONS_DIR: path.join(root, "meta-sessions"),
|
|
143
|
+
ENTWURF_META_RECEIVERS_DIR: path.join(root, "meta-receivers"),
|
|
144
|
+
ENTWURF_META_SENDERS_DIR: path.join(root, "meta-senders"),
|
|
145
|
+
ENTWURF_META_MAILBOX_DIR: path.join(root, "meta-mailbox"),
|
|
146
|
+
ENTWURF_V2_LOCK_DIR: path.join(root, "v2-locks"),
|
|
147
|
+
};
|
|
148
|
+
const referenced: Record<string, string> = {
|
|
149
|
+
PI_CODING_AGENT_DIR: REAL_PI_AGENT_DIR,
|
|
150
|
+
};
|
|
151
|
+
for (const dir of Object.values(fenced)) fs.mkdirSync(dir, { recursive: true });
|
|
152
|
+
fs.mkdirSync(scratch, { recursive: true });
|
|
153
|
+
// A runtime dir is a private surface by convention and tmux checks it.
|
|
154
|
+
fs.chmodSync(fenced.XDG_RUNTIME_DIR, 0o700);
|
|
155
|
+
for (const [k, v] of Object.entries(fenced)) process.env[k] = v;
|
|
156
|
+
for (const [k, v] of Object.entries(referenced)) process.env[k] = v;
|
|
157
|
+
delete process.env.CLAUDE_CONFIG_DIR;
|
|
158
|
+
process.chdir(scratch);
|
|
159
|
+
|
|
160
|
+
// Import the meta layer only AFTER the redirects — a module that resolved its roots at import
|
|
161
|
+
// time would have captured the operator's.
|
|
162
|
+
const meta = await import("../pi-extensions/lib/meta-session.ts");
|
|
163
|
+
const { freshCall } = await import("../pi-extensions/lib/mux-fresh-call.ts");
|
|
164
|
+
|
|
165
|
+
const siblingGids = new Set<string>();
|
|
166
|
+
const siblingPids = new Set<number>();
|
|
167
|
+
const privateSockets = new Set<string>();
|
|
168
|
+
let cleanupError: Error | null = null;
|
|
169
|
+
let runError: unknown = null;
|
|
170
|
+
try {
|
|
171
|
+
ok(
|
|
172
|
+
"fence: every entwurf-owned WRITE root is inside the fixture and none is the operator's home",
|
|
173
|
+
Object.values(fenced).every((d) => d.startsWith(root)) && !Object.values(fenced).includes(REAL_HOME),
|
|
174
|
+
);
|
|
175
|
+
ok(
|
|
176
|
+
"fence: runtime auth roots are real — this smoke tests configured runtimes, not empty ones",
|
|
177
|
+
referenced.PI_CODING_AGENT_DIR === REAL_PI_AGENT_DIR &&
|
|
178
|
+
!referenced.PI_CODING_AGENT_DIR.startsWith(root) &&
|
|
179
|
+
!REAL_CLAUDE_CONFIG_DIR.startsWith(root),
|
|
180
|
+
);
|
|
181
|
+
ok(
|
|
182
|
+
"fence: the meta layer resolved its roots to the fixture, and cwd is the scratch dir",
|
|
183
|
+
meta.defaultMetaSessionsDir().startsWith(root) &&
|
|
184
|
+
meta.defaultMetaMailboxDir().startsWith(root) &&
|
|
185
|
+
process.cwd().startsWith(root),
|
|
186
|
+
);
|
|
187
|
+
ok("fence: XDG_RUNTIME_DIR is 0700", (fs.statSync(fenced.XDG_RUNTIME_DIR).mode & 0o777) === 0o700);
|
|
188
|
+
|
|
189
|
+
// ── The caller: a self-fetch citizen whose mailbox we can drain ──────────
|
|
190
|
+
const nativeSessionId = `mux-fresh-call-live-${process.pid}`;
|
|
191
|
+
const caller = meta.upsertMetaSession({
|
|
192
|
+
input: { backend: "claude-code", nativeSessionId, cwd: scratch },
|
|
193
|
+
});
|
|
194
|
+
const callerGid = caller.record.gardenId;
|
|
195
|
+
// A record proves identity; only an ARMED receiver marker makes the mailbox deliverable —
|
|
196
|
+
// without it the callbacks would be refused as mailbox-undeliverable and this smoke would
|
|
197
|
+
// measure nothing.
|
|
198
|
+
meta.writeMetaReceiverMarker({
|
|
199
|
+
gardenId: callerGid,
|
|
200
|
+
backend: "claude-code",
|
|
201
|
+
nativeSessionId,
|
|
202
|
+
ownerPid: process.pid,
|
|
203
|
+
armProvenance: "session-start",
|
|
204
|
+
});
|
|
205
|
+
ok(
|
|
206
|
+
"fixture: the caller citizen was minted INSIDE the fixture store and its mailbox is armed",
|
|
207
|
+
Boolean(callerGid) && caller.path.startsWith(root),
|
|
208
|
+
);
|
|
209
|
+
|
|
210
|
+
// ── One private tmux server per backend ──────────────────────────────────
|
|
211
|
+
// Pi needs fixture HOME so its control socket is isolated. Claude needs canonical
|
|
212
|
+
// operator HOME (and the operator's optional CLAUDE_CONFIG_DIR) or it enters first-run
|
|
213
|
+
// onboarding instead of exercising the configured runtime. Separate servers preserve
|
|
214
|
+
// those backend-native environments without adding an env carrier to the product.
|
|
215
|
+
const nonces = new Map<string, string>();
|
|
216
|
+
for (const backend of ["pi", "claude-code"] as const) {
|
|
217
|
+
const socket = path.join(root, `${backend}.sock`);
|
|
218
|
+
privateSockets.add(socket);
|
|
219
|
+
const env = { ...process.env } as NodeJS.ProcessEnv;
|
|
220
|
+
delete env.TMUX;
|
|
221
|
+
delete env.TMUX_PANE;
|
|
222
|
+
if (backend === "claude-code") {
|
|
223
|
+
env.HOME = REAL_HOME;
|
|
224
|
+
// EXACT operator-env parity on the four XDG roots (issue #67): real HOME plus a
|
|
225
|
+
// fixture XDG_DATA_HOME is the measured state in which Claude's self-update rewrote
|
|
226
|
+
// the operator's real launcher into the fixture tree and teardown dangled it. Each
|
|
227
|
+
// variable is restored to its original value; an originally absent one is DELETED,
|
|
228
|
+
// never filled with a canonical default.
|
|
229
|
+
restoreOriginalXdg(env, ORIGINAL_XDG);
|
|
230
|
+
if (ORIGINAL_CLAUDE_CONFIG_DIR) env.CLAUDE_CONFIG_DIR = ORIGINAL_CLAUDE_CONFIG_DIR;
|
|
231
|
+
else delete env.CLAUDE_CONFIG_DIR;
|
|
232
|
+
}
|
|
233
|
+
if (tmux(socket, ["new-session", "-d", "-s", "fixture", "-c", scratch, "-n", "anchor"], env).status !== 0) {
|
|
234
|
+
throw new Error(`${LABEL}: could not start the private ${backend} tmux server at ${socket}`);
|
|
235
|
+
}
|
|
236
|
+
const anchorPane = tmux(socket, ["display-message", "-p", "-t", "fixture:anchor", "#{pane_id}"], env).stdout;
|
|
237
|
+
const inherited: NodeJS.ProcessEnv = { ...env, TMUX: `${socket},0,0`, TMUX_PANE: anchorPane };
|
|
238
|
+
ok(`${backend}: tmux server is a private socket, never the operator's`, String(inherited.TMUX).startsWith(root));
|
|
239
|
+
|
|
240
|
+
const result = freshCall(
|
|
241
|
+
{
|
|
242
|
+
backend,
|
|
243
|
+
model: LIVE_MODEL[backend],
|
|
244
|
+
task: "Reply with the single word ACK and then stop. Do not read files.",
|
|
245
|
+
callerGardenId: callerGid,
|
|
246
|
+
},
|
|
247
|
+
inherited,
|
|
248
|
+
);
|
|
249
|
+
ok(`${backend}: launch receipt is ok`, result.ok);
|
|
250
|
+
if (!result.ok) return;
|
|
251
|
+
nonces.set(backend, result.receipt.nonce);
|
|
252
|
+
siblingPids.add(Number(result.receipt.panePid));
|
|
253
|
+
ok(
|
|
254
|
+
`${backend}: the receipt carries tmux coordinates, requested model and nonce, and nothing about delivery`,
|
|
255
|
+
Boolean(
|
|
256
|
+
result.receipt.windowId &&
|
|
257
|
+
result.receipt.paneId &&
|
|
258
|
+
result.receipt.model === LIVE_MODEL[backend] &&
|
|
259
|
+
result.receipt.nonce,
|
|
260
|
+
) && !("gardenId" in result.receipt),
|
|
261
|
+
);
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// The sender envelope rides INSIDE the mailbox body (` session: <gid> (…)`) — that line
|
|
265
|
+
// is the whole point of this smoke, so it is parsed rather than assumed. `readMetaInbox`
|
|
266
|
+
// drains, so every arrival must be recorded on the pass that saw it.
|
|
267
|
+
const deadline = Date.now() + CALLBACK_WAIT_MS;
|
|
268
|
+
const arrived = new Map<string, string>();
|
|
269
|
+
while (Date.now() < deadline && arrived.size < nonces.size) {
|
|
270
|
+
for (const msg of meta.readMetaInbox({ gardenId: callerGid }).messages) {
|
|
271
|
+
const sender = /^\s*session:\s+(\S+)/m.exec(msg.body)?.[1] ?? "";
|
|
272
|
+
for (const [backend, nonce] of nonces) if (msg.body.includes(nonce) && sender) arrived.set(backend, sender);
|
|
273
|
+
}
|
|
274
|
+
if (arrived.size < nonces.size) spawnSync("sleep", ["3"]);
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
for (const backend of nonces.keys()) {
|
|
278
|
+
const sender = arrived.get(backend);
|
|
279
|
+
ok(
|
|
280
|
+
`${backend}: the nonce came back and its SENDER ENVELOPE carries a garden id — correlation without asking the sibling`,
|
|
281
|
+
Boolean(sender) && /^\d{8}T\d{6}-[0-9a-f]{6}$/.test(String(sender)),
|
|
282
|
+
);
|
|
283
|
+
if (sender) siblingGids.add(sender);
|
|
284
|
+
}
|
|
285
|
+
ok(
|
|
286
|
+
"correlation: the two siblings reported DIFFERENT garden ids — the envelope identifies each one, not the launcher",
|
|
287
|
+
siblingGids.size === arrived.size && !siblingGids.has(callerGid),
|
|
288
|
+
);
|
|
289
|
+
ok(
|
|
290
|
+
"fence: those sibling records live in the FIXTURE store",
|
|
291
|
+
[...siblingGids].every(
|
|
292
|
+
(gid) => fs.existsSync(meta.defaultMetaSessionsDir()) && entrySet(meta.defaultMetaSessionsDir()).includes(gid),
|
|
293
|
+
),
|
|
294
|
+
);
|
|
295
|
+
} catch (err) {
|
|
296
|
+
// Captured, not rethrown here: the teardown below must run and its findings must AGGREGATE
|
|
297
|
+
// with the run error — neither may hide the other.
|
|
298
|
+
runError = err;
|
|
299
|
+
} finally {
|
|
300
|
+
for (const socket of privateSockets) tmux(socket, ["kill-server"], process.env);
|
|
301
|
+
const panesGone = waitForPidsGone(siblingPids);
|
|
302
|
+
process.chdir(originalCwd);
|
|
303
|
+
const problems: string[] = [];
|
|
304
|
+
// Launcher integrity BEFORE fixture removal, on success and failure alike (issue #67).
|
|
305
|
+
// Removal happens only when it is PROVEN non-destructive: the launcher demonstrably does
|
|
306
|
+
// not reference the fixture tree AND every TRACKED launched pane process is proven gone —
|
|
307
|
+
// a live pane could still rewrite the launcher after the check. (Tracked-pane quiescence
|
|
308
|
+
// only; this claims nothing about untracked detached descendants.) An unproven state
|
|
309
|
+
// blocks removal loudly rather than guessing.
|
|
310
|
+
const launcherProblems = verifyClaudeLauncher(launcherSnapshot);
|
|
311
|
+
const cleanup = assessLauncherCleanup(launcherSnapshot);
|
|
312
|
+
for (const p of cleanup.problems) problems.push(`launcher cleanup: ${p}`);
|
|
313
|
+
if (!panesGone) {
|
|
314
|
+
problems.push(
|
|
315
|
+
`tracked sibling pane processes were not proven gone after private tmux teardown: ${[...siblingPids].join(", ")}`,
|
|
316
|
+
);
|
|
317
|
+
}
|
|
318
|
+
if (!cleanup.safeToRemove || !panesGone) {
|
|
319
|
+
problems.push(
|
|
320
|
+
`fixture removal of ${root} is BLOCKED — ${
|
|
321
|
+
cleanup.safeToRemove
|
|
322
|
+
? "tracked pane processes are not proven gone"
|
|
323
|
+
: "the real claude launcher references the fixture tree or could not be proven safe"
|
|
324
|
+
}; resolve the named problems above, then remove the tree by hand`,
|
|
325
|
+
);
|
|
326
|
+
} else {
|
|
327
|
+
fs.rmSync(root, { recursive: true, force: true });
|
|
328
|
+
}
|
|
329
|
+
for (const p of launcherProblems) problems.push(`launcher integrity: ${p}`);
|
|
330
|
+
if (problems.length > 0) cleanupError = new Error(problems.join("\n "));
|
|
331
|
+
}
|
|
332
|
+
if (runError || cleanupError) {
|
|
333
|
+
const parts: string[] = [];
|
|
334
|
+
if (runError) parts.push(`RUN: ${runError instanceof Error ? runError.message : String(runError)}`);
|
|
335
|
+
if (cleanupError) parts.push(`CLEANUP:\n ${cleanupError.message}`);
|
|
336
|
+
throw new Error(`${LABEL}: run did not complete cleanly —\n\n${parts.join("\n\n")}`);
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
// ── Prove the fence held, by name and by entry set ───────────────────────
|
|
340
|
+
ok(
|
|
341
|
+
"self-fence: the operator's meta-session store has the same entry set as before",
|
|
342
|
+
entrySet(REAL_META_SESSIONS) === beforeRealStore,
|
|
343
|
+
);
|
|
344
|
+
ok(
|
|
345
|
+
"self-fence: the operator's control-socket dir has the same entry set as before",
|
|
346
|
+
entrySet(REAL_CONTROL_DIR) === beforeRealSockets,
|
|
347
|
+
);
|
|
348
|
+
const realStoreNow = entrySet(REAL_META_SESSIONS);
|
|
349
|
+
const realSocketsNow = entrySet(REAL_CONTROL_DIR);
|
|
350
|
+
ok(
|
|
351
|
+
"self-fence: not one fixture sibling garden id appears in the operator's store or leaves a control-socket residue",
|
|
352
|
+
[...siblingGids].every((gid) => !realStoreNow.includes(gid) && !realSocketsNow.includes(gid)),
|
|
353
|
+
);
|
|
354
|
+
ok(
|
|
355
|
+
"self-fence: the real claude launcher, its link and its resolved target are exactly as pinned before launch",
|
|
356
|
+
verifyClaudeLauncher(launcherSnapshot).length === 0,
|
|
357
|
+
);
|
|
358
|
+
|
|
359
|
+
console.log(`\n${LABEL}: ${passed} checks passed`);
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
main().catch((err) => {
|
|
363
|
+
console.error(`${LABEL}: ${err instanceof Error ? err.message : String(err)}`);
|
|
364
|
+
process.exit(1);
|
|
365
|
+
});
|