@junghanacs/entwurf 0.12.9 → 0.13.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +135 -248
- package/BASELINE.md +46 -7
- package/CHANGELOG.md +35 -0
- package/CONTRIBUTING.md +4 -2
- package/DELIVERY.md +5 -4
- package/README.md +46 -18
- package/VERIFY.md +10 -6
- package/demo/demo.sh +7 -1
- package/docs/setup-clean-host.md +10 -4
- package/mcp/entwurf-bridge/dist/mcp/entwurf-bridge/src/index.js +159 -66
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/backend-adapter.js +148 -5
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/config.js +16 -4
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/models.js +66 -7
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/overlay.js +190 -3
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/tool-surface.js +12 -4
- 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 +15 -17
- 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 +9 -11
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-spawn-production.js +3 -1
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/meta-session.js +17 -20
- package/mcp/entwurf-bridge/src/index.ts +190 -82
- package/package.json +7 -3
- package/pi/settings.reference.json +1 -1
- package/pi-extensions/acp-provider.ts +20 -10
- package/pi-extensions/entwurf-control.ts +69 -27
- package/pi-extensions/lib/acp/augment.ts +60 -2
- package/pi-extensions/lib/acp/backend-adapter.ts +183 -8
- package/pi-extensions/lib/acp/backend.ts +5 -1
- package/pi-extensions/lib/acp/config.ts +19 -5
- package/pi-extensions/lib/acp/engraving.ts +3 -1
- package/pi-extensions/lib/acp/event-mapper.ts +10 -3
- package/pi-extensions/lib/acp/models.ts +69 -7
- package/pi-extensions/lib/acp/overlay.ts +234 -3
- package/pi-extensions/lib/acp/tool-surface.ts +12 -4
- 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 +15 -17
- 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 +14 -11
- package/pi-extensions/lib/entwurf-v2-spawn-production.ts +3 -1
- package/pi-extensions/lib/meta-session.ts +17 -20
- package/run.sh +200 -62
- package/scripts/agy-bridge-config.py +249 -49
- package/scripts/agy-bridge.sh +59 -14
- package/scripts/check-acp-carrier-augment.ts +34 -2
- package/scripts/check-acp-cortex.ts +668 -0
- package/scripts/check-acp-provider-surface.ts +50 -6
- package/scripts/check-acp-sdk-surface.ts +22 -11
- package/scripts/check-acp-session-reuse.ts +64 -1
- package/scripts/check-agy-permission-matrix.py +655 -0
- package/scripts/check-entwurf-deliverability.ts +9 -6
- package/scripts/check-entwurf-fact-provider.ts +7 -7
- package/scripts/check-entwurf-facts.ts +12 -12
- package/scripts/check-entwurf-resume-args.ts +22 -60
- package/scripts/check-entwurf-self-address.ts +187 -4
- package/scripts/check-entwurf-v2-contract.ts +2 -2
- package/scripts/check-entwurf-v2-production.ts +5 -5
- package/scripts/check-entwurf-v2-spawn-production.ts +6 -2
- package/scripts/check-entwurf-v2-surface.ts +327 -14
- package/scripts/check-gate-qualification.ts +787 -0
- package/scripts/check-meta-identity-consumers.ts +1 -1
- package/scripts/check-probe-cli-shim.ts +879 -0
- package/scripts/check-probe-ordering.ts +2450 -0
- package/scripts/check-shell-quote.ts +5 -4
- package/scripts/fixtures/probe-cli-shim +20 -0
- package/scripts/fixtures/probe-mcp-server.ts +168 -12
- package/scripts/lib/mutation-qualify.ts +794 -0
- package/scripts/lib/probe-acp-turn.ts +207 -0
- package/scripts/lib/probe-cli-shim.ts +464 -0
- package/scripts/lib/probe-cli-target.ts +165 -0
- package/scripts/lib/probe-event-log.ts +383 -0
- package/scripts/lib/probe-verdict.ts +1213 -0
- package/scripts/mutants/acp-augment.json +30 -0
- package/scripts/mutants/acp-cortex.json +196 -0
- package/scripts/mutants/agy-permission.json +144 -0
- package/scripts/mutants/meta-identity.json +17 -0
- package/scripts/mutants/probe-ordering.json +1032 -0
- package/scripts/mutants/self-address.json +59 -0
- package/scripts/mutants/v2-surface.json +87 -0
- package/scripts/smoke-acp-cortex-live.ts +392 -0
- package/scripts/smoke-acp-ordering-probe-live.ts +848 -0
- package/scripts/smoke-acp-raw-turn-live.ts +1 -1
- 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/pi-extensions/lib/entwurf-mailbox-guard.ts +0 -100
- package/scripts/check-entwurf-mailbox-guard.ts +0 -266
|
@@ -32,27 +32,42 @@ Subcommands (argv[1]):
|
|
|
32
32
|
<target>` prefix / `invalid-json` / `not-configured` / `command <cmd>`. Never mutates.
|
|
33
33
|
|
|
34
34
|
permission-install <settings_path> <state_path>
|
|
35
|
-
The OTHER half of "agy can call our bridge": registering the server (above) makes the
|
|
35
|
+
The OTHER half of "agy can call our bridge": registering the server (above) makes the tools
|
|
36
36
|
reachable, but agy's permission engine defaults every `mcp` action to Ask, so every single
|
|
37
|
-
|
|
37
|
+
call stops for a y/n. This adds our allow rules to agy's settings.json.
|
|
38
38
|
|
|
39
|
-
Ownership is ELEMENT-level, not subtree: we own exactly the
|
|
40
|
-
`mcp(entwurf-bridge
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
39
|
+
Ownership is ELEMENT-level, not subtree: we own exactly the strings
|
|
40
|
+
`mcp(entwurf-bridge/<tool>)` for the tools the normal agy workflow calls, inside
|
|
41
|
+
`permissions.allow`. Every tool the server exposes is visible to the model; these are the
|
|
42
|
+
ones we auto-grant, which is a smaller set on purpose.
|
|
43
|
+
The operator's own rules (command(*), their ask/deny lists, everything else) are ours to
|
|
44
|
+
preserve, never to manage — granting ourselves broad permissions would be a trust decision
|
|
45
|
+
that is not the installer's to make. Same discipline as the single mcpServers key above.
|
|
46
|
+
Idempotent; refuses a symlink.
|
|
44
47
|
|
|
45
48
|
permission-uninstall <state_path>
|
|
46
|
-
Honest inverse: remove OUR
|
|
47
|
-
keeps it), then drop the `allow`/`permissions` containers only if we created them
|
|
48
|
-
are now empty. Removes the settings file only if we created it and nothing
|
|
49
|
+
Honest inverse: remove each of OUR rules only if WE added that one (an operator who already
|
|
50
|
+
had a rule keeps it), then drop the `allow`/`permissions` containers only if we created them
|
|
51
|
+
and they are now empty. Removes the settings file only if we created it and nothing remains.
|
|
49
52
|
|
|
50
53
|
permission-doctor <settings_path>
|
|
51
54
|
One token line: `absent` / `invalid-json` / `not-configured` / `configured` /
|
|
52
|
-
`
|
|
55
|
+
`partially-configured <missing...>` / `covered-by-allow <rule>` /
|
|
56
|
+
`shadowed-by-<list> <broad|exact> <rule>`. The shadow check exists because agy evaluates
|
|
53
57
|
Deny > Ask > Allow: an operator rule like `mcp(*)` in their ask list SILENTLY defeats our
|
|
54
58
|
allow, and agy starts prompting again with our install still green. That would be a
|
|
55
|
-
debugging hole ("why is it asking me every time?"), so the doctor names it instead
|
|
59
|
+
debugging hole ("why is it asking me every time?"), so the doctor names it instead — with
|
|
60
|
+
the SCOPE, because a broad rule takes every tool while an exact one takes only its own.
|
|
61
|
+
|
|
62
|
+
permission-state-doctor <state_path>
|
|
63
|
+
Is the OWNERSHIP RECORD itself readable? `absent` / `ok <n>` / `corrupt <why>`. Separate from
|
|
64
|
+
permission-doctor, which reads the settings file: runtime can be perfect while the record that
|
|
65
|
+
distinguishes our grants from the operator's is unreadable (hard rule 13, two axes). Uses the
|
|
66
|
+
same strict parser as install/uninstall, so "readable" has one definition. Never mutates.
|
|
67
|
+
|
|
68
|
+
permission-rules
|
|
69
|
+
Print the allow rules we own, space-separated. The single source the shell and gates render
|
|
70
|
+
from, so operator messages can never drift from what install actually writes.
|
|
56
71
|
|
|
57
72
|
Exit codes: 0 ok · 2 no-state · 3 refuse-symlink · 4 invalid-json · 5 usage.
|
|
58
73
|
"""
|
|
@@ -62,23 +77,48 @@ import os
|
|
|
62
77
|
import sys
|
|
63
78
|
|
|
64
79
|
SERVER_KEY = "entwurf-bridge"
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
#
|
|
68
|
-
#
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
#
|
|
80
|
+
# TWO independent state files, TWO independent schemas. They are versioned apart because they
|
|
81
|
+
# change apart: widening the permission rule set says nothing about the MCP install-state's shape,
|
|
82
|
+
# and stamping a bump onto a file whose layout never moved makes the version a lie — the next reader
|
|
83
|
+
# to branch on it would be branching on noise.
|
|
84
|
+
STATE_SCHEMA_VERSION = 1 # MCP install-state (server key + preimage); shape unchanged
|
|
85
|
+
# 2: permission state records a rule SET (`rules` / `rulesExistedBefore`) instead of a single
|
|
86
|
+
# `rule` / `ruleExistedBefore`. Install migrates a v1 state in place; uninstall reads both shapes.
|
|
87
|
+
PERMISSION_STATE_SCHEMA_VERSION = 2
|
|
88
|
+
|
|
89
|
+
# The permission rules we own — one per tool the NORMAL agy workflow calls. (Every tool the server
|
|
90
|
+
# exposes is visible to the model; auto-granting is the smaller, deliberate set.) Each is
|
|
91
|
+
# scoped to a single tool on our own server: not mcp(*), not the server-wide mcp(entwurf-bridge).
|
|
92
|
+
# An installer grants itself the narrowest rules that make the thing it installed work, and nothing
|
|
93
|
+
# more; three narrow grants are still narrow, and none of them is a trust decision about anyone
|
|
94
|
+
# else's server.
|
|
95
|
+
#
|
|
96
|
+
# Granting ONLY entwurf_v2 was a shipped defect (measured 2026-07-27 on a live agy citizen): agy
|
|
97
|
+
# defaults every mcp action to Ask, so `entwurf_peers` and `entwurf_self` stopped for a y/n on
|
|
98
|
+
# EVERY call. The bridge was registered and two thirds of it was unusable — the same failure the
|
|
99
|
+
# doctor already names for entwurf_v2, just never checked for the other two.
|
|
100
|
+
#
|
|
101
|
+
# The two tools deliberately NOT here, because a grant we do not need is a grant we should not take:
|
|
102
|
+
# entwurf_inbox_read — native-push has no inbox to drain. An agy citizen has no meta-mailbox
|
|
103
|
+
# directory at all (measured), so granting it would pre-approve a rail this
|
|
104
|
+
# backend does not have.
|
|
105
|
+
# entwurf_register_native — explicit/manual fallback, not the normal birth path (DELIVERY.md):
|
|
106
|
+
# agy births automatically from PreInvocation. Auto-approving a registration
|
|
107
|
+
# verb the normal path never calls is exactly the excess this list avoids.
|
|
108
|
+
ALLOW_RULES = tuple(f"mcp({SERVER_KEY}/{tool})" for tool in ("entwurf_v2", "entwurf_peers", "entwurf_self"))
|
|
109
|
+
# Rules that MATCH one of our tools: its exact grant, the server-wide rule, and the action wildcard.
|
|
72
110
|
# Membership here is a statement about agy's matcher, not about which list the rule sits in — the
|
|
73
|
-
# same
|
|
74
|
-
#
|
|
111
|
+
# same rules cover our tools wherever they appear. From a higher-precedence list they override our
|
|
112
|
+
# allow (shadowing); from `allow` itself they already grant it (covering). SCOPE differs, though:
|
|
113
|
+
# the two broad rules cover every tool on the server, an exact rule only its own.
|
|
75
114
|
#
|
|
76
|
-
# The doctor used to read them ONE WAY ONLY — shadowing — and demanded a literal
|
|
115
|
+
# The doctor used to read them ONE WAY ONLY — shadowing — and demanded a literal own-rule string
|
|
77
116
|
# in `allow`. So a host whose operator had granted a broad `mcp(*)` (their trust decision, not
|
|
78
117
|
# ours) was reported as "NOT granted, agy prompts on EVERY entwurf_v2 call": a false red about a
|
|
79
118
|
# surface that in fact works. Reading the same coverage in both directions is the fix; treating
|
|
80
119
|
# mcp(*) as matching in deny/ask but not in allow was never defensible.
|
|
81
|
-
|
|
120
|
+
BROAD_RULES = (f"mcp({SERVER_KEY})", "mcp(*)") # cover EVERY tool on our server, not just one
|
|
121
|
+
MATCHING_RULES = ALLOW_RULES + BROAD_RULES
|
|
82
122
|
SHADOWING_RULES = MATCHING_RULES # same set, higher-precedence lists
|
|
83
123
|
# Deny > Ask > Allow (agy permissions engine).
|
|
84
124
|
SHADOWING_LISTS = ("deny", "ask")
|
|
@@ -135,6 +175,68 @@ def _prior_state(state_path: str, managed_key: str, managed_path: str) -> dict:
|
|
|
135
175
|
return prior
|
|
136
176
|
|
|
137
177
|
|
|
178
|
+
class _PermissionStateError(Exception):
|
|
179
|
+
"""A permission install-state we refuse to act on. Raised, not exited, so the doctor can REPORT
|
|
180
|
+
corruption while install/uninstall REFUSE on it — one parser, three honest reactions."""
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def _parse_permission_state(state: dict) -> tuple:
|
|
184
|
+
"""Validate a permission install-state; return (rules, {rule: was-it-already-theirs}).
|
|
185
|
+
|
|
186
|
+
Two shapes, because install/uninstall/doctor can all meet a state file an older entwurf wrote:
|
|
187
|
+
schemaVersion 2 carries `rules` + `rulesExistedBefore`; schemaVersion 1 carried a single `rule`
|
|
188
|
+
+ `ruleExistedBefore`. Either way the answer is the same question asked per rule, and a rule the
|
|
189
|
+
operator already had is theirs in both.
|
|
190
|
+
|
|
191
|
+
STRICT, because the failure mode deletes someone else's configuration. A missing or non-boolean
|
|
192
|
+
provenance entry read as "not theirs" silently promotes the operator's own rule to ours and the
|
|
193
|
+
inverse takes it away — `rulesExistedBefore: {}` alone would revoke every rule in the file. An
|
|
194
|
+
unknown version, a wrong type, or an INCOMPLETE provenance map is an error, never a permissive
|
|
195
|
+
default. Refusing is recoverable; deleting a grant we never owned is not.
|
|
196
|
+
|
|
197
|
+
ONE parser for every caller. Install used to do its own shallow shape check (dict? values bool?)
|
|
198
|
+
which an EMPTY map passed — `all()` of nothing is true — so a corrupt prior state was silently
|
|
199
|
+
overwritten with freshly re-captured provenance, the exact re-capture the state file exists to
|
|
200
|
+
prevent. A second, laxer copy of a strict rule is the same as not having the rule.
|
|
201
|
+
"""
|
|
202
|
+
version = state.get("schemaVersion")
|
|
203
|
+
if version == PERMISSION_STATE_SCHEMA_VERSION:
|
|
204
|
+
rules = state.get("rules")
|
|
205
|
+
existed = state.get("rulesExistedBefore")
|
|
206
|
+
if not isinstance(rules, list) or not rules or not all(isinstance(r, str) for r in rules):
|
|
207
|
+
raise _PermissionStateError(
|
|
208
|
+
"`rules` must be a non-empty list of strings — refusing to guess which rules are ours")
|
|
209
|
+
if not isinstance(existed, dict):
|
|
210
|
+
raise _PermissionStateError(
|
|
211
|
+
"`rulesExistedBefore` must be an object — refusing to act on unreadable provenance")
|
|
212
|
+
missing = [r for r in rules if not isinstance(existed.get(r), bool)]
|
|
213
|
+
if missing:
|
|
214
|
+
raise _PermissionStateError(
|
|
215
|
+
f"no boolean provenance for {' '.join(missing)} — refusing to treat a rule that may "
|
|
216
|
+
"be the operator's as ours")
|
|
217
|
+
return tuple(rules), {r: existed[r] for r in rules}
|
|
218
|
+
if version == 1:
|
|
219
|
+
legacy = state.get("rule")
|
|
220
|
+
owned = state.get("ruleExistedBefore")
|
|
221
|
+
if not isinstance(legacy, str) or not isinstance(owned, bool):
|
|
222
|
+
raise _PermissionStateError(
|
|
223
|
+
"schemaVersion 1 needs a string `rule` and a boolean `ruleExistedBefore`")
|
|
224
|
+
return (legacy,), {legacy: owned}
|
|
225
|
+
raise _PermissionStateError(
|
|
226
|
+
f"unknown schemaVersion {version!r} — this entwurf does not know which rules that install "
|
|
227
|
+
"owned and will not touch any")
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def _owned_rules(state: dict) -> list:
|
|
231
|
+
"""The rules in this install-state that are OURS to remove — never the operator's."""
|
|
232
|
+
try:
|
|
233
|
+
rules, existed = _parse_permission_state(state)
|
|
234
|
+
except _PermissionStateError as exc:
|
|
235
|
+
_die(4, f"agy-bridge: permission install-state is unusable — {exc}. Check permissions.allow "
|
|
236
|
+
"yourself, then delete the state file by hand if it is genuinely stale.")
|
|
237
|
+
return [r for r in rules if not existed[r]]
|
|
238
|
+
|
|
239
|
+
|
|
138
240
|
def cmd_install(config_path: str, command: str, state_path: str) -> None:
|
|
139
241
|
# REFUSE a symlink — it is someone else's SSOT (an agent-config link). Never clobber.
|
|
140
242
|
if os.path.islink(config_path):
|
|
@@ -294,33 +396,62 @@ def cmd_permission_install(settings_path: str, state_path: str) -> None:
|
|
|
294
396
|
# rule, we did not add it, so uninstall must not take it away. On a RE-install the rule on disk
|
|
295
397
|
# is our own previous write — reading it as "pre-existing" would hand the operator credit for
|
|
296
398
|
# our entry and strand it forever. So provenance is taken from the first install and carried.
|
|
297
|
-
on_disk =
|
|
298
|
-
|
|
399
|
+
on_disk = {rule: rule in allow for rule in ALLOW_RULES} # what the file says NOW
|
|
400
|
+
# Provenance is PER RULE. A host can easily have granted one of these by hand (agy's own
|
|
401
|
+
# "always allow" prompt persists exactly one rule at a time — that is how an operator ends up
|
|
402
|
+
# owning entwurf_self while entwurf owns entwurf_v2), and a set-wide flag would either strand
|
|
403
|
+
# their rule or take it away. Each rule carries its own answer to "was this yours before us?".
|
|
404
|
+
rules_existed = dict(on_disk)
|
|
299
405
|
prior = _prior_state(state_path, "managedSettingsPath", settings_path)
|
|
300
406
|
if prior:
|
|
301
|
-
rule_existed = prior.get("ruleExistedBefore", rule_existed)
|
|
302
407
|
detect_mode = prior.get("detectMode", detect_mode)
|
|
303
408
|
settings_existed = prior.get("settingsExistedBefore", settings_existed)
|
|
304
409
|
permissions_existed = prior.get("permissionsExistedBefore", permissions_existed)
|
|
305
410
|
allow_existed = prior.get("allowExistedBefore", allow_existed)
|
|
306
|
-
|
|
307
|
-
|
|
411
|
+
# Carry recorded provenance forward, branching on the VERSION rather than on which keys
|
|
412
|
+
# happen to be present — a duck-typed read of a malformed state is how a wrong answer here
|
|
413
|
+
# becomes a deleted operator rule at uninstall time. A rule the prior state never described
|
|
414
|
+
# (a tool we only started granting now) keeps the preimage we just read from disk, which is
|
|
415
|
+
# the first honest observation we have of it.
|
|
416
|
+
# The SAME strict parser uninstall uses. A prior state we cannot read is a REFUSAL, not a
|
|
417
|
+
# licence to re-capture: overwriting it would replace the operator's recorded answer with a
|
|
418
|
+
# fresh reading of a file we ourselves wrote, which is precisely what this state file exists
|
|
419
|
+
# to prevent. Only rules the prior state actually RECORDED are carried; a tool we started
|
|
420
|
+
# granting later keeps the preimage read from disk, its first honest observation.
|
|
421
|
+
try:
|
|
422
|
+
_prior_rules, prior_existed = _parse_permission_state(prior)
|
|
423
|
+
except _PermissionStateError as exc:
|
|
424
|
+
_die(4, f"agy-bridge: prior permission install-state is unusable — {exc}. Refusing to "
|
|
425
|
+
"overwrite provenance this entwurf cannot read; inspect it by hand.")
|
|
426
|
+
for rule in ALLOW_RULES:
|
|
427
|
+
if rule in prior_existed:
|
|
428
|
+
rules_existed[rule] = prior_existed[rule]
|
|
429
|
+
for rule in ALLOW_RULES:
|
|
430
|
+
if rule not in allow:
|
|
431
|
+
allow.append(rule)
|
|
308
432
|
|
|
309
433
|
_atomic_write(settings_path, _dump(data))
|
|
310
434
|
|
|
311
435
|
state = {
|
|
312
|
-
"schemaVersion":
|
|
436
|
+
"schemaVersion": PERMISSION_STATE_SCHEMA_VERSION,
|
|
313
437
|
"managedSettingsPath": os.path.abspath(settings_path),
|
|
314
|
-
"
|
|
438
|
+
"rules": list(ALLOW_RULES),
|
|
315
439
|
"detectMode": detect_mode,
|
|
316
440
|
"settingsExistedBefore": settings_existed,
|
|
317
441
|
"permissionsExistedBefore": permissions_existed,
|
|
318
442
|
"allowExistedBefore": allow_existed,
|
|
319
|
-
|
|
443
|
+
# true = the operator's rule, not ours to remove
|
|
444
|
+
"rulesExistedBefore": {rule: rules_existed[rule] for rule in ALLOW_RULES},
|
|
320
445
|
"installedAt": _now(),
|
|
321
446
|
}
|
|
322
447
|
_atomic_write(state_path, _dump(state))
|
|
323
|
-
|
|
448
|
+
added = [rule for rule in ALLOW_RULES if not on_disk[rule]]
|
|
449
|
+
present = [rule for rule in ALLOW_RULES if on_disk[rule]]
|
|
450
|
+
if added:
|
|
451
|
+
sys.stdout.write(f"added {' '.join(added)}\n" if not present else
|
|
452
|
+
f"added {' '.join(added)} (already-present {' '.join(present)})\n")
|
|
453
|
+
else:
|
|
454
|
+
sys.stdout.write(f"already-present {' '.join(ALLOW_RULES)}\n")
|
|
324
455
|
|
|
325
456
|
|
|
326
457
|
def cmd_permission_uninstall(state_path: str) -> None:
|
|
@@ -331,6 +462,17 @@ def cmd_permission_uninstall(state_path: str) -> None:
|
|
|
331
462
|
if not isinstance(settings_path, str):
|
|
332
463
|
_die(4, f"agy-bridge: permission install-state {state_path} has no managedSettingsPath")
|
|
333
464
|
|
|
465
|
+
# DECIDE BEFORE MUTATING. Per rule: the operator already had it before us → it is theirs; leave
|
|
466
|
+
# it. Reads a v1 state (single `rule`) as well as v2, so an uninstall from an older install-state
|
|
467
|
+
# still removes exactly what that install added and nothing else.
|
|
468
|
+
#
|
|
469
|
+
# This runs FIRST, ahead of every write and removal, because a refusal has to leave the world
|
|
470
|
+
# untouched. It used to be computed inside the settings-exists branch and then, for a host whose
|
|
471
|
+
# settings file was gone, first reached only in the closing message — AFTER `os.remove(state_path)`
|
|
472
|
+
# had already run. A malformed state on such a host was "refused" with the state file deleted:
|
|
473
|
+
# the one record of what we owed the operator, destroyed by the safety check itself.
|
|
474
|
+
ours = _owned_rules(state)
|
|
475
|
+
|
|
334
476
|
if os.path.islink(settings_path):
|
|
335
477
|
_die(3, f"agy-bridge: refusing to uninstall — {settings_path} became a symlink since install "
|
|
336
478
|
f"(someone else's SSOT now). Resolve by hand.")
|
|
@@ -340,9 +482,8 @@ def cmd_permission_uninstall(state_path: str) -> None:
|
|
|
340
482
|
perms = data.get("permissions")
|
|
341
483
|
if isinstance(perms, dict):
|
|
342
484
|
allow = perms.get("allow")
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
perms["allow"] = [r for r in allow if r != ALLOW_RULE]
|
|
485
|
+
if isinstance(allow, list) and ours:
|
|
486
|
+
perms["allow"] = [r for r in allow if r not in ours]
|
|
346
487
|
# Drop only the containers WE created, and only while they are empty. An operator
|
|
347
488
|
# who has since added their own rules keeps their structure untouched.
|
|
348
489
|
if not state.get("allowExistedBefore", False) and perms["allow"] == []:
|
|
@@ -356,7 +497,8 @@ def cmd_permission_uninstall(state_path: str) -> None:
|
|
|
356
497
|
_atomic_write(settings_path, _dump(data))
|
|
357
498
|
|
|
358
499
|
os.remove(state_path)
|
|
359
|
-
|
|
500
|
+
what = " ".join(ours) if ours else "(nothing — every rule was already the operator's)"
|
|
501
|
+
sys.stdout.write(f"uninstalled {what} from {settings_path}\n")
|
|
360
502
|
|
|
361
503
|
|
|
362
504
|
def cmd_permission_doctor(settings_path: str) -> None:
|
|
@@ -376,30 +518,77 @@ def cmd_permission_doctor(settings_path: str) -> None:
|
|
|
376
518
|
# Precedence FIRST (Deny > Ask > Allow): a matching rule in a higher list means agy prompts (or
|
|
377
519
|
# blocks) no matter what our allow says. Reporting `configured` there would be a green light on
|
|
378
520
|
# a surface that is actually still stopping every call.
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
521
|
+
# SCOPE is part of the truth. `mcp(*)` and the server-wide `mcp(entwurf-bridge)` shadow every
|
|
522
|
+
# tool we grant; an exact rule like `mcp(entwurf-bridge/entwurf_self)` shadows ONLY that one and
|
|
523
|
+
# the other tools keep working. Both are red — but telling an operator that every entwurf call
|
|
524
|
+
# is blocked when only `entwurf_self` is would send them hunting the wrong thing. Broad first,
|
|
525
|
+
# so a host carrying both is described by the rule that actually covers the most.
|
|
526
|
+
# TWO passes over ALL the shadowing lists, not one pass per list. Broad-most-covering is a
|
|
527
|
+
# statement about the whole file: a deny of one exact tool alongside an ask of the server-wide
|
|
528
|
+
# rule is a host where EVERYTHING is shadowed, and reporting the exact hit merely because `deny`
|
|
529
|
+
# is scanned first would tell the operator "your other grants still work" while agy prompts on
|
|
530
|
+
# all of them. Precedence still decides WHICH list to name once the scope is settled.
|
|
531
|
+
for rule_set, scope in ((BROAD_RULES, "broad"), (ALLOW_RULES, "exact")):
|
|
532
|
+
for list_name in SHADOWING_LISTS:
|
|
533
|
+
rules = perms.get(list_name)
|
|
534
|
+
if not isinstance(rules, list):
|
|
535
|
+
continue
|
|
536
|
+
for rule in rules:
|
|
537
|
+
if rule in rule_set:
|
|
538
|
+
sys.stdout.write(f"shadowed-by-{list_name} {scope} {rule}\n")
|
|
539
|
+
return
|
|
387
540
|
|
|
388
541
|
allow = perms.get("allow")
|
|
389
542
|
if isinstance(allow, list):
|
|
390
|
-
|
|
543
|
+
# EVERY tool we grant must be granted. A partial set is not a working surface: agy stops for
|
|
544
|
+
# a y/n on whichever tool is missing, which is the same "registered and unusable" state the
|
|
545
|
+
# doctor already refuses to call green — it was simply never checked past entwurf_v2.
|
|
546
|
+
if all(rule in allow for rule in ALLOW_RULES):
|
|
391
547
|
sys.stdout.write("configured\n")
|
|
392
548
|
return
|
|
393
|
-
#
|
|
394
|
-
# agy will not prompt; the
|
|
395
|
-
# the day the operator narrows that wildcard,
|
|
549
|
+
# Not every rule of ours is here, but a BROADER operator rule in the same list already
|
|
550
|
+
# matches our tools. agy will not prompt; the calls work. Say so — and say whose rule is
|
|
551
|
+
# carrying it, because the day the operator narrows that wildcard, the unowned grant goes.
|
|
396
552
|
for rule in MATCHING_RULES:
|
|
397
|
-
if rule
|
|
553
|
+
if rule not in ALLOW_RULES and rule in allow:
|
|
398
554
|
sys.stdout.write(f"covered-by-allow {rule}\n")
|
|
399
555
|
return
|
|
556
|
+
missing = [rule for rule in ALLOW_RULES if rule not in allow]
|
|
557
|
+
if len(missing) != len(ALLOW_RULES):
|
|
558
|
+
# Some of ours are present and some are not: name the gap rather than report a flat
|
|
559
|
+
# "nothing granted", which would read as "install never ran" on a half-granted host.
|
|
560
|
+
sys.stdout.write(f"partially-configured {' '.join(missing)}\n")
|
|
561
|
+
return
|
|
400
562
|
sys.stdout.write("not-configured\n")
|
|
401
563
|
|
|
402
564
|
|
|
565
|
+
def cmd_permission_state_doctor(state_path: str) -> None:
|
|
566
|
+
"""Is the ownership record itself readable? One token line: `absent` / `ok <n>` / `corrupt <why>`.
|
|
567
|
+
|
|
568
|
+
Hard rule 13 — runtime truth and OWNERSHIP truth are separate axes. The settings file can carry
|
|
569
|
+
all three exact rules (runtime perfectly fine, agy prompts on nothing) while the state that says
|
|
570
|
+
WHICH of them are ours is unreadable. The permission doctor reads settings and would call that
|
|
571
|
+
host `configured`; the independent state check read only `managedSettingsPath` and agreed. So a
|
|
572
|
+
corrupt ownership record rounded up to a green overall verdict, and the operator would learn
|
|
573
|
+
about it at uninstall time — the worst possible moment to discover we cannot tell their rules
|
|
574
|
+
from ours. Same parser as install/uninstall, so there is exactly one definition of readable.
|
|
575
|
+
"""
|
|
576
|
+
if not os.path.exists(state_path):
|
|
577
|
+
sys.stdout.write("absent\n")
|
|
578
|
+
return
|
|
579
|
+
try:
|
|
580
|
+
state = _load_config(state_path)
|
|
581
|
+
except SystemExit:
|
|
582
|
+
sys.stdout.write("corrupt not valid JSON\n")
|
|
583
|
+
return
|
|
584
|
+
try:
|
|
585
|
+
rules, _existed = _parse_permission_state(state)
|
|
586
|
+
except _PermissionStateError as exc:
|
|
587
|
+
sys.stdout.write(f"corrupt {exc}\n")
|
|
588
|
+
return
|
|
589
|
+
sys.stdout.write(f"ok {len(rules)}\n")
|
|
590
|
+
|
|
591
|
+
|
|
403
592
|
def _now() -> str:
|
|
404
593
|
import datetime
|
|
405
594
|
|
|
@@ -426,6 +615,17 @@ def main(argv: list) -> None:
|
|
|
426
615
|
if len(argv) != 3:
|
|
427
616
|
_die(5, "usage: agy-bridge-config.py doctor-static <config_path>")
|
|
428
617
|
cmd_doctor_static(argv[2])
|
|
618
|
+
elif sub == "permission-state-doctor":
|
|
619
|
+
if len(argv) != 3:
|
|
620
|
+
_die(5, "usage: agy-bridge-config.py permission-state-doctor <state_path>")
|
|
621
|
+
cmd_permission_state_doctor(argv[2])
|
|
622
|
+
elif sub == "permission-rules":
|
|
623
|
+
if len(argv) != 2:
|
|
624
|
+
_die(5, "usage: agy-bridge-config.py permission-rules")
|
|
625
|
+
# The shell renders these in operator messages. It reads them from HERE rather than
|
|
626
|
+
# repeating the strings, so the rule set has exactly one source and a tool added to
|
|
627
|
+
# ALLOW_RULES can never drift out of what the doctor tells the operator to fix.
|
|
628
|
+
sys.stdout.write(" ".join(ALLOW_RULES) + "\n")
|
|
429
629
|
elif sub == "permission-install":
|
|
430
630
|
if len(argv) != 4:
|
|
431
631
|
_die(5, "usage: agy-bridge-config.py permission-install <settings_path> <state_path>")
|
package/scripts/agy-bridge.sh
CHANGED
|
@@ -39,12 +39,15 @@ COMMAND="${AGY_BRIDGE_COMMAND:-entwurf-bridge}"
|
|
|
39
39
|
STATE_DIR="${XDG_DATA_HOME:-$HOME/.local/share}/entwurf/agy-bridge"
|
|
40
40
|
STATE_FILE="$STATE_DIR/install-state.json"
|
|
41
41
|
# agy's settings.json — the SAME file the statusline adapter owns, but a DIFFERENT element: it owns
|
|
42
|
-
# the `statusLine` subtree, we own
|
|
42
|
+
# the `statusLine` subtree, we own a few strings in `permissions.allow`. Element-level ownership on
|
|
43
43
|
# both sides is what lets two adapters share a file without either clobbering the other (or the
|
|
44
44
|
# operator's own rules). Tracked in its own state file so each half has an honest inverse.
|
|
45
45
|
SETTINGS_FILE="${AGY_SETTINGS_CONFIG:-$HOME/.gemini/antigravity-cli/settings.json}"
|
|
46
46
|
PERMISSION_STATE_FILE="$STATE_DIR/permission-state.json"
|
|
47
|
-
|
|
47
|
+
# One narrow allow rule per tool the NORMAL agy workflow calls (all five MCP tools are visible to
|
|
48
|
+
# the model; these are the ones auto-granted). Read from the config engine, never retyped here:
|
|
49
|
+
# a second copy of this list is a drift bug waiting for the next tool we ship.
|
|
50
|
+
ALLOW_RULES="$(python3 "$CONFIG_PY" permission-rules)"
|
|
48
51
|
|
|
49
52
|
log() { printf '%s\n' "$*"; }
|
|
50
53
|
fail() { printf 'FAIL: %s\n' "$*" >&2; exit 1; }
|
|
@@ -131,8 +134,8 @@ install_permission() {
|
|
|
131
134
|
set -e
|
|
132
135
|
case "$rc" in
|
|
133
136
|
0) log " permission: ${out} in $SETTINGS_FILE" ;;
|
|
134
|
-
3) fail "permission refused (symlink) — $SETTINGS_FILE is someone else's SSOT; left intact. The MCP server is registered but NOT granted, so agy would prompt on every
|
|
135
|
-
4) fail "permission invalid JSON — $SETTINGS_FILE could not be parsed; left intact. The MCP server is registered but NOT granted, so agy would prompt on every
|
|
137
|
+
3) fail "permission refused (symlink) — $SETTINGS_FILE is someone else's SSOT; left intact. The MCP server is registered but NOT granted, so agy would prompt on every entwurf tool call. Add these to permissions.allow there: $ALLOW_RULES — or replace the symlink with a regular file and re-run." ;;
|
|
138
|
+
4) fail "permission invalid JSON — $SETTINGS_FILE could not be parsed; left intact. The MCP server is registered but NOT granted, so agy would prompt on every entwurf tool call. Repair that file, then re-run." ;;
|
|
136
139
|
*) fail "permission could not be granted (rc=$rc) — ${out}" ;;
|
|
137
140
|
esac
|
|
138
141
|
}
|
|
@@ -162,7 +165,7 @@ do_uninstall() {
|
|
|
162
165
|
case "$rc" in
|
|
163
166
|
0) log " permission: ${out}" ;;
|
|
164
167
|
2) : ;; # never granted → nothing to undo (idempotent)
|
|
165
|
-
3) fail "permission refused (symlink) — $SETTINGS_FILE became a symlink since install; our
|
|
168
|
+
3) fail "permission refused (symlink) — $SETTINGS_FILE became a symlink since install; our rules are STILL THERE. Remove ours from permissions.allow by hand at the link target: $ALLOW_RULES" ;;
|
|
166
169
|
*) fail "permission could not be revoked (rc=$rc) — ${out}" ;;
|
|
167
170
|
esac
|
|
168
171
|
}
|
|
@@ -209,7 +212,14 @@ doctor_permission() {
|
|
|
209
212
|
status="$(python3 "$CONFIG_PY" permission-doctor "$SETTINGS_FILE")"
|
|
210
213
|
case "$status" in
|
|
211
214
|
configured)
|
|
212
|
-
log " permission ($SETTINGS_FILE): allow →
|
|
215
|
+
log " permission ($SETTINGS_FILE): allow → $ALLOW_RULES (agy calls every entwurf tool without prompting)"; return 0 ;;
|
|
216
|
+
partially-configured\ *)
|
|
217
|
+
# SOME of our tools are granted and some are not. agy stops for a y/n on exactly the missing
|
|
218
|
+
# ones, so the bridge half-works — the most confusing shape there is ("it worked yesterday
|
|
219
|
+
# when I sent a message, why is it asking now?"). Name the missing rules, never round up to
|
|
220
|
+
# configured because the tool the operator happened to try first was granted.
|
|
221
|
+
log " permission ($SETTINGS_FILE): DRIFT — granted, but NOT for: ${status#partially-configured } . agy defaults mcp to Ask, so every call to those tools prompts. Fix: ./run.sh install-agy-bridge"
|
|
222
|
+
return 1 ;;
|
|
213
223
|
covered-by-allow\ *)
|
|
214
224
|
# The operator's own broader rule already grants our tool, so entwurf_v2 does NOT prompt —
|
|
215
225
|
# calling this DRIFT would be a false red about a working surface. But it is THEIR rule, not
|
|
@@ -228,27 +238,45 @@ except Exception:
|
|
|
228
238
|
sys.exit(1)
|
|
229
239
|
p = s.get("managedSettingsPath")
|
|
230
240
|
same = isinstance(p, str) and os.path.isabs(p) and os.path.abspath(p) == os.path.abspath(sys.argv[2])
|
|
231
|
-
|
|
232
|
-
|
|
241
|
+
# Owned = WE added at least one rule. schemaVersion 2 answers per rule (`rulesExistedBefore`);
|
|
242
|
+
# schemaVersion 1 had a single `ruleExistedBefore`. Read both — an old state file must not silently
|
|
243
|
+
# report "we own nothing" and turn owned drift into a green NOTE.
|
|
244
|
+
existed = s.get("rulesExistedBefore")
|
|
245
|
+
if isinstance(existed, dict):
|
|
246
|
+
owned = any(v is False for v in existed.values())
|
|
247
|
+
else:
|
|
248
|
+
owned = s.get("ruleExistedBefore") is False
|
|
249
|
+
sys.exit(0 if same and owned else 1)' "$PERMISSION_STATE_FILE" "$SETTINGS_FILE"; then
|
|
250
|
+
log " permission ($SETTINGS_FILE): DRIFT — entwurf installed its allow rules here ($ALLOW_RULES) and they are now GONE. Your '$covering' still covers the tools, so agy does not prompt today — but the grant entwurf owns (and repairs) no longer exists. Fix: ./run.sh install-agy-bridge"
|
|
233
251
|
return 1
|
|
234
252
|
fi
|
|
235
|
-
log " permission ($SETTINGS_FILE): NOTE — we own no rule here, but your '$covering' in permissions.allow already matches
|
|
253
|
+
log " permission ($SETTINGS_FILE): NOTE — we own no rule here, but your '$covering' in permissions.allow already matches our tools, so agy calls them without prompting. That grant is YOURS: narrow '$covering' and every entwurf tool starts prompting again. Run ./run.sh install-agy-bridge if you want the narrow rules ($ALLOW_RULES) owned (and repaired) by entwurf."
|
|
236
254
|
return 0 ;;
|
|
237
255
|
not-configured|absent)
|
|
238
|
-
local what="
|
|
256
|
+
local what="none of $ALLOW_RULES granted"
|
|
239
257
|
[ "$status" = absent ] && what="settings file absent, so no grant"
|
|
240
258
|
if [ "$installed" -eq 1 ]; then
|
|
241
|
-
log " permission ($SETTINGS_FILE): DRIFT — $what, but the bridge IS installed. agy defaults mcp to Ask, so EVERY
|
|
259
|
+
log " permission ($SETTINGS_FILE): DRIFT — $what, but the bridge IS installed. agy defaults mcp to Ask, so EVERY entwurf tool call prompts: the server is registered and unusable without a y/n. Fix: ./run.sh install-agy-bridge"
|
|
242
260
|
return 1
|
|
243
261
|
fi
|
|
244
262
|
log " permission ($SETTINGS_FILE): $what (bridge not installed here — nothing to grant yet)"; return 0 ;;
|
|
245
263
|
invalid-json)
|
|
246
264
|
log " permission ($SETTINGS_FILE): INVALID JSON — cannot read the permission engine's config"; return 1 ;;
|
|
247
265
|
shadowed-by-*)
|
|
248
|
-
local list rule
|
|
266
|
+
local list rest scope rule
|
|
249
267
|
list="${status%% *}"; list="${list#shadowed-by-}"
|
|
250
|
-
|
|
251
|
-
|
|
268
|
+
rest="${status#* }" # "<scope> <rule>"
|
|
269
|
+
scope="${rest%% *}"
|
|
270
|
+
rule="${rest#* }"
|
|
271
|
+
# Say what is actually blocked. A broad rule takes the whole server; an exact rule takes ONE
|
|
272
|
+
# tool and the rest keep working — reporting that as a total outage sends the operator hunting
|
|
273
|
+
# the wrong config. Red either way: a tool we grant that agy still stops on is not green.
|
|
274
|
+
if [ "$scope" = broad ]; then
|
|
275
|
+
log " permission ($SETTINGS_FILE): SHADOWED — your '$list' list carries '$rule', and agy evaluates Deny > Ask > Allow, so it OVERRIDES our allow of $ALLOW_RULES. agy will keep prompting (or blocking) on EVERY entwurf tool call until that rule is narrowed."
|
|
276
|
+
else
|
|
277
|
+
log " permission ($SETTINGS_FILE): SHADOWED — your '$list' list carries '$rule', and agy evaluates Deny > Ask > Allow, so it OVERRIDES our allow of that ONE tool. Our other grants still work; agy will keep prompting (or blocking) on that tool alone until the rule is removed."
|
|
278
|
+
fi
|
|
279
|
+
return 1 ;;
|
|
252
280
|
*) log " permission ($SETTINGS_FILE): unexpected status '$status'"; return 1 ;;
|
|
253
281
|
esac
|
|
254
282
|
}
|
|
@@ -323,6 +351,23 @@ do_doctor() {
|
|
|
323
351
|
elif [ "$pmanaged" != "$expected_settings" ]; then
|
|
324
352
|
log " state: FOREIGN TARGET (permission) — permission-state manages '$pmanaged', but agy reads '$expected_settings'. The grant recorded there is not the grant on this host."
|
|
325
353
|
hard_fail=1
|
|
354
|
+
else
|
|
355
|
+
# Pointing at the right file is not the same as being READABLE. The schema and the per-rule
|
|
356
|
+
# provenance are what tell OUR grants from the operator's; without them the inverse cannot run
|
|
357
|
+
# and we would only find out at uninstall time. Runtime can be perfect (all rules present, agy
|
|
358
|
+
# prompting on nothing) while this axis is broken — hard rule 13 keeps them separate, so this
|
|
359
|
+
# is red on its own evidence and never rounded up by a green permission line above.
|
|
360
|
+
local pstate
|
|
361
|
+
pstate="$(python3 "$CONFIG_PY" permission-state-doctor "$PERMISSION_STATE_FILE")"
|
|
362
|
+
case "$pstate" in
|
|
363
|
+
ok\ *) : ;;
|
|
364
|
+
corrupt\ *)
|
|
365
|
+
log " state: CORRUPT (permission) — the ownership record cannot be read: ${pstate#corrupt }. Runtime may be fine, but entwurf can no longer tell ITS grants from yours, so uninstall will refuse. Fix: ./run.sh install-agy-bridge (or delete $PERMISSION_STATE_FILE by hand once you have checked permissions.allow yourself)."
|
|
366
|
+
hard_fail=1 ;;
|
|
367
|
+
*)
|
|
368
|
+
log " state: unexpected permission-state verdict '$pstate'"
|
|
369
|
+
hard_fail=1 ;;
|
|
370
|
+
esac
|
|
326
371
|
fi
|
|
327
372
|
fi
|
|
328
373
|
|
|
@@ -18,6 +18,7 @@ import { strict as assert } from "node:assert";
|
|
|
18
18
|
import { mkdtempSync, writeFileSync } from "node:fs";
|
|
19
19
|
import { tmpdir } from "node:os";
|
|
20
20
|
import { join } from "node:path";
|
|
21
|
+
import { fileURLToPath } from "node:url";
|
|
21
22
|
import type { Context } from "@earendil-works/pi-ai";
|
|
22
23
|
import {
|
|
23
24
|
buildPiContextAugment,
|
|
@@ -32,6 +33,7 @@ import { buildClaudeSessionMeta } from "../pi-extensions/lib/acp/tool-surface.ts
|
|
|
32
33
|
import { ENTWURF_PROJECT_CONTEXT_OPEN_TAG } from "../protocol.js";
|
|
33
34
|
|
|
34
35
|
const tmp = mkdtempSync(join(tmpdir(), "acp-carrier-augment-"));
|
|
36
|
+
const REPO_DIR = fileURLToPath(new URL("..", import.meta.url));
|
|
35
37
|
const BRIDGE_MARK = "operating through entwurf";
|
|
36
38
|
|
|
37
39
|
// ===========================================================================
|
|
@@ -285,7 +287,37 @@ function ctxWith(firstUser: string): Context {
|
|
|
285
287
|
writeFileSync(join(bigHome, "AGENTS.md"), "X".repeat(80 * 1024));
|
|
286
288
|
const big = buildPiContextAugment({ backend: "claude", cwd: tmp, mcpServerNames: [], homeDir: bigHome });
|
|
287
289
|
assert.ok(Buffer.byteLength(big, "utf8") <= 50 * 1024, "augment is truncated to the 50KB cap");
|
|
288
|
-
assert.match(
|
|
290
|
+
assert.match(
|
|
291
|
+
big,
|
|
292
|
+
/context augment truncated to \d+ bytes/,
|
|
293
|
+
"truncation leaves an honest marker [QK:AUGMENT-TRUNC-MARKER]",
|
|
294
|
+
);
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
// ===========================================================================
|
|
298
|
+
// 9) The shipped repo AGENTS.md fits with a realistic operator-global budget.
|
|
299
|
+
// This guards the real 2026-07-27 failure: project AGENTS.md alone exceeded
|
|
300
|
+
// the cap, tail-cutting its own later rules plus Current date/cwd. The cap
|
|
301
|
+
// remains an honest fallback; the maintained package prompt must not hit it.
|
|
302
|
+
// ===========================================================================
|
|
303
|
+
{
|
|
304
|
+
const budgetHome = mkdtempSync(join(tmpdir(), "acp-budget-home-"));
|
|
305
|
+
writeFileSync(join(budgetHome, "AGENTS.md"), "H".repeat(12 * 1024));
|
|
306
|
+
const actual = buildPiContextAugment({
|
|
307
|
+
backend: "claude",
|
|
308
|
+
cwd: REPO_DIR,
|
|
309
|
+
mcpServerNames: ["entwurf-bridge"],
|
|
310
|
+
homeDir: budgetHome,
|
|
311
|
+
});
|
|
312
|
+
assert.doesNotMatch(
|
|
313
|
+
actual,
|
|
314
|
+
/context augment truncated/,
|
|
315
|
+
"repo AGENTS.md + 12KB global baseline fits without truncation [QK:AUGMENT-BUDGET-FITS]",
|
|
316
|
+
);
|
|
317
|
+
assert.ok(
|
|
318
|
+
actual.includes(`Current working directory: ${REPO_DIR}`),
|
|
319
|
+
"non-truncated augment retains the trailing cwd fact",
|
|
320
|
+
);
|
|
289
321
|
}
|
|
290
322
|
|
|
291
323
|
console.log(
|
|
@@ -293,5 +325,5 @@ console.log(
|
|
|
293
325
|
"empty/whitespace/missing → null, shipped-default → non-empty v1 lever (preset replaced), carrier absent → no _meta.systemPrompt key, carrier change → " +
|
|
294
326
|
"signature change (stable carrier → stable signature); augment: prepended on `new` only (reuse delta has none), " +
|
|
295
327
|
"wire-only so it never enters contextMessageSignatures, entwurf cwd/AGENTS.md de-dup (present → drop only that " +
|
|
296
|
-
"section, home kept; absent → kept), day-granularity date, 50KB truncation marker",
|
|
328
|
+
"section, home kept; absent → kept), day-granularity date, 50KB truncation marker, shipped AGENTS budget",
|
|
297
329
|
);
|