@junghanacs/entwurf 0.13.1 → 0.14.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +59 -15
- package/BASELINE.md +3 -3
- package/CHANGELOG.md +74 -0
- package/CONTRIBUTING.md +13 -9
- package/DELIVERY.md +7 -6
- package/README.md +37 -28
- package/VERIFY.md +22 -14
- package/demo/README.md +1 -1
- package/demo/demo-baseline.sh +1 -3
- package/demo/demo.sh +2 -5
- package/docs/acp-backend-rail.md +9 -4
- package/docs/external-mcp-host.md +4 -5
- package/docs/setup-clean-host.md +8 -7
- package/mcp/entwurf-bridge/dist/mcp/entwurf-bridge/src/index.js +155 -28
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/overlay.js +8 -6
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/classify-tmux-cwd.js +47 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-control-rpc.js +7 -5
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-core.js +13 -14
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-resume-args.js +45 -40
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-contract.js +117 -95
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-decider.js +23 -57
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-lock.js +16 -7
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-production.js +5 -53
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-release.js +21 -36
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-runner.js +3 -15
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-send-fallback.js +12 -11
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-send.js +2 -7
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-surface.js +30 -67
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-visible-resume.js +256 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/meta-session.js +91 -5
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/mux-fresh-call.js +300 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/mux-launch.js +202 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/mux-placement.js +289 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/mux-resume-call.js +141 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/resume-launch-identity.js +136 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/session-id.js +8 -5
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/socket-discovery.js +3 -3
- package/mcp/entwurf-bridge/dist/scripts/meta-facts.js +51 -0
- package/mcp/entwurf-bridge/dist/scripts/new-session-id.js +9 -4
- package/mcp/entwurf-bridge/src/index.ts +173 -28
- package/mcp/entwurf-bridge/start.sh +2 -2
- package/mcp/entwurf-bridge/test.sh +23 -9
- package/mcp/entwurf-bridge/tsconfig.build.json +11 -2
- package/package.json +22 -11
- package/pi-extensions/entwurf-control.ts +227 -40
- package/pi-extensions/lib/acp/backend.ts +71 -12
- package/pi-extensions/lib/acp/overlay.ts +8 -6
- package/pi-extensions/lib/classify-tmux-cwd.ts +50 -0
- package/pi-extensions/lib/entwurf-control-rpc.ts +7 -5
- package/pi-extensions/lib/entwurf-core.ts +15 -15
- package/pi-extensions/lib/entwurf-resume-args.ts +41 -52
- package/pi-extensions/lib/entwurf-v2-contract-schema.ts +1 -1
- package/pi-extensions/lib/entwurf-v2-contract.ts +120 -99
- package/pi-extensions/lib/entwurf-v2-decider.ts +30 -91
- package/pi-extensions/lib/entwurf-v2-lock.ts +16 -7
- package/pi-extensions/lib/entwurf-v2-production.ts +4 -78
- package/pi-extensions/lib/entwurf-v2-release.ts +25 -49
- package/pi-extensions/lib/entwurf-v2-runner.ts +6 -21
- package/pi-extensions/lib/entwurf-v2-send-fallback.ts +12 -11
- package/pi-extensions/lib/entwurf-v2-send.ts +2 -7
- package/pi-extensions/lib/entwurf-v2-surface.ts +36 -76
- package/pi-extensions/lib/entwurf-v2-visible-resume.ts +370 -0
- package/pi-extensions/lib/meta-session.ts +93 -5
- package/pi-extensions/lib/mux-fresh-call.ts +381 -0
- package/pi-extensions/lib/mux-launch.ts +267 -0
- package/pi-extensions/lib/mux-placement.ts +387 -0
- package/pi-extensions/lib/mux-resume-call.ts +189 -0
- package/pi-extensions/lib/resume-launch-identity.ts +162 -0
- package/pi-extensions/lib/session-id.js +8 -5
- package/pi-extensions/lib/socket-discovery.ts +3 -3
- package/run.sh +471 -201
- package/scripts/agy-bridge-config.py +5 -1
- package/scripts/check-acp-backend-preflight.ts +1 -1
- package/scripts/check-acp-overlay.ts +13 -3
- package/scripts/check-acp-stream-hooks.ts +504 -0
- package/scripts/check-elapsed.sh +25 -0
- package/scripts/check-entwurf-bridge-boot.ts +51 -4
- package/scripts/check-entwurf-bridge-pi-free.ts +6 -5
- package/scripts/check-entwurf-control-rpc.ts +4 -3
- package/scripts/check-entwurf-resume-args.ts +72 -70
- package/scripts/check-entwurf-session-identity.ts +14 -10
- package/scripts/check-entwurf-v2-contract.ts +34 -59
- package/scripts/check-entwurf-v2-decider.ts +17 -177
- package/scripts/check-entwurf-v2-lock.ts +5 -2
- package/scripts/check-entwurf-v2-matrix.ts +3 -53
- package/scripts/check-entwurf-v2-production.ts +2 -91
- package/scripts/check-entwurf-v2-release.ts +10 -105
- package/scripts/check-entwurf-v2-runner.ts +4 -85
- package/scripts/check-entwurf-v2-send-fallback.ts +5 -6
- package/scripts/check-entwurf-v2-send.ts +0 -28
- package/scripts/check-entwurf-v2-surface.ts +157 -128
- package/scripts/check-entwurf-v2-visible-resume.ts +445 -0
- package/scripts/check-fresh-cut-gate.sh +1 -1
- package/scripts/check-gate-qualification.ts +99 -7
- package/scripts/check-install-container.sh +10 -2
- package/scripts/check-install-surface.ts +1 -1
- package/scripts/check-keyset-overlap.py +1 -1
- package/scripts/check-meta-facts.ts +249 -0
- package/scripts/check-meta-identity-consumers.ts +1 -1
- package/scripts/check-meta-session.ts +169 -0
- package/scripts/check-mux-launch-tmux.ts +316 -0
- package/scripts/check-mux-launch.ts +288 -0
- package/scripts/check-mux-launcher-fence.ts +264 -0
- package/scripts/check-mux-parent-artifact.ts +195 -0
- package/scripts/check-mux-placement-tmux.ts +322 -0
- package/scripts/check-mux-placement.ts +323 -0
- package/scripts/check-mux-resume-call.ts +283 -0
- package/scripts/check-probe-cli-shim.ts +25 -22
- package/scripts/check-probe-ordering.ts +84 -76
- package/scripts/check-release-gate-outcomes.ts +127 -7
- package/scripts/check-resume-launch-identity.ts +244 -0
- package/scripts/check-socket-discovery.ts +1 -1
- package/scripts/fixtures/mux-parent-transcript.scrubbed.jsonl +3 -0
- package/scripts/inventory-verification-surface.ts +349 -0
- package/scripts/lib/claude-launcher-fence.ts +322 -0
- package/scripts/lib/mutation-qualify.ts +109 -3
- package/scripts/meta-bridge-doctor.sh +6 -8
- package/scripts/meta-bridge-state.py +75 -1
- package/scripts/meta-facts.ts +60 -0
- package/scripts/mutants/acp-overlay.json +17 -0
- package/scripts/mutants/acp-stream-hooks.json +158 -0
- package/scripts/mutants/bridge-boot-resume.json +45 -0
- package/scripts/mutants/meta-facts.json +50 -0
- package/scripts/mutants/meta-identity.json +36 -0
- package/scripts/mutants/meta-retire.json +47 -0
- package/scripts/mutants/mux-boundary.json +196 -0
- package/scripts/mutants/mux-fresh-call.json +229 -0
- package/scripts/mutants/mux-launcher-fence.json +123 -0
- package/scripts/mutants/mux-parent-artifact.json +39 -0
- package/scripts/mutants/mux-resume-call.json +148 -0
- package/scripts/mutants/probe-ordering.json +0 -1037
- package/scripts/mutants/release-gate.json +35 -0
- package/scripts/mutants/resume-args.json +76 -0
- package/scripts/mutants/resume-launch-identity.json +96 -0
- package/scripts/mutants/v2-surface.json +58 -18
- package/scripts/mutants/v2-visible-resume.json +215 -0
- package/scripts/new-session-id.ts +9 -4
- package/scripts/smoke-acp-raw-turn-live.ts +1 -1
- package/scripts/smoke-agy-native-push-live.ts +6 -17
- package/scripts/smoke-entwurf-chain-live.ts +11 -3
- package/scripts/smoke-entwurf-v2-matrix-live.ts +1 -1
- package/scripts/smoke-meta-honesty.sh +1 -1
- package/scripts/smoke-meta-install-state.sh +169 -3
- package/scripts/smoke-mux-fresh-call-live.ts +365 -0
- package/scripts/smoke-mux-lifecycle-live.ts +1136 -0
- package/scripts/smoke-pi-attach.ts +1 -1
- package/scripts/smoke-user-scope-citizen.sh +1 -1
- package/scripts/tsconfig.json +1 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-preflight.js +0 -160
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-spawn-production.js +0 -273
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-spawn.js +0 -216
- package/pi-extensions/lib/entwurf-v2-spawn-production.ts +0 -373
- package/pi-extensions/lib/entwurf-v2-spawn.ts +0 -323
- package/scripts/check-acp-sdk-surface.ts +0 -275
- package/scripts/check-entwurf-v2-spawn-production.ts +0 -551
- package/scripts/check-entwurf-v2-spawn.ts +0 -399
- package/scripts/smoke-entwurf-v2-spawn-live.ts +0 -188
- package/scripts/smoke-entwurf-v2-spawn-resume-live.ts +0 -467
|
@@ -25,7 +25,7 @@
|
|
|
25
25
|
* WHY THE PI SIDE IS THE SEAM AND NOT A REAL `pi` PROCESS. The record + address half
|
|
26
26
|
* is driven through `birthPiCitizen` — the exact function `entwurf-control.ts`'s
|
|
27
27
|
* session_start calls — so this gate stays deterministic (no model, no network, no
|
|
28
|
-
* cost) and belongs in `pnpm check`. That a REAL `pi --entwurf-control` process
|
|
28
|
+
* cost) and belongs in `pnpm run check:full`. That a REAL `pi --entwurf-control` process
|
|
29
29
|
* executes that seam is a different axis, owned by the LIVE gate
|
|
30
30
|
* (`smoke-resident-garden-guard`, inverted in this same cut). Two axes, neither
|
|
31
31
|
* pretending to be the other.
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
# cwd because entwurf was absent from ~/.pi/agent/settings.json packages[]).
|
|
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
|
-
# regresses, `pnpm check` goes red.
|
|
11
|
+
# regresses, `pnpm run check:full` goes red.
|
|
12
12
|
#
|
|
13
13
|
# Cells 11–13 close the second omission (#53 B): every case here drove a fake
|
|
14
14
|
# settings file with ABSOLUTE entries, so the portable, settings-relative form this
|
package/scripts/tsconfig.json
CHANGED
|
@@ -1,160 +0,0 @@
|
|
|
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
|
-
}
|
|
@@ -1,273 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* entwurf-v2-spawn-production — the PRODUCTION `SpawnBgResumeDeps` factory for the 5c-3a
|
|
3
|
-
* spawn-bg watcher (0.11 Stage 0 step 5c-3c). It wires the six injected IO seams the pure
|
|
4
|
-
* watcher (`executeSpawnBgResume`) declares onto the real world: spawn a resume child as a
|
|
5
|
-
* resident citizen, observe its control socket by CONNECTABILITY (not mere existence),
|
|
6
|
-
* watch its exit, time the observe window, and kill on timeout. The watcher's release
|
|
7
|
-
* policy is untouched — this module only supplies the IO.
|
|
8
|
-
*
|
|
9
|
-
* Every seam stays injectable (defaults = the real fns) so a DETERMINISTIC gate drives
|
|
10
|
-
* spawnChild's argv, the exit/timeout/kill wiring, and the socket-alive verdict WITHOUT a
|
|
11
|
-
* real pi spawn or socket — the heavy live path (a real `pi --entwurf-control` resident
|
|
12
|
-
* child + a real unix-socket connect) is proven once by a separate opt-in smoke
|
|
13
|
-
* (`smoke-entwurf-v2-spawn-live`), kept OUT of `pnpm check` so the every-commit loop stays
|
|
14
|
-
* fast and deterministic (D5: chain = deterministic gate; live smoke = phase gate before 5d).
|
|
15
|
-
*
|
|
16
|
-
* The factory does NOT capture `plan` or `lock` (D3): `SpawnBgResumeDeps.spawnChild(plan)`
|
|
17
|
-
* already takes the plan, and the lock is the watcher's authority — it flows to
|
|
18
|
-
* `deps.releaseLock` from the watcher, never from here. A captured plan/lock would be a
|
|
19
|
-
* second authority that could drift from the one the watcher holds.
|
|
20
|
-
*
|
|
21
|
-
* socket-alive = CONNECTABLE, never file-exists (GPT 5c-3c, Q5): inspect the EXACT
|
|
22
|
-
* `plan.expectedSocketPath` (via the path-addressed `inspectControlSocketPath` — no gid
|
|
23
|
-
* re-derivation), map to liveness through the shared `mapInspectionToLiveness`, then apply
|
|
24
|
-
* `socketWatchVerdict`: a connectable socket is alive; a forged address (symlink / not a
|
|
25
|
-
* socket) is rejected IMMEDIATELY (time does not heal a forged path → the watcher's backstop
|
|
26
|
-
* kills and fail-closes to a retained lock); a dead/indeterminate socket keeps waiting (the
|
|
27
|
-
* dormant citizen's stale socket file, or a stall, is "not up yet", not a failure).
|
|
28
|
-
*/
|
|
29
|
-
import { spawn } from "node:child_process";
|
|
30
|
-
import { existsSync } from "node:fs";
|
|
31
|
-
import * as fs from "node:fs/promises";
|
|
32
|
-
import { getEntwurfExplicitExtensions, mirrorChildStderr, readSessionIdentity } from "./entwurf-core.js";
|
|
33
|
-
import { buildResumePiArgs } from "./entwurf-resume-args.js";
|
|
34
|
-
import { releaseLock } from "./entwurf-v2-lock.js";
|
|
35
|
-
import { readAddressableMetaIdentity } from "./meta-session.js";
|
|
36
|
-
import { inspectControlSocketPath, mapInspectionToLiveness } from "./socket-discovery.js";
|
|
37
|
-
import { probeSocketLiveness } from "./socket-probe.js";
|
|
38
|
-
/**
|
|
39
|
-
* Translate a mapped socket observation into the watcher's poll decision.
|
|
40
|
-
* - forged — an address-conflict (symlink / not-a-socket). NOT a transient liveness
|
|
41
|
-
* state: time cannot turn a forged path into a live socket, so the poll must REJECT
|
|
42
|
-
* immediately (→ watcher backstop → kill → retained), never keep waiting.
|
|
43
|
-
* - alive — a connectable socket. Resolve: the resumed citizen is up.
|
|
44
|
-
* - wait — dead (the dormant citizen's stale/absent socket — "not up yet") or
|
|
45
|
-
* indeterminate (a stall). Keep polling until the socket connects, or the observe
|
|
46
|
-
* timeout / abort ends the wait.
|
|
47
|
-
*/
|
|
48
|
-
export function socketWatchVerdict(mapped) {
|
|
49
|
-
if ("addressConflict" in mapped)
|
|
50
|
-
return "forged";
|
|
51
|
-
return mapped.liveness === "alive" ? "alive" : "wait";
|
|
52
|
-
}
|
|
53
|
-
/** Narrow a watcher-supplied SpawnedChild back to the production handle. A child that is
|
|
54
|
-
* NOT one we spawned (no `proc` / `exitPromise`) is a mis-wire — fail loud, never no-op. */
|
|
55
|
-
function requireProductionChild(child) {
|
|
56
|
-
const c = child;
|
|
57
|
-
if (!c.proc || !c.exitPromise) {
|
|
58
|
-
throw new Error("entwurf-v2-spawn-production: child handle has no proc/exitPromise (mis-wired SpawnBgResumeDeps).");
|
|
59
|
-
}
|
|
60
|
-
return c;
|
|
61
|
-
}
|
|
62
|
-
/**
|
|
63
|
-
* Resolve launch identity for a resume. The TARGET is now resolved through the
|
|
64
|
-
* meta-record (#50 C2): `gardenId → record.transcriptPath`. It used to be a global
|
|
65
|
-
* header scan for a JSONL whose header id equalled the garden id — which only worked
|
|
66
|
-
* while entwurf forced pi's session id to BE the garden id. With the record minting the
|
|
67
|
-
* address, that scan cannot find anything (a citizen's header carries pi's own uuid), so
|
|
68
|
-
* keeping it would not have been a "smaller change", it would have been a broken one.
|
|
69
|
-
*
|
|
70
|
-
* The record is also the AUTHORIZATION now (#50 C3). The old gates — `requireEntwurf`
|
|
71
|
-
* (an `entwurf` tag in the session NAME, planted by a name mirror that no longer
|
|
72
|
-
* exists) and the sessionId-bound resume-marker env — are deleted. Record-backed pi
|
|
73
|
-
* citizens are all siblings (LOCKED PROTOCOL 6), so "this garden id names a pi citizen
|
|
74
|
-
* with a recorded transcript" is the whole test, PLUS one integrity check: the resumed
|
|
75
|
-
* file's header id must equal `record.nativeSessionId` (pi owns the transcript, the
|
|
76
|
-
* record remembers whose it is — a mismatch means the transcriptPath is stale or
|
|
77
|
-
* foreign, and resuming it would put a turn into a different being's session).
|
|
78
|
-
*
|
|
79
|
-
* That check is per-record, and per-record is not enough: the v2 lock domain is keyed on
|
|
80
|
-
* GARDEN ID, so two records sharing one `nativeSessionId` would each pass their own
|
|
81
|
-
* integrity check and resume the SAME transcript concurrently under two different locks.
|
|
82
|
-
* Hence {@link readAddressableMetaIdentity} rather than the plain targeted read (#52) —
|
|
83
|
-
* a resume is exactly the moment a record stops being data and becomes an address.
|
|
84
|
-
*
|
|
85
|
-
* Everything else is unchanged authority: readSessionIdentity (first model_change) for
|
|
86
|
-
* provider/model/cwd, getEntwurfExplicitExtensions for bridge re-injection (#29 fail-fast).
|
|
87
|
-
* Throws on anything that makes a resume impossible; each throw becomes the watcher's
|
|
88
|
-
* `spawn-start-failed` (no child to watch → release), never a silent no-op.
|
|
89
|
-
*/
|
|
90
|
-
export function resolveResumeLaunchIdentity(plan) {
|
|
91
|
-
const record = readAddressableMetaIdentity(plan.sessionId);
|
|
92
|
-
if (record.backend !== "pi") {
|
|
93
|
-
throw new Error(`entwurf-v2-spawn-production: ${plan.sessionId} is a ${record.backend} citizen — ` +
|
|
94
|
-
`spawn-bg resume is a host-adapter capability, and its domain currently contains backend pi only. ` +
|
|
95
|
-
`This is a relaunch capability, not the control-socket rail and not citizen rank.`);
|
|
96
|
-
}
|
|
97
|
-
const sessionFile = record.transcriptPath;
|
|
98
|
-
if (!sessionFile) {
|
|
99
|
-
throw new Error(`entwurf-v2-spawn-production: ${plan.sessionId} has no recorded transcriptPath — ` +
|
|
100
|
-
`the citizen never wrote a session file (no turn yet), so there is nothing to resume.`);
|
|
101
|
-
}
|
|
102
|
-
// A recorded path is only a resume target while the file is actually on disk.
|
|
103
|
-
// Without this check a missing transcript falls through readSessionIdentity's
|
|
104
|
-
// ENOENT swallow and surfaces as "no recorded model" — the wrong cause (F7):
|
|
105
|
-
// the transcript was deleted, or the record carries a phantom path minted
|
|
106
|
-
// before birth guarded on file existence.
|
|
107
|
-
if (!existsSync(sessionFile)) {
|
|
108
|
-
throw new Error(`entwurf-v2-spawn-production: ${plan.sessionId} recorded transcriptPath "${sessionFile}" ` +
|
|
109
|
-
`does not exist on disk — the transcript was deleted, or the record carries a phantom ` +
|
|
110
|
-
`path from a pre-guard birth; nothing to resume.`);
|
|
111
|
-
}
|
|
112
|
-
const identity = readSessionIdentity(sessionFile);
|
|
113
|
-
const resumeModel = identity?.modelId ?? null;
|
|
114
|
-
if (!identity || !resumeModel) {
|
|
115
|
-
throw new Error(`entwurf-v2-spawn-production: ${plan.sessionId} has no recorded model — cannot resume.`);
|
|
116
|
-
}
|
|
117
|
-
if (identity.sessionId !== record.nativeSessionId) {
|
|
118
|
-
throw new Error(`entwurf-v2-spawn-production: ${plan.sessionId} transcript header id "${identity.sessionId ?? "(none)"}" ` +
|
|
119
|
-
`does not match the record's nativeSessionId "${record.nativeSessionId}" — the recorded transcriptPath ` +
|
|
120
|
-
`is stale or points at a foreign session file; refusing to resume another being's transcript.`);
|
|
121
|
-
}
|
|
122
|
-
const explicitExtensions = getEntwurfExplicitExtensions(resumeModel, false, identity.provider);
|
|
123
|
-
if (explicitExtensions.unresolvedAcpIntent) {
|
|
124
|
-
throw new Error(`entwurf-v2-spawn-production: ${plan.sessionId} recorded provider=entwurf but the bridge ` +
|
|
125
|
-
`extension could not be resolved — refusing to resume with an unknown provider (#29).`);
|
|
126
|
-
}
|
|
127
|
-
if (!identity.cwd) {
|
|
128
|
-
throw new Error(`entwurf-v2-spawn-production: ${plan.sessionId} header has no cwd (the cold-resume authority, #9).`);
|
|
129
|
-
}
|
|
130
|
-
return {
|
|
131
|
-
sessionFile,
|
|
132
|
-
cwd: identity.cwd,
|
|
133
|
-
explicitExtensionArgs: explicitExtensions.args,
|
|
134
|
-
provider: explicitExtensions.provider ?? identity.provider,
|
|
135
|
-
model: explicitExtensions.modelOverride ?? resumeModel,
|
|
136
|
-
};
|
|
137
|
-
}
|
|
138
|
-
const DEFAULT_POLL_INTERVAL_MS = 150;
|
|
139
|
-
const DEFAULT_KILL_GRACE_MS = 5_000;
|
|
140
|
-
/** The default spawnChild: a detached, unref'd `pi` resident child with stderr mirrored —
|
|
141
|
-
* the same launch posture as the legacy worker, minus `--no-extensions` (the argv comes
|
|
142
|
-
* from buildResumePiArgs v2-control). Detached so the resumed citizen survives this parent. */
|
|
143
|
-
function defaultSpawnChild(cmd, args, cwd) {
|
|
144
|
-
const proc = spawn(cmd, [...args], {
|
|
145
|
-
cwd,
|
|
146
|
-
shell: false,
|
|
147
|
-
detached: true,
|
|
148
|
-
stdio: ["ignore", "ignore", "pipe"],
|
|
149
|
-
});
|
|
150
|
-
proc.unref();
|
|
151
|
-
mirrorChildStderr(proc);
|
|
152
|
-
return proc;
|
|
153
|
-
}
|
|
154
|
-
/**
|
|
155
|
-
* Build the production `SpawnBgResumeDeps` the 5c-3a watcher consumes. The factory captures
|
|
156
|
-
* NO plan and NO lock (D3) — both flow through the watcher. Pass `opts` to inject fakes for
|
|
157
|
-
* the deterministic gate; the defaults are the real IO.
|
|
158
|
-
*/
|
|
159
|
-
export function makeProductionSpawnBgResumeDeps(opts = {}) {
|
|
160
|
-
const pollIntervalMs = opts.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS;
|
|
161
|
-
const killGraceMs = opts.killGraceMs ?? DEFAULT_KILL_GRACE_MS;
|
|
162
|
-
const resolveIdentity = opts.resolveIdentity ?? resolveResumeLaunchIdentity;
|
|
163
|
-
const spawnChildFn = opts.spawnChild ?? defaultSpawnChild;
|
|
164
|
-
const lstatFn = opts.lstatFn ?? ((p) => fs.lstat(p));
|
|
165
|
-
const probeFn = opts.probeFn ?? ((socketPath) => probeSocketLiveness(socketPath, { timeoutMs: opts.probeTimeoutMs }));
|
|
166
|
-
const releaseFn = opts.releaseFn ?? ((lock) => void releaseLock(lock));
|
|
167
|
-
const setTimeoutFn = opts.setTimeoutFn ?? ((cb, ms) => setTimeout(cb, ms));
|
|
168
|
-
const clearTimeoutFn = opts.clearTimeoutFn ?? ((t) => clearTimeout(t));
|
|
169
|
-
// An abortable delay: resolves after `ms`, or rejects on abort (clearing the timer). Used
|
|
170
|
-
// by both awaitTimeout (the observe/grace deadlines) and the socket poll's inter-poll sleep.
|
|
171
|
-
const abortableDelay = (ms, signal) => new Promise((resolve, reject) => {
|
|
172
|
-
if (signal.aborted) {
|
|
173
|
-
reject(new Error("aborted"));
|
|
174
|
-
return;
|
|
175
|
-
}
|
|
176
|
-
const timer = setTimeoutFn(() => {
|
|
177
|
-
signal.removeEventListener("abort", onAbort);
|
|
178
|
-
resolve();
|
|
179
|
-
}, ms);
|
|
180
|
-
const onAbort = () => {
|
|
181
|
-
clearTimeoutFn(timer);
|
|
182
|
-
reject(new Error("aborted"));
|
|
183
|
-
};
|
|
184
|
-
signal.addEventListener("abort", onAbort, { once: true });
|
|
185
|
-
});
|
|
186
|
-
return {
|
|
187
|
-
killGraceMs,
|
|
188
|
-
spawnChild: async (plan) => {
|
|
189
|
-
const identity = resolveIdentity(plan);
|
|
190
|
-
const args = buildResumePiArgs({
|
|
191
|
-
variant: "v2-control",
|
|
192
|
-
sessionFile: identity.sessionFile,
|
|
193
|
-
explicitExtensionArgs: identity.explicitExtensionArgs,
|
|
194
|
-
provider: identity.provider,
|
|
195
|
-
model: identity.model,
|
|
196
|
-
prompt: plan.prompt,
|
|
197
|
-
launchArgs: plan.launchArgs,
|
|
198
|
-
});
|
|
199
|
-
const proc = spawnChildFn("pi", args, identity.cwd);
|
|
200
|
-
// B2: capture exit EAGERLY — the instant the proc exists, before we even await the
|
|
201
|
-
// spawn — so a fast exit cannot slip through the gap before awaitChildExit. Resolve-
|
|
202
|
-
// only (a signal kill reports null); it never rejects, so racing it against abort is safe.
|
|
203
|
-
const exitPromise = new Promise((resolve) => {
|
|
204
|
-
proc.on("exit", (code) => resolve(typeof code === "number" ? code : null));
|
|
205
|
-
});
|
|
206
|
-
// B1: a started process is NOT guaranteed by `spawn()` returning — a spawn-time
|
|
207
|
-
// failure (ENOENT pi / exec error) arrives as an `error` event, not a sync throw. Wait
|
|
208
|
-
// for the `spawn` event to confirm a real start; an `error` before it rejects, which the
|
|
209
|
-
// watcher turns into spawn-start-failed (release, nothing to watch) — NOT a silent
|
|
210
|
-
// spawn-started that later stalls into a wrongful retained lock.
|
|
211
|
-
await new Promise((resolve, reject) => {
|
|
212
|
-
const onSpawn = () => {
|
|
213
|
-
cleanup();
|
|
214
|
-
resolve();
|
|
215
|
-
};
|
|
216
|
-
const onError = (err) => {
|
|
217
|
-
cleanup();
|
|
218
|
-
reject(err instanceof Error ? err : new Error(`spawn failed: ${String(err)}`));
|
|
219
|
-
};
|
|
220
|
-
const cleanup = () => {
|
|
221
|
-
proc.removeListener("spawn", onSpawn);
|
|
222
|
-
proc.removeListener("error", onError);
|
|
223
|
-
};
|
|
224
|
-
proc.on("spawn", onSpawn);
|
|
225
|
-
proc.on("error", onError);
|
|
226
|
-
});
|
|
227
|
-
const child = { pid: proc.pid, proc, exitPromise };
|
|
228
|
-
return child;
|
|
229
|
-
},
|
|
230
|
-
awaitSocketAlive: async (socketPath, signal) => {
|
|
231
|
-
// Poll the EXACT path (no gid re-derivation). Connectable → resolve; forged →
|
|
232
|
-
// reject NOW; dead/indeterminate → wait one interval and re-poll, until abort.
|
|
233
|
-
while (!signal.aborted) {
|
|
234
|
-
const inspection = await inspectControlSocketPath(socketPath, lstatFn);
|
|
235
|
-
const mapped = await mapInspectionToLiveness(inspection, probeFn);
|
|
236
|
-
const verdict = socketWatchVerdict(mapped);
|
|
237
|
-
if (verdict === "alive")
|
|
238
|
-
return;
|
|
239
|
-
if (verdict === "forged") {
|
|
240
|
-
throw new Error(`entwurf-v2-spawn-production: forged control-socket address at ${socketPath} (never connected).`);
|
|
241
|
-
}
|
|
242
|
-
// wait — sleep one interval, honoring abort (rejects → loop exits below).
|
|
243
|
-
await abortableDelay(pollIntervalMs, signal);
|
|
244
|
-
}
|
|
245
|
-
throw new Error("entwurf-v2-spawn-production: awaitSocketAlive aborted before the socket became alive.");
|
|
246
|
-
},
|
|
247
|
-
awaitChildExit: (child, signal) =>
|
|
248
|
-
// Race the EAGER exitPromise (installed at spawn, so a fast exit is already captured —
|
|
249
|
-
// B2) against abort. exitPromise is resolve-only, so the loser never surfaces as an
|
|
250
|
-
// unhandled rejection; only the abort branch rejects.
|
|
251
|
-
new Promise((resolve, reject) => {
|
|
252
|
-
const { exitPromise } = requireProductionChild(child);
|
|
253
|
-
if (signal.aborted) {
|
|
254
|
-
reject(new Error("aborted"));
|
|
255
|
-
return;
|
|
256
|
-
}
|
|
257
|
-
const onAbort = () => reject(new Error("aborted"));
|
|
258
|
-
signal.addEventListener("abort", onAbort, { once: true });
|
|
259
|
-
exitPromise.then((code) => {
|
|
260
|
-
signal.removeEventListener("abort", onAbort);
|
|
261
|
-
resolve(code);
|
|
262
|
-
});
|
|
263
|
-
}),
|
|
264
|
-
awaitTimeout: (ms, signal) => abortableDelay(ms, signal),
|
|
265
|
-
killChild: (child) => {
|
|
266
|
-
// SIGTERM only this slice — the watcher's killGrace then waits for the resulting
|
|
267
|
-
// child-exited; if none arrives it returns lock-retained (a SIGKILL escalator is a
|
|
268
|
-
// separate policy + smoke). Best-effort: the watcher catches a throw here.
|
|
269
|
-
requireProductionChild(child).proc.kill("SIGTERM");
|
|
270
|
-
},
|
|
271
|
-
releaseLock: (lock) => releaseFn(lock),
|
|
272
|
-
};
|
|
273
|
-
}
|