@junghanacs/entwurf 0.16.0 → 0.17.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 +5 -3
- package/CHANGELOG.md +337 -0
- package/README.md +8 -11
- package/VERIFY.md +8 -1
- package/demo/README.md +1 -1
- package/docs/acp-backend-rail.md +25 -14
- package/docs/setup-clean-host.md +24 -10
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/acp-client.js +1 -1
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/backend-adapter.js +34 -10
- package/package.json +10 -10
- package/pi-extensions/lib/acp/acp-client.ts +57 -4
- package/pi-extensions/lib/acp/backend-adapter.ts +78 -9
- package/pi-extensions/lib/acp/backend.ts +578 -18
- package/pi-extensions/lib/acp/claude-acp-launch.js +100 -0
- package/pi-extensions/lib/acp/event-mapper.ts +43 -6
- package/run.sh +129 -32
- package/scripts/check-acp-launch-namespace.ts +127 -0
- package/scripts/check-acp-prompt-lifecycle.ts +145 -2
- package/scripts/check-acp-stop-reason.ts +8 -2
- package/scripts/check-acp-usage-accounting.ts +1074 -0
- package/scripts/check-copilot-birth-hook.ts +28 -1
- package/scripts/check-gate-qualification.ts +4 -2
- package/scripts/check-omp-fresh-preflight.ts +27 -0
- package/scripts/check-setup-qualification.sh +40 -2
- package/scripts/copilot-bridge-oracle.sh +14 -6
- package/scripts/fake-copilot-vendor.sh +4 -2
- package/scripts/lib/pi-record-discovery.ts +47 -0
- package/scripts/mutants/acp-launch-namespace.json +34 -0
- package/scripts/mutants/acp-prompt-lifecycle.json +67 -2
- package/scripts/mutants/acp-stream-hooks.json +4 -2
- package/scripts/mutants/acp-usage-accounting.json +181 -0
- package/scripts/mutants/copilot-birth.json +3 -5
- package/scripts/mutants/pack-install.json +2 -2
- package/scripts/mutants/setup-verdict.json +35 -0
- package/scripts/omp-config-xdev.py +310 -0
- package/scripts/omp-config-xdev.sh +76 -0
- package/scripts/omp-tool-surface.py +61 -10
- package/scripts/raw-acp-child-exit-measure/README.md +285 -0
- package/scripts/raw-acp-child-exit-measure/acp-turn-population.py +89 -0
- package/scripts/raw-acp-child-exit-measure/reaper-correlation.py +47 -0
- package/scripts/smoke-acp-bundled-mcp-live.ts +2 -2
- package/scripts/smoke-acp-cortex-live.ts +2 -2
- package/scripts/smoke-acp-raw-turn-live.ts +1 -1
- package/scripts/smoke-acp-socket-citizen-live.ts +2 -2
- package/scripts/smoke-acp-v2-send-live.ts +2 -2
- package/scripts/smoke-entwurf-v2-matrix-live.ts +60 -10
- package/scripts/smoke-mux-lifecycle-live.ts +46 -2
- package/scripts/smoke-setup-verdict.sh +48 -3
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
// entwurf-owned launcher for the Claude ACP backend (#72).
|
|
2
|
+
//
|
|
3
|
+
// WHY THIS FILE EXISTS — it is not a wrapper for its own sake.
|
|
4
|
+
//
|
|
5
|
+
// The vendor bin is named `claude-agent-acp`, and that name is not ours: any
|
|
6
|
+
// harness on the host that spawns the same package produces a process with the
|
|
7
|
+
// same name. On GLG's oracle host a janitor installed for a DIFFERENT harness
|
|
8
|
+
// (openclaw's acpx, upstream PR #245) selects `claude-agent-acp` by argv
|
|
9
|
+
// SUBSTRING and SIGTERMs anything older than 900s. entwurf's child is retained
|
|
10
|
+
// across turns, so its age is the age of the SESSION, not of a turn — every
|
|
11
|
+
// session older than 15 minutes was shot at every 5 minutes. Measured: 12 of 12
|
|
12
|
+
// anomalous terminations across two boots, pid- and timestamp-locked (receipts
|
|
13
|
+
// in `scripts/raw-acp-child-exit-measure/README.md` §ANSWERED).
|
|
14
|
+
//
|
|
15
|
+
// So this launcher does two things, and deliberately nothing else:
|
|
16
|
+
//
|
|
17
|
+
// 1. NAMESPACE. It carries a name that is ours, so a name-matching janitor
|
|
18
|
+
// stops selecting our process. The vendor is `import`ed INTO this same
|
|
19
|
+
// process — not spawned as a child. That distinction is what keeps this
|
|
20
|
+
// inside #72's repair fence: with no child to restart, this cannot become
|
|
21
|
+
// the supervisor/watcher/retry the issue forbids. Any variant that spawns
|
|
22
|
+
// the vendor breaks the fence and must not be written.
|
|
23
|
+
//
|
|
24
|
+
// 2. OBSERVE A CAUGHT SIGNAL. The vendor's own handler turns SIGTERM/SIGINT
|
|
25
|
+
// into `dispose(); process.exit(0)`, so by the time entwurf sees the child
|
|
26
|
+
// end there is an exit code 0 and NO signal — a clean external kill and a
|
|
27
|
+
// vendor fault are indistinguishable. Registering first lets us record
|
|
28
|
+
// that a terminating signal was caught, before the vendor erases it.
|
|
29
|
+
//
|
|
30
|
+
// WHAT THIS COSTS, STATED SO NOBODY DISCOVERS IT LATER. Once the name split is
|
|
31
|
+
// in place, a host janitor that scans for the vendor name can no longer see
|
|
32
|
+
// entwurf's children AT ALL — including ones that genuinely leaked. If pi is
|
|
33
|
+
// SIGKILLed so `teardownChild` never runs, this adapter reparents to PID 1 and
|
|
34
|
+
// matches neither that janitor's name phase nor its orphan phase (which looks
|
|
35
|
+
// for a bare `claude`). That leak class now belongs to entwurf: after the split,
|
|
36
|
+
// we own our own cleanup story and cannot expect someone else's timer to cover
|
|
37
|
+
// it. This is a deliberate trade — being killed mid-turn is worse than leaking
|
|
38
|
+
// a process on an abnormal exit — but it is a trade, not a free win.
|
|
39
|
+
//
|
|
40
|
+
// ARGV IS NOT OURS TO TOUCH. The vendor reads `--cli` / `--version` /
|
|
41
|
+
// `--hide-claude-auth` from `process.argv`, and builds its own re-invocation
|
|
42
|
+
// command from `process.argv.slice(1)` for the terminal-auth advert. Under this
|
|
43
|
+
// launcher that advert becomes `node <this file> --cli auth login …`, which
|
|
44
|
+
// keeps working ONLY because we consume no flags and resolve the vendor
|
|
45
|
+
// ourselves. Never give this file an option of its own.
|
|
46
|
+
|
|
47
|
+
import { readFileSync, writeSync } from "node:fs";
|
|
48
|
+
import { createRequire } from "node:module";
|
|
49
|
+
import { dirname, join } from "node:path";
|
|
50
|
+
import { pathToFileURL } from "node:url";
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* The one line entwurf's stderr drain looks for, matched as an EXACT full line.
|
|
54
|
+
* A fixed enum, never interpolated from vendor text: the backend must be unable
|
|
55
|
+
* to mistake vendor prose (which mentions signals) for our own observation.
|
|
56
|
+
*/
|
|
57
|
+
const SIGNAL_FRAME_PREFIX = "ENTWURF_ACP_LAUNCH_SIGNAL=";
|
|
58
|
+
|
|
59
|
+
/** Signals the vendor normalizes to exit 0, and which are therefore invisible downstream. */
|
|
60
|
+
const OBSERVED_SIGNALS = /** @type {const} */ (["SIGTERM", "SIGINT"]);
|
|
61
|
+
|
|
62
|
+
for (const signal of OBSERVED_SIGNALS) {
|
|
63
|
+
process.on(signal, () => {
|
|
64
|
+
// writeSync, not console.error: a handler may run while the process is
|
|
65
|
+
// tearing down and an async write can be dropped.
|
|
66
|
+
try {
|
|
67
|
+
writeSync(2, `${SIGNAL_FRAME_PREFIX}${signal}\n`);
|
|
68
|
+
} catch {
|
|
69
|
+
// stderr already gone — the observation is best-effort, never fatal.
|
|
70
|
+
}
|
|
71
|
+
// THE SINK GUARD. Registering a handler SUPPRESSES node's default
|
|
72
|
+
// termination. If we are still the only listener — the vendor has not
|
|
73
|
+
// registered yet, or its import failed — this observer would make the
|
|
74
|
+
// process immune to the very signal it is observing, including entwurf's
|
|
75
|
+
// own teardown SIGTERM. Stand down and let the signal land for real.
|
|
76
|
+
if (process.listenerCount(signal) === 1) {
|
|
77
|
+
process.removeAllListeners(signal);
|
|
78
|
+
process.kill(process.pid, signal);
|
|
79
|
+
}
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Resolution lives INSIDE the same try as the import so both failures speak with
|
|
84
|
+
// one voice: a missing package and a broken package are the same event to an
|
|
85
|
+
// operator reading stderr, and only one of them would otherwise be legible.
|
|
86
|
+
try {
|
|
87
|
+
const require = createRequire(import.meta.url);
|
|
88
|
+
const pkgJsonPath = require.resolve("@agentclientprotocol/claude-agent-acp/package.json");
|
|
89
|
+
const pkgJson = JSON.parse(readFileSync(pkgJsonPath, "utf8"));
|
|
90
|
+
const binPath = typeof pkgJson.bin === "string" ? pkgJson.bin : pkgJson.bin?.["claude-agent-acp"];
|
|
91
|
+
if (!binPath) throw new Error("@agentclientprotocol/claude-agent-acp resolved but exposes no bin entry");
|
|
92
|
+
// Same process, no argv touched. The vendor bin has no main-module guard, so
|
|
93
|
+
// importing it starts the agent exactly as executing it would.
|
|
94
|
+
await import(pathToFileURL(join(dirname(pkgJsonPath), binPath)).href);
|
|
95
|
+
} catch (err) {
|
|
96
|
+
// Fail loud and DIE. No retry, no fallback: a launcher that survives its own
|
|
97
|
+
// failure is the hidden supervisor #72 forbids.
|
|
98
|
+
writeSync(2, `entwurf acp launcher: vendor import failed: ${err instanceof Error ? err.stack : String(err)}\n`);
|
|
99
|
+
process.exit(1);
|
|
100
|
+
}
|
|
@@ -49,6 +49,22 @@ export type AcpPiStreamState = {
|
|
|
49
49
|
/** When false, tool/permission notices are suppressed (kept terse for smokes). */
|
|
50
50
|
showToolNotifications?: boolean;
|
|
51
51
|
observedTools?: Map<string, ObservedToolState>;
|
|
52
|
+
/**
|
|
53
|
+
* RAW, UNINTERPRETED observations from this turn's `usage_update` notifications
|
|
54
|
+
* — carried beside the pi message, never folded into it here.
|
|
55
|
+
*
|
|
56
|
+
* The mapper is COMMON layer and deliberately assigns no meaning to either
|
|
57
|
+
* number: what `cost.amount` is CUMULATIVE OVER, and whether that even holds
|
|
58
|
+
* for a non-claude backend, is a per-backend measurement. backend.ts seals them
|
|
59
|
+
* only for a backend whose adapter declares `sealsTurnAccounting` (#93).
|
|
60
|
+
*
|
|
61
|
+
* Last write wins, never a sum: both are latest session-level observations, and
|
|
62
|
+
* one turn can legitimately see several (claude emits one per `result` message,
|
|
63
|
+
* including a sub-agent's own — read at claude-agent-acp 0.73.0
|
|
64
|
+
* `dist/acp-agent.js:2918-2933`), each carrying that result's current values.
|
|
65
|
+
*/
|
|
66
|
+
observedSessionCostUsd?: number;
|
|
67
|
+
observedContextOccupancyTokens?: number;
|
|
52
68
|
};
|
|
53
69
|
|
|
54
70
|
/** A zeroed pi Usage block. */
|
|
@@ -328,13 +344,34 @@ export function applyAcpSessionUpdate(
|
|
|
328
344
|
break;
|
|
329
345
|
}
|
|
330
346
|
case "usage_update": {
|
|
331
|
-
//
|
|
332
|
-
//
|
|
333
|
-
//
|
|
334
|
-
//
|
|
335
|
-
|
|
347
|
+
// `used` is OCCUPANCY-shaped — the backend's post-turn context size, not
|
|
348
|
+
// the prompt response's turn aggregate. Claude sends `lastAssistantTotalUsage`
|
|
349
|
+
// as `used` (read at claude-agent-acp 0.73.0
|
|
350
|
+
// `dist/acp-agent.js:3290-3297`), after constructing that scalar from the
|
|
351
|
+
// latest assistant snapshot (`:3273-3289`). pi reads `usage.totalTokens` as
|
|
352
|
+
// exactly that occupancy (`calculateContextTokens(usage) = usage.totalTokens
|
|
353
|
+
// || input + output + cacheRead + cacheWrite`, read at pi-coding-agent
|
|
354
|
+
// `dist/core/compaction/compaction.js:86-88`). The assignment below is the
|
|
355
|
+
// raw observation; backend.ts seals the measured occupancy and relays the
|
|
356
|
+
// turn aggregate separately on `usage.acp`, never onto pi's four fields.
|
|
357
|
+
if (typeof update.used === "number") {
|
|
358
|
+
state.output.usage.totalTokens = update.used;
|
|
359
|
+
state.observedContextOccupancyTokens = update.used;
|
|
360
|
+
}
|
|
361
|
+
// `cost.amount` is a running SESSION total on the claude backend, so
|
|
362
|
+
// assigning it to a TURN field is wrong wherever an adapter has measured
|
|
363
|
+
// that (#93: pi sums per-message cost, which inflated a long session
|
|
364
|
+
// 10-18x). It stays here for a backend whose adapter has NOT measured its
|
|
365
|
+
// semantics — for those, this coarse assignment is the pre-#93 behaviour
|
|
366
|
+
// and changing it would be an unmeasured claim. backend.ts OVERWRITES
|
|
367
|
+
// this field authoritatively (from the baseline diff) for every turn of a
|
|
368
|
+
// backend that HAS sealsTurnAccounting, so the cumulative can never reach
|
|
369
|
+
// an operator's dashboard as a turn cost on that path.
|
|
336
370
|
const cost = update.cost as { amount?: unknown } | undefined;
|
|
337
|
-
if (typeof cost?.amount === "number")
|
|
371
|
+
if (typeof cost?.amount === "number") {
|
|
372
|
+
state.output.usage.cost.total = cost.amount;
|
|
373
|
+
state.observedSessionCostUsd = cost.amount;
|
|
374
|
+
}
|
|
338
375
|
break;
|
|
339
376
|
}
|
|
340
377
|
default:
|
package/run.sh
CHANGED
|
@@ -119,7 +119,7 @@ run_vitest() {
|
|
|
119
119
|
usage() {
|
|
120
120
|
cat <<'EOF'
|
|
121
121
|
Usage:
|
|
122
|
-
./run.sh setup [project-dir] # ONE presence-driven composition (#86): mode-first (source bootstrap only on a checkout), then per-component PASS/SKIP/FAIL for pi (presence+floor)/claude/agy/copilot (all four units: birth→MCP→receiver→footer, independently) + stable dev bins + v2 install smoke; absent harness = zero-state SKIP, detected-incomplete = named FAIL + nonzero exit. Never installs a harness or touches credentials
|
|
122
|
+
./run.sh setup [project-dir] # ONE presence-driven composition (#86): mode-first (source bootstrap only on a checkout), then per-component PASS/SKIP/FAIL for pi (presence+floor)/claude/agy/copilot (all four units: birth→MCP→receiver→footer, independently)/omp (four units: birth→MCP→tools.xdev setting→receiver, independently) + stable dev bins + v2 install smoke; absent harness = zero-state SKIP, detected-incomplete = named FAIL + nonzero exit. Never installs a harness or touches credentials
|
|
123
123
|
./run.sh release-gate [project-dir] [--cut] [--allow-skip-gemini] # SINGLE release gate: full static (pnpm run check:full) + the v2-native live gates (v2 matrix-live, check-bridge, doctor-pi-provider, RGG) + the ACP plugin acceptance floor (12 LIVE smokes: socket-citizen/raw-turn/overlay/provider/session-reuse/carrier-augment/memory-containment/rgg/mcp/skill/bundled-mcp/v2-send) + the one surviving axis the aggregate used to omit silently (claude-native-resume; Cortex stays a documented on-demand direct call) + the cross-harness delivery chain (smoke-entwurf-chain-live). TWO-TIER summary: MUST (release-blocking, owns the exit code — "green" applies here) + BEHAVIOR (advisory, non-blocking: RGG positives model-in-loop turn). STEP OUTCOME protocol: every step is INVOKED and reports its own PASS / SKIP (exit 97, a prerequisite it does not have) / FAIL — a skip is never counted as a pass. Without --cut this is the unattended diagnostic (SKIPs reported, exit 0). WITH --cut it is read as release acceptance and ANY MUST SKIP is red, which is what makes "a CUT needs LIVE=1, SKIP=0" executable instead of prose. --allow-skip-gemini accepted-but-ignored (back-compat). final cut authorization is GLG's.
|
|
124
124
|
./run.sh check-bridge # entwurf-bridge direct MCP smoke + protocol/negative-path test.sh (live substrate = v2 live smokes)
|
|
125
125
|
./run.sh check-entwurf-bridge-boot # deterministic gate (5d-5-pre, G1a/G1b/G1e/G1f, IN pnpm run check:full): boot start.sh under strip-types + assert v2 fence graph loads + entwurf_v2 and entwurf_resume_call registered/schema + the tools/list surface is EXACTLY the seven shipped garden verbs; tools/list only, no auth/side-effect
|
|
@@ -218,6 +218,8 @@ Usage:
|
|
|
218
218
|
./run.sh install-copilot-mcp # #82 RAIL 5: register ONE entwurf-bridge server in ~/.copilot/mcp-config.json (adopt / create / REFUSE symlink), type:local, install-state under $XDG_DATA_HOME/entwurf/copilot-mcp/
|
|
219
219
|
./run.sh uninstall-copilot-mcp # honest inverse of install-copilot-mcp from install-state
|
|
220
220
|
./run.sh doctor-copilot-mcp # static ownership/config/boot doctor; RED only when install-state exists
|
|
221
|
+
./run.sh install-omp-config # #87 follow-on: write the ONE operator setting omp's tool hand requires — `tools.xdev: false` in <omp agent dir>/config.yml. Owns exactly the line(s) it adds (recorded in install-state), refuses a symlinked config, refuses a config it cannot parse, and refuses an EXPLICIT operator `xdev: true` by name rather than overwriting a decision. Without it the vendor default wraps every MCP tool behind `xd://` and the doorbell announces a tool the model cannot call
|
|
222
|
+
./run.sh uninstall-omp-config # honest inverse from install-state: takes back exactly the recorded line(s), removes the file only when entwurf created it, REFUSES when the config changed since install
|
|
221
223
|
./run.sh install-omp-bridge # #87: install the OMP BIRTH extension into <omp agent dir>/extensions/entwurf-meta-omp (index.ts|js + lib + capability registry). No launcher and no bake — an omp hook is an in-process extension. Refuses when an inherited PI_CODING_AGENT_DIR/PI_CONFIG_DIR/PI_PROFILE makes the target agent dir ambiguous (ledger M6), and refuses ANY pre-existing artifact at the unit path that entwurf holds no ownership state for — a shape is not a proof of ownership
|
|
222
224
|
./run.sh uninstall-omp-bridge # honest inverse from install-state (exact unit dir + recorded entry; no-state host REFUSES; state deleted LAST). Records already minted are preserved
|
|
223
225
|
./run.sh doctor-omp-bridge # #87: runtime axis (importable unit, writer/registry parity, mint vs sender-marker errors on SEPARATE axes, scope-fence receipts, a root-grammar preflight that goes RED on a relative ENTWURF_META_* override instead of reporting on some other directory, CERTIFIED omp record count via meta-facts under the omp root policy — never a text grep, live omp processes carrying inherited PI_SESSION_ID/PI_AGENT_ID) + ownership axis. PI_CODING_AGENT_DIR is the vendor's own agent dir here and is reported as ignored, never as contamination. Zero records = NOT-YET, never red
|
|
@@ -250,7 +252,7 @@ Usage:
|
|
|
250
252
|
./run.sh check-dep-versions # local deterministic check that the pi pin agrees across package.json (devDeps + peer range), run.sh (peer-install pins), and the baseline docs (AGENTS/README/ROADMAP/setup-clean-host/demo)
|
|
251
253
|
./run.sh check-node-floor-coherence # binds the Node floor (24+, single axis) across engines.node, run.sh setup preflight, meta-bridge install/doctor judgment logic, clean-host docs, the bridge launcher header, and the CI runner node-version — engines.node is the SSOT, everything else is derived; sweeps tracked contract text for an unregistered declaration
|
|
252
254
|
./run.sh check-pack # publish gate (dry-run): npm pack --dry-run + tarball invariants (runtime-critical present, dev residue absent)
|
|
253
|
-
./run.sh check-pack-pin-matcher # pure self-test of check-pack-install's pin-leak matcher against synthetic .pnpm lookalikes (version boundary: @0.84.
|
|
255
|
+
./run.sh check-pack-pin-matcher # pure self-test of check-pack-install's pin-leak matcher against synthetic .pnpm lookalikes (version boundary: @0.84.40 must leak, @0.84.4 bare/peer-hash must pass); snapshot-safe qualification oracle, also run first inside check-pack-install
|
|
254
256
|
./run.sh check-fresh-cut-gate # SOURCE cell of the generation-boundary proof (IN pnpm run check:full): drives real install/setup/fresh-cut in a sandbox; certification refusal is pre-write, quiescence is fail-closed, archives preserve bytes, and the #54 exit matrix distinguishes complete / no-move / usage / incomplete transition / complete-with-cleanup-residue. No model/network/cost
|
|
255
257
|
./run.sh check-pack-install # heavy publish gate (prepublishOnly): actual npm pack + tar -tf + fresh-temp install smoke with the pinned pi peers (pins derived from the package.json devDep; check-dep-versions binds them) + the npm-installed bridge BOOTS (tools/list) and DELIVERS (tools/call entwurf_v2 → .msg lands) + the installed all-absent and copilot-present (four-unit fake-vendor) `entwurf setup` rows + the INSTALLED generation lifecycle on a seeded previous-generation host (REFUSE before activation writes / zero Claude invocations → installed fresh-cut archives + opens empty → install-meta-bridge PASSES) + the INSTALLED-PACKAGE branch of the Copilot and OMP birth installers actually RUN (compiled entry selected, no raw .ts, and a real birth edge mints a citizen — the half a required-artifact list can never stand in for)
|
|
256
258
|
./run.sh check-install-container # 0.12.8 (#51 C): Linux artifact-CONSUMER gate — one candidate .tgz handed read-only to a checkout-invisible node:<engines-major>-bookworm cell. Default packs once to temp; ENTWURF_CANDIDATE_TGZ=/absolute/preserved.tgz consumes those exact bytes with no re-pack and prints canonical path+sha256 for release. Non-root global PATH install, frozen package, MCP tools/list, fake-Claude install-meta-bridge, path+sha256 fence, strict doctor, and the GENERATION host-state matrix (clean / v3-only store bytes unchanged / previous-generation REFUSE→fresh-cut→retry PASS) seeded inline. Docker missing = honest SKIP; ENTWURF_REQUIRE_DOCKER=1 makes that RED (required CI)
|
|
@@ -1825,13 +1827,13 @@ assert.equal(peerTui, piAi,
|
|
|
1825
1827
|
// floor tracks the devDep pin so a consumer can't install against a pi lacking
|
|
1826
1828
|
// the public trust exports the bridge imports at the pinned minor, AND an upper
|
|
1827
1829
|
// bound at the next minor stops a fresh install from silently pulling a future
|
|
1828
|
-
// pi (past the declared ceiling — 0.85+ at the current 0.84.
|
|
1830
|
+
// pi (past the declared ceiling — 0.85+ at the current 0.84.4 pin) whose
|
|
1829
1831
|
// internal export surface has drifted from the one we typecheck against.
|
|
1830
1832
|
// pi moves its public surface every minor (the 0.79→0.80 getModels→provider-
|
|
1831
1833
|
// factory churn is exactly this), so an open `>=` floor is exactly how the next
|
|
1832
1834
|
// installer re-acquires the drift. The floor is also the HARD MINIMUM a consumer
|
|
1833
|
-
// install resolves: at `>=0.84.
|
|
1834
|
-
// Expected shape: `>=<devDep> <0.<minor+1>` (e.g. `>=0.84.
|
|
1835
|
+
// install resolves: at `>=0.84.4` an existing 0.84.3 host is upgraded, not kept.
|
|
1836
|
+
// Expected shape: `>=<devDep> <0.<minor+1>` (e.g. `>=0.84.4 <0.85`).
|
|
1835
1837
|
const [piMaj, piMin] = piAi.split('.').map(Number);
|
|
1836
1838
|
assert.equal(piMaj, 0,
|
|
1837
1839
|
`pi pin major must stay 0 for the next-minor ceiling rule (got ${piAi}); revisit check-dep-versions when pi reaches 1.x`);
|
|
@@ -2698,6 +2700,24 @@ check_acp_event_mapper() {
|
|
|
2698
2700
|
run_ts scripts/check-acp-event-mapper.ts
|
|
2699
2701
|
}
|
|
2700
2702
|
|
|
2703
|
+
check_acp_usage_accounting() {
|
|
2704
|
+
# Deterministic gate for the ACP USAGE ACCOUNTING contract (#93). A long-lived
|
|
2705
|
+
# Claude ACP session's dashboard read 10-18x high on three live ledgers,
|
|
2706
|
+
# because the per-turn token partition was dropped at the type boundary while
|
|
2707
|
+
# the backend's RUNNING SESSION TOTAL was assigned to a per-turn cost field pi
|
|
2708
|
+
# then summed. Drives streamAcpTurn against a fake ACP child + connection whose
|
|
2709
|
+
# turns are scripted (usage_update notification + PromptResponse.usage) for
|
|
2710
|
+
# five cells: the four-way token partition reaches the pi message; per-turn
|
|
2711
|
+
# costs are adjacent diffs of the running total and sum back to it across a
|
|
2712
|
+
# reused session; a turn with no cost notification attributes $0 and HOLDS the
|
|
2713
|
+
# baseline; a decreasing total rebaselines, attributes $0 and tells the
|
|
2714
|
+
# operator; totalTokens stays CONTEXT OCCUPANCY (asserted through pi's own
|
|
2715
|
+
# calculateContextTokens) and carries forward; and cortex — no measured
|
|
2716
|
+
# extractor — keeps its pre-#93 output untouched.
|
|
2717
|
+
section "ACP usage accounting (turn partition + adjacent-diff cost)"
|
|
2718
|
+
run_ts scripts/check-acp-usage-accounting.ts
|
|
2719
|
+
}
|
|
2720
|
+
|
|
2701
2721
|
check_acp_stop_reason() {
|
|
2702
2722
|
# Deterministic gate for the ACP stop-reason contract. Drives every member of
|
|
2703
2723
|
# the closed ACP terminal set (end_turn / max_tokens / max_turn_requests /
|
|
@@ -2729,6 +2749,17 @@ check_acp_prompt_lifecycle() {
|
|
|
2729
2749
|
run_ts scripts/check-acp-prompt-lifecycle.ts
|
|
2730
2750
|
}
|
|
2731
2751
|
|
|
2752
|
+
check_acp_launch_namespace() {
|
|
2753
|
+
# #72: the Claude ACP child must launch under a name entwurf OWNS. A janitor
|
|
2754
|
+
# installed on the host for ANOTHER harness selects the vendor process name
|
|
2755
|
+
# `claude-agent-acp` by argv substring and SIGTERMs it by age; entwurf retains
|
|
2756
|
+
# its child across turns, so its age is the session's. This gate holds the
|
|
2757
|
+
# name split, holds the launcher transparent (the vendor still answers
|
|
2758
|
+
# --version through it), and keeps an explicit operator override verbatim.
|
|
2759
|
+
section "ACP launch namespace (#72 — no vendor process name in our argv)"
|
|
2760
|
+
run_ts scripts/check-acp-launch-namespace.ts
|
|
2761
|
+
}
|
|
2762
|
+
|
|
2732
2763
|
check_acp_stream_hooks() {
|
|
2733
2764
|
# Deterministic gate for the pi 0.84 streamSimple hook contract on the ACP rail
|
|
2734
2765
|
# (#63; upstream pi-mono #7372 → doc-only #7576). before_provider_request
|
|
@@ -3067,15 +3098,15 @@ check_pack() {
|
|
|
3067
3098
|
# The pin-leak filter for the install tree's .pnpm listing, shared by the real scan and its
|
|
3068
3099
|
# self-test below. The version BOUNDARY is load-bearing: a pnpm .pnpm entry is
|
|
3069
3100
|
# `<name>@<version>` followed by either `_<peer-hash>` or end-of-name (measured pnpm 11.20.0
|
|
3070
|
-
# on this tree), so an unbounded substring match would bless a lookalike such as `@0.84.
|
|
3101
|
+
# on this tree), so an unbounded substring match would bless a lookalike such as `@0.84.40`
|
|
3071
3102
|
# while announcing the pinned floor — a false-green oracle (found by independent review,
|
|
3072
3103
|
# 2026-08-25).
|
|
3073
3104
|
pack_install_leaked_pi() {
|
|
3074
|
-
grep '^@earendil-works+pi-' | grep -Ev '@0\.84\.
|
|
3105
|
+
grep '^@earendil-works+pi-' | grep -Ev '@0\.84\.4(_|$)' || true
|
|
3075
3106
|
}
|
|
3076
3107
|
|
|
3077
3108
|
# Matcher self-test on SYNTHETIC lookalikes: a healthy install tree cannot exercise the
|
|
3078
|
-
# false-green shape (it contains no 0.84.
|
|
3109
|
+
# false-green shape (it contains no 0.84.40), so the oracle is proven against a fixture
|
|
3079
3110
|
# listing. Expected: the two lookalikes leak, the pinned version passes bare and with a
|
|
3080
3111
|
# peer-hash suffix. Exposed as its own snapshot-safe subcommand because the heavy
|
|
3081
3112
|
# check-pack-install cannot run inside the qualification snapshot (no install environment),
|
|
@@ -3084,13 +3115,13 @@ pack_install_leaked_pi() {
|
|
|
3084
3115
|
check_pack_pin_matcher() {
|
|
3085
3116
|
local matcher_probe
|
|
3086
3117
|
matcher_probe=$(printf '%s\n' \
|
|
3087
|
-
'@earendil-works+pi-ai@0.84.
|
|
3088
|
-
'@earendil-works+pi-ai@0.84.
|
|
3089
|
-
'@earendil-works+pi-ai@0.84.
|
|
3090
|
-
'@earendil-works+pi-agent-core@0.84.
|
|
3091
|
-
if [ "$matcher_probe" != '@earendil-works+pi-ai@0.84.
|
|
3092
|
-
@earendil-works+pi-agent-core@0.84.
|
|
3093
|
-
fail "[QK:PACK-INSTALL-PIN-MATCHER-BOUNDED] the pin-leak matcher must flag the 0.84.
|
|
3118
|
+
'@earendil-works+pi-ai@0.84.4' \
|
|
3119
|
+
'@earendil-works+pi-ai@0.84.4_@modelcontextprotocol+sdk@1.29.0_zod@4.3.6' \
|
|
3120
|
+
'@earendil-works+pi-ai@0.84.40' \
|
|
3121
|
+
'@earendil-works+pi-agent-core@0.84.3' | pack_install_leaked_pi)
|
|
3122
|
+
if [ "$matcher_probe" != '@earendil-works+pi-ai@0.84.40
|
|
3123
|
+
@earendil-works+pi-agent-core@0.84.3' ]; then
|
|
3124
|
+
fail "[QK:PACK-INSTALL-PIN-MATCHER-BOUNDED] the pin-leak matcher must flag the 0.84.40/0.84.3 lookalikes and pass 0.84.4 bare or with a peer-hash — got: ${matcher_probe:-<nothing leaked>}"
|
|
3094
3125
|
return 1
|
|
3095
3126
|
fi
|
|
3096
3127
|
echo "[check-pack-pin-matcher] ok — the pin-leak matcher is version-bounded (lookalikes leak, pinned version passes bare and with a peer-hash)"
|
|
@@ -3320,7 +3351,7 @@ _check_pack_install_impl() {
|
|
|
3320
3351
|
printf '%s\n' '{ "name": "entwurf-install-smoke", "version": "0.0.0", "private": true }' > "$tmp/package.json"
|
|
3321
3352
|
|
|
3322
3353
|
# pi-agent-core is pinned even though we never import it: pi-coding-agent depends
|
|
3323
|
-
# on it by CARET (`^0.84.
|
|
3354
|
+
# on it by CARET (`^0.84.x`), so with no lockfile in this fresh temp project it
|
|
3324
3355
|
# floats to whatever pi published last — and that newer core then drags a NESTED
|
|
3325
3356
|
# pi-ai of its own. Measured 2026-07-21: pinning only the three we import left
|
|
3326
3357
|
# pi-agent-core@0.80.10 + pi-ai@0.80.10 in the tree while the gate still announced
|
|
@@ -3335,24 +3366,24 @@ _check_pack_install_impl() {
|
|
|
3335
3366
|
# first time. Unpinned they would float exactly like pi-agent-core did in the
|
|
3336
3367
|
# 2026-07-21 incident above. pi-telemetry joined the explicit pin list on
|
|
3337
3368
|
# 2026-08-31: it arrives transitively (pi-agent-core and pi-ai both carry
|
|
3338
|
-
# `^0.84.
|
|
3369
|
+
# `^0.84.x` carets on it), and upstream's 0.84.4 patch publish (2026-08-28
|
|
3339
3370
|
# 22:04Z) floated that caret in this lockfile-less temp install, turning CI
|
|
3340
|
-
# red through the leak assertion below.
|
|
3341
|
-
#
|
|
3342
|
-
#
|
|
3371
|
+
# red through the leak assertion below. That explicit pin is what held the
|
|
3372
|
+
# line until the bump lane ran; the verified floor is 0.84.4 as of
|
|
3373
|
+
# 2026-09-01, and the pin moved WITH it rather than being retired. The leak
|
|
3343
3374
|
# assertion below still covers every other pi package, including any package
|
|
3344
3375
|
# a future pi bump adds to the closure.
|
|
3345
3376
|
echo "[check-pack-install] pnpm add into $tmp (with 0.84.x peers + typebox)"
|
|
3346
3377
|
local install_log
|
|
3347
3378
|
install_log=$(cd "$tmp" && pnpm add \
|
|
3348
3379
|
"$tgz_path" \
|
|
3349
|
-
"@earendil-works/pi-ai@0.84.
|
|
3350
|
-
"@earendil-works/pi-coding-agent@0.84.
|
|
3351
|
-
"@earendil-works/pi-tui@0.84.
|
|
3352
|
-
"@earendil-works/pi-agent-core@0.84.
|
|
3353
|
-
"@earendil-works/pi-client@0.84.
|
|
3354
|
-
"@earendil-works/pi-protocol@0.84.
|
|
3355
|
-
"@earendil-works/pi-telemetry@0.84.
|
|
3380
|
+
"@earendil-works/pi-ai@0.84.4" \
|
|
3381
|
+
"@earendil-works/pi-coding-agent@0.84.4" \
|
|
3382
|
+
"@earendil-works/pi-tui@0.84.4" \
|
|
3383
|
+
"@earendil-works/pi-agent-core@0.84.4" \
|
|
3384
|
+
"@earendil-works/pi-client@0.84.4" \
|
|
3385
|
+
"@earendil-works/pi-protocol@0.84.4" \
|
|
3386
|
+
"@earendil-works/pi-telemetry@0.84.4" \
|
|
3356
3387
|
"typebox@latest" \
|
|
3357
3388
|
--ignore-workspace --ignore-scripts 2>&1) || {
|
|
3358
3389
|
fail "[check-pack-install] pnpm add failed:"
|
|
@@ -3362,17 +3393,17 @@ _check_pack_install_impl() {
|
|
|
3362
3393
|
|
|
3363
3394
|
# A pin is a wish until the resolved tree is read back. Assert it: EVERY
|
|
3364
3395
|
# @earendil-works pi package present — direct or transitive, top level or nested —
|
|
3365
|
-
# must be the pinned 0.84.
|
|
3396
|
+
# must be the pinned 0.84.4. Anything else means an unpinned caret floated and the
|
|
3366
3397
|
# rest of this gate would be exercising a runtime nobody verified, while still
|
|
3367
|
-
# printing "pinned pi 0.84.
|
|
3398
|
+
# printing "pinned pi 0.84.4". Fail loud instead of proving the wrong floor.
|
|
3368
3399
|
local leaked_pi
|
|
3369
3400
|
leaked_pi=$(ls "$tmp/node_modules/.pnpm" 2>/dev/null | pack_install_leaked_pi)
|
|
3370
3401
|
if [ -n "$leaked_pi" ]; then
|
|
3371
|
-
fail "[check-pack-install] UNVERIFIED pi runtime resolved into the install tree (expected only 0.84.
|
|
3402
|
+
fail "[check-pack-install] UNVERIFIED pi runtime resolved into the install tree (expected only 0.84.4):"
|
|
3372
3403
|
printf '%s\n' "$leaked_pi" | sed 's/^/ /' >&2
|
|
3373
3404
|
return 1
|
|
3374
3405
|
fi
|
|
3375
|
-
echo "[check-pack-install] pi runtime tree pin verified: every @earendil-works pi package is 0.84.
|
|
3406
|
+
echo "[check-pack-install] pi runtime tree pin verified: every @earendil-works pi package is 0.84.4"
|
|
3376
3407
|
|
|
3377
3408
|
# Resolve the installed package.json and confirm pi.extensions
|
|
3378
3409
|
# arrived intact. If pi.extensions is empty or missing, the
|
|
@@ -4983,6 +5014,52 @@ setup_all() {
|
|
|
4983
5014
|
fi
|
|
4984
5015
|
fi
|
|
4985
5016
|
|
|
5017
|
+
# ── omp (oh-my-pi) ── presence-driven composition, same shape as copilot. OMP
|
|
5018
|
+
# was admitted as a garden citizen in v0.16.0 with installers, doctors and
|
|
5019
|
+
# inverses for every unit — but it was never composed HERE, so the one-command
|
|
5020
|
+
# surface left the fifth backend to a hand-run verb list and the operator setting
|
|
5021
|
+
# to a documentation step. That gap is what `docs/adding-a-harness.md` step 10
|
|
5022
|
+
# now closes for every future harness: an onboarding is not finished until setup
|
|
5023
|
+
# composes it. Four units, each independent: birth (who the citizen is) → MCP
|
|
5024
|
+
# hand (what it can call) → the tools.xdev operator setting (whether those calls
|
|
5025
|
+
# are REACHABLE) → receiver (whether a reply can land). OMP_BIN pins the PROBE
|
|
5026
|
+
# for hermetic gates; the unit scripts address `omp` on PATH.
|
|
5027
|
+
local omp_rc
|
|
5028
|
+
if ! command -v "${OMP_BIN:-omp}" >/dev/null 2>&1; then
|
|
5029
|
+
setup_result omp SKIP "omp not on PATH — zero OMP wiring written"
|
|
5030
|
+
else
|
|
5031
|
+
section "omp units (native harness detected: oh-my-pi)"
|
|
5032
|
+
omp_rc=0; (cd "$REPO_DIR" && bash scripts/omp-bridge-install.sh) || omp_rc=$?
|
|
5033
|
+
if [ "$omp_rc" -eq 0 ]; then
|
|
5034
|
+
setup_result omp-birth PASS "birth extension installed — verify: ./run.sh doctor-omp-bridge"
|
|
5035
|
+
else
|
|
5036
|
+
setup_result omp-birth FAIL "detected omp, but the birth extension install did not complete (see above) — repair, then re-run setup"
|
|
5037
|
+
fi
|
|
5038
|
+
omp_rc=0; (cd "$REPO_DIR" && bash scripts/omp-mcp-bridge.sh install) || omp_rc=$?
|
|
5039
|
+
if [ "$omp_rc" -eq 0 ]; then
|
|
5040
|
+
setup_result omp-mcp PASS "MCP server registered — verify: ./run.sh doctor-omp-mcp"
|
|
5041
|
+
else
|
|
5042
|
+
setup_result omp-mcp FAIL "detected omp, but the MCP registration did not complete (see above) — repair, then re-run setup"
|
|
5043
|
+
fi
|
|
5044
|
+
# The operator setting is a component of its own because its FAIL is a real
|
|
5045
|
+
# disagreement, not a broken install: an explicit `tools.xdev: true` is the
|
|
5046
|
+
# operator's decision and the writer refuses it by name. Naming that as a
|
|
5047
|
+
# component FAIL puts the choice in front of the operator instead of silently
|
|
5048
|
+
# shipping a citizen whose tools nobody can call.
|
|
5049
|
+
omp_rc=0; (cd "$REPO_DIR" && bash scripts/omp-config-xdev.sh install) || omp_rc=$?
|
|
5050
|
+
if [ "$omp_rc" -eq 0 ]; then
|
|
5051
|
+
setup_result omp-config PASS "tools.xdev: false written — verify: ./run.sh doctor-omp-mcp"
|
|
5052
|
+
else
|
|
5053
|
+
setup_result omp-config FAIL "detected omp, but the tools.xdev operator setting did not land (see above) — resolve it, then re-run setup"
|
|
5054
|
+
fi
|
|
5055
|
+
omp_rc=0; (cd "$REPO_DIR" && bash scripts/omp-receive-install.sh) || omp_rc=$?
|
|
5056
|
+
if [ "$omp_rc" -eq 0 ]; then
|
|
5057
|
+
setup_result omp-receive PASS "receiver extension installed — verify: ./run.sh doctor-omp-receive"
|
|
5058
|
+
else
|
|
5059
|
+
setup_result omp-receive FAIL "detected omp, but the receiver extension install did not complete (see above) — repair, then re-run setup"
|
|
5060
|
+
fi
|
|
5061
|
+
fi
|
|
5062
|
+
|
|
4986
5063
|
# ── core bridge boundary ── deterministic preflight lives in `pnpm run
|
|
4987
5064
|
# check:full`; live substrate acceptance lives in `LIVE=1 ./run.sh
|
|
4988
5065
|
# release-gate <scratch> --cut`. Setup is the install path, so it verifies the
|
|
@@ -5003,7 +5080,7 @@ setup_all() {
|
|
|
5003
5080
|
local entry rest s_name s_verdict s_fails=""
|
|
5004
5081
|
for entry in "${SETUP_RESULTS[@]}"; do
|
|
5005
5082
|
s_name="${entry%%|*}"; rest="${entry#*|}"; s_verdict="${rest%%|*}"
|
|
5006
|
-
printf ' %-
|
|
5083
|
+
printf ' %-18s %-4s %s\n' "$s_name" "$s_verdict" "${rest#*|}"
|
|
5007
5084
|
if [ "$s_verdict" = "FAIL" ]; then s_fails="$s_fails $s_name"; fi
|
|
5008
5085
|
done
|
|
5009
5086
|
echo ""
|
|
@@ -6121,6 +6198,20 @@ case "$cmd" in
|
|
|
6121
6198
|
smoke-omp-mcp-state)
|
|
6122
6199
|
(cd "$REPO_DIR" && bash scripts/smoke-omp-mcp-state.sh)
|
|
6123
6200
|
;;
|
|
6201
|
+
install-omp-config)
|
|
6202
|
+
# #87 follow-on: the ONE operator setting omp's tool hand requires (`tools.xdev: false`).
|
|
6203
|
+
# A separate unit from install-omp-mcp because it answers a different question — the MCP
|
|
6204
|
+
# hand registers the server, this decides whether the registered tools are REACHABLE. It
|
|
6205
|
+
# owns exactly the line it adds and refuses an explicit operator `xdev: true` by name.
|
|
6206
|
+
shift || true
|
|
6207
|
+
(cd "$REPO_DIR" && bash scripts/omp-config-xdev.sh install "$@")
|
|
6208
|
+
;;
|
|
6209
|
+
uninstall-omp-config)
|
|
6210
|
+
# honest inverse from install-state: takes back exactly the recorded line(s), removes the
|
|
6211
|
+
# file only when entwurf created it, and REFUSES when the config changed since install.
|
|
6212
|
+
shift || true
|
|
6213
|
+
(cd "$REPO_DIR" && bash scripts/omp-config-xdev.sh uninstall "$@")
|
|
6214
|
+
;;
|
|
6124
6215
|
install-omp-bridge)
|
|
6125
6216
|
# #87: the OMP BIRTH install. Not a mode of the Claude or Copilot installer, and for a
|
|
6126
6217
|
# structural reason rather than a stylistic one: an omp "hook" IS an in-process
|
|
@@ -6447,12 +6538,18 @@ case "$cmd" in
|
|
|
6447
6538
|
check-acp-event-mapper)
|
|
6448
6539
|
check_acp_event_mapper
|
|
6449
6540
|
;;
|
|
6541
|
+
check-acp-usage-accounting)
|
|
6542
|
+
check_acp_usage_accounting
|
|
6543
|
+
;;
|
|
6450
6544
|
check-acp-stop-reason)
|
|
6451
6545
|
check_acp_stop_reason
|
|
6452
6546
|
;;
|
|
6453
6547
|
check-acp-prompt-lifecycle)
|
|
6454
6548
|
check_acp_prompt_lifecycle
|
|
6455
6549
|
;;
|
|
6550
|
+
check-acp-launch-namespace)
|
|
6551
|
+
check_acp_launch_namespace
|
|
6552
|
+
;;
|
|
6456
6553
|
check-acp-stream-hooks)
|
|
6457
6554
|
check_acp_stream_hooks
|
|
6458
6555
|
;;
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
// Deterministic gate — the Claude ACP child launches under a name entwurf OWNS.
|
|
2
|
+
//
|
|
3
|
+
// WHY THIS IS A GATE AND NOT A COMMENT (#72). The vendor bin is called
|
|
4
|
+
// `claude-agent-acp`, and that name belongs to the package, not to us: any
|
|
5
|
+
// harness on the host spawning the same package produces a process with the
|
|
6
|
+
// same name. On GLG's oracle host a janitor installed for a DIFFERENT harness
|
|
7
|
+
// selects `claude-agent-acp` by argv SUBSTRING and SIGTERMs anything older than
|
|
8
|
+
// 900s. Because entwurf RETAINS its child across turns, its age is the age of
|
|
9
|
+
// the session — 12 of 12 anomalous terminations across two boots were that
|
|
10
|
+
// janitor (receipts: scripts/raw-acp-child-exit-measure/README.md §ANSWERED).
|
|
11
|
+
//
|
|
12
|
+
// The defense is a launcher whose own name carries no vendor substring. That is
|
|
13
|
+
// a property of a STRING, which is exactly the kind of thing that rots silently
|
|
14
|
+
// under a rename or a "harmless" revert — hence a gate that fails loudly.
|
|
15
|
+
//
|
|
16
|
+
// The second half matters as much: the launcher must remain TRANSPARENT. The
|
|
17
|
+
// vendor reads `--cli` / `--version` from `process.argv` and builds its own
|
|
18
|
+
// re-invocation command from `process.argv.slice(1)`. A launcher that consumed
|
|
19
|
+
// a flag of its own, or failed to start the vendor at all, would trade one
|
|
20
|
+
// silent breakage for another — so we RUN it and require the vendor to answer.
|
|
21
|
+
|
|
22
|
+
import { strict as assert } from "node:assert";
|
|
23
|
+
import { execFileSync } from "node:child_process";
|
|
24
|
+
import { rmdirSync, rmSync } from "node:fs";
|
|
25
|
+
import { resolve } from "node:path";
|
|
26
|
+
import { pathToFileURL } from "node:url";
|
|
27
|
+
|
|
28
|
+
/** The substring the janitor matches on — the thing our launch argv must not contain. */
|
|
29
|
+
const VENDOR_PROCESS_MATCHER = "claude-agent-acp";
|
|
30
|
+
|
|
31
|
+
const TMP_EMIT = ".tmp-verify/acp-launch-namespace";
|
|
32
|
+
rmSync(TMP_EMIT, { recursive: true, force: true });
|
|
33
|
+
try {
|
|
34
|
+
execFileSync("node_modules/.bin/tsc", ["--outDir", TMP_EMIT, "--rootDir", ".", "--noEmit", "false"], {
|
|
35
|
+
stdio: "pipe",
|
|
36
|
+
});
|
|
37
|
+
const adapterUrl = pathToFileURL(resolve(TMP_EMIT, "pi-extensions/lib/acp/backend-adapter.js")).href;
|
|
38
|
+
const mod = (await import(adapterUrl)) as {
|
|
39
|
+
claudeAdapter: {
|
|
40
|
+
resolveLaunch: (p: { cwd: string; modelId: string; nativeModelId: string; config: unknown }) => {
|
|
41
|
+
command: string;
|
|
42
|
+
args: string[];
|
|
43
|
+
};
|
|
44
|
+
};
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
const launch = mod.claudeAdapter.resolveLaunch({
|
|
48
|
+
cwd: process.cwd(),
|
|
49
|
+
modelId: "claude-sonnet-5",
|
|
50
|
+
nativeModelId: "claude-sonnet-5",
|
|
51
|
+
config: {},
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
// ----------------------------------------------------------------------
|
|
55
|
+
// The claim: nothing a name-matching janitor scans contains the vendor name.
|
|
56
|
+
// We check the WHOLE argv the way `ps` presents it, because that is what the
|
|
57
|
+
// janitor's awk actually reads — not just the basename.
|
|
58
|
+
// ----------------------------------------------------------------------
|
|
59
|
+
const psLine = [launch.command, ...launch.args].join(" ");
|
|
60
|
+
assert.ok(
|
|
61
|
+
!psLine.includes(VENDOR_PROCESS_MATCHER),
|
|
62
|
+
`[QK:CLAUDE-LAUNCH-IS-NAMESPACED] the default Claude ACP launch must not put "${VENDOR_PROCESS_MATCHER}" anywhere ` +
|
|
63
|
+
"in its argv: a janitor installed for another harness selects that substring by age and SIGTERMs it, which is " +
|
|
64
|
+
`the whole of #72. Got: ${JSON.stringify(psLine)}`,
|
|
65
|
+
);
|
|
66
|
+
assert.ok(
|
|
67
|
+
launch.args.length === 1 && launch.args[0].endsWith("claude-acp-launch.js"),
|
|
68
|
+
"the default launch is the entwurf-owned launcher and NOTHING else — an extra argv entry would be a flag of our " +
|
|
69
|
+
`own, which the vendor's argv.slice(1) self-reinvocation cannot survive. Got: ${JSON.stringify(launch.args)}`,
|
|
70
|
+
);
|
|
71
|
+
|
|
72
|
+
// ----------------------------------------------------------------------
|
|
73
|
+
// The launcher must still BE the vendor. A name split that stopped starting
|
|
74
|
+
// the agent would pass every string assertion above and ship a dead backend,
|
|
75
|
+
// so run it and make the vendor answer through it.
|
|
76
|
+
// ----------------------------------------------------------------------
|
|
77
|
+
const version = execFileSync(process.execPath, [...launch.args, "--version"], {
|
|
78
|
+
encoding: "utf8",
|
|
79
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
80
|
+
timeout: 60_000,
|
|
81
|
+
}).trim();
|
|
82
|
+
assert.match(
|
|
83
|
+
version,
|
|
84
|
+
/^\d+\.\d+\.\d+/,
|
|
85
|
+
"[QK:CLAUDE-LAUNCH-IS-TRANSPARENT] the launcher must pass argv through untouched and start the real vendor — " +
|
|
86
|
+
`\`--version\` has to reach it and answer. Got: ${JSON.stringify(version)}`,
|
|
87
|
+
);
|
|
88
|
+
|
|
89
|
+
// ----------------------------------------------------------------------
|
|
90
|
+
// The debug override is an EXPLICIT operator choice and must stay literal:
|
|
91
|
+
// an operator who names their own command owns the result, including losing
|
|
92
|
+
// the name split. Routing it through the launcher would silently overrule them.
|
|
93
|
+
// ----------------------------------------------------------------------
|
|
94
|
+
process.env.CLAUDE_AGENT_ACP_COMMAND = "echo overridden";
|
|
95
|
+
try {
|
|
96
|
+
const overridden = mod.claudeAdapter.resolveLaunch({
|
|
97
|
+
cwd: process.cwd(),
|
|
98
|
+
modelId: "claude-sonnet-5",
|
|
99
|
+
nativeModelId: "claude-sonnet-5",
|
|
100
|
+
config: {},
|
|
101
|
+
});
|
|
102
|
+
assert.deepEqual(
|
|
103
|
+
[overridden.command, ...overridden.args],
|
|
104
|
+
["bash", "-lc", "echo overridden"],
|
|
105
|
+
"CLAUDE_AGENT_ACP_COMMAND must stay verbatim — the launcher is the DEFAULT, never an override of the operator",
|
|
106
|
+
);
|
|
107
|
+
} finally {
|
|
108
|
+
delete process.env.CLAUDE_AGENT_ACP_COMMAND;
|
|
109
|
+
}
|
|
110
|
+
} finally {
|
|
111
|
+
rmSync(TMP_EMIT, { recursive: true, force: true });
|
|
112
|
+
try {
|
|
113
|
+
// A leftover EMPTY parent dir reads as IMPURE tree drift in the
|
|
114
|
+
// qualification harness; a concurrent sibling gate's emit keeps it alive
|
|
115
|
+
// and this rmdir simply fails.
|
|
116
|
+
rmdirSync(".tmp-verify");
|
|
117
|
+
} catch {
|
|
118
|
+
// non-empty or already gone — fine either way
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
console.log(
|
|
123
|
+
"[check-acp-launch-namespace] ok — the default Claude ACP launch carries no vendor process name in its argv (so a " +
|
|
124
|
+
"name-matching janitor installed for another harness cannot select it), is exactly the entwurf-owned launcher " +
|
|
125
|
+
"with no flag of its own, still starts the real vendor through argv passed untouched, and leaves an explicit " +
|
|
126
|
+
"CLAUDE_AGENT_ACP_COMMAND override verbatim",
|
|
127
|
+
);
|