@junghanacs/entwurf 0.12.0 → 0.12.2
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/CHANGELOG.md +40 -0
- package/README.md +88 -28
- package/docs/setup-clean-host.md +117 -219
- package/mcp/entwurf-bridge/dist/mcp/entwurf-bridge/src/index.js +454 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-control-rpc.js +111 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-core.js +1683 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-deliverability.js +76 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-fact-provider.js +121 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-facts.js +155 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-peers-render.js +119 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-preflight.js +160 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-resume-args.js +63 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-self-address.js +81 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-contract.js +290 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-decider.js +254 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-lock.js +365 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-mailbox.js +64 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-production.js +218 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-release.js +108 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-resume-marker.js +33 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-runner.js +116 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-send-fallback.js +125 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-send.js +184 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-spawn-production.js +237 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-spawn.js +216 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-surface.js +164 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/meta-mailbox-body.js +66 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/meta-session.js +1502 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/session-id.js +50 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/socket-discovery.js +259 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/socket-probe.js +81 -0
- package/mcp/entwurf-bridge/dist/protocol.js +29 -0
- package/mcp/entwurf-bridge/start.sh +49 -7
- package/mcp/entwurf-bridge/test.sh +12 -3
- package/mcp/entwurf-bridge/tsconfig.build.json +42 -0
- package/package.json +30 -9
- package/pi/meta-bridge/.claude-plugin/marketplace.json +0 -1
- package/pi-extensions/lib/entwurf-v2-contract-schema.ts +101 -0
- package/pi-extensions/lib/entwurf-v2-contract.ts +10 -78
- package/pi-extensions/lib/entwurf-v2-decider.ts +6 -2
- package/pi-extensions/lib/entwurf-v2-production.ts +26 -4
- package/run.sh +150 -15
- package/scripts/check-entwurf-bridge-pi-free.ts +146 -0
- package/scripts/check-entwurf-v2-contract.ts +6 -4
- package/scripts/check-meta-manifest-schema.py +145 -0
- package/scripts/meta-bridge-install.sh +17 -3
- package/scripts/meta-bridge-state.py +37 -10
- package/scripts/smoke-acp-bundled-mcp-live.ts +13 -2
- package/scripts/smoke-acp-carrier-augment-live.ts +35 -19
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* entwurf-deliverability — the PURE conversational-mailbox deliverability predicate
|
|
3
|
+
* (SE-1/SE-2 slice 2c). "If I enqueue a conversational reply to this target's mailbox
|
|
4
|
+
* right now, will a model actually see it — or will it rot as garbage?"
|
|
5
|
+
*
|
|
6
|
+
* Two layers, both pure (facts injected, no IO):
|
|
7
|
+
*
|
|
8
|
+
* - computeMetaReceiverActive(facts): the shared "is this receiver active?" atom —
|
|
9
|
+
* recordBacked AND ownerAlive AND watchArmed. This is the SAME conjunction the
|
|
10
|
+
* self-addressability predicate uses for its meta branch; both import it so the
|
|
11
|
+
* "active receiver" definition has ONE source of truth (concept shared, API split).
|
|
12
|
+
*
|
|
13
|
+
* - mailboxConversationalDeliverable(facts): the enqueue gate. A mailbox enqueue +
|
|
14
|
+
* doorbell only delivers for a SELF-FETCH backend (Claude Code / Codex / agy): the
|
|
15
|
+
* receiver drains its own inbox on wake. A DIRECT-INJECT backend (pi) has no
|
|
16
|
+
* mailbox drain at all — enqueuing for it is the SE-1 false success ("✓ delivered"
|
|
17
|
+
* into a void). So deliverable = wakeMode === "self-fetch" AND the receiver is
|
|
18
|
+
* active. This is the guard that the v1 fallback, MCP v1, pi-native v1, and the v2
|
|
19
|
+
* decider/send-fallback enqueue sites must all pass before writing a .msg (slice 2d).
|
|
20
|
+
*
|
|
21
|
+
* The contract is "mailboxConversationalDeliverable", NOT a broad "deliverable": it is
|
|
22
|
+
* specifically about a conversational reply that needs a live doorbell wake, NOT about
|
|
23
|
+
* an archival mailbox someone reads later. Naming it narrowly keeps a future archival
|
|
24
|
+
* path from silently inheriting this gate.
|
|
25
|
+
*/
|
|
26
|
+
/**
|
|
27
|
+
* The shared active-receiver atom. Every axis is required (fail-closed: an undefined
|
|
28
|
+
* fact is treated as false, never optimistic), and each failure names its own cause so
|
|
29
|
+
* a terminated-owner is never conflated with a missing record or an unarmed watch.
|
|
30
|
+
*/
|
|
31
|
+
export function computeMetaReceiverActive(facts) {
|
|
32
|
+
if (facts.recordBacked !== true) {
|
|
33
|
+
return { active: false, reason: "no backing meta-record" };
|
|
34
|
+
}
|
|
35
|
+
if (facts.ownerAlive !== true) {
|
|
36
|
+
return { active: false, reason: "owner not alive (start-key mismatch — session exited or pid reused)" };
|
|
37
|
+
}
|
|
38
|
+
if (facts.watchArmed !== true) {
|
|
39
|
+
return { active: false, reason: "idle-watch not armed — a reply would enqueue with no doorbell wake" };
|
|
40
|
+
}
|
|
41
|
+
return { active: true, reason: "record backed, owner alive, watch armed" };
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Does this presence marker actually belong to the target identity? A marker that is
|
|
45
|
+
* absent, or whose garden id / backend / native session id has drifted from the record,
|
|
46
|
+
* is NOT this receiver — fail-closed (a stale/foreign marker must never raise a dead
|
|
47
|
+
* target to "active"). The single source of truth for "marker ↔ identity match" shared
|
|
48
|
+
* by the v1 mailbox guard (gatherMailboxDeliverabilityFacts) and the v2 production
|
|
49
|
+
* `mailboxDeliverabilityFor` seam, so the two paths cannot drift to different meanings.
|
|
50
|
+
*/
|
|
51
|
+
export function receiverMarkerMatchesIdentity(marker, identity) {
|
|
52
|
+
return (!!marker &&
|
|
53
|
+
marker.gardenId === identity.gardenId &&
|
|
54
|
+
marker.backend === identity.backend &&
|
|
55
|
+
marker.nativeSessionId === identity.nativeSessionId);
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* The conversational-mailbox enqueue gate. False (no enqueue) unless the backend is
|
|
59
|
+
* self-fetch AND the receiver is active. A direct-inject backend (pi) is refused
|
|
60
|
+
* outright — it has no mailbox drain, so an enqueue would be a silent false success.
|
|
61
|
+
*/
|
|
62
|
+
export function mailboxConversationalDeliverable(facts) {
|
|
63
|
+
if (facts.wakeMode !== "self-fetch") {
|
|
64
|
+
return {
|
|
65
|
+
deliverable: false,
|
|
66
|
+
reason: `backend wake mode ${facts.wakeMode ?? "(unset)"} is not self-fetch — a mailbox enqueue would never be drained`,
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
const recv = computeMetaReceiverActive(facts);
|
|
70
|
+
return {
|
|
71
|
+
deliverable: recv.active,
|
|
72
|
+
reason: recv.active
|
|
73
|
+
? `self-fetch receiver active (${recv.reason})`
|
|
74
|
+
: `self-fetch receiver inactive — ${recv.reason}`,
|
|
75
|
+
};
|
|
76
|
+
}
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* entwurf-fact-provider — the fact-provider's ASSEMBLY layer (0.11 Stage 0 step
|
|
3
|
+
* 4, slice 4b). Composes the two axes into the listing the MCP `entwurf_peers`
|
|
4
|
+
* surface (slice 4c) renders. Lives in its own module so nothing imports it back
|
|
5
|
+
* (one-way: provider → facts / socket-discovery / meta-session) — no import cycle
|
|
6
|
+
* with `entwurf-facts.ts` (which owns `SocketProbe`/`resolveFactList`).
|
|
7
|
+
*
|
|
8
|
+
* listAllMetaIdentities → pi gid 추출 → scanSocketProbes(piGids)
|
|
9
|
+
* → pre-quarantine non-pi/socket conflicts → resolveFactList(clean)
|
|
10
|
+
* → { facts, diagnostics }
|
|
11
|
+
*
|
|
12
|
+
* Two throw-vs-diagnostics policies, kept distinct (GPT힣 C-원칙):
|
|
13
|
+
* - EXPECTED data corruption → diagnostics, listing survives. A meta-record
|
|
14
|
+
* parse failure (from listAllMetaIdentities) and a gardenId↔socket address
|
|
15
|
+
* collision are external-state problems; one must not blind `entwurf_peers`.
|
|
16
|
+
* - IMPOSSIBLE wiring invariant → throw, NOT swallowed. resolveFactList's
|
|
17
|
+
* duplicate-identity / unprobed-in-domain throws are assembly BUGS; catching
|
|
18
|
+
* them here would hide a code defect. We feed resolveFactList only CLEAN
|
|
19
|
+
* inputs (conflicts pre-removed), so its throw stays the last line of defense
|
|
20
|
+
* — that is not a re-implementation of the collision rule, it is input
|
|
21
|
+
* sanitation that leaves the pure-core invariant intact.
|
|
22
|
+
*
|
|
23
|
+
* The non-pi+socket collision quarantines BOTH sides (the PeerFact AND the
|
|
24
|
+
* socket): gardenId is the universal address and a send path reads the socket
|
|
25
|
+
* first, so surfacing the record alone (as a clean `unsupported` PeerFact) while
|
|
26
|
+
* a same-gid socket exists would be half a lie. Both leave the normal output;
|
|
27
|
+
* one diagnostic carries the fact. (pi + same-gid socket = the normal merge.)
|
|
28
|
+
*/
|
|
29
|
+
import { isNonPiGardenIdSocketConflict, resolveFactList } from "./entwurf-facts.js";
|
|
30
|
+
import { isLivenessSupported } from "./entwurf-v2-contract.js";
|
|
31
|
+
import { listAllMetaIdentities } from "./meta-session.js";
|
|
32
|
+
import { scanSocketProbes } from "./socket-discovery.js";
|
|
33
|
+
function diagnosticSortKey(d) {
|
|
34
|
+
switch (d.kind) {
|
|
35
|
+
case "meta-record-read-error":
|
|
36
|
+
return `0:${d.filename}`;
|
|
37
|
+
case "garden-id-socket-conflict":
|
|
38
|
+
return `1:${d.gardenId}`;
|
|
39
|
+
case "socket-symlink-rejected":
|
|
40
|
+
return `2:${d.gardenId}`;
|
|
41
|
+
case "malformed-socket-name":
|
|
42
|
+
return `3:${d.name}`;
|
|
43
|
+
case "socket-dir-read-error":
|
|
44
|
+
return "4:";
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* Assemble the facts-only listing. Pure over its injected deps (no direct IO) so
|
|
49
|
+
* the gate drives it without a filesystem; slice 4c supplies the real readdir /
|
|
50
|
+
* readFile / probe. Live socket probes may carry get_info runtime enrich
|
|
51
|
+
* (cwd/model/idle); null remains honest and renders as "not enriched".
|
|
52
|
+
*/
|
|
53
|
+
export async function listEntwurfFacts(deps) {
|
|
54
|
+
const diagnostics = [];
|
|
55
|
+
// 1. meta-store axis — expected corruption becomes diagnostics, not a throw.
|
|
56
|
+
const { identities, errors } = listAllMetaIdentities(deps.metaEntries, deps.readRecord);
|
|
57
|
+
for (const e of errors) {
|
|
58
|
+
diagnostics.push({ kind: "meta-record-read-error", filename: e.filename, message: e.message });
|
|
59
|
+
}
|
|
60
|
+
// 2. socket axis — probe (dir sockets) ∪ (in-domain citizen canonical paths).
|
|
61
|
+
// Its three hazards (symlink forgery / malformed name / dir-read error) are
|
|
62
|
+
// folded into diagnostics here so the listing survives but never lies.
|
|
63
|
+
const piGids = identities.filter((i) => isLivenessSupported(i.backend)).map((i) => i.gardenId);
|
|
64
|
+
const scan = await scanSocketProbes(piGids, deps.socket ?? {});
|
|
65
|
+
const probes = scan.probes;
|
|
66
|
+
const socketGids = new Set(probes.map((p) => p.gardenId));
|
|
67
|
+
const symlinkedGids = new Set(scan.symlinkedGardenIds);
|
|
68
|
+
for (const gardenId of scan.symlinkedGardenIds) {
|
|
69
|
+
diagnostics.push({
|
|
70
|
+
kind: "socket-symlink-rejected",
|
|
71
|
+
gardenId,
|
|
72
|
+
message: "control socket is a symlink — never probed (it could redirect to another session's listener and forge " +
|
|
73
|
+
"an alive liveness for this gardenId); a citizen owning it is treated as dead (dormant), a record-less one dropped.",
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
for (const name of scan.malformedNames) {
|
|
77
|
+
diagnostics.push({
|
|
78
|
+
kind: "malformed-socket-name",
|
|
79
|
+
name,
|
|
80
|
+
message: "control-socket filename is not a garden id — no citizen to correlate to; dropped from the listing.",
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
if (scan.dirError !== null) {
|
|
84
|
+
diagnostics.push({
|
|
85
|
+
kind: "socket-dir-read-error",
|
|
86
|
+
message: `control-socket directory unreadable (socket axis incomplete; meta-record citizens still listed): ${scan.dirError}`,
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
// 3. pre-quarantine non-pi citizens that collide with a control socket. The
|
|
90
|
+
// predicate is SHARED with the v2 decider (isNonPiGardenIdSocketConflict) so
|
|
91
|
+
// listing and dispatch cannot drift, and it unions socketGids with the
|
|
92
|
+
// symlinkedGids: a symlinked socket is never probed (absent from socketGids),
|
|
93
|
+
// so the old socketGids-only check let a non-pi citizen with a forged
|
|
94
|
+
// (symlinked) socket survive as a clean PeerFact while the legacy send path
|
|
95
|
+
// still followed the symlink — the gap this closes.
|
|
96
|
+
const conflictGids = new Set();
|
|
97
|
+
for (const id of identities) {
|
|
98
|
+
if (isNonPiGardenIdSocketConflict(id.backend, id.gardenId, socketGids, symlinkedGids)) {
|
|
99
|
+
conflictGids.add(id.gardenId);
|
|
100
|
+
diagnostics.push({
|
|
101
|
+
kind: "garden-id-socket-conflict",
|
|
102
|
+
gardenId: id.gardenId,
|
|
103
|
+
backend: id.backend,
|
|
104
|
+
message: `non-pi citizen (${id.backend}) shares its gardenId with a control socket (real or symlinked) — address ` +
|
|
105
|
+
"ambiguity; both the citizen and the socket are quarantined from the listing.",
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
// 4. resolveFactList over CLEAN inputs only. Its throws (duplicate identity /
|
|
110
|
+
// unprobed in-domain citizen) are impossible wiring invariants — left to
|
|
111
|
+
// fire as the last line of defense, never caught here.
|
|
112
|
+
const cleanIdentities = identities.filter((i) => !conflictGids.has(i.gardenId));
|
|
113
|
+
const cleanProbes = probes.filter((p) => !conflictGids.has(p.gardenId));
|
|
114
|
+
const facts = resolveFactList(cleanIdentities, cleanProbes);
|
|
115
|
+
diagnostics.sort((a, b) => {
|
|
116
|
+
const ka = diagnosticSortKey(a);
|
|
117
|
+
const kb = diagnosticSortKey(b);
|
|
118
|
+
return ka < kb ? -1 : ka > kb ? 1 : 0;
|
|
119
|
+
});
|
|
120
|
+
return { facts, diagnostics };
|
|
121
|
+
}
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* entwurf-facts — the TS fact-provider's PURE core (0.11 Stage 0 step 4).
|
|
3
|
+
*
|
|
4
|
+
* The "brain" reads disk SSOT (meta-record) + a socket probe and emits FACTS,
|
|
5
|
+
* never verbs. This module holds the single pure composition step:
|
|
6
|
+
*
|
|
7
|
+
* (MetaIdentity, SocketLiveness | null) → PeerFact
|
|
8
|
+
*
|
|
9
|
+
* What it deliberately does NOT do (동결결정 10 / bucket B boundary):
|
|
10
|
+
* - NO verb-routing. A `PeerFact` carries no `resumable`/`sendable`/`transport`
|
|
11
|
+
* /`dispatch` field. Whether a target is resumed or sent-to is computed at
|
|
12
|
+
* call time by the entwurf_v2 dispatch table from `liveness` — baking that
|
|
13
|
+
* decision into the fact layer is exactly what makes `entwurf_peers` lie
|
|
14
|
+
* (the reason 동결결정 10 orders contract-lock before this provider).
|
|
15
|
+
* - NO IO. readdir of the meta-store and the live socket probe are slice-2
|
|
16
|
+
* wiring; this slice locks the fact SHAPE and the R1/R3b liveness invariant
|
|
17
|
+
* in code first (gate-first discipline), with both inputs injected.
|
|
18
|
+
* - NO transcriptPath. The transcript path is a private on-disk location, not
|
|
19
|
+
* a peer-facing fact; `entwurf_peers` exposes identity + cwd-history, not
|
|
20
|
+
* filesystem internals. (who-can / dispatch read it via the meta-record
|
|
21
|
+
* directly when they genuinely need it — it does not belong in the listing.)
|
|
22
|
+
*
|
|
23
|
+
* The 4-value liveness (`alive|dead|indeterminate|unsupported`, R3b) and the
|
|
24
|
+
* out-of-domain → `unsupported` rule (R1: never coerce an unprobed backend to
|
|
25
|
+
* `dead`/`indeterminate`) come from entwurf-v2-contract's `factLivenessOf` — the
|
|
26
|
+
* frozen contract is the single source for that mapping; this module only shapes
|
|
27
|
+
* the surrounding identity facts around it.
|
|
28
|
+
*/
|
|
29
|
+
import { factLivenessOf, isLivenessSupported } from "./entwurf-v2-contract.js";
|
|
30
|
+
/**
|
|
31
|
+
* Compose a `PeerFact` from a citizen's identity and an optional socket probe.
|
|
32
|
+
*
|
|
33
|
+
* `socket` is the 3-value control-socket result for an IN-DOMAIN backend (pi),
|
|
34
|
+
* or `null` when no probe was taken (out-of-domain backend, or in-domain with no
|
|
35
|
+
* socket found). `factLivenessOf` resolves the 4-value fact:
|
|
36
|
+
* - out-of-domain backend → `unsupported` (R1, regardless of `socket`)
|
|
37
|
+
* - in-domain, socket present → that socket value
|
|
38
|
+
* - in-domain, socket null → `indeterminate` (no proof, never `dead`)
|
|
39
|
+
*
|
|
40
|
+
* Pure: same inputs → same output, no IO.
|
|
41
|
+
*/
|
|
42
|
+
export function resolvePeerFact(identity, socket) {
|
|
43
|
+
return {
|
|
44
|
+
gardenId: identity.gardenId,
|
|
45
|
+
backend: identity.backend,
|
|
46
|
+
nativeSessionId: identity.nativeSessionId,
|
|
47
|
+
cwd: identity.cwd,
|
|
48
|
+
model: identity.model,
|
|
49
|
+
parentGardenId: identity.parentGardenId,
|
|
50
|
+
isEntwurf: identity.isEntwurf,
|
|
51
|
+
createdAt: identity.createdAt,
|
|
52
|
+
recordUpdatedAt: identity.recordUpdatedAt,
|
|
53
|
+
liveness: factLivenessOf(identity.backend, socket),
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* A non-pi RECORD whose gardenId collides with a control socket — a real (probed)
|
|
58
|
+
* one OR a symlinked/forged one. The gardenId is the universal address (동결결정3),
|
|
59
|
+
* so a non-pi citizen sharing it with a socket means a send-path that reaches the
|
|
60
|
+
* socket first hits a DIFFERENT receiver than the record names — an address split.
|
|
61
|
+
* Both the citizen and the socket are quarantined from the facts listing.
|
|
62
|
+
*
|
|
63
|
+
* The union `socketGids ∪ symlinkedGardenIds` is load-bearing: `socketGids` are
|
|
64
|
+
* gids with a real probed `*.sock`, but `symlinkedGardenIds` are NEVER probed (P1)
|
|
65
|
+
* and so are absent from `socketGids`. Looking at `socketGids` alone (the
|
|
66
|
+
* fact-provider:125 gap this closes) let a non-pi citizen with a *symlinked* socket
|
|
67
|
+
* survive as a clean PeerFact while the legacy send path still followed the symlink
|
|
68
|
+
* to a forged receiver. Both axes claim the gid → both must quarantine it.
|
|
69
|
+
*
|
|
70
|
+
* SCOPE: this is the RECORD-side, non-pi conflict only — shared by the fact-provider
|
|
71
|
+
* (listing) and the v2 decider (dispatch) so the two cannot drift (4c "재유도 금지"
|
|
72
|
+
* 동형; only the observation-bit source is parameterized). A pi citizen whose own
|
|
73
|
+
* canonical socket is a symlink is NOT this predicate's concern — that is a
|
|
74
|
+
* target-specific lstat conflict the decider's `inspectTargetControlSocket` raises
|
|
75
|
+
* as `address-conflict`, kept deliberately separate (GPT 1차 검수 C).
|
|
76
|
+
*/
|
|
77
|
+
export function isNonPiGardenIdSocketConflict(backend, gardenId, socketGids, symlinkedGardenIds) {
|
|
78
|
+
return !isLivenessSupported(backend) && (socketGids.has(gardenId) || symlinkedGardenIds.has(gardenId));
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* Pure union of the meta-store axis (citizens) and the socket axis (probes) into
|
|
82
|
+
* a facts-only listing. No IO — slice-3 wiring reads the meta-store and probes
|
|
83
|
+
* the sockets, then injects both lists.
|
|
84
|
+
*
|
|
85
|
+
* Correlation key = `gardenId` (동결결정3; `nativeSessionId` is backend-local, not
|
|
86
|
+
* a global key). Rules frozen 2026-06-11 (GPT힣 + Fable):
|
|
87
|
+
* - in-domain (pi) citizen: liveness = its socket probe (3-value preserved).
|
|
88
|
+
* The wiring MUST probe every in-domain citizen's canonical socket path, so a
|
|
89
|
+
* citizen ABSENT from `socketProbes` is a wiring-invariant violation → throw.
|
|
90
|
+
* We never pass `null` for a pi citizen (resolvePeerFact would map it to
|
|
91
|
+
* `indeterminate` and strand a dormant citizen as un-resumable); a dormant
|
|
92
|
+
* citizen's absent socket file is probed to `dead` (ENOENT) by the wiring and
|
|
93
|
+
* arrives here AS `dead` → dormant → resumable.
|
|
94
|
+
* - out-of-domain citizen WITH a control socket at its gardenId → fail-loud
|
|
95
|
+
* (address ambiguity; a non-pi citizen must not own a pi control socket).
|
|
96
|
+
* - out-of-domain citizen without a socket → `unsupported` (via resolvePeerFact).
|
|
97
|
+
* - a probed gardenId with NO citizen → `SocketOnlyFact` (socket-only pi).
|
|
98
|
+
* A gardenId is never emitted as both a `PeerFact` and a `SocketOnlyFact`; once a
|
|
99
|
+
* pi meta-record writer ships, a socket-only entry is promoted to a `PeerFact`.
|
|
100
|
+
*/
|
|
101
|
+
export function resolveFactList(identities, socketProbes) {
|
|
102
|
+
const probeMap = new Map();
|
|
103
|
+
for (const probe of socketProbes) {
|
|
104
|
+
if (probeMap.has(probe.gardenId)) {
|
|
105
|
+
throw new Error(`resolveFactList: duplicate socket probe for gardenId ${probe.gardenId}`);
|
|
106
|
+
}
|
|
107
|
+
probeMap.set(probe.gardenId, probe);
|
|
108
|
+
}
|
|
109
|
+
const peers = [];
|
|
110
|
+
const consumed = new Set();
|
|
111
|
+
for (const identity of identities) {
|
|
112
|
+
const gid = identity.gardenId;
|
|
113
|
+
if (consumed.has(gid)) {
|
|
114
|
+
throw new Error(`resolveFactList: duplicate meta-record for gardenId ${gid}`);
|
|
115
|
+
}
|
|
116
|
+
let socket;
|
|
117
|
+
if (isLivenessSupported(identity.backend)) {
|
|
118
|
+
const probe = probeMap.get(gid);
|
|
119
|
+
if (!probe) {
|
|
120
|
+
throw new Error(`resolveFactList: in-domain citizen ${gid} (${identity.backend}) was not probed — ` +
|
|
121
|
+
"wiring must probe every in-domain citizen's canonical socket path (absent file → dead, never unprobed)");
|
|
122
|
+
}
|
|
123
|
+
socket = probe.liveness;
|
|
124
|
+
}
|
|
125
|
+
else {
|
|
126
|
+
if (probeMap.has(gid)) {
|
|
127
|
+
throw new Error(`resolveFactList: out-of-domain citizen ${gid} (${identity.backend}) has a control socket — ` +
|
|
128
|
+
"address ambiguity (a non-pi citizen must not own a pi control socket)");
|
|
129
|
+
}
|
|
130
|
+
socket = null;
|
|
131
|
+
}
|
|
132
|
+
peers.push(resolvePeerFact(identity, socket));
|
|
133
|
+
consumed.add(gid);
|
|
134
|
+
}
|
|
135
|
+
const socketOnly = [];
|
|
136
|
+
for (const probe of socketProbes) {
|
|
137
|
+
if (consumed.has(probe.gardenId))
|
|
138
|
+
continue;
|
|
139
|
+
socketOnly.push({
|
|
140
|
+
kind: "socket-only",
|
|
141
|
+
gardenId: probe.gardenId,
|
|
142
|
+
liveness: probe.liveness,
|
|
143
|
+
cwd: probe.cwd,
|
|
144
|
+
model: probe.model,
|
|
145
|
+
idle: probe.idle,
|
|
146
|
+
infoError: probe.infoError,
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
// Sort by gardenId with a plain `<` compare (not localeCompare) so both fact
|
|
150
|
+
// surfaces and the socket scan share one locale-independent ordering.
|
|
151
|
+
const byGardenId = (a, b) => a.gardenId < b.gardenId ? -1 : a.gardenId > b.gardenId ? 1 : 0;
|
|
152
|
+
peers.sort(byGardenId);
|
|
153
|
+
socketOnly.sort(byGardenId);
|
|
154
|
+
return { peers, socketOnly };
|
|
155
|
+
}
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* entwurf-peers-render — the PURE render/payload layer for the MCP `entwurf_peers`
|
|
3
|
+
* surface (0.11 Stage 0 step 4, slice 4c). The MCP handler stays thin: it does IO
|
|
4
|
+
* (readdir the meta-store + probe sockets via `listEntwurfFacts`) and then calls
|
|
5
|
+
* THIS to shape the text + JSON. No IO here, so the gate drives it without a
|
|
6
|
+
* filesystem and the SAME facts can feed pi-native / doctor / v2 dispatch later
|
|
7
|
+
* (a handler that did its own brain work would deny them that reuse).
|
|
8
|
+
*
|
|
9
|
+
* Three hard rules carried from the frozen contract (동결결정 10):
|
|
10
|
+
* - FACTS ONLY, NO VERB-ROUTING. Neither the payload nor the text may carry a
|
|
11
|
+
* `sendable`/`resumable`/`dispatch`/`action`/`transport`/`mailboxDeliverable`
|
|
12
|
+
* field or word. Whether a target is sent-to or resumed is computed at call
|
|
13
|
+
* time by the entwurf_v2 dispatch table from `liveness` — baking it into the
|
|
14
|
+
* listing is exactly what makes `entwurf_peers` lie. The gate scans both the
|
|
15
|
+
* JSON keys AND the text for the forbidden words (a section title like
|
|
16
|
+
* "resumable peers" leaks routing that a key scan would miss).
|
|
17
|
+
* - THREE SECTIONS, NEVER MERGED. `peers` (citizens, 4-value liveness) and
|
|
18
|
+
* `socketOnly` (record-less sockets, 3-value liveness) are DISTINCT subjects
|
|
19
|
+
* (slice 2's two-array split); `diagnostics` is a third. Merging them into one
|
|
20
|
+
* array collapses the subject separation at the surface.
|
|
21
|
+
* - LEGACY `sessions` IS A PROJECTION OF FACTS, not a second scan. We do NOT
|
|
22
|
+
* re-run the old `getLiveSessions` (a separate live-socket scan would bypass
|
|
23
|
+
* the provider's quarantine — a non-pi citizen colliding with a socket, which
|
|
24
|
+
* `listEntwurfFacts` removes from BOTH normal arrays, could reappear in
|
|
25
|
+
* `sessions`). `sessions` is derived from the SAME facts: alive pi citizens +
|
|
26
|
+
* alive socket-only entries. Its socketPath is built by `controlSocketPath`
|
|
27
|
+
* (the SSOT helper), never re-concatenated, so the filename↔gardenId
|
|
28
|
+
* correlation authority (동결결정3) cannot drift between scan and render.
|
|
29
|
+
*/
|
|
30
|
+
import { controlSocketPath } from "./socket-discovery.js";
|
|
31
|
+
/**
|
|
32
|
+
* Derive the legacy `sessions` projection from the facts: an active session is an
|
|
33
|
+
* alive pi citizen OR an alive record-less socket. `peers` and `socketOnly` are
|
|
34
|
+
* gid-disjoint (resolveFactList guarantees a gid is in one or the other, never
|
|
35
|
+
* both), so the concatenation needs no dedup. socketPath via `controlSocketPath`
|
|
36
|
+
* (SSOT). Sorted by sessionId for determinism.
|
|
37
|
+
*/
|
|
38
|
+
function deriveSessions(peers, socketOnly, controlDir) {
|
|
39
|
+
const sessions = [];
|
|
40
|
+
for (const p of peers) {
|
|
41
|
+
if (p.backend === "pi" && p.liveness === "alive") {
|
|
42
|
+
sessions.push({ sessionId: p.gardenId, socketPath: controlSocketPath(p.gardenId, controlDir) });
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
for (const s of socketOnly) {
|
|
46
|
+
if (s.liveness === "alive") {
|
|
47
|
+
sessions.push({ sessionId: s.gardenId, socketPath: controlSocketPath(s.gardenId, controlDir) });
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
sessions.sort((a, b) => (a.sessionId < b.sessionId ? -1 : a.sessionId > b.sessionId ? 1 : 0));
|
|
51
|
+
return sessions;
|
|
52
|
+
}
|
|
53
|
+
function renderPeerLine(p) {
|
|
54
|
+
const model = p.model ?? "(unknown)";
|
|
55
|
+
const entwurf = p.isEntwurf ? " entwurf" : "";
|
|
56
|
+
return `- ${p.gardenId} backend=${p.backend} liveness=${p.liveness} cwd=${p.cwd} model=${model}${entwurf}`;
|
|
57
|
+
}
|
|
58
|
+
function renderSocketOnlyLine(s) {
|
|
59
|
+
// Null enrich is "(not enriched)" — NOT "(unknown)", which would read as
|
|
60
|
+
// identity-unknown rather than not-yet-fetched / not available for this socket.
|
|
61
|
+
const cwd = s.cwd ?? "(not enriched)";
|
|
62
|
+
const model = s.model ?? "(not enriched)";
|
|
63
|
+
const idle = s.idle === null ? "" : ` idle=${s.idle ? "yes" : "no"}`;
|
|
64
|
+
const infoError = s.infoError === null ? "" : ` infoError=${s.infoError}`;
|
|
65
|
+
return `- ${s.gardenId} liveness=${s.liveness} cwd=${cwd} model=${model}${idle}${infoError}`;
|
|
66
|
+
}
|
|
67
|
+
function renderDiagnosticLine(d) {
|
|
68
|
+
switch (d.kind) {
|
|
69
|
+
case "meta-record-read-error":
|
|
70
|
+
return `- meta-record-read-error ${d.filename}: ${d.message}`;
|
|
71
|
+
case "garden-id-socket-conflict":
|
|
72
|
+
return `- garden-id-socket-conflict ${d.gardenId} (backend=${d.backend}): ${d.message}`;
|
|
73
|
+
case "socket-symlink-rejected":
|
|
74
|
+
return `- socket-symlink-rejected ${d.gardenId}: ${d.message}`;
|
|
75
|
+
case "malformed-socket-name":
|
|
76
|
+
return `- malformed-socket-name ${d.name}: ${d.message}`;
|
|
77
|
+
case "socket-dir-read-error":
|
|
78
|
+
return `- socket-dir-read-error: ${d.message}`;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
function compactLines(lines, max = 32) {
|
|
82
|
+
if (lines.length <= max)
|
|
83
|
+
return lines;
|
|
84
|
+
const omitted = lines.length - max;
|
|
85
|
+
return [` … (${omitted} older entries omitted; showing latest ${max})`, ...lines.slice(-max)];
|
|
86
|
+
}
|
|
87
|
+
function section(title, lines, opts = {}) {
|
|
88
|
+
// Empty sections render "(none)" — hiding them would erase the honesty the
|
|
89
|
+
// listing exists to provide (especially diagnostics: "(none)" is a trust
|
|
90
|
+
// signal, and an `unsupported` peer must never be silently dropped).
|
|
91
|
+
const rendered = opts.compact ? compactLines(lines) : lines;
|
|
92
|
+
return rendered.length > 0 ? `${title}\n${rendered.join("\n")}` : `${title}\n (none)`;
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* Shape the facts into the `entwurf_peers` text + JSON. Pure over its inputs.
|
|
96
|
+
* `controlDir` is the same directory the socket scan used — passing it here (not
|
|
97
|
+
* re-deriving) keeps the socketPath SSOT.
|
|
98
|
+
*/
|
|
99
|
+
export function renderEntwurfPeers(result, controlDir) {
|
|
100
|
+
const { peers, socketOnly } = result.facts;
|
|
101
|
+
const { diagnostics } = result;
|
|
102
|
+
const sessions = deriveSessions(peers, socketOnly, controlDir);
|
|
103
|
+
const text = [
|
|
104
|
+
section("Garden citizens (meta-record):", peers.map(renderPeerLine), { compact: true }),
|
|
105
|
+
"",
|
|
106
|
+
section("Socket-only control sockets (no meta-record):", socketOnly.map(renderSocketOnlyLine), { compact: true }),
|
|
107
|
+
"",
|
|
108
|
+
section("Diagnostics:", diagnostics.map(renderDiagnosticLine)),
|
|
109
|
+
].join("\n");
|
|
110
|
+
const payload = {
|
|
111
|
+
controlDir,
|
|
112
|
+
count: sessions.length,
|
|
113
|
+
sessions,
|
|
114
|
+
peers,
|
|
115
|
+
socketOnly,
|
|
116
|
+
diagnostics,
|
|
117
|
+
};
|
|
118
|
+
return { text, payload };
|
|
119
|
+
}
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* entwurf-preflight — 0.11 Stage 0 (2): the SINGLE trust/launch decision owner.
|
|
3
|
+
*
|
|
4
|
+
* The controlled-launch surface, the global `project_trust` handler, and any
|
|
5
|
+
* MCP fact tool ALL consume this module's outcome — nobody else re-derives a
|
|
6
|
+
* prefix, re-reads `trust.json`, or re-probes trust inputs. pi's raw trust
|
|
7
|
+
* semantics are followed by importing pi's PUBLIC root exports directly (frozen
|
|
8
|
+
* decision 9, 재구현 금지): `ProjectTrustStore` (the canonical `trust.json`
|
|
9
|
+
* reader, which itself canonicalizes the cwd and takes a `proper-lockfile` on
|
|
10
|
+
* every read) and `hasTrustRequiringProjectResources` (the trust-input probe).
|
|
11
|
+
* We never copy pi's trust detail — if pi changes it, this import tracks it.
|
|
12
|
+
*
|
|
13
|
+
* The returned `PreflightOutcome` is deliberately RICH, not just {kind,reason}:
|
|
14
|
+
* a fact tool must explain *why* a cwd is approved and *what* it may load
|
|
15
|
+
* without re-running the probe, and an error/handler must name the matched root
|
|
16
|
+
* or the trust-store value. Thin outcomes would push callers to recompute, which
|
|
17
|
+
* is exactly the re-derivation this module exists to prevent.
|
|
18
|
+
*
|
|
19
|
+
* trust ≠ discovery: this decision touches the store for a SINGLE launch-time
|
|
20
|
+
* cwd only. `peers`/`who-can` discovery does not call here (frozen decision 4).
|
|
21
|
+
*
|
|
22
|
+
* Precedence (frozen decision 8) — saved distrust is stronger than a prefix
|
|
23
|
+
* allow; a prefix only promotes the UNDECIDED (null) case; no-trust-inputs is
|
|
24
|
+
* trusted but needs no launch arg; everything else is fail-fast:
|
|
25
|
+
*
|
|
26
|
+
* saved === false → deny (explicit distrust; store wins)
|
|
27
|
+
* saved === true → approve (saved trust → internal --approve)
|
|
28
|
+
* null + prefix match → approve (operator prefix promotes null→yes)
|
|
29
|
+
* null + no trust inputs → trusted-no-arg (no trust-gated input — pi 0.79.x
|
|
30
|
+
* excludes AGENTS.md/CLAUDE.md, so
|
|
31
|
+
* context files may still be loaded)
|
|
32
|
+
* else (null + inputs) → fail-fast (unknown/untrusted controlled launch)
|
|
33
|
+
*
|
|
34
|
+
* Injection (frozen decision 4): `agentDir` defaults to `getAgentDir()` but is
|
|
35
|
+
* overridable so tests point `ProjectTrustStore` at a temp dir (or set
|
|
36
|
+
* `PI_CODING_AGENT_DIR`, same isolation as 0.10.0) and never read or dirty the
|
|
37
|
+
* operator's real `~/.pi/agent/trust.json`. `prefixRoots` is an OPERATOR-policy
|
|
38
|
+
* input with NO package default (frozen decision 7): a public package must not
|
|
39
|
+
* hardcode a broad auto-approve, so an empty roots list means "no prefix
|
|
40
|
+
* promotion" — the caller injects the operator's roots (e.g. `~/repos/gh`).
|
|
41
|
+
*/
|
|
42
|
+
import { realpathSync } from "node:fs";
|
|
43
|
+
import { homedir } from "node:os";
|
|
44
|
+
import { isAbsolute, join, resolve, sep } from "node:path";
|
|
45
|
+
import { getAgentDir, hasTrustRequiringProjectResources, ProjectTrustStore, } from "@earendil-works/pi-coding-agent";
|
|
46
|
+
/**
|
|
47
|
+
* Render the human-facing reason a controlled launch was refused (N3b). This is
|
|
48
|
+
* a PURE formatter over a deny outcome — it does NOT touch a launcher, a socket,
|
|
49
|
+
* or pi; wiring it into the controlled-launch surface is bucket B (step 5), not
|
|
50
|
+
* here. The launcher/handler/error layers all call this so the refusal text is
|
|
51
|
+
* identical everywhere and always sourced from F5a evidence.
|
|
52
|
+
*
|
|
53
|
+
* The inherited-false branch is the one that matters: an operator distrust on an
|
|
54
|
+
* ANCESTOR (e.g. `~/repos/gh`) silently denies a child cwd, and an agent CANNOT
|
|
55
|
+
* lift it — that is an intended security property (N3a: a controlled launch
|
|
56
|
+
* short-circuits on `trustOverride` and never reaches the human-only active
|
|
57
|
+
* prompt). So the message must (1) name the inherited source (`inheritedFrom`)
|
|
58
|
+
* and (2) give the only real remedy: open an interactive pi AT the cwd and
|
|
59
|
+
* approve, which writes a direct child trust that beats the inherited decision
|
|
60
|
+
* (the "escape direction" proven in check-pi-preflight #13b).
|
|
61
|
+
*/
|
|
62
|
+
export function formatPreflightDenial(outcome) {
|
|
63
|
+
const cwd = outcome.canonicalCwd;
|
|
64
|
+
const openHere = `open an interactive pi at ${cwd} and approve when prompted`;
|
|
65
|
+
if (outcome.reason === "saved-false") {
|
|
66
|
+
if (outcome.trustStoreInherited && outcome.trustStoreEntryPath !== undefined) {
|
|
67
|
+
return (`Controlled launch refused: ${cwd} is distrusted by inheritance from ${outcome.trustStoreEntryPath} ` +
|
|
68
|
+
`(an ancestor carries a saved "no"). An agent cannot self-promote trust — this is an intended ` +
|
|
69
|
+
`security property. To trust THIS cwd only, ${openHere}; that writes a direct decision for ${cwd} ` +
|
|
70
|
+
`which overrides the inherited one.`);
|
|
71
|
+
}
|
|
72
|
+
return `Controlled launch refused: ${cwd} is explicitly distrusted (a saved "no" on this directory). To change it, ${openHere}.`;
|
|
73
|
+
}
|
|
74
|
+
// fail-fast: undecided + trust inputs + no operator prefix root.
|
|
75
|
+
return (`Controlled launch refused: ${cwd} is untrusted — it has trust inputs but no saved decision and no ` +
|
|
76
|
+
`operator prefix root. Refusing a silent degraded launch. Either add ${cwd} under an operator prefix ` +
|
|
77
|
+
`root, or ${openHere}.`);
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* Normalize a path the way pi resolves one before the trust store sees it:
|
|
81
|
+
* expand a leading `~`, make it absolute (`path.resolve`), then `realpathSync`;
|
|
82
|
+
* on a resolve failure fall back to the RESOLVED absolute path (not the raw
|
|
83
|
+
* input), so a not-yet-existing root still compares on an absolute basis.
|
|
84
|
+
*/
|
|
85
|
+
function normalizePath(p) {
|
|
86
|
+
let expanded = p;
|
|
87
|
+
if (p === "~") {
|
|
88
|
+
expanded = homedir();
|
|
89
|
+
}
|
|
90
|
+
else if (p.startsWith("~/")) {
|
|
91
|
+
expanded = join(homedir(), p.slice(2));
|
|
92
|
+
}
|
|
93
|
+
const abs = isAbsolute(expanded) ? expanded : resolve(expanded);
|
|
94
|
+
try {
|
|
95
|
+
return realpathSync(abs);
|
|
96
|
+
}
|
|
97
|
+
catch {
|
|
98
|
+
return abs;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
/**
|
|
102
|
+
* Return the canonical operator root that contains `canonicalCwd`, by canonical
|
|
103
|
+
* path + separator boundary (frozen decision 7). `/org` matches `/org/a` but NOT
|
|
104
|
+
* `/org2` — never a bare `startsWith`. Roots are normalized the same as the cwd.
|
|
105
|
+
*/
|
|
106
|
+
function matchedPrefixRoot(canonicalCwd, roots) {
|
|
107
|
+
for (const root of roots) {
|
|
108
|
+
const r = normalizePath(root);
|
|
109
|
+
if (canonicalCwd === r || canonicalCwd.startsWith(r + sep)) {
|
|
110
|
+
return r;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
return undefined;
|
|
114
|
+
}
|
|
115
|
+
/** Decide trust for a single controlled-launch cwd. See module header. */
|
|
116
|
+
export function preflight(input) {
|
|
117
|
+
const agentDir = input.agentDir ?? getAgentDir();
|
|
118
|
+
const prefixRoots = input.prefixRoots ?? [];
|
|
119
|
+
const canonicalCwd = normalizePath(input.cwd);
|
|
120
|
+
const store = new ProjectTrustStore(agentDir);
|
|
121
|
+
// getEntry, not get: get() throws away which path decided. getEntry returns
|
|
122
|
+
// `{ path, decision } | null` — the nearest ancestor (or the cwd itself)
|
|
123
|
+
// carrying an explicit decision. We recover the same decision value AND the
|
|
124
|
+
// deciding path, so the fact/handler/error layers can name an inherited
|
|
125
|
+
// source without re-walking the store. entry.path is pi-canonical, the same
|
|
126
|
+
// realpath axis as `canonicalCwd`, so an entry on the cwd ITSELF compares
|
|
127
|
+
// equal (= direct) and an ancestor compares unequal (= inherited).
|
|
128
|
+
const entry = store.getEntry(input.cwd);
|
|
129
|
+
const trustStoreDecision = entry?.decision ?? null;
|
|
130
|
+
const trustStoreInherited = entry !== null && entry.path !== canonicalCwd;
|
|
131
|
+
// Computed unconditionally: a fact tool must report what a prefix-approved
|
|
132
|
+
// cwd could load, so the probe runs even when a prefix already decides.
|
|
133
|
+
const hasTrustInputs = hasTrustRequiringProjectResources(input.cwd);
|
|
134
|
+
const matched = matchedPrefixRoot(canonicalCwd, prefixRoots);
|
|
135
|
+
const evidence = {
|
|
136
|
+
launchArgs: [],
|
|
137
|
+
trustStoreDecision,
|
|
138
|
+
trustStoreInherited,
|
|
139
|
+
hasTrustInputs,
|
|
140
|
+
canonicalCwd,
|
|
141
|
+
...(entry !== null ? { trustStoreEntryPath: entry.path } : {}),
|
|
142
|
+
...(matched !== undefined ? { matchedPrefixRoot: matched } : {}),
|
|
143
|
+
};
|
|
144
|
+
// Explicit distrust wins over everything, including a prefix match.
|
|
145
|
+
if (trustStoreDecision === false) {
|
|
146
|
+
return { ...evidence, kind: "deny", reason: "saved-false" };
|
|
147
|
+
}
|
|
148
|
+
if (trustStoreDecision === true) {
|
|
149
|
+
return { ...evidence, kind: "approve", reason: "saved-true", launchArgs: ["--approve"] };
|
|
150
|
+
}
|
|
151
|
+
// trustStoreDecision === null (undecided): a prefix promotes it; otherwise the
|
|
152
|
+
// absence of trust inputs makes it trusted-but-no-arg; otherwise fail-fast.
|
|
153
|
+
if (matched !== undefined) {
|
|
154
|
+
return { ...evidence, kind: "approve", reason: "prefix-match", launchArgs: ["--approve"] };
|
|
155
|
+
}
|
|
156
|
+
if (!hasTrustInputs) {
|
|
157
|
+
return { ...evidence, kind: "trusted-no-arg", reason: "no-trust-inputs" };
|
|
158
|
+
}
|
|
159
|
+
return { ...evidence, kind: "deny", reason: "fail-fast" };
|
|
160
|
+
}
|