@junghanacs/entwurf 0.14.0 → 0.14.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/AGENTS.md +13 -2
- package/CHANGELOG.md +63 -0
- package/DELIVERY.md +57 -0
- package/README.md +16 -7
- package/VERIFY.md +4 -4
- package/demo/README.md +3 -1
- package/demo/demo-baseline.sh +12 -1
- package/demo/demo.sh +9 -1
- package/docs/acp-backend-rail.md +103 -4
- package/docs/external-mcp-host.md +1 -1
- package/docs/setup-clean-host.md +3 -3
- package/mcp/entwurf-bridge/dist/mcp/entwurf-bridge/src/index.js +12 -5
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/classify-tmux-cwd.js +47 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/mux-fresh-call.js +45 -3
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/mux-resume-call.js +18 -47
- package/mcp/entwurf-bridge/dist/scripts/doctor-pi-provider.js +139 -47
- package/mcp/entwurf-bridge/dist/scripts/probe-bridge-command.js +294 -0
- package/mcp/entwurf-bridge/src/index.ts +14 -5
- package/mcp/entwurf-bridge/tsconfig.build.json +15 -5
- package/package.json +9 -9
- package/pi-extensions/entwurf-control.ts +13 -4
- package/pi-extensions/lib/acp/backend.ts +229 -9
- package/pi-extensions/lib/classify-tmux-cwd.ts +50 -0
- package/pi-extensions/lib/mux-fresh-call.ts +57 -4
- package/pi-extensions/lib/mux-resume-call.ts +21 -53
- package/run.sh +70 -25
- package/scripts/agy-bridge-config.py +47 -13
- package/scripts/agy-bridge.sh +73 -23
- package/scripts/check-acp-prompt-lifecycle.ts +221 -9
- package/scripts/check-entwurf-bridge-boot.ts +28 -0
- package/scripts/check-gate-qualification.ts +5 -3
- package/scripts/check-mux-resume-call.ts +11 -10
- package/scripts/check-probe-bridge-command.ts +201 -0
- package/scripts/check-release-gate-outcomes.ts +54 -1
- package/scripts/doctor-pi-provider.ts +155 -51
- package/scripts/meta-bridge-state.py +75 -1
- package/scripts/mutants/acp-prompt-lifecycle.json +25 -3
- package/scripts/mutants/bridge-command-boot.json +107 -0
- package/scripts/mutants/meta-retire.json +47 -0
- package/scripts/mutants/mux-fresh-call.json +48 -4
- package/scripts/mutants/mux-resume-call.json +3 -3
- package/scripts/mutants/release-gate.json +13 -0
- package/scripts/probe-bridge-command.ts +330 -0
- package/scripts/raw-async-delivery/README.md +158 -1
- package/scripts/raw-async-delivery/copilot-ui-server-probe.mjs +337 -0
- package/scripts/smoke-acp-raw-turn-live.ts +1 -1
- package/scripts/smoke-agy-install-state.sh +76 -2
- package/scripts/smoke-entwurf-chain-live.ts +12 -4
- package/scripts/smoke-entwurf-v2-matrix-live.ts +2 -2
- package/scripts/smoke-meta-install-state.sh +169 -3
- package/scripts/smoke-mux-fresh-call-live.ts +1 -1
- package/scripts/smoke-mux-lifecycle-live.ts +1 -1
- package/scripts/smoke-pi-provider-state.sh +135 -6
- package/scripts/smoke-resident-garden-guard.sh +2 -2
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
# raw-async-delivery — RAW async message delivery into LIVE Claude Code / agy / Codex sessions
|
|
1
|
+
# raw-async-delivery — RAW async message delivery into LIVE Claude Code / agy / Codex / Copilot sessions
|
|
2
2
|
|
|
3
3
|
Goal: deliver an async message INTO an already-running **subscription** session,
|
|
4
4
|
free, with no `claude -p` / backend CLI prompt spawn — and in particular wake an
|
|
@@ -12,6 +12,12 @@ Codex is split by launch surface:
|
|
|
12
12
|
auto-attached to a default-path app-server): raw `turn/start` over
|
|
13
13
|
WebSocket-over-UDS wakes the live thread — **demonstrated, no managed
|
|
14
14
|
standalone, no cloud**.
|
|
15
|
+
- **Copilot CLI 1.0.80 TUI+server**: official SDK `1.0.11` can resolve the
|
|
16
|
+
foreground native session and enqueue into that exact idle TUI; D7 was
|
|
17
|
+
demonstrated once (L4 direct-native, one Linux workstation). The launch flag
|
|
18
|
+
`--ui-server` is hidden from CLI help and its loopback RPC authentication is
|
|
19
|
+
not established, so this remains a probe, not a managed lane. Undocumented
|
|
20
|
+
`~/.copilot/run/ws.*` is still not a rail.
|
|
15
21
|
|
|
16
22
|
## TL;DR — three reception paths, ranked
|
|
17
23
|
|
|
@@ -82,6 +88,7 @@ do not ship that. Proven: deliver to A's sessionId → A wakes (FileChanged), B'
|
|
|
82
88
|
- `raw-agy-send.sh <conv_id> …` — agy parity: PUSH into a live Antigravity session (LS gRPC)
|
|
83
89
|
- `raw-codex-ws-turn-start.py <sock> <thread_id> …` — Codex parity (no managed standalone, no cloud): PUSH `turn/start` over WebSocket-over-UDS into a bare `app-server --listen` socket
|
|
84
90
|
- `codex-local-appserver.sh [sock]` — start a bare local app-server so plain `codex` auto-attaches and becomes addressable
|
|
91
|
+
- `copilot-ui-server-probe.mjs` — official-SDK D0 probe by default; `LIVE=1` performs one addressed idle enqueue and prints D0–D8
|
|
85
92
|
|
|
86
93
|
### Reproduction drivers
|
|
87
94
|
- `repro-plugin-idle-wake.sh` — single-session smoke.
|
|
@@ -244,6 +251,150 @@ send-message-v2` spins a fresh thread, not the live one.
|
|
|
244
251
|
4. **Unix-socket transport is WebSocket** (tokio-tungstenite), not newline JSON-RPC,
|
|
245
252
|
and requires **no auth token** on the UDS. A plain WS client suffices.
|
|
246
253
|
|
|
254
|
+
## Copilot CLI raw delivery status (1.0.80) — positive TUI+server probe, not a managed lane
|
|
255
|
+
|
|
256
|
+
Copilot is a GitHub **harness** (issues, PR, CI, model `auto`, mode `autopilot`,
|
|
257
|
+
remote/delegate), not a second GPT provider next to pi. This makes a native lane
|
|
258
|
+
product-distinct from the declined Codex lane: Copilot can own GitHub work while
|
|
259
|
+
implementation checkpoints arrive from another garden id. That split is operator
|
|
260
|
+
etiquette plus dispatch, not a substrate role system.
|
|
261
|
+
|
|
262
|
+
The cost premise must stay honest. Copilot reports AI Credits and a monthly
|
|
263
|
+
premium-interaction entitlement; model `auto` chooses a path within that budget.
|
|
264
|
+
The reason to use it is GitHub specialization and auto routing, not an unlimited
|
|
265
|
+
subscription claim. Model `auto` and mode `autopilot` are separate axes.
|
|
266
|
+
|
|
267
|
+
### Positive transport
|
|
268
|
+
|
|
269
|
+
The first-party `@github/copilot-sdk` exposes TUI+server coordination:
|
|
270
|
+
`getForegroundSessionId()`, `getSessionMetadata()`, `resumeSession(id)`, and
|
|
271
|
+
session-scoped `send({mode:"enqueue"|"immediate"})`. The native TUI launch mode is:
|
|
272
|
+
|
|
273
|
+
```bash
|
|
274
|
+
copilot --ui-server --port 43817 --model auto
|
|
275
|
+
```
|
|
276
|
+
|
|
277
|
+
`--ui-server` is accepted by CLI 1.0.80 and named by the official SDK API docs,
|
|
278
|
+
but hidden from `copilot --help`. Launch mode is therefore part of the capability,
|
|
279
|
+
and this evidence does not apply to an already-running plain `copilot` TUI.
|
|
280
|
+
|
|
281
|
+
Measured once on 2026-08-19 with SDK 1.0.11, **using the earlier chronological-slice
|
|
282
|
+
probe** (it scored the events following the marker's position in the history). The probe
|
|
283
|
+
in the tree today scores a named turn instead — see "Current probe contract" below — and
|
|
284
|
+
that contract has NOT been run LIVE, so nothing in this list is retroactively evidence
|
|
285
|
+
for it. Evidence level for the list: **L4 direct-native, ONE Linux workstation, one run**;
|
|
286
|
+
the receipt is host-local probe stdout and was NOT archived as a durable artifact, so what
|
|
287
|
+
follows is reproducible by instruction, not citable to a stored file:
|
|
288
|
+
|
|
289
|
+
- protocol v3 ping succeeded;
|
|
290
|
+
- foreground session id joined to metadata containing cwd/git root/branch;
|
|
291
|
+
- `resumeSession(id)` + idle `enqueue` woke the visible TUI with zero typing;
|
|
292
|
+
- the unique body appeared as `user.message` with `delivery:"idle"`;
|
|
293
|
+
- model `auto` selected `gpt-5.6-luna` and returned the exact marker;
|
|
294
|
+
- `assistant.message`, `assistant.turn_end`, and (in the one-session run)
|
|
295
|
+
ephemeral `session.idle` gave completion/reply evidence;
|
|
296
|
+
- a two-session control then targeted A while B received no `user.message`, turn,
|
|
297
|
+
or assistant event, so D3 passed too.
|
|
298
|
+
|
|
299
|
+
D0–D7 passed; D8 is unproven. One robustness defect surfaced in the two-session
|
|
300
|
+
shape: the target visibly replied and persisted `assistant.message` + `turn_end`,
|
|
301
|
+
but that joining SDK client did not receive ephemeral `session.idle`, so SDK
|
|
302
|
+
`sendAndWait()` timed out after 60 seconds. The probe therefore uses `send()` plus
|
|
303
|
+
bounded polling of the official session event-history API (`session.getEvents()` /
|
|
304
|
+
`getMessages()`) and reports completion at `turn_end`. Name that surface at its real
|
|
305
|
+
size: it is the SDK's own full event history — no narrower, no more privileged — and it
|
|
306
|
+
is **not** TUI, file, or database transcript scraping; the probe never reads Copilot's
|
|
307
|
+
storage. This is evidence, not a product retry/polling design.
|
|
308
|
+
|
|
309
|
+
### Current probe contract — designed, not yet run LIVE
|
|
310
|
+
|
|
311
|
+
Attribution is the load-bearing part, and it runs off the probe's own marker body:
|
|
312
|
+
|
|
313
|
+
```text
|
|
314
|
+
unique marker body → exactly one user.message
|
|
315
|
+
→ its interactionId
|
|
316
|
+
→ exactly one assistant.turn_start on that interaction
|
|
317
|
+
→ that turn_start's required turnId
|
|
318
|
+
→ only assistant.message / assistant.turn_end on that turnId
|
|
319
|
+
```
|
|
320
|
+
|
|
321
|
+
Every link is required and unambiguous; absent or matched twice, the probe FAILS CLOSED.
|
|
322
|
+
There is no positional fallback and no "the turn after ours" rule, because scoring "the
|
|
323
|
+
newest assistant.message" in a session a human is also typing into is how a probe reports
|
|
324
|
+
someone else's turn as its own delivery.
|
|
325
|
+
|
|
326
|
+
What is deliberately NOT the key: `session.send()` on the bundled CLI 1.0.80 resolves to a
|
|
327
|
+
`Promise<string>` that is the SDK's own submission handle. The server-side `user.message`
|
|
328
|
+
does not carry that string, and it is a different axis from that event's
|
|
329
|
+
`id`/`interactionId` — a join on it cannot hold, so the probe logs it as a diagnostic and
|
|
330
|
+
never matches on it.
|
|
331
|
+
|
|
332
|
+
The D3 control cell uses the same sentence it prints: across `onEvent` and `getEvents`, the
|
|
333
|
+
non-target session received no `user.message` and no `assistant.*` event of any kind.
|
|
334
|
+
|
|
335
|
+
Lifecycle, stated precisely rather than flatteringly: the probe never deletes target
|
|
336
|
+
session A and issues no `A.disconnect()` of its own. `client.stop()` DOES tear down every
|
|
337
|
+
tracked session — A included — as a wire `session.destroy`; because A's foreground
|
|
338
|
+
ownership is re-confirmed immediately before teardown, the TUI keeps A as its foreground
|
|
339
|
+
session, so the net effect on A is detach-equivalent, not removal. The probe deletes only
|
|
340
|
+
the control session it created itself, and reads `client.stop()`'s returned error list so a
|
|
341
|
+
failed teardown cannot exit 0 behind a printed verdict. Reaching past the SDK for a raw
|
|
342
|
+
detach wrapper is out of bounds — it would be a second lifecycle authority.
|
|
343
|
+
|
|
344
|
+
This contract has not been exercised by a LIVE turn. The next LIVE run is what would
|
|
345
|
+
demonstrate it, and it also confirms the real event key names.
|
|
346
|
+
|
|
347
|
+
### Reproduce with the pinned official SDK
|
|
348
|
+
|
|
349
|
+
The SDK is deliberately not an entwurf production dependency while this is only
|
|
350
|
+
a raw probe. Install it in scratch and point the probe at its ESM entry:
|
|
351
|
+
|
|
352
|
+
```bash
|
|
353
|
+
mkdir -p /tmp/copilot-sdk-probe
|
|
354
|
+
printf '{"private":true,"type":"module"}\n' >/tmp/copilot-sdk-probe/package.json
|
|
355
|
+
pnpm --dir /tmp/copilot-sdk-probe add --ignore-scripts @github/copilot-sdk@1.0.11
|
|
356
|
+
|
|
357
|
+
# terminal A: visible native session (accept folder trust)
|
|
358
|
+
copilot --ui-server --port 43817 --model auto
|
|
359
|
+
|
|
360
|
+
# terminal B: D0 only, no model call
|
|
361
|
+
COPILOT_SDK_MODULE=/tmp/copilot-sdk-probe/node_modules/@github/copilot-sdk/dist/index.js \
|
|
362
|
+
./copilot-ui-server-probe.mjs
|
|
363
|
+
|
|
364
|
+
# one paid/subscription turn: addressed idle enqueue through the official SDK
|
|
365
|
+
LIVE=1 COPILOT_SDK_MODULE=/tmp/copilot-sdk-probe/node_modules/@github/copilot-sdk/dist/index.js \
|
|
366
|
+
./copilot-ui-server-probe.mjs
|
|
367
|
+
|
|
368
|
+
# creates a second no-turn control session and proves only the target receives the marker
|
|
369
|
+
LIVE=1 COPILOT_D3_CONTROL=1 \
|
|
370
|
+
COPILOT_SDK_MODULE=/tmp/copilot-sdk-probe/node_modules/@github/copilot-sdk/dist/index.js \
|
|
371
|
+
./copilot-ui-server-probe.mjs
|
|
372
|
+
```
|
|
373
|
+
|
|
374
|
+
### Admission blockers and negative evidence
|
|
375
|
+
|
|
376
|
+
- **Transport authentication is not established.** The TUI server bound loopback,
|
|
377
|
+
but an SDK client without a token connected. Setting `COPILOT_CONNECTION_TOKEN`
|
|
378
|
+
on the UI server did not enable auth: the unauthenticated client still connected,
|
|
379
|
+
while a token-bearing client received `AUTHENTICATION_NOT_CONFIGURED`. Do not
|
|
380
|
+
ship this as a rail until the supported same-user/fail-closed boundary is proved.
|
|
381
|
+
- Shell command-hook `sessionStart` input is `{timestamp,cwd,source,initialPrompt?}`;
|
|
382
|
+
it does **not** carry `sessionId`. SDK callbacks receive `sessionId` separately in
|
|
383
|
+
their invocation object. The discarded command-hook probe therefore could not
|
|
384
|
+
establish D0, and UUID-only process-log scraping did not satisfy D0's cwd/liveness
|
|
385
|
+
join. Do not revive that path.
|
|
386
|
+
- Hooks have no `FileChanged` / `watchPaths` / `asyncRewake`; the Claude mailbox
|
|
387
|
+
mechanism cannot be copied.
|
|
388
|
+
- `--acp` is a pi-host child path, not this native TUI citizen path. `--remote` is
|
|
389
|
+
GitHub web/mobile steering, not a local `entwurf_v2` API. Undocumented
|
|
390
|
+
`~/.copilot/run/ws.*` remains out of bounds.
|
|
391
|
+
|
|
392
|
+
Do not add `backend:"copilot"`, `FRESH_CALL_BACKENDS`, a schema change, or an OPEN
|
|
393
|
+
issue from these positive probes. Next evidence is active-turn enqueue/immediate
|
|
394
|
+
behavior, TUI-vs-SDK permission ownership, the missing multi-session `session.idle`,
|
|
395
|
+
endpoint staleness/crash behavior, and a supported authenticated or equivalent local
|
|
396
|
+
boundary.
|
|
397
|
+
|
|
247
398
|
## Live SSOT for "is the target session alive?"
|
|
248
399
|
|
|
249
400
|
- Claude Code: `~/.claude/sessions/<pid>.json` (pid, sessionId, cwd, status). NOT
|
|
@@ -256,3 +407,9 @@ send-message-v2` spins a fresh thread, not the live one.
|
|
|
256
407
|
`$HOME/.codex/app-server-control/app-server-control.sock` (or any owned 0700
|
|
257
408
|
socket via `codex app-server --listen unix://PATH`) is the delivery surface.
|
|
258
409
|
threadId comes from the newest rollout's `session_meta.id`.
|
|
410
|
+
- Copilot TUI+server probe: SDK `ping` + `getForegroundSessionId()` +
|
|
411
|
+
`getSessionMetadata()` identify the currently displayed native session, and
|
|
412
|
+
the official session event-history API (`getEvents()` / `getMessages()`) reads
|
|
413
|
+
a turn back — no transcript file or database is touched. The TCP port is a
|
|
414
|
+
runtime endpoint, never an identity axis; no managed liveness join exists
|
|
415
|
+
until its authentication and stale-endpoint behavior are proved.
|
|
@@ -0,0 +1,337 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Raw Copilot CLI TUI+server delivery probe.
|
|
4
|
+
*
|
|
5
|
+
* This is probe evidence, not a shipped adapter. Start a visible native TUI:
|
|
6
|
+
* copilot --ui-server --port 43817 --model auto
|
|
7
|
+
*
|
|
8
|
+
* The official SDK is intentionally not a production dependency of entwurf.
|
|
9
|
+
* Point COPILOT_SDK_MODULE at its ESM entry (README has the pinned setup).
|
|
10
|
+
* Without LIVE=1 this proves only D0 and spends no model credit.
|
|
11
|
+
*
|
|
12
|
+
* ATTRIBUTION IS THE WHOLE CLAIM. A delivery probe that reads "the newest
|
|
13
|
+
* assistant.message" is reading the TUI, not its own turn: a human typing in the
|
|
14
|
+
* visible session, a queued earlier prompt, or a retried send all produce events
|
|
15
|
+
* that would be scored as OUR delivery.
|
|
16
|
+
*
|
|
17
|
+
* The join runs off the PROBE-OWNED MARKER BODY, not off what `send()` returned.
|
|
18
|
+
* On the bundled CLI 1.0.80 `session.send()` resolves to a `Promise<string>` whose
|
|
19
|
+
* id is the SDK's own submission handle: the server-side `user.message` does NOT
|
|
20
|
+
* carry it, and it is a different axis from that event's `id`/`interactionId`. A
|
|
21
|
+
* join on the returned string is therefore not merely fragile, it cannot hold — so
|
|
22
|
+
* the returned value is kept for the diagnostic log only. The chain that carries
|
|
23
|
+
* the claim is:
|
|
24
|
+
*
|
|
25
|
+
* unique marker body → exactly one `user.message`
|
|
26
|
+
* → its `interactionId`
|
|
27
|
+
* → exactly one `assistant.turn_start` on that interaction
|
|
28
|
+
* → that turn_start's required `turnId`
|
|
29
|
+
* → only `assistant.message` / `assistant.turn_end` on that turnId
|
|
30
|
+
*
|
|
31
|
+
* Every link is required and unambiguous. Absent, or matched more than once, the
|
|
32
|
+
* probe FAILS CLOSED (throws). There is no positional fallback and no "the next
|
|
33
|
+
* turn after ours" rule, because a wrong pass here would be published as capability
|
|
34
|
+
* evidence.
|
|
35
|
+
*/
|
|
36
|
+
import { pathToFileURL } from "node:url";
|
|
37
|
+
|
|
38
|
+
const modulePath = process.env.COPILOT_SDK_MODULE;
|
|
39
|
+
if (!modulePath?.startsWith("/")) {
|
|
40
|
+
throw new Error("COPILOT_SDK_MODULE must be an absolute path to @github/copilot-sdk/dist/index.js");
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const { CopilotClient, RuntimeConnection } = await import(pathToFileURL(modulePath).href);
|
|
44
|
+
const server = process.env.COPILOT_UI_SERVER ?? "localhost:43817";
|
|
45
|
+
const live = process.env.LIVE === "1";
|
|
46
|
+
const twoSessionControl = process.env.COPILOT_D3_CONTROL === "1";
|
|
47
|
+
if (twoSessionControl && !live) throw new Error("COPILOT_D3_CONTROL=1 requires LIVE=1");
|
|
48
|
+
const marker = `NATIVE-ENTWURF-${Date.now()}`;
|
|
49
|
+
const TURN_BUDGET_MS = 30_000;
|
|
50
|
+
/** Cleanup gets its own small bound: a probe must not hang on the way out. */
|
|
51
|
+
const CLEANUP_BOUND_MS = 5_000;
|
|
52
|
+
const seen = new Set();
|
|
53
|
+
const controlSeen = new Set();
|
|
54
|
+
let controlSession;
|
|
55
|
+
|
|
56
|
+
const client = new CopilotClient({
|
|
57
|
+
connection: RuntimeConnection.forUri(server),
|
|
58
|
+
mode: "copilot-cli",
|
|
59
|
+
logLevel: "error",
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Bound ONE awaited SDK call. Promise.race does not cancel the underlying request —
|
|
64
|
+
* nothing in the SDK offers that — so this is a liveness bound on the probe, not a
|
|
65
|
+
* cancellation. That is the honest guarantee: the probe always reaches a verdict or
|
|
66
|
+
* an error within its budget, and never sits forever on a call that will not return.
|
|
67
|
+
*/
|
|
68
|
+
async function bounded(label, promise, ms) {
|
|
69
|
+
if (ms <= 0) throw new Error(`${label}: no time left in the ${TURN_BUDGET_MS}ms turn budget`);
|
|
70
|
+
let timer;
|
|
71
|
+
try {
|
|
72
|
+
return await Promise.race([
|
|
73
|
+
promise,
|
|
74
|
+
new Promise((_, reject) => {
|
|
75
|
+
timer = setTimeout(() => reject(new Error(`${label} exceeded its ${ms}ms bound`)), ms);
|
|
76
|
+
}),
|
|
77
|
+
]);
|
|
78
|
+
} finally {
|
|
79
|
+
clearTimeout(timer);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Identifier readers for the two links that DO exist server-side. The SDK nests payloads
|
|
84
|
+
// under `data`, but a top-level carrier is accepted too so a protocol revision that flattens
|
|
85
|
+
// one field does not silently turn a cell into a positional guess — an ABSENT id still fails
|
|
86
|
+
// closed below. There is deliberately no reader for send()'s returned handle: it is not a key
|
|
87
|
+
// any event carries, so joining on it would be inventing a relation.
|
|
88
|
+
const interactionOf = (event) => event?.data?.interactionId ?? event?.interactionId;
|
|
89
|
+
const turnOf = (event) => event?.data?.turnId ?? event?.turnId;
|
|
90
|
+
|
|
91
|
+
function observe(event) {
|
|
92
|
+
seen.add(event.type);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function report(cells) {
|
|
96
|
+
console.log("DELIVERY_LEVELS:");
|
|
97
|
+
console.log("harness=copilot-cli");
|
|
98
|
+
console.log(`transport=official-sdk-over-hidden-ui-server server=${server}`);
|
|
99
|
+
for (const [level, value] of Object.entries(cells)) console.log(`${level} ${value}`);
|
|
100
|
+
console.log(
|
|
101
|
+
"notes=probe-only; --ui-server is hidden from CLI help; loopback RPC authentication is not established; " +
|
|
102
|
+
"no managed citizen lane; completion is read through the official session event-history API " +
|
|
103
|
+
"(getEvents/getMessages) — no TUI, file, or database transcript scraping; " +
|
|
104
|
+
"evidence=L4 direct-native on ONE Linux workstation; this stdout is host-local and was not archived as a durable artifact",
|
|
105
|
+
);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** Everything the probe must do on the way out, each bounded. Returns failure lines. */
|
|
109
|
+
async function cleanup(foregroundSessionId) {
|
|
110
|
+
const problems = [];
|
|
111
|
+
const attempt = async (label, thunk) => {
|
|
112
|
+
try {
|
|
113
|
+
return await bounded(label, thunk(), CLEANUP_BOUND_MS);
|
|
114
|
+
} catch (err) {
|
|
115
|
+
problems.push(`${label}: ${String(err)}`);
|
|
116
|
+
return undefined;
|
|
117
|
+
}
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
// RE-CONFIRM A as the foreground session BEFORE anything is torn down: creating the control
|
|
121
|
+
// session moved the TUI's foreground, and leaving the operator's visible window pointing at a
|
|
122
|
+
// probe-owned session would be the probe editing the operator's state. Read first and write
|
|
123
|
+
// only on drift — a probe that unconditionally re-sets the foreground is making a write it
|
|
124
|
+
// cannot tell apart from a no-op, and it would report a drift it caused as clean.
|
|
125
|
+
if (foregroundSessionId) {
|
|
126
|
+
const current = await attempt("getForegroundSessionId()", () => client.getForegroundSessionId());
|
|
127
|
+
if (current !== foregroundSessionId) {
|
|
128
|
+
console.error(`CLEANUP_NOTE foreground was ${current ?? "none"}, not target A — restoring A`);
|
|
129
|
+
await attempt("setForegroundSessionId(A)", () => client.setForegroundSessionId(foregroundSessionId));
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// Only the session the PROBE created is deleted. A is the operator's live session and is
|
|
134
|
+
// never deleted here.
|
|
135
|
+
if (controlSession?.sessionId) {
|
|
136
|
+
await attempt("deleteSession(B)", () => client.deleteSession(controlSession.sessionId));
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// Say what stop() actually does, not the flattering version. The probe issues no
|
|
140
|
+
// A.disconnect() of its own, but client.stop() tears down EVERY tracked session — including
|
|
141
|
+
// the resumed A — and that teardown goes out on the wire as session.destroy. What keeps this
|
|
142
|
+
// honest rather than destructive is the order above: A's foreground ownership is re-confirmed
|
|
143
|
+
// first, so the TUI keeps A as its foreground session and the net effect on A is
|
|
144
|
+
// detach-equivalent, not removal. A is not deleted. Do NOT "fix" this by reaching past the
|
|
145
|
+
// SDK for a raw detach — a bespoke wrapper would be a second lifecycle authority, and this
|
|
146
|
+
// probe's whole claim is that it used the official surface.
|
|
147
|
+
//
|
|
148
|
+
// stop() also reports its teardown failures by RETURNING them, so an unread array is a
|
|
149
|
+
// cleanup failure laundered into success.
|
|
150
|
+
const stopErrors = await attempt("client.stop()", () => client.stop());
|
|
151
|
+
if (Array.isArray(stopErrors) && stopErrors.length > 0) {
|
|
152
|
+
problems.push(
|
|
153
|
+
`client.stop() reported ${stopErrors.length} teardown error(s): ${stopErrors.map(String).join("; ")}`,
|
|
154
|
+
);
|
|
155
|
+
}
|
|
156
|
+
return problems;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
let foregroundSessionId;
|
|
160
|
+
try {
|
|
161
|
+
await client.start();
|
|
162
|
+
const ping = await client.ping("entwurf-native-probe");
|
|
163
|
+
const sessionId = await client.getForegroundSessionId();
|
|
164
|
+
if (!sessionId) throw new Error("ui-server has no foreground session");
|
|
165
|
+
foregroundSessionId = sessionId;
|
|
166
|
+
const metadata = await client.getSessionMetadata(sessionId);
|
|
167
|
+
if (!metadata?.context?.workingDirectory) throw new Error("foreground session metadata has no workingDirectory");
|
|
168
|
+
|
|
169
|
+
console.error(
|
|
170
|
+
`protocol=${ping.protocolVersion ?? "unknown"} sessionId=${sessionId} cwd=${metadata.context.workingDirectory}`,
|
|
171
|
+
);
|
|
172
|
+
if (!live) {
|
|
173
|
+
report({
|
|
174
|
+
"D0 live_identity:": 'pass reason="ping + foreground session id + metadata cwd"',
|
|
175
|
+
"D1 native_continuation:": 'unproven reason="set LIVE=1 for one model turn"',
|
|
176
|
+
"D2 receiver_armed:": 'pass reason="SDK connected to the TUI ui-server"',
|
|
177
|
+
"D3 addressed_enqueue:": "unproven",
|
|
178
|
+
"D4 idle_wake:": "unproven",
|
|
179
|
+
"D5 context_injection:": "unproven",
|
|
180
|
+
"D6 continuity:": "unproven",
|
|
181
|
+
"D7 completion_reply:": "unproven",
|
|
182
|
+
"D8 robustness:": "unproven",
|
|
183
|
+
});
|
|
184
|
+
process.exitCode = 0;
|
|
185
|
+
} else {
|
|
186
|
+
if (twoSessionControl) {
|
|
187
|
+
controlSession = await client.createSession({
|
|
188
|
+
model: "auto",
|
|
189
|
+
workingDirectory: metadata.context.workingDirectory,
|
|
190
|
+
skipCustomInstructions: true,
|
|
191
|
+
onEvent: (event) => controlSeen.add(event.type),
|
|
192
|
+
});
|
|
193
|
+
await client.setForegroundSessionId(sessionId);
|
|
194
|
+
console.error(`controlSessionId=${controlSession.sessionId}`);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
const session = await client.resumeSession(sessionId, {
|
|
198
|
+
suppressResumeEvent: true,
|
|
199
|
+
onEvent: observe,
|
|
200
|
+
});
|
|
201
|
+
// send() resolves to the SDK's own submission handle. On the bundled CLI 1.0.80 that string
|
|
202
|
+
// appears on NO server event — not as user.message.id, not as its interactionId — so it is
|
|
203
|
+
// logged as a diagnostic and never joined on. The marker body below is the real key.
|
|
204
|
+
const sentHandle = await session.send({
|
|
205
|
+
prompt: `This is a native delivery probe. Reply with exactly ${marker} and nothing else.`,
|
|
206
|
+
mode: "enqueue",
|
|
207
|
+
});
|
|
208
|
+
console.error(`sendHandle=${String(sentHandle)} (diagnostic only — not an event key)`);
|
|
209
|
+
|
|
210
|
+
/** Resolve the marker turn from the event history, or return why it is not resolvable yet. */
|
|
211
|
+
const resolveMarkerTurn = (events) => {
|
|
212
|
+
const matches = events.filter((event) => event.type === "user.message" && event.data?.content?.includes(marker));
|
|
213
|
+
if (matches.length === 0) return { pending: `no user.message carries the unique marker ${marker}` };
|
|
214
|
+
// AMBIGUITY IS A FAILURE, NOT A TIE-BREAK. Two user.messages with this body means the send
|
|
215
|
+
// was duplicated (retry, replay, a human pasting it); picking one would be a guess.
|
|
216
|
+
if (matches.length > 1) {
|
|
217
|
+
return {
|
|
218
|
+
fatal: `${matches.length} user.message events carry the unique marker ${marker} — the delivery is ambiguous`,
|
|
219
|
+
};
|
|
220
|
+
}
|
|
221
|
+
const markerEvent = matches[0];
|
|
222
|
+
const interactionId = interactionOf(markerEvent);
|
|
223
|
+
if (interactionId === undefined) {
|
|
224
|
+
return {
|
|
225
|
+
fatal: "the marker user.message exposes no interactionId — assistant events cannot be attributed to it",
|
|
226
|
+
};
|
|
227
|
+
}
|
|
228
|
+
const starts = events.filter(
|
|
229
|
+
(event) => event.type === "assistant.turn_start" && interactionOf(event) === interactionId,
|
|
230
|
+
);
|
|
231
|
+
if (starts.length === 0) return { pending: `no assistant.turn_start yet on interactionId=${interactionId}` };
|
|
232
|
+
if (starts.length > 1) {
|
|
233
|
+
return {
|
|
234
|
+
fatal: `${starts.length} assistant.turn_start events share interactionId=${interactionId} — the turn is ambiguous`,
|
|
235
|
+
};
|
|
236
|
+
}
|
|
237
|
+
const turnId = turnOf(starts[0]);
|
|
238
|
+
if (turnId === undefined) {
|
|
239
|
+
return {
|
|
240
|
+
fatal: `the assistant.turn_start on interactionId=${interactionId} exposes no turnId — its assistant events cannot be attributed`,
|
|
241
|
+
};
|
|
242
|
+
}
|
|
243
|
+
return { markerEvent, interactionId, turnId };
|
|
244
|
+
};
|
|
245
|
+
|
|
246
|
+
const deadline = Date.now() + TURN_BUDGET_MS;
|
|
247
|
+
const remaining = () => deadline - Date.now();
|
|
248
|
+
let events = [];
|
|
249
|
+
let resolved = { pending: "no getEvents() read completed" };
|
|
250
|
+
while (remaining() > 0) {
|
|
251
|
+
events = await bounded("session.getEvents()", session.getEvents(), remaining());
|
|
252
|
+
for (const event of events) observe(event);
|
|
253
|
+
resolved = resolveMarkerTurn(events);
|
|
254
|
+
// A fatal shape will not become valid by waiting — stop polling and fail closed below.
|
|
255
|
+
if (resolved.fatal) break;
|
|
256
|
+
if (resolved.turnId !== undefined) {
|
|
257
|
+
const inTurn = events.filter((event) => turnOf(event) === resolved.turnId);
|
|
258
|
+
const replied = inTurn.some((event) => event.type === "assistant.message");
|
|
259
|
+
const ended = inTurn.some((event) => event.type === "assistant.turn_end");
|
|
260
|
+
if (replied && ended) break;
|
|
261
|
+
}
|
|
262
|
+
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
// FAIL CLOSED — an ambiguous or unnameable turn is a broken measurement, and a turn that
|
|
266
|
+
// never resolved inside the budget is an unfinished one. Neither is a partial pass, and
|
|
267
|
+
// neither falls back to "the assistant events that came after ours".
|
|
268
|
+
if (resolved.fatal) throw new Error(resolved.fatal);
|
|
269
|
+
if (resolved.turnId === undefined) {
|
|
270
|
+
throw new Error(
|
|
271
|
+
`the marker turn did not resolve within ${TURN_BUDGET_MS}ms (${resolved.pending}) — the delivery is unattributable`,
|
|
272
|
+
);
|
|
273
|
+
}
|
|
274
|
+
const { markerEvent, turnId } = resolved;
|
|
275
|
+
|
|
276
|
+
// From here on ONLY this turn's events are evidence.
|
|
277
|
+
const turnEvents = events.filter((event) => turnOf(event) === turnId);
|
|
278
|
+
const turnReplies = turnEvents.filter((event) => event.type === "assistant.message");
|
|
279
|
+
const replied = turnReplies.length > 0;
|
|
280
|
+
const completed = replied && turnEvents.some((event) => event.type === "assistant.turn_end");
|
|
281
|
+
const responseContent = turnReplies.at(-1)?.data?.content;
|
|
282
|
+
const responseModel = turnReplies.at(-1)?.data?.model;
|
|
283
|
+
const exact = responseContent === marker;
|
|
284
|
+
// D4 is read off the marker event itself — an older idle wake in the transcript is
|
|
285
|
+
// somebody else's evidence.
|
|
286
|
+
const idleDelivery = markerEvent.data?.delivery === "idle";
|
|
287
|
+
if (!completed) throw new Error("timeout waiting for the marker turn to complete");
|
|
288
|
+
|
|
289
|
+
if (controlSession) {
|
|
290
|
+
// Bounded like every other read. The turn finished inside the budget, so what is left of
|
|
291
|
+
// it is the natural bound; if it landed on the very edge, D3 still gets the cleanup bound
|
|
292
|
+
// rather than an unbounded call.
|
|
293
|
+
const controlBound = Math.max(remaining(), CLEANUP_BOUND_MS);
|
|
294
|
+
const controlEvents = await bounded("controlSession.getEvents()", controlSession.getEvents(), controlBound);
|
|
295
|
+
for (const event of controlEvents) controlSeen.add(event.type);
|
|
296
|
+
}
|
|
297
|
+
// D3's predicate is the SAME sentence the verdict prints: across onEvent AND getEvents,
|
|
298
|
+
// the non-target session received no user.message and no assistant.* event of any kind.
|
|
299
|
+
// The old check enumerated two assistant types, so an isolation break that arrived as
|
|
300
|
+
// assistant.turn_end (or any future assistant.*) would have been reported as a pass.
|
|
301
|
+
const controlTouched = [...controlSeen].some((type) => type === "user.message" || type.startsWith("assistant."));
|
|
302
|
+
|
|
303
|
+
report({
|
|
304
|
+
"D0 live_identity:": 'pass reason="ping + foreground session id + metadata cwd"',
|
|
305
|
+
"D1 native_continuation:": replied
|
|
306
|
+
? 'pass reason="the turn opened on the marker user.message\'s interactionId replied in the same session"'
|
|
307
|
+
: 'fail reason="marker turn did not continue"',
|
|
308
|
+
"D2 receiver_armed:": 'pass reason="SDK connected to the TUI ui-server"',
|
|
309
|
+
"D3 addressed_enqueue:": twoSessionControl
|
|
310
|
+
? controlTouched
|
|
311
|
+
? `fail reason="non-target control session received a user.message or assistant.* event (${[...controlSeen].sort().join(",")})"`
|
|
312
|
+
: 'pass reason="exact target woke; second session received no user.message and no assistant.* event"'
|
|
313
|
+
: 'partial reason="exact session id selected; rerun with COPILOT_D3_CONTROL=1"',
|
|
314
|
+
"D4 idle_wake:": idleDelivery
|
|
315
|
+
? 'pass reason="the marker user.message itself carries delivery=idle"'
|
|
316
|
+
: 'fail reason="the marker user.message does not carry delivery=idle"',
|
|
317
|
+
"D5 context_injection:": exact
|
|
318
|
+
? 'pass reason="unique marker entered the attributed user.message and was returned exactly"'
|
|
319
|
+
: 'pass reason="unique marker entered the attributed user.message; reply was non-exact"',
|
|
320
|
+
"D6 continuity:": responseModel
|
|
321
|
+
? `pass reason="the same session answered this turn on ${responseModel}"`
|
|
322
|
+
: 'fail reason="the attributed turn exposed no reply model"',
|
|
323
|
+
"D7 completion_reply:": seen.has("session.idle")
|
|
324
|
+
? 'pass reason="attributed assistant.message + assistant.turn_end; session.idle also seen"'
|
|
325
|
+
: 'pass reason="attributed assistant.message + assistant.turn_end; session.idle not observed"',
|
|
326
|
+
"D8 robustness:": 'unproven reason="permission ownership, crash, ordering, auth, and stale endpoint remain"',
|
|
327
|
+
});
|
|
328
|
+
}
|
|
329
|
+
} finally {
|
|
330
|
+
const problems = await cleanup(foregroundSessionId);
|
|
331
|
+
if (problems.length > 0) {
|
|
332
|
+
// A probe that cannot close what it opened has not finished cleanly, and a delivery
|
|
333
|
+
// verdict printed above must not carry a zero exit past that.
|
|
334
|
+
for (const problem of problems) console.error(`CLEANUP_FAILED ${problem}`);
|
|
335
|
+
process.exitCode = 1;
|
|
336
|
+
}
|
|
337
|
+
}
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
// LIVE=1 ./run.sh smoke-acp-raw-turn-live
|
|
4
4
|
//
|
|
5
5
|
// What this proves (and ONLY this): the pinned Claude ACP adapter
|
|
6
|
-
// (@agentclientprotocol/claude-agent-acp@0.
|
|
6
|
+
// (@agentclientprotocol/claude-agent-acp@0.70.0) spawns, speaks the ACP wire
|
|
7
7
|
// protocol over stdio NDJSON, and returns one real model turn. It is the
|
|
8
8
|
// bytes-flow proof that the S2a dep surface is not just installable but
|
|
9
9
|
// actually drivable — before any provider/overlay/streamSimple code (S2b+).
|
|
@@ -54,9 +54,48 @@ PSTATE="$XDG_DATA_HOME/entwurf/agy-bridge/permission-state.json"
|
|
|
54
54
|
mkdir -p "$(dirname "$GLOBAL")" "$(dirname "$LEGACY")" "$SB/bin"
|
|
55
55
|
|
|
56
56
|
# fake stable bin (on PATH) + fake ss (unused by the deterministic path) — fake agy toggled per case.
|
|
57
|
-
|
|
57
|
+
#
|
|
58
|
+
# #81: the doctor now BOOTS the configured command and requires the entwurf MCP tool surface back,
|
|
59
|
+
# because `command -v` succeeding is not evidence agy gets a bridge — a relocated launcher resolves
|
|
60
|
+
# and still exits 127. So the healthy fake speaks the two frames the probe sends; the dead fake
|
|
61
|
+
# below reproduces the observed relocated-shim failure for the negative cell.
|
|
62
|
+
write_mcp_fake() { # $1 = path
|
|
63
|
+
cat > "$1" <<'FAKE'
|
|
64
|
+
#!/usr/bin/env bash
|
|
65
|
+
while IFS= read -r line; do
|
|
66
|
+
case "$line" in
|
|
67
|
+
*'"id":1'*) printf '%s\n' '{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":"2024-11-05","capabilities":{},"serverInfo":{"name":"fake-entwurf-bridge","version":"0"}}}' ;;
|
|
68
|
+
*'"id":2'*) printf '%s\n' '{"jsonrpc":"2.0","id":2,"result":{"tools":[{"name":"entwurf_v2"},{"name":"entwurf_self"},{"name":"entwurf_peers"},{"name":"entwurf_inbox_read"},{"name":"entwurf_register_native"},{"name":"entwurf_fresh_call"},{"name":"entwurf_resume_call"}]}}' ;;
|
|
69
|
+
esac
|
|
70
|
+
done
|
|
71
|
+
FAKE
|
|
72
|
+
chmod +x "$1"
|
|
73
|
+
}
|
|
74
|
+
write_dead_fake() { # $1 = path — resolves, then dies on exec (the relocated-shim shape)
|
|
75
|
+
cat > "$1" <<'FAKE'
|
|
76
|
+
#!/usr/bin/env bash
|
|
77
|
+
echo "bash: /nonexistent/../global/v11/deadbeef/node_modules/@junghanacs/entwurf/mcp/entwurf-bridge/start.sh: No such file or directory" >&2
|
|
78
|
+
exit 127
|
|
79
|
+
FAKE
|
|
80
|
+
chmod +x "$1"
|
|
81
|
+
}
|
|
82
|
+
write_invocation_sensitive_fake() { # command alone is healthy; configured argv or env makes it fail
|
|
83
|
+
cat > "$1" <<'FAKE'
|
|
84
|
+
#!/usr/bin/env bash
|
|
85
|
+
if [ "${1:-}" = "--fail" ]; then echo configured-arg-failure >&2; exit 23; fi
|
|
86
|
+
if [ "${REVIEW_ENV:-}" = "fail" ]; then echo configured-env-failure >&2; exit 24; fi
|
|
87
|
+
while IFS= read -r line; do
|
|
88
|
+
case "$line" in
|
|
89
|
+
*'"id":1'*) printf '%s\n' '{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":"2024-11-05","capabilities":{},"serverInfo":{"name":"fake-entwurf-bridge","version":"0"}}}' ;;
|
|
90
|
+
*'"id":2'*) printf '%s\n' '{"jsonrpc":"2.0","id":2,"result":{"tools":[{"name":"entwurf_v2"},{"name":"entwurf_self"},{"name":"entwurf_peers"},{"name":"entwurf_inbox_read"},{"name":"entwurf_register_native"},{"name":"entwurf_fresh_call"},{"name":"entwurf_resume_call"}]}}' ;;
|
|
91
|
+
esac
|
|
92
|
+
done
|
|
93
|
+
FAKE
|
|
94
|
+
chmod +x "$1"
|
|
95
|
+
}
|
|
96
|
+
write_mcp_fake "$SB/bin/entwurf-bridge"
|
|
58
97
|
printf '#!/usr/bin/env bash\nexit 0\n' > "$SB/bin/ss"
|
|
59
|
-
chmod +x "$SB/bin/
|
|
98
|
+
chmod +x "$SB/bin/ss"
|
|
60
99
|
export PATH="$SB/bin:$PATH"
|
|
61
100
|
export AGY_MCP_CONFIG="$GLOBAL"
|
|
62
101
|
export AGY_MCP_CONFIG_ALT="$LEGACY"
|
|
@@ -96,6 +135,41 @@ want "doctor(no-agy): SKIP is not disguised as a pass" "! printf '%s' \"\$DOC_OU
|
|
|
96
135
|
want "doctor(installed): state-evidence confirms the managed config still configured" \
|
|
97
136
|
"printf '%s' \"\$DOC_OUT\" | grep -q 'still configures entwurf-bridge'"
|
|
98
137
|
|
|
138
|
+
# ── B-boot: #81 A/B — a configured command that RESOLVES but does not serve MCP ──
|
|
139
|
+
# The static tier used to print "(resolvable)" and stay green here, which is exactly the state in
|
|
140
|
+
# which agy would have had no entwurf tool at all. Sandbox PATH only — the operator's launcher is
|
|
141
|
+
# never touched. `set -e` is fenced around the drive alone so the assertions still run.
|
|
142
|
+
write_dead_fake "$SB/bin/entwurf-bridge"
|
|
143
|
+
set +e; DOC_OUT="$(bash "$BRIDGE" doctor 2>&1)"; DOC_RC=$?; set -e
|
|
144
|
+
want "[QK:AGY-DOCTOR-BOOT-NEGATIVE] doctor(cmd resolves, does not boot): FAILS instead of blessing a resolvable name" "[ '$DOC_RC' -ne 0 ]"
|
|
145
|
+
want "doctor(cmd resolves, does not boot): says it does NOT serve MCP" \
|
|
146
|
+
"printf '%s' \"\$DOC_OUT\" | grep -q 'does NOT serve MCP with its configured args/env'"
|
|
147
|
+
want "doctor(cmd resolves, does not boot): carries the launcher's own stderr" \
|
|
148
|
+
"printf '%s' \"\$DOC_OUT\" | grep -q 'No such file or directory'"
|
|
149
|
+
want "doctor(cmd resolves, does not boot): does not offer to clobber a foreign launcher" \
|
|
150
|
+
"printf '%s' \"\$DOC_OUT\" | grep -q 'repair/remove it yourself'"
|
|
151
|
+
write_mcp_fake "$SB/bin/entwurf-bridge"
|
|
152
|
+
DOC_OUT="$(bash "$BRIDGE" doctor)"; DOC_RC=$?
|
|
153
|
+
want "doctor(after repair): the SAME unchanged doctor goes green once the command boots" "[ '$DOC_RC' -eq 0 ]"
|
|
154
|
+
want "doctor(after repair): green names the exact invocation BOOT evidence" \
|
|
155
|
+
"printf '%s' \"\$DOC_OUT\" | grep -q 'exact configured invocation boots the entwurf MCP surface'"
|
|
156
|
+
|
|
157
|
+
# The configured invocation is command + args + env, not command alone. Replant each review
|
|
158
|
+
# shape independently: this fake boots with defaults, but agy's configured argv/env make it fail.
|
|
159
|
+
write_invocation_sensitive_fake "$SB/bin/invocation-sensitive"
|
|
160
|
+
python3 -c 'import json,sys; d=json.load(open(sys.argv[1])); d["mcpServers"]["entwurf-bridge"]={"command":"invocation-sensitive","args":["--fail"],"env":{"REVIEW_ENV":"ok"}}; json.dump(d,open(sys.argv[1],"w"))' "$GLOBAL"
|
|
161
|
+
set +e; DOC_OUT="$(bash "$BRIDGE" doctor 2>&1)"; DOC_RC=$?; set -e
|
|
162
|
+
want "[QK:AGY-DOCTOR-PROBES-ARGS] doctor(command healthy, configured argv red): FAILS exact invocation" "[ '$DOC_RC' -ne 0 ]"
|
|
163
|
+
want "doctor(configured argv red): carries the argv-sensitive stderr" \
|
|
164
|
+
"printf '%s' \"\$DOC_OUT\" | grep -q 'configured-arg-failure'"
|
|
165
|
+
python3 -c 'import json,sys; d=json.load(open(sys.argv[1])); d["mcpServers"]["entwurf-bridge"]={"command":"invocation-sensitive","args":[],"env":{"REVIEW_ENV":"fail"}}; json.dump(d,open(sys.argv[1],"w"))' "$GLOBAL"
|
|
166
|
+
set +e; DOC_OUT="$(bash "$BRIDGE" doctor 2>&1)"; DOC_RC=$?; set -e
|
|
167
|
+
want "[QK:AGY-DOCTOR-PROBES-ENV] doctor(command healthy, configured env red): FAILS exact invocation" "[ '$DOC_RC' -ne 0 ]"
|
|
168
|
+
want "doctor(configured env red): carries the env-sensitive stderr" \
|
|
169
|
+
"printf '%s' \"\$DOC_OUT\" | grep -q 'configured-env-failure'"
|
|
170
|
+
# Restore the managed shape for the remaining lifecycle cells.
|
|
171
|
+
python3 "$REPO_DIR/scripts/agy-bridge-config.py" install "$GLOBAL" entwurf-bridge "$STATE" >/dev/null
|
|
172
|
+
|
|
99
173
|
# ── C: doctor with a fake agy present → live is CONSISTENT (honest, not overclaimed) ──
|
|
100
174
|
fake_agy on
|
|
101
175
|
DOC_OUT="$(bash "$BRIDGE" doctor)"; DOC_RC=$?
|