@junghanacs/entwurf 0.12.8 → 0.12.10
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 +134 -248
- package/BASELINE.md +1 -1
- package/CHANGELOG.md +39 -1
- package/DELIVERY.md +5 -4
- package/README.md +148 -23
- package/VERIFY.md +3 -2
- package/demo/README.md +2 -2
- package/demo/demo.sh +7 -1
- package/docs/setup-clean-host.md +24 -7
- package/mcp/entwurf-bridge/dist/mcp/entwurf-bridge/src/index.js +168 -81
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/models.js +12 -12
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/overlay.js +5 -3
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-core.js +26 -9
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-deliverability.js +10 -6
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-fact-provider.js +16 -18
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-facts.js +13 -13
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-resume-args.js +31 -41
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-self-address.js +8 -9
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-contract.js +32 -25
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-decider.js +4 -4
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-lock.js +5 -4
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-production.js +14 -13
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-spawn-production.js +11 -3
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/meta-sender-identity.js +15 -4
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/meta-session.js +500 -54
- package/mcp/entwurf-bridge/dist/pi-extensions/meta-bridge-hook.js +8 -3
- package/mcp/entwurf-bridge/dist/scripts/agy-imprint.js +14 -2
- package/mcp/entwurf-bridge/dist/scripts/meta-bridge-fresh-cut.js +155 -29
- package/mcp/entwurf-bridge/src/index.ts +200 -94
- package/package.json +11 -9
- package/pi-extensions/entwurf-control.ts +81 -39
- package/pi-extensions/lib/acp/models.ts +12 -12
- package/pi-extensions/lib/acp/overlay.ts +5 -3
- package/pi-extensions/lib/entwurf-core.ts +26 -9
- package/pi-extensions/lib/entwurf-deliverability.ts +10 -6
- package/pi-extensions/lib/entwurf-fact-provider.ts +24 -19
- package/pi-extensions/lib/entwurf-facts.ts +13 -13
- package/pi-extensions/lib/entwurf-resume-args.ts +40 -46
- package/pi-extensions/lib/entwurf-self-address.ts +8 -9
- package/pi-extensions/lib/entwurf-v2-contract-schema.ts +7 -2
- package/pi-extensions/lib/entwurf-v2-contract.ts +32 -25
- package/pi-extensions/lib/entwurf-v2-decider.ts +8 -8
- package/pi-extensions/lib/entwurf-v2-lock.ts +5 -4
- package/pi-extensions/lib/entwurf-v2-production.ts +22 -13
- package/pi-extensions/lib/entwurf-v2-spawn-production.ts +11 -3
- package/pi-extensions/lib/meta-sender-identity.ts +15 -5
- package/pi-extensions/lib/meta-session.ts +543 -58
- package/pi-extensions/meta-bridge-hook.ts +8 -2
- package/run.sh +78 -67
- package/scripts/agy-bridge-config.py +249 -49
- package/scripts/agy-bridge.sh +59 -14
- package/scripts/agy-imprint.ts +15 -1
- package/scripts/check-acp-carrier-augment.ts +34 -2
- package/scripts/check-acp-sdk-surface.ts +22 -11
- package/scripts/check-acp-session-reuse.ts +1 -1
- package/scripts/check-acp-session-store.ts +3 -3
- package/scripts/check-agy-permission-matrix.py +655 -0
- package/scripts/check-agy-sender-identity.ts +83 -0
- package/scripts/check-entwurf-control-rpc.ts +2 -2
- package/scripts/check-entwurf-deliverability.ts +9 -6
- package/scripts/check-entwurf-fact-provider.ts +16 -8
- package/scripts/check-entwurf-facts.ts +13 -13
- package/scripts/check-entwurf-resume-args.ts +25 -63
- package/scripts/check-entwurf-self-address.ts +187 -4
- package/scripts/check-entwurf-session-identity.ts +7 -6
- package/scripts/check-entwurf-v2-contract.ts +2 -2
- package/scripts/check-entwurf-v2-production.ts +9 -7
- package/scripts/check-entwurf-v2-spawn-production.ts +8 -4
- package/scripts/check-entwurf-v2-surface.ts +327 -14
- package/scripts/check-fresh-cut-gate.sh +305 -4
- package/scripts/check-gate-qualification.ts +785 -0
- package/scripts/check-meta-identity-consumers.ts +501 -1
- package/scripts/check-meta-listing.ts +91 -9
- package/scripts/check-meta-receiver-marker.ts +54 -0
- package/scripts/check-model-lock.ts +1 -1
- package/scripts/check-shell-quote.ts +2 -1
- package/scripts/lib/mutation-qualify.ts +794 -0
- package/scripts/meta-bridge-fresh-cut.ts +164 -28
- package/scripts/mutants/acp-augment.json +30 -0
- package/scripts/mutants/agy-permission.json +144 -0
- package/scripts/mutants/meta-identity.json +17 -0
- package/scripts/mutants/self-address.json +59 -0
- package/scripts/mutants/v2-surface.json +87 -0
- package/scripts/pi_settings_io.py +65 -0
- package/scripts/register-pi-package.py +183 -37
- package/scripts/register-pi-provider.py +68 -10
- package/scripts/smoke-acp-raw-turn-live.ts +1 -1
- package/scripts/smoke-acp-socket-citizen-live.ts +2 -2
- package/scripts/smoke-acp-v2-send-live.ts +33 -18
- package/scripts/smoke-agy-install-state.sh +205 -20
- package/scripts/smoke-entwurf-v2-matrix-live.ts +3 -2
- package/scripts/smoke-meta-install-state.sh +1 -1
- package/scripts/smoke-pi-attach.ts +7 -2
- package/scripts/smoke-user-scope-citizen.sh +177 -0
- package/pi-extensions/lib/entwurf-mailbox-guard.ts +0 -100
- package/scripts/check-entwurf-mailbox-guard.ts +0 -262
|
@@ -452,32 +452,72 @@ rm -f "$LEGACY" "$DLINK" "$DSTATE"
|
|
|
452
452
|
unset ENTWURF_DEV_BIN_DIR ENTWURF_BRIDGE_TARGET ENTWURF_AGY_STATUSLINE_TARGET
|
|
453
453
|
|
|
454
454
|
# ── K: permission grant — the OTHER half of a usable bridge ───────────────────
|
|
455
|
-
# Registering the server only makes the
|
|
456
|
-
# without an allow rule every
|
|
457
|
-
# half-installed. We grant
|
|
458
|
-
#
|
|
455
|
+
# Registering the server only makes the tools REACHABLE. agy defaults every `mcp` action to Ask, so
|
|
456
|
+
# without an allow rule every call stops for a y/n — a registered-but-ungranted bridge is
|
|
457
|
+
# half-installed. We grant ONE NARROW STRING PER NORMAL-PATH TOOL in `permissions.allow` (every
|
|
458
|
+
# tool the server exposes is visible to the model; auto-granting is the smaller, deliberate set).
|
|
459
|
+
# The operator's own rules are preserved, never managed (granting ourselves command(*) would be their
|
|
460
|
+
# trust decision, not ours). The rule list comes from the config engine, never retyped here — a
|
|
461
|
+
# second copy is how a newly shipped tool silently stops being granted.
|
|
459
462
|
SETTINGS="$HOME/.gemini/antigravity-cli/settings.json"
|
|
460
463
|
SLSTATE="$XDG_DATA_HOME/entwurf/agy-statusline/install-state.json"
|
|
461
464
|
STATUSLINE="$REPO_DIR/scripts/agy-statusline-bridge.sh"
|
|
462
|
-
|
|
465
|
+
RULES="$(python3 "$REPO_DIR/scripts/agy-bridge-config.py" permission-rules)"
|
|
466
|
+
RULE="${RULES%% *}" # first rule, for the single-string cases below
|
|
467
|
+
|
|
468
|
+
# ── INDEPENDENT CONTRACT ─────────────────────────────────────────────────────
|
|
469
|
+
# Everything below drives the SUT with $RULES, which the SUT itself printed. That is the right way
|
|
470
|
+
# to keep operator messages and assertions from drifting — but as an ORACLE it is circular: adding
|
|
471
|
+
# entwurf_inbox_read to ALLOW_RULES, or swapping entwurf_peers for something else, would sail
|
|
472
|
+
# through every rule-set assertion because the expectation moved with the code. So the SET ITSELF is
|
|
473
|
+
# pinned here, literally, once. Over-granting is the failure this catches: a permission we take and
|
|
474
|
+
# do not need is not a smaller bug than one we forget.
|
|
475
|
+
EXPECT_RULES='mcp(entwurf-bridge/entwurf_v2) mcp(entwurf-bridge/entwurf_peers) mcp(entwurf-bridge/entwurf_self)'
|
|
476
|
+
want "contract: the auto-granted rule set is EXACTLY the three normal-path tools, in order [QK:AGY-EXACT-RULE-SET]" \
|
|
477
|
+
"[ \"$RULES\" = \"$EXPECT_RULES\" ]"
|
|
478
|
+
# Named negatives, not just an equality: these two are the tools a future edit is most likely to add
|
|
479
|
+
# by reflex ("grant everything the server exposes"), and each would be a grant we never justified —
|
|
480
|
+
# inbox_read names a rail native-push does not have, register_native is a manual fallback the normal
|
|
481
|
+
# birth path never calls.
|
|
482
|
+
for forbidden in entwurf_inbox_read entwurf_register_native; do
|
|
483
|
+
want "contract: '$forbidden' is NOT auto-granted (we do not take permissions the normal path never uses)" \
|
|
484
|
+
"! printf '%s' \"$RULES\" | grep -qF 'mcp(entwurf-bridge/$forbidden)'"
|
|
485
|
+
done
|
|
463
486
|
has_rule() { python3 -c "
|
|
464
487
|
import json,sys
|
|
465
488
|
d=json.load(open('$1'))
|
|
466
|
-
|
|
489
|
+
allow=(d.get('permissions') or {}).get('allow', [])
|
|
490
|
+
sys.exit(0 if all(r in allow for r in '''$RULES'''.split()) else 1)"; }
|
|
467
491
|
|
|
468
492
|
rm -f "$SETTINGS" "$PSTATE" "$STATE" "$GLOBAL"
|
|
469
493
|
bash "$BRIDGE" install >/dev/null 2>&1
|
|
470
|
-
want "permission: install grants
|
|
494
|
+
want "permission: install grants EVERY normal-path rule into permissions.allow" "has_rule '$SETTINGS'"
|
|
495
|
+
|
|
496
|
+
# TWO state files, TWO schemas, versioned APART. The permission state moved to 2 when its shape
|
|
497
|
+
# changed to a rule set; the MCP install-state's layout never moved and must stay 1. Sharing one
|
|
498
|
+
# constant makes the version a lie in whichever file did not change — a later reader branching on it
|
|
499
|
+
# would be branching on noise, and "the schema bumped" would stop meaning "the shape changed".
|
|
500
|
+
want "state schemas are independent: MCP install-state stays at its own version (1) [QK:AGY-SCHEMA-VERSIONS-APART]" \
|
|
501
|
+
"[ \"\$(python3 -c \"import json;print(json.load(open('$STATE'))['schemaVersion'])\")\" = 1 ]"
|
|
502
|
+
want "state schemas are independent: permission-state carries the rule-set version (2)" \
|
|
503
|
+
"[ \"\$(python3 -c \"import json;print(json.load(open('$PSTATE'))['schemaVersion'])\")\" = 2 ]"
|
|
504
|
+
# The defect this pins (measured 2026-07-27 on a live agy citizen): install granted entwurf_v2 only,
|
|
505
|
+
# so entwurf_peers and entwurf_self prompted for a y/n on every call while the doctor reported green.
|
|
506
|
+
# A grant for the tool we happen to check first must never stand in for the whole surface.
|
|
507
|
+
want "permission: a PARTIAL grant is not reported as configured (agy still prompts on the rest)" \
|
|
508
|
+
"printf '{\"permissions\":{\"allow\":[\"%s\"]}}\n' '$RULE' > '$SETTINGS'; \
|
|
509
|
+
[ \"\$(python3 '$REPO_DIR/scripts/agy-bridge-config.py' permission-doctor '$SETTINGS' | cut -d' ' -f1)\" = partially-configured ]"
|
|
510
|
+
bash "$BRIDGE" install >/dev/null 2>&1
|
|
471
511
|
|
|
472
512
|
# IDEMPOTENCY, and not only of the file: a re-install must not rewrite PROVENANCE either. Re-reading
|
|
473
513
|
# the rule we ourselves wrote as "the operator already had it" would strand it forever (the inverse
|
|
474
514
|
# would decline to remove it). Installers are re-run on every upgrade, so this path is the norm.
|
|
475
515
|
bash "$BRIDGE" install >/dev/null 2>&1
|
|
476
516
|
bash "$BRIDGE" install >/dev/null 2>&1
|
|
477
|
-
want "permission: re-install is idempotent (rule appears exactly once)" \
|
|
478
|
-
"[ \"\$(python3 -c \"import json;
|
|
479
|
-
want "permission: re-install does NOT re-attribute OUR
|
|
480
|
-
"[ \"\$(python3 -c \"import json;
|
|
517
|
+
want "permission: re-install is idempotent (every rule appears exactly once)" \
|
|
518
|
+
"[ \"\$(python3 -c \"import json;a=json.load(open('$SETTINGS'))['permissions']['allow'];print(max(a.count(r) for r in '''$RULES'''.split()))\")\" = 1 ]"
|
|
519
|
+
want "permission: re-install does NOT re-attribute OUR rules to the operator (provenance is sticky)" \
|
|
520
|
+
"[ \"\$(python3 -c \"import json;e=json.load(open('$PSTATE'))['rulesExistedBefore'];print(any(e.values()))\")\" = False ]"
|
|
481
521
|
|
|
482
522
|
# Honest inverse after those re-installs: WE created the file and both containers, so nothing of
|
|
483
523
|
# ours may survive. (Before the provenance fix, install×2 → uninstall left the rule behind.)
|
|
@@ -496,16 +536,145 @@ bash "$BRIDGE" uninstall >/dev/null 2>&1
|
|
|
496
536
|
want "permission: uninstall takes back ONLY our rule, leaving the operator's structure intact" \
|
|
497
537
|
"python3 -c \"import json,sys;p=json.load(open('$SETTINGS'))['permissions'];sys.exit(0 if p['allow']==['command(*)'] and p['deny']==['read_file(/etc)'] else 1)\""
|
|
498
538
|
|
|
499
|
-
# The
|
|
539
|
+
# The rules were ALREADY the operator's before we ever installed → never ours to take away. Written
|
|
540
|
+
# as the FULL set so that nothing here is ours: provenance is per rule, so a host where the operator
|
|
541
|
+
# granted some and we added the rest is the MIXED case, pinned separately below.
|
|
542
|
+
python3 -c "import json;json.dump({'permissions':{'allow':'''$RULES'''.split()}},open('$SETTINGS','w'))"
|
|
543
|
+
bash "$BRIDGE" install >/dev/null 2>&1
|
|
544
|
+
want "permission: an operator's pre-existing rules are recorded as theirs (rulesExistedBefore)" \
|
|
545
|
+
"[ \"\$(python3 -c \"import json;e=json.load(open('$PSTATE'))['rulesExistedBefore'];print(all(e.values()))\")\" = True ]"
|
|
546
|
+
bash "$BRIDGE" uninstall >/dev/null 2>&1
|
|
547
|
+
want "permission: uninstall does NOT revoke rules the operator already had" "has_rule '$SETTINGS'"
|
|
548
|
+
|
|
549
|
+
# ── v1 → v2 permission-state migration, from a REAL v1 fixture ───────────────
|
|
550
|
+
# A shipped host is mid-migration right now: its permission-state was written by an entwurf that
|
|
551
|
+
# granted one rule and recorded `rule`/`ruleExistedBefore`. The release contract is that upgrading
|
|
552
|
+
# carries that answer instead of re-capturing it — a re-capture would read the rule WE wrote as the
|
|
553
|
+
# operator's and strand it, or read THEIRS as ours and delete it. Prose claimed this; now the gate
|
|
554
|
+
# owns it, including a direct uninstall from an unmigrated v1 state.
|
|
555
|
+
V1_RULE="$RULE" # what a v1 install granted
|
|
556
|
+
V1_OTHER="${RULES#* }"; V1_OTHER="${V1_OTHER%% *}" # second rule: operator-owned in this fixture
|
|
557
|
+
V1_NEW="${RULES##* }" # third rule: nobody has it yet → ours on migration
|
|
558
|
+
rm -f "$STATE" "$PSTATE"
|
|
559
|
+
python3 -c "import json;json.dump({'permissions':{'allow':['$V1_RULE','$V1_OTHER']}},open('$SETTINGS','w'))"
|
|
560
|
+
python3 -c "
|
|
561
|
+
import json,os
|
|
562
|
+
json.dump({'schemaVersion':1,'managedSettingsPath':os.path.abspath('$SETTINGS'),
|
|
563
|
+
'rule':'$V1_RULE','detectMode':'adopt-regular-file','settingsExistedBefore':True,
|
|
564
|
+
'permissionsExistedBefore':True,'allowExistedBefore':True,'ruleExistedBefore':False,
|
|
565
|
+
'installedAt':'2026-07-26T00:00:18Z'}, open('$PSTATE','w'))"
|
|
566
|
+
want "v1→v2: a v1 permission-state uninstalls on its own shape (removes ours, keeps theirs)" \
|
|
567
|
+
"bash '$BRIDGE' uninstall >/dev/null 2>&1 && python3 -c \"import json,sys;a=json.load(open('$SETTINGS'))['permissions']['allow'];sys.exit(0 if a==['$V1_OTHER'] else 1)\""
|
|
568
|
+
|
|
569
|
+
# Now the migration proper: same v1 fixture, but install (upgrade) runs first.
|
|
570
|
+
rm -f "$STATE" "$PSTATE"
|
|
571
|
+
python3 -c "import json;json.dump({'permissions':{'allow':['$V1_RULE','$V1_OTHER']}},open('$SETTINGS','w'))"
|
|
572
|
+
python3 -c "
|
|
573
|
+
import json,os
|
|
574
|
+
json.dump({'schemaVersion':1,'managedSettingsPath':os.path.abspath('$SETTINGS'),
|
|
575
|
+
'rule':'$V1_RULE','detectMode':'adopt-regular-file','settingsExistedBefore':True,
|
|
576
|
+
'permissionsExistedBefore':True,'allowExistedBefore':True,'ruleExistedBefore':False,
|
|
577
|
+
'installedAt':'2026-07-26T00:00:18Z'}, open('$PSTATE','w'))"
|
|
578
|
+
bash "$BRIDGE" install >/dev/null 2>&1
|
|
579
|
+
want "v1→v2: install migrates the state in place (schemaVersion 2, per-rule provenance)" \
|
|
580
|
+
"[ \"\$(python3 -c \"import json;s=json.load(open('$PSTATE'));print(s['schemaVersion']==2 and isinstance(s.get('rulesExistedBefore'),dict))\")\" = True ]"
|
|
581
|
+
# The three-way answer, which is the whole point of per-rule provenance:
|
|
582
|
+
# the v1 rule stays OURS (carried, not re-read) · the operator's rule stays THEIRS
|
|
583
|
+
# the newly granted tool becomes OURS
|
|
584
|
+
want "v1→v2: provenance is carried per rule (v1 rule ours, operator's rule theirs, new rule ours)" \
|
|
585
|
+
"[ \"\$(python3 -c \"import json;e=json.load(open('$PSTATE'))['rulesExistedBefore'];print(e['$V1_RULE'] is False and e['$V1_OTHER'] is True and e['$V1_NEW'] is False)\")\" = True ]"
|
|
586
|
+
bash "$BRIDGE" uninstall >/dev/null 2>&1
|
|
587
|
+
want "v1→v2: the migrated inverse takes back only ours and leaves the operator's rule" \
|
|
588
|
+
"python3 -c \"import json,sys;a=json.load(open('$SETTINGS'))['permissions']['allow'];sys.exit(0 if a==['$V1_OTHER'] else 1)\""
|
|
589
|
+
|
|
590
|
+
# A state shape we cannot read must REFUSE, never fall through to "nothing was theirs" and revoke.
|
|
591
|
+
rm -f "$STATE" "$PSTATE"
|
|
592
|
+
python3 -c "import json;json.dump({'permissions':{'allow':'''$RULES'''.split()}},open('$SETTINGS','w'))"
|
|
593
|
+
python3 -c "
|
|
594
|
+
import json,os
|
|
595
|
+
json.dump({'schemaVersion':99,'managedSettingsPath':os.path.abspath('$SETTINGS')}, open('$PSTATE','w'))"
|
|
596
|
+
want "malformed state: an unknown schemaVersion FAILS the uninstall instead of guessing" \
|
|
597
|
+
"! bash '$BRIDGE' uninstall >/dev/null 2>&1"
|
|
598
|
+
want "malformed state: and the operator's rules are still there afterwards (no blind revoke)" \
|
|
599
|
+
"has_rule '$SETTINGS'"
|
|
600
|
+
# The specific deletion hazard: a v2 state whose provenance map is EMPTY. Read permissively, every
|
|
601
|
+
# rule reads as "not theirs" and the inverse revokes the operator's whole set.
|
|
602
|
+
python3 -c "
|
|
603
|
+
import json,os
|
|
604
|
+
json.dump({'schemaVersion':2,'managedSettingsPath':os.path.abspath('$SETTINGS'),
|
|
605
|
+
'rules':'''$RULES'''.split(),'rulesExistedBefore':{}}, open('$PSTATE','w'))"
|
|
606
|
+
want "malformed state: an INCOMPLETE provenance map fails loud (it must not promote their rules to ours)" \
|
|
607
|
+
"! bash '$BRIDGE' uninstall >/dev/null 2>&1"
|
|
608
|
+
want "malformed state: the operator's rules survive the incomplete-provenance refusal" \
|
|
609
|
+
"has_rule '$SETTINGS'"
|
|
610
|
+
|
|
611
|
+
# INSTALL must refuse the same unreadable prior, not silently re-capture over it. A shallow shape
|
|
612
|
+
# check (dict? values bool?) passes an EMPTY map — all() of nothing is true — and the reinstall then
|
|
613
|
+
# rewrote provenance from disk, which is exactly the re-capture this state file exists to prevent:
|
|
614
|
+
# every rule would be recorded as the operator's and the inverse would decline to remove any of it.
|
|
615
|
+
python3 -c "
|
|
616
|
+
import json,os
|
|
617
|
+
json.dump({'schemaVersion':2,'managedSettingsPath':os.path.abspath('$SETTINGS'),
|
|
618
|
+
'rules':'''$RULES'''.split(),'rulesExistedBefore':{}}, open('$PSTATE','w'))"
|
|
619
|
+
PRE_S="$(python3 -c "import hashlib;print(hashlib.sha256(open('$SETTINGS','rb').read()).hexdigest())")"
|
|
620
|
+
PRE_P="$(python3 -c "import hashlib;print(hashlib.sha256(open('$PSTATE','rb').read()).hexdigest())")"
|
|
621
|
+
want "malformed state: INSTALL refuses an unreadable prior instead of re-capturing provenance [QK:AGY-INSTALL-MALFORMED-PRIOR]" \
|
|
622
|
+
"! bash '$BRIDGE' install >/dev/null 2>&1"
|
|
623
|
+
want "malformed state: the refused install left settings AND state byte-identical" \
|
|
624
|
+
"[ \"\$(python3 -c \"import hashlib;print(hashlib.sha256(open('$SETTINGS','rb').read()).hexdigest())\")\" = '$PRE_S' ] && \
|
|
625
|
+
[ \"\$(python3 -c \"import hashlib;print(hashlib.sha256(open('$PSTATE','rb').read()).hexdigest())\")\" = '$PRE_P' ]"
|
|
626
|
+
|
|
627
|
+
# A refusal must leave the world untouched even when the settings file is GONE. The validation used
|
|
628
|
+
# to run inside the settings-exists branch, so on this host it was first reached in the closing
|
|
629
|
+
# message — AFTER os.remove(state_path). The safety check destroyed the only record of what we owed.
|
|
630
|
+
rm -f "$SETTINGS"
|
|
631
|
+
want "malformed state: uninstall refuses when the settings file is absent (nothing to guess from) [QK:AGY-UNINSTALL-VALIDATE-FIRST]" \
|
|
632
|
+
"! bash '$BRIDGE' uninstall >/dev/null 2>&1"
|
|
633
|
+
want "malformed state: the refused uninstall did NOT delete the permission-state" \
|
|
634
|
+
"[ -f '$PSTATE' ]"
|
|
635
|
+
|
|
636
|
+
# DOCTOR must not round a corrupt ownership record up to green. Runtime here is perfect — all three
|
|
637
|
+
# rules present, agy prompts on nothing — while the record that tells our grants from theirs is
|
|
638
|
+
# unreadable. Hard rule 13: runtime truth and ownership truth are separate axes.
|
|
639
|
+
python3 -c "import json;json.dump({'permissions':{'allow':'''$RULES'''.split()}},open('$SETTINGS','w'))"
|
|
640
|
+
if bash "$BRIDGE" doctor >/dev/null 2>&1; then
|
|
641
|
+
die "corrupt-ownership: doctor must FAIL on an unreadable permission-state even when runtime is fine [QK:AGY-DOCTOR-OWNERSHIP-AXIS]"
|
|
642
|
+
fi
|
|
643
|
+
ok "permission: doctor FAILS on a path-correct but UNREADABLE permission-state (ownership axis)"
|
|
644
|
+
DOC_OUT="$(bash "$BRIDGE" doctor 2>&1 || true)"
|
|
645
|
+
want "permission: the corrupt-ownership report names the axis and does not blame runtime" \
|
|
646
|
+
"printf '%s' \"\$DOC_OUT\" | grep -q 'CORRUPT (permission)'"
|
|
647
|
+
rm -f "$STATE" "$PSTATE"
|
|
648
|
+
|
|
649
|
+
# CROSS-LIST scope: broad-most-covering is a statement about the whole file, not about one list.
|
|
650
|
+
# A deny of ONE exact tool next to an ask of the server-wide rule is a host where EVERYTHING is
|
|
651
|
+
# shadowed; naming the exact hit because `deny` is scanned first would tell the operator their other
|
|
652
|
+
# grants still work while agy prompts on all of them.
|
|
653
|
+
python3 -c "
|
|
654
|
+
import json
|
|
655
|
+
json.dump({'permissions':{'allow':'''$RULES'''.split(),
|
|
656
|
+
'deny':['${RULES##* }'],
|
|
657
|
+
'ask':['mcp(entwurf-bridge)']}}, open('$SETTINGS','w'))"
|
|
658
|
+
want "cross-list shadow: a broad rule in ANY list outranks an exact hit in a higher-precedence list" \
|
|
659
|
+
"[ \"\$(python3 '$REPO_DIR/scripts/agy-bridge-config.py' permission-doctor '$SETTINGS' | cut -d' ' -f2)\" = broad ]"
|
|
660
|
+
DOC_OUT="$(bash "$BRIDGE" doctor 2>&1 || true)"
|
|
661
|
+
want "cross-list shadow: the report says EVERY call is blocked, not 'other grants still work'" \
|
|
662
|
+
"printf '%s' \"\$DOC_OUT\" | grep -q 'EVERY entwurf tool call'"
|
|
663
|
+
rm -f "$STATE" "$PSTATE"
|
|
664
|
+
|
|
665
|
+
# MIXED provenance — the shape agy's own "always allow" prompt creates, one rule at a time. The
|
|
666
|
+
# operator owns one; we add the rest; the inverse must split them exactly, taking back only ours.
|
|
667
|
+
rm -f "$STATE" "$PSTATE"
|
|
500
668
|
printf '{"permissions":{"allow":["%s"]}}\n' "$RULE" > "$SETTINGS"
|
|
501
669
|
bash "$BRIDGE" install >/dev/null 2>&1
|
|
502
|
-
want "permission:
|
|
503
|
-
"[ \"\$(python3 -c \"import json;
|
|
670
|
+
want "permission: mixed provenance is recorded per rule (theirs stays theirs, ours stays ours)" \
|
|
671
|
+
"[ \"\$(python3 -c \"import json;e=json.load(open('$PSTATE'))['rulesExistedBefore'];print(e['$RULE'] is True and sum(1 for v in e.values() if v is False)==len(e)-1)\")\" = True ]"
|
|
504
672
|
bash "$BRIDGE" uninstall >/dev/null 2>&1
|
|
505
|
-
want "permission: uninstall
|
|
673
|
+
want "permission: uninstall keeps THEIR rule and removes only the ones WE added" \
|
|
674
|
+
"python3 -c \"import json,sys;a=json.load(open('$SETTINGS'))['permissions']['allow'];sys.exit(0 if a==['$RULE'] else 1)\""
|
|
506
675
|
|
|
507
676
|
# ── K2: TWO adapters, ONE file — element ownership is what keeps them apart ────
|
|
508
|
-
# The statusline adapter owns the `statusLine` subtree of this same settings.json; we own
|
|
677
|
+
# The statusline adapter owns the `statusLine` subtree of this same settings.json; we own our rules
|
|
509
678
|
# in `permissions.allow`. Neither may restore a whole-file preimage, or uninstalling one would
|
|
510
679
|
# silently revert the other. Both orders, both inverses.
|
|
511
680
|
rm -f "$SETTINGS" "$PSTATE" "$SLSTATE"
|
|
@@ -550,6 +719,22 @@ ok "permission: doctor FAILS when a higher-precedence ask/deny rule shadows our
|
|
|
550
719
|
DOC_OUT="$(bash "$BRIDGE" doctor 2>&1 || true)"
|
|
551
720
|
want "permission: the shadow report names the offending list and rule" \
|
|
552
721
|
"printf '%s' \"\$DOC_OUT\" | grep -q 'SHADOWED'"
|
|
722
|
+
want "permission: a BROAD shadow is described as covering EVERY entwurf tool call" \
|
|
723
|
+
"printf '%s' \"\$DOC_OUT\" | grep -q 'EVERY entwurf tool call'"
|
|
724
|
+
|
|
725
|
+
# SCOPE HONESTY: an EXACT shadow takes one tool, not the surface. Saying "every entwurf tool call is
|
|
726
|
+
# blocked" when only entwurf_self is sends the operator hunting a wildcard that is not there. The
|
|
727
|
+
# verdict stays red — a granted tool that agy still stops on is not green — but the diagnosis must
|
|
728
|
+
# match what agy will actually do.
|
|
729
|
+
EXACT_SHADOW="${RULES##* }" # last rule in the set, shadowed alone
|
|
730
|
+
python3 -c "
|
|
731
|
+
import json
|
|
732
|
+
json.dump({'permissions':{'allow':'''$RULES'''.split(),'ask':['$EXACT_SHADOW']}}, open('$SETTINGS','w'))"
|
|
733
|
+
if bash "$BRIDGE" doctor >/dev/null 2>&1; then die "exact-shadow: doctor should FAIL when one of our tools is shadowed"; fi
|
|
734
|
+
ok "permission: doctor FAILS when an EXACT rule shadows one granted tool (still not green)"
|
|
735
|
+
DOC_OUT="$(bash "$BRIDGE" doctor 2>&1 || true)"
|
|
736
|
+
want "permission: an EXACT shadow names that ONE tool and does NOT claim every call is blocked" \
|
|
737
|
+
"printf '%s' \"\$DOC_OUT\" | grep -qF '$EXACT_SHADOW' && printf '%s' \"\$DOC_OUT\" | grep -q 'that ONE tool' && ! printf '%s' \"\$DOC_OUT\" | grep -q 'EVERY entwurf tool call'"
|
|
553
738
|
|
|
554
739
|
# THE MIRROR OF IT: the same rules that shadow our allow from ask/deny COVER it from allow. An
|
|
555
740
|
# operator who granted a broad mcp(*) has already made entwurf_v2 callable — reporting that host as
|
|
@@ -588,11 +773,11 @@ want "permission: an operator wildcard does NOT mask drift of the rule WE instal
|
|
|
588
773
|
want "permission: the owned-drift report names both axes (our grant gone, their rule covering)" \
|
|
589
774
|
"printf '%s' \"\$DOC_OUT\" | grep -q 'DRIFT' && printf '%s' \"\$DOC_OUT\" | grep -qF 'mcp(*)'"
|
|
590
775
|
|
|
591
|
-
# …but an operator's OWN pre-existing
|
|
592
|
-
#
|
|
776
|
+
# …but an operator's OWN pre-existing rules vanishing is not our drift: rulesExistedBefore all true
|
|
777
|
+
# means nothing here was ours to lose. Their file, their edit; the wildcard covering it stays a NOTE.
|
|
593
778
|
rm -f "$STATE" "$PSTATE"
|
|
594
|
-
|
|
595
|
-
bash "$BRIDGE" install >/dev/null 2>&1 #
|
|
779
|
+
python3 -c "import json;json.dump({'permissions':{'allow':'''$RULES'''.split()}},open('$SETTINGS','w'))"
|
|
780
|
+
bash "$BRIDGE" install >/dev/null 2>&1 # rules pre-existed → recorded as theirs
|
|
596
781
|
python3 -c "import json;d=json.load(open('$SETTINGS'));d['permissions']['allow']=['mcp(*)'];json.dump(d,open('$SETTINGS','w'))"
|
|
597
782
|
if ! bash "$BRIDGE" doctor >/dev/null 2>&1; then
|
|
598
783
|
die "covered: losing the OPERATOR's own pre-existing rule must not be reported as OUR drift"
|
|
@@ -66,8 +66,9 @@ import {
|
|
|
66
66
|
import { terminateChild } from "./lib/acp-child-cleanup.ts";
|
|
67
67
|
import { waitForPiRecord } from "./lib/pi-record-discovery.ts";
|
|
68
68
|
|
|
69
|
-
// pi's control socket lives at the canonical dir keyed by
|
|
70
|
-
//
|
|
69
|
+
// pi's control socket lives at the canonical dir keyed by the RECORD's garden id (#50 C4: the
|
|
70
|
+
// record is the sole address authority — never a transcript/session id; :196 below proves it),
|
|
71
|
+
// so C1 must point the decider's controlSocketDir at the REAL dir (a fresh gid avoids collision).
|
|
71
72
|
const REAL_CONTROL_DIR = path.join(os.homedir(), ".pi", "entwurf-control");
|
|
72
73
|
const SOCKET_SUFFIX = ".sock";
|
|
73
74
|
// Release-gate topology: repo-under-test, not deployment smoke. Load only this
|
|
@@ -534,7 +534,7 @@ valid_record "20260606T000000-aaaaaa" "native-a" > "$STORE/20260606T000000-aaaaa
|
|
|
534
534
|
valid_record "20260606T000001-bbbbbb" "native-b" > "$STORE/20260606T000001-bbbbbb.meta.json"
|
|
535
535
|
|
|
536
536
|
STATUS_INPUT_MATCH='{"session_id":"native-a","workspace":{"current_dir":"/tmp"},"model":{"id":"claude-sonnet-5"},"context_window":{"context_window_size":200000,"used_percentage":2,"current_usage":{"input_tokens":10}}}'
|
|
537
|
-
STATUS_INPUT_MISS='{"session_id":"native-missing","workspace":{"current_dir":"/tmp"},"model":{"id":"claude-opus-
|
|
537
|
+
STATUS_INPUT_MISS='{"session_id":"native-missing","workspace":{"current_dir":"/tmp"},"model":{"id":"claude-opus-5"}}'
|
|
538
538
|
STATUS_INPUT_READY='{"workspace":{"current_dir":"/tmp"},"model":{"id":"claude-haiku-4-5"}}'
|
|
539
539
|
STATUS_OUT_MATCH="$(printf '%s' "$STATUS_INPUT_MATCH" | ENTWURF_META_SESSIONS_DIR="$STORE" "$REPO/scripts/meta-bridge-statusline.sh")"
|
|
540
540
|
if [ "$(printf '%s\n' "$STATUS_OUT_MATCH" | wc -l | tr -d ' ')" = "2" ]; then ok "statusline renders exactly two rows"; else bad "statusline should render two rows: $STATUS_OUT_MATCH"; fi
|
|
@@ -52,12 +52,17 @@ import net from "node:net";
|
|
|
52
52
|
import os from "node:os";
|
|
53
53
|
import path from "node:path";
|
|
54
54
|
import { type AcpKeyValue, type AcpMcpServer, enrichMcpServersWithEnvelope } from "../pi-extensions/lib/acp/config.ts";
|
|
55
|
-
import {
|
|
55
|
+
import {
|
|
56
|
+
listAllMetaIdentities,
|
|
57
|
+
makeStoreRecordReader,
|
|
58
|
+
parseMetaRecordV3,
|
|
59
|
+
readActiveStoreEntries,
|
|
60
|
+
} from "../pi-extensions/lib/meta-session.ts";
|
|
56
61
|
import { birthPiCitizen } from "../pi-extensions/lib/pi-citizen-birth.ts";
|
|
57
62
|
|
|
58
63
|
/** Record count in the isolated store, read the same way production reads it. */
|
|
59
64
|
function citizenCount(dir: string): number {
|
|
60
|
-
return listAllMetaIdentities(
|
|
65
|
+
return listAllMetaIdentities(readActiveStoreEntries(dir), makeStoreRecordReader(dir), {
|
|
61
66
|
mode: "strict",
|
|
62
67
|
}).identities.length;
|
|
63
68
|
}
|
|
@@ -9,6 +9,11 @@
|
|
|
9
9
|
# statusline `?` had a tripwire; THIS omission had none and hid until GLG hit it
|
|
10
10
|
# in another repo. This gate is that missing tripwire: if the registration ever
|
|
11
11
|
# regresses, `pnpm check` goes red.
|
|
12
|
+
#
|
|
13
|
+
# Cells 11–13 close the second omission (#53 B): every case here drove a fake
|
|
14
|
+
# settings file with ABSOLUTE entries, so the portable, settings-relative form this
|
|
15
|
+
# repo actually commits was never registered against — and `setup` duplicated and
|
|
16
|
+
# restyled the tracked file for four cuts without a single gate seeing it.
|
|
12
17
|
set -euo pipefail
|
|
13
18
|
|
|
14
19
|
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
@@ -187,5 +192,177 @@ assert d['defaultProvider']=='openai-codex', 'run.sh remove-user-scope dropped a
|
|
|
187
192
|
# idempotent: a second remove-user-scope is a clean no-op (no crash on absent entry)
|
|
188
193
|
if XDG_DATA_HOME="$TMP/xdg" PI_CODING_AGENT_DIR="$AGENT_DIR" bash "$RUN" remove-user-scope >/dev/null 2>&1; then ok "run.sh remove-user-scope is idempotent (no-op second run)"; else bad "run.sh remove-user-scope second run crashed"; fi
|
|
189
194
|
|
|
195
|
+
# ── 11–13. #53 B: install must not dirty a tracked, formatter-governed settings file
|
|
196
|
+
# The cell whose ABSENCE let this ship. Every case above drove a fake settings file
|
|
197
|
+
# with absolute entries, so the one shape this repo actually commits — the portable,
|
|
198
|
+
# settings-relative `".."` that check-install-surface S7c pins — was never registered
|
|
199
|
+
# against. `setup` therefore appended the absolute path BESIDE it and rewrote the
|
|
200
|
+
# tracked, biome-owned bytes at indent=2, and `pnpm check` died at step 1 reading
|
|
201
|
+
# "format error" instead of "install wrote this".
|
|
202
|
+
#
|
|
203
|
+
# The seed is the repo's OWN committed settings, copied into a stand-in checkout so
|
|
204
|
+
# `".."` resolves to that clone rather than to the real repo (nothing here touches
|
|
205
|
+
# $REPO). Setup landing is PROVEN before any product verdict: if the seed is not the
|
|
206
|
+
# portable form, this says SETUP MISS in its own name instead of blaming the code.
|
|
207
|
+
CLONE="$TMP/selfclone"; mkdir -p "$CLONE/.pi"
|
|
208
|
+
CS="$CLONE/.pi/settings.json"
|
|
209
|
+
seed_src=""
|
|
210
|
+
if (cd "$REPO" && git show :.pi/settings.json) > "$CS" 2>/dev/null && [ -s "$CS" ]; then
|
|
211
|
+
seed_src="candidate index"
|
|
212
|
+
elif cp "$REPO/.pi/settings.json" "$CS" 2>/dev/null; then
|
|
213
|
+
seed_src="worktree"
|
|
214
|
+
fi
|
|
215
|
+
if [ ! -s "$CS" ]; then
|
|
216
|
+
bad "11 SETUP MISS: could not seed this repo's own .pi/settings.json (no git object, no worktree file)"
|
|
217
|
+
elif ! python3 -c "
|
|
218
|
+
import json,sys
|
|
219
|
+
p=json.load(open(sys.argv[1])).get('packages')
|
|
220
|
+
sys.exit(0 if p==['..'] else 1)" "$CS"; then
|
|
221
|
+
bad "11 SETUP MISS: the seed ($seed_src) is not the committed portable form (packages != ['..']) — product verdict withheld"
|
|
222
|
+
else
|
|
223
|
+
BEFORE="$(sha256sum "$CS" | cut -d' ' -f1)"; MT_B="$(stat -c %Y "$CS")"; sleep 1
|
|
224
|
+
OUT_SELF="$(python3 "$REG" "$CS" "$CLONE")"
|
|
225
|
+
AFTER="$(sha256sum "$CS" | cut -d' ' -f1)"; MT_A="$(stat -c %Y "$CS")"
|
|
226
|
+
if printf '%s' "$OUT_SELF" | grep -q 'no-op'; then
|
|
227
|
+
ok "11 register against this repo's OWN committed settings is a no-op (seed: $seed_src)"
|
|
228
|
+
else
|
|
229
|
+
bad "11 register duplicated/absolutized the portable '..' entry" "$OUT_SELF"
|
|
230
|
+
fi
|
|
231
|
+
if [ "$BEFORE" = "$AFTER" ] && [ "$MT_B" = "$MT_A" ]; then
|
|
232
|
+
ok "11b the tracked, formatter-governed bytes are UNCHANGED (sha256 + mtime)"
|
|
233
|
+
else
|
|
234
|
+
bad "11b install rewrote the tracked settings file (sha $BEFORE -> $AFTER, mtime $MT_B -> $MT_A)"
|
|
235
|
+
fi
|
|
236
|
+
# The inverse direction of the same asymmetry: the shared matcher now RECOGNIZES
|
|
237
|
+
# `".."`, so an uninstall that deleted it would edit committed source — the same
|
|
238
|
+
# defect pointed the other way. remove leaves it and says so; dry-run agrees,
|
|
239
|
+
# because both ask one predicate.
|
|
240
|
+
BEFORE_R="$(sha256sum "$CS" | cut -d' ' -f1)"
|
|
241
|
+
OUT_RM="$(python3 "$REG" "$CS" "$CLONE" --remove)"
|
|
242
|
+
OUT_DRY="$(python3 "$REG" "$CS" "$CLONE" --remove --dry-run)"
|
|
243
|
+
if [ "$BEFORE_R" = "$(sha256sum "$CS" | cut -d' ' -f1)" ]; then
|
|
244
|
+
ok "11c --remove does NOT delete the committed portable entry (bytes unchanged)"
|
|
245
|
+
else
|
|
246
|
+
bad "11c --remove edited the repo's committed settings source" "$OUT_RM"
|
|
247
|
+
fi
|
|
248
|
+
if printf '%s' "$OUT_RM" | grep -q 'kept 1 settings-relative'; then
|
|
249
|
+
ok "11d the inverse REPORTS what it deliberately left behind (never a silent partial uninstall)"
|
|
250
|
+
else
|
|
251
|
+
bad "11d --remove left the entry without saying so" "$OUT_RM"
|
|
252
|
+
fi
|
|
253
|
+
if printf '%s' "$OUT_DRY" | grep -q 'no entwurf packages\[\] entry to remove'; then
|
|
254
|
+
ok "11e --dry-run agrees with remove (one predicate, no over-report)"
|
|
255
|
+
else
|
|
256
|
+
bad "11e --dry-run disagreed with what remove actually does" "$OUT_DRY"
|
|
257
|
+
fi
|
|
258
|
+
fi
|
|
259
|
+
|
|
260
|
+
# 12. the state a pre-fix `setup` already left on real hosts: the portable entry AND
|
|
261
|
+
# the absolute one. The repair must collapse onto the PORTABLE form — absolutizing
|
|
262
|
+
# it would fix the duplicate and dirty the tracked bytes in the same breath.
|
|
263
|
+
DUP="$TMP/dupclone"; mkdir -p "$DUP/.pi"
|
|
264
|
+
DS="$DUP/.pi/settings.json"
|
|
265
|
+
cat > "$DS" <<JSON
|
|
266
|
+
{"packages": ["..", "$DUP", "../../repos/gh/andenken"]}
|
|
267
|
+
JSON
|
|
268
|
+
python3 "$REG" "$DS" "$DUP" >/dev/null
|
|
269
|
+
if python3 -c "
|
|
270
|
+
import json
|
|
271
|
+
p=json.load(open('$DS'))['packages']
|
|
272
|
+
assert p.count('..')==1, f'the portable entry did not survive as the sole self-reference: {p}'
|
|
273
|
+
assert '$DUP' not in p, f'the absolute path was written beside/instead of the portable one: {p}'
|
|
274
|
+
assert '../../repos/gh/andenken' in p, 'unrelated relative package dropped'
|
|
275
|
+
" 2>/dev/null; then ok "12 a duplicated ('..' + absolute) settings file repairs onto the PORTABLE entry"; else bad "12 the duplicate repair absolutized or dropped the portable entry: $(cat "$DS")"; fi
|
|
276
|
+
|
|
277
|
+
# 13. a genuine rewrite keeps the file's own indent unit. Narrow by design: it does
|
|
278
|
+
# NOT make the output formatter-clean (biome also decides where short arrays
|
|
279
|
+
# collapse), which is why byte-identity for this repo's own file rests on the
|
|
280
|
+
# no-op above, not on the writer's style.
|
|
281
|
+
printf '{\n\t"packages": [\n\t\t"/old/moved/entwurf"\n\t]\n}\n' > "$S"
|
|
282
|
+
python3 "$REG" "$S" "$FAKE_REPO" >/dev/null
|
|
283
|
+
if grep -q $'^\t"packages"' "$S"; then ok "13 a rewrite preserves the file's tab indentation (never forces 2 spaces)"; else bad "13 the rewrite restyled a file it does not own: $(cat -A "$S" | head -3)"; fi
|
|
284
|
+
|
|
285
|
+
# ── 14. THE cell #53 B needed: the whole `run.sh install` drive, end to end ──
|
|
286
|
+
# Cells 11–13 call ONE python writer. That is what let the second round ship: the same
|
|
287
|
+
# settings file has TWO writers (register-pi-package for packages[], register-pi-provider
|
|
288
|
+
# for entwurfProvider.mcpServers) plus a user-scope side effect, and closing only the
|
|
289
|
+
# first left `install` still re-serializing this repo's tracked, biome-governed bytes at
|
|
290
|
+
# indent=2 — semantically a no-op, byte-wise a RED `pnpm check` diagnosed as "formatting".
|
|
291
|
+
# A per-writer cell can never see that; only the real drive can. So this one runs
|
|
292
|
+
# `run.sh install <checkout>` for real and demands sha256 + mtime invariance.
|
|
293
|
+
#
|
|
294
|
+
# The checkout is a stand-in: run.sh resolves its OWN symlinks to find REPO_DIR, so
|
|
295
|
+
# run.sh is COPIED (a link would point the drive back at the operator's real repo) while
|
|
296
|
+
# the trees it only reads are linked. That makes REPO_DIR == the stand-in, which is the
|
|
297
|
+
# shape that matters — `".."` in <checkout>/.pi/settings.json resolves to the very repo
|
|
298
|
+
# being registered, exactly as it does in a dev clone.
|
|
299
|
+
CK="$TMP/checkout"
|
|
300
|
+
mkdir -p "$CK/.pi"
|
|
301
|
+
cp "$REPO/run.sh" "$CK/run.sh"
|
|
302
|
+
for entry in scripts node_modules package.json mcp pi-extensions pi protocol.js; do
|
|
303
|
+
[ -e "$REPO/$entry" ] && ln -s "$REPO/$entry" "$CK/$entry"
|
|
304
|
+
done
|
|
305
|
+
CKS="$CK/.pi/settings.json"
|
|
306
|
+
ck_seed=""
|
|
307
|
+
if (cd "$REPO" && git show :.pi/settings.json) > "$CKS" 2>/dev/null && [ -s "$CKS" ]; then
|
|
308
|
+
ck_seed="candidate index"
|
|
309
|
+
elif cp "$REPO/.pi/settings.json" "$CKS" 2>/dev/null; then
|
|
310
|
+
ck_seed="worktree"
|
|
311
|
+
fi
|
|
312
|
+
# Landing is PROVEN before any product verdict — a bad seed or a drive that died for its
|
|
313
|
+
# own reasons is SETUP MISS, never "install dirtied the file".
|
|
314
|
+
if [ ! -s "$CKS" ]; then
|
|
315
|
+
bad "14 SETUP MISS: could not seed the stand-in checkout's .pi/settings.json"
|
|
316
|
+
elif ! python3 -c "
|
|
317
|
+
import json,sys
|
|
318
|
+
d=json.load(open(sys.argv[1]))
|
|
319
|
+
sys.exit(0 if d.get('packages')==['..'] and isinstance(d.get('entwurfProvider'),dict) else 1)" "$CKS"; then
|
|
320
|
+
bad "14 SETUP MISS: the seed ($ck_seed) is not the committed portable form (packages ['..'] + entwurfProvider)"
|
|
321
|
+
else
|
|
322
|
+
CK_BEFORE="$(sha256sum "$CKS" | cut -d' ' -f1)"; CK_MT="$(stat -c %Y "$CKS")"
|
|
323
|
+
sleep 1
|
|
324
|
+
set +e
|
|
325
|
+
# Isolated on the same line as the drive, not only by the exports above: this is a
|
|
326
|
+
# MUTATING run.sh command and it writes through three roots (agent dir, XDG state,
|
|
327
|
+
# $HOME for the user-scope registration). check-install-surface S5c matches the drive
|
|
328
|
+
# and then demands exactly these — which is why the path is unquoted here, so the
|
|
329
|
+
# tripwire can SEE the drive it is meant to police.
|
|
330
|
+
ck_out="$(HOME="$TMP/home" XDG_DATA_HOME="$TMP/xdg" XDG_STATE_HOME="$TMP/state" XDG_CACHE_HOME="$TMP/cache" PI_CODING_AGENT_DIR="$TMP/agent14" bash $CK/run.sh install "$CK" 2>&1)"
|
|
331
|
+
ck_rc=$?
|
|
332
|
+
set -e
|
|
333
|
+
if [ "$ck_rc" != 0 ]; then
|
|
334
|
+
bad "14 SETUP MISS: the stand-in \`run.sh install\` drive did not complete (exit $ck_rc) — no byte verdict from a drive that failed for its own reasons" "$ck_out"
|
|
335
|
+
else
|
|
336
|
+
ok "14 the stand-in \`run.sh install\` drive completed (seed: $ck_seed)"
|
|
337
|
+
if [ "$CK_BEFORE" = "$(sha256sum "$CKS" | cut -d' ' -f1)" ] && [ "$CK_MT" = "$(stat -c %Y "$CKS")" ]; then
|
|
338
|
+
ok "14b end-to-end: install left the tracked .pi/settings.json byte-identical (sha256 + mtime)"
|
|
339
|
+
else
|
|
340
|
+
bad "14b \`run.sh install\` rewrote the tracked settings file — one of its writers does not know the contract" "$(diff <(printf '%s' "$CK_BEFORE") <(sha256sum "$CKS" | cut -d' ' -f1); cat -A "$CKS" | head -6)"
|
|
341
|
+
fi
|
|
342
|
+
# The drive really did reach the writers (a no-op that never ran proves nothing), and
|
|
343
|
+
# the user-scope side effect landed in the sandbox rather than the operator's home.
|
|
344
|
+
case "$ck_out" in
|
|
345
|
+
*"entwurf-bridge"*) ok "14c the provider writer really ran (it reported its classification)" ;;
|
|
346
|
+
*) bad "14c the drive never reached the provider writer — 14b would be vacuous" "$ck_out" ;;
|
|
347
|
+
esac
|
|
348
|
+
if [ -f "$TMP/agent14/settings.json" ]; then
|
|
349
|
+
ok "14d the user-scope registration landed in the SANDBOX agent dir (isolation held)"
|
|
350
|
+
else
|
|
351
|
+
bad "14d the user-scope citizen was not written under the sandboxed PI_CODING_AGENT_DIR" "$ck_out"
|
|
352
|
+
fi
|
|
353
|
+
fi
|
|
354
|
+
fi
|
|
355
|
+
|
|
356
|
+
# 15. WIRING: both writers of this one file must share the serializer, not copy it.
|
|
357
|
+
# A duplicated indent-detector is how the provider writer stayed open after the
|
|
358
|
+
# package writer was closed; a parity check is cheaper than a third round.
|
|
359
|
+
for w in register-pi-package.py register-pi-provider.py; do
|
|
360
|
+
if grep -q "from pi_settings_io import" "$REPO/scripts/$w"; then
|
|
361
|
+
ok "15 $w routes through the shared pi_settings_io serializer"
|
|
362
|
+
else
|
|
363
|
+
bad "15 $w serializes settings on its own again (copied rule = the #53 B shape)"
|
|
364
|
+
fi
|
|
365
|
+
done
|
|
366
|
+
|
|
190
367
|
echo
|
|
191
368
|
if [ "$fail" -eq 0 ]; then echo "smoke-user-scope-citizen: PASS"; else echo "smoke-user-scope-citizen: FAIL (see above)"; exit 1; fi
|
|
@@ -1,100 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* entwurf-mailbox-guard — the IO orchestration that gates a conversational mailbox
|
|
3
|
-
* enqueue on the (pure) deliverability predicate (SE-1/SE-2 slice 2d). Kept SEPARATE
|
|
4
|
-
* from entwurf-deliverability.ts so the predicate stays pure (gate-pinnable) and only
|
|
5
|
-
* this thin wrapper touches the record store / capability registry / presence marker.
|
|
6
|
-
*
|
|
7
|
-
* The contract every conversational-reply enqueue site (MCP entwurf_v2 fallback,
|
|
8
|
-
* pi-native entwurf_v2 fallback) must use INSTEAD of calling enqueueMetaMessage
|
|
9
|
-
* directly:
|
|
10
|
-
* 1. gather facts for the target garden id (record? wakeMode? receiver active?),
|
|
11
|
-
* 2. ask mailboxConversationalDeliverable,
|
|
12
|
-
* 3. enqueue ONLY when deliverable; otherwise return a typed non-delivery with a
|
|
13
|
-
* reason — no .msg written, no doorbell poked.
|
|
14
|
-
*
|
|
15
|
-
* This is the SE-2 "no garbage in a dead/absent receiver's mailbox" guard and the
|
|
16
|
-
* SE-1 "never enqueue for a backend with no mailbox drain (pi)" guard, in one place.
|
|
17
|
-
* The low-level enqueueMetaMessage stays a raw primitive — it has no in-band reject;
|
|
18
|
-
* the refusal happens HERE, before transport, not inside it.
|
|
19
|
-
*/
|
|
20
|
-
|
|
21
|
-
import {
|
|
22
|
-
type MailboxDeliverabilityFacts,
|
|
23
|
-
mailboxConversationalDeliverable,
|
|
24
|
-
receiverMarkerMatchesIdentity,
|
|
25
|
-
} from "./entwurf-deliverability.ts";
|
|
26
|
-
import {
|
|
27
|
-
type MetaCapability,
|
|
28
|
-
type MetaCitizenBackend,
|
|
29
|
-
type MetaIdentity,
|
|
30
|
-
type MetaReceiverMarker,
|
|
31
|
-
metaCapabilityFor,
|
|
32
|
-
readMetaIdentityByGardenId,
|
|
33
|
-
readMetaReceiverMarker,
|
|
34
|
-
} from "./meta-session.ts";
|
|
35
|
-
|
|
36
|
-
export interface MailboxGuardDeps {
|
|
37
|
-
/** Read the target's identity (throws when there is no backing record). */
|
|
38
|
-
readIdentity?: (gardenId: string) => MetaIdentity;
|
|
39
|
-
/** Read the target's receiver presence marker (null = no live, armed receiver). */
|
|
40
|
-
readReceiverMarker?: (gardenId: string) => MetaReceiverMarker | null;
|
|
41
|
-
/** Resolve a backend's capability (wake mode). */
|
|
42
|
-
capabilityFor?: (backend: MetaCitizenBackend) => MetaCapability;
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
/**
|
|
46
|
-
* Gather the deliverability facts for a target garden id, all via injected (or
|
|
47
|
-
* production-default) readers. recordBacked/wakeMode come from the record + capability
|
|
48
|
-
* registry; ownerAlive and watchArmed both derive from the receiver presence marker —
|
|
49
|
-
* at runtime they move together (a verified marker means a live owner that reached the
|
|
50
|
-
* watch-arm path; its absence/dead-owner means neither), but the pure predicate keeps
|
|
51
|
-
* the axes separate so each cause stays nameable. A present marker only counts when it
|
|
52
|
-
* actually belongs to THIS identity (garden/backend/native id match via the shared
|
|
53
|
-
* receiverMarkerMatchesIdentity helper) — a drifted/foreign marker is fail-closed to
|
|
54
|
-
* inactive, the same SSOT the v2 production mailboxDeliverabilityFor seam uses.
|
|
55
|
-
*/
|
|
56
|
-
export function gatherMailboxDeliverabilityFacts(
|
|
57
|
-
gardenId: string,
|
|
58
|
-
deps: MailboxGuardDeps = {},
|
|
59
|
-
): MailboxDeliverabilityFacts {
|
|
60
|
-
const readIdentity = deps.readIdentity ?? ((g: string) => readMetaIdentityByGardenId(g));
|
|
61
|
-
const readReceiverMarker = deps.readReceiverMarker ?? ((g: string) => readMetaReceiverMarker({ gardenId: g }));
|
|
62
|
-
const capabilityFor = deps.capabilityFor ?? metaCapabilityFor;
|
|
63
|
-
|
|
64
|
-
let recordBacked = false;
|
|
65
|
-
let wakeMode: string | undefined;
|
|
66
|
-
let identity: MetaIdentity | null = null;
|
|
67
|
-
try {
|
|
68
|
-
identity = readIdentity(gardenId);
|
|
69
|
-
recordBacked = true;
|
|
70
|
-
wakeMode = capabilityFor(identity.backend).wakeMode;
|
|
71
|
-
} catch {
|
|
72
|
-
recordBacked = false;
|
|
73
|
-
identity = null;
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
// No record → no identity → never matched (a marker without a record cannot be
|
|
77
|
-
// verified against one). With a record, the marker must agree on garden/backend/
|
|
78
|
-
// native id; otherwise it is a stale or foreign marker and the receiver is inactive.
|
|
79
|
-
const marker = readReceiverMarker(gardenId);
|
|
80
|
-
const matched = identity !== null && receiverMarkerMatchesIdentity(marker, identity);
|
|
81
|
-
return { wakeMode, recordBacked, ownerAlive: matched, watchArmed: matched };
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
export type GuardedMailboxOutcome<T> = { delivered: true; result: T } | { delivered: false; reason: string };
|
|
85
|
-
|
|
86
|
-
/**
|
|
87
|
-
* Enqueue a conversational reply to the target's mailbox ONLY when it is deliverable.
|
|
88
|
-
* When not, returns `{ delivered: false, reason }` and the injected `enqueue` is never
|
|
89
|
-
* called — the SE-2 guarantee that a refused send mutates nothing.
|
|
90
|
-
*/
|
|
91
|
-
export function guardedMailboxEnqueue<T>(
|
|
92
|
-
gardenId: string,
|
|
93
|
-
deps: MailboxGuardDeps,
|
|
94
|
-
enqueue: () => T,
|
|
95
|
-
): GuardedMailboxOutcome<T> {
|
|
96
|
-
const facts = gatherMailboxDeliverabilityFacts(gardenId, deps);
|
|
97
|
-
const verdict = mailboxConversationalDeliverable(facts);
|
|
98
|
-
if (!verdict.deliverable) return { delivered: false, reason: verdict.reason };
|
|
99
|
-
return { delivered: true, result: enqueue() };
|
|
100
|
-
}
|