@junghanacs/entwurf 0.12.9 → 0.13.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +135 -248
- package/BASELINE.md +46 -7
- package/CHANGELOG.md +35 -0
- package/CONTRIBUTING.md +4 -2
- package/DELIVERY.md +5 -4
- package/README.md +46 -18
- package/VERIFY.md +10 -6
- package/demo/demo.sh +7 -1
- package/docs/setup-clean-host.md +10 -4
- package/mcp/entwurf-bridge/dist/mcp/entwurf-bridge/src/index.js +159 -66
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/backend-adapter.js +148 -5
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/config.js +16 -4
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/models.js +66 -7
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/overlay.js +190 -3
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/tool-surface.js +12 -4
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-core.js +26 -9
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-deliverability.js +10 -6
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-fact-provider.js +15 -17
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-facts.js +13 -13
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-resume-args.js +31 -41
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-self-address.js +8 -9
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-contract.js +32 -25
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-decider.js +4 -4
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-lock.js +5 -4
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-production.js +9 -11
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-spawn-production.js +3 -1
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/meta-session.js +17 -20
- package/mcp/entwurf-bridge/src/index.ts +190 -82
- package/package.json +7 -3
- package/pi/settings.reference.json +1 -1
- package/pi-extensions/acp-provider.ts +20 -10
- package/pi-extensions/entwurf-control.ts +69 -27
- package/pi-extensions/lib/acp/augment.ts +60 -2
- package/pi-extensions/lib/acp/backend-adapter.ts +183 -8
- package/pi-extensions/lib/acp/backend.ts +5 -1
- package/pi-extensions/lib/acp/config.ts +19 -5
- package/pi-extensions/lib/acp/engraving.ts +3 -1
- package/pi-extensions/lib/acp/event-mapper.ts +10 -3
- package/pi-extensions/lib/acp/models.ts +69 -7
- package/pi-extensions/lib/acp/overlay.ts +234 -3
- package/pi-extensions/lib/acp/tool-surface.ts +12 -4
- package/pi-extensions/lib/entwurf-core.ts +26 -9
- package/pi-extensions/lib/entwurf-deliverability.ts +10 -6
- package/pi-extensions/lib/entwurf-fact-provider.ts +15 -17
- package/pi-extensions/lib/entwurf-facts.ts +13 -13
- package/pi-extensions/lib/entwurf-resume-args.ts +40 -46
- package/pi-extensions/lib/entwurf-self-address.ts +8 -9
- package/pi-extensions/lib/entwurf-v2-contract-schema.ts +7 -2
- package/pi-extensions/lib/entwurf-v2-contract.ts +32 -25
- package/pi-extensions/lib/entwurf-v2-decider.ts +8 -8
- package/pi-extensions/lib/entwurf-v2-lock.ts +5 -4
- package/pi-extensions/lib/entwurf-v2-production.ts +14 -11
- package/pi-extensions/lib/entwurf-v2-spawn-production.ts +3 -1
- package/pi-extensions/lib/meta-session.ts +17 -20
- package/run.sh +200 -62
- package/scripts/agy-bridge-config.py +249 -49
- package/scripts/agy-bridge.sh +59 -14
- package/scripts/check-acp-carrier-augment.ts +34 -2
- package/scripts/check-acp-cortex.ts +668 -0
- package/scripts/check-acp-provider-surface.ts +50 -6
- package/scripts/check-acp-sdk-surface.ts +22 -11
- package/scripts/check-acp-session-reuse.ts +64 -1
- package/scripts/check-agy-permission-matrix.py +655 -0
- package/scripts/check-entwurf-deliverability.ts +9 -6
- package/scripts/check-entwurf-fact-provider.ts +7 -7
- package/scripts/check-entwurf-facts.ts +12 -12
- package/scripts/check-entwurf-resume-args.ts +22 -60
- package/scripts/check-entwurf-self-address.ts +187 -4
- package/scripts/check-entwurf-v2-contract.ts +2 -2
- package/scripts/check-entwurf-v2-production.ts +5 -5
- package/scripts/check-entwurf-v2-spawn-production.ts +6 -2
- package/scripts/check-entwurf-v2-surface.ts +327 -14
- package/scripts/check-gate-qualification.ts +787 -0
- package/scripts/check-meta-identity-consumers.ts +1 -1
- package/scripts/check-probe-cli-shim.ts +879 -0
- package/scripts/check-probe-ordering.ts +2450 -0
- package/scripts/check-shell-quote.ts +5 -4
- package/scripts/fixtures/probe-cli-shim +20 -0
- package/scripts/fixtures/probe-mcp-server.ts +168 -12
- package/scripts/lib/mutation-qualify.ts +794 -0
- package/scripts/lib/probe-acp-turn.ts +207 -0
- package/scripts/lib/probe-cli-shim.ts +464 -0
- package/scripts/lib/probe-cli-target.ts +165 -0
- package/scripts/lib/probe-event-log.ts +383 -0
- package/scripts/lib/probe-verdict.ts +1213 -0
- package/scripts/mutants/acp-augment.json +30 -0
- package/scripts/mutants/acp-cortex.json +196 -0
- package/scripts/mutants/agy-permission.json +144 -0
- package/scripts/mutants/meta-identity.json +17 -0
- package/scripts/mutants/probe-ordering.json +1032 -0
- package/scripts/mutants/self-address.json +59 -0
- package/scripts/mutants/v2-surface.json +87 -0
- package/scripts/smoke-acp-cortex-live.ts +392 -0
- package/scripts/smoke-acp-ordering-probe-live.ts +848 -0
- package/scripts/smoke-acp-raw-turn-live.ts +1 -1
- package/scripts/smoke-acp-v2-send-live.ts +33 -18
- package/scripts/smoke-agy-install-state.sh +205 -20
- package/scripts/smoke-entwurf-v2-matrix-live.ts +3 -2
- package/pi-extensions/lib/entwurf-mailbox-guard.ts +0 -100
- package/scripts/check-entwurf-mailbox-guard.ts +0 -266
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
// §11-7 ordering probe — the ACP-side turn driver (probe-dedicated raw client).
|
|
2
|
+
//
|
|
3
|
+
// §11-7 names two client seams and requires picking one explicitly. This lane
|
|
4
|
+
// picks the RAW CLIENT (not the stdio wire proxy), so it carries §11-7's bound:
|
|
5
|
+
// it is NOT the production path and must be gated as issuing the SAME calls,
|
|
6
|
+
// arguments, and order as the backend's real sequence (backend.ts runNewTurn) —
|
|
7
|
+
// otherwise the probe measures a lookalike. check-probe-ordering is that gate;
|
|
8
|
+
// it drives this exact function over a recording fake connection.
|
|
9
|
+
//
|
|
10
|
+
// Production values arrive through the `adapter` / `enrichMcpServers` seams —
|
|
11
|
+
// the LIVE runner tsc-emits pi-extensions and injects the REAL claudeAdapter +
|
|
12
|
+
// enrichMcpServersWithEnvelope (backend-adapter.ts carries `.js` value imports,
|
|
13
|
+
// so it is not strip-types-loadable from here; the emit-then-import pattern is
|
|
14
|
+
// the house one, see check-acp-session-reuse). This module therefore holds ONLY
|
|
15
|
+
// the orchestration backend.ts owns: phase order, arg assembly, timeouts, and
|
|
16
|
+
// phase-attributed failure — each pinned against backend.ts source by the gate
|
|
17
|
+
// so production drift turns the gate red instead of silently unbinding the probe.
|
|
18
|
+
//
|
|
19
|
+
// Every phase start/end is stamped into the shared NDJSON log — including
|
|
20
|
+
// set-model, because §11-7 measured that dropping it misreads an enforceModel
|
|
21
|
+
// stall as C or D.
|
|
22
|
+
|
|
23
|
+
import { PROTOCOL_VERSION } from "@agentclientprotocol/sdk";
|
|
24
|
+
import type { AcpConnectionLike } from "../../pi-extensions/lib/acp/acp-client.ts";
|
|
25
|
+
import type { ResolvedAcpConfig } from "../../pi-extensions/lib/acp/config.ts";
|
|
26
|
+
import { PROBE_EVENTS, type ProbeEventName } from "./probe-event-log.ts";
|
|
27
|
+
|
|
28
|
+
export interface ProbePhaseTimeouts {
|
|
29
|
+
initializeMs: number;
|
|
30
|
+
newSessionMs: number;
|
|
31
|
+
setModelMs: number;
|
|
32
|
+
promptMs: number;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// Phase timeouts — MUST equal backend.ts's INITIALIZE/NEW_SESSION/SET_MODEL/
|
|
36
|
+
// PROMPT_TIMEOUT_MS (check-probe-ordering pins them against the source). §11-7:
|
|
37
|
+
// a D verdict is only readable against these boundaries, so the probe may not
|
|
38
|
+
// invent its own.
|
|
39
|
+
export const PROBE_PHASE_TIMEOUTS: ProbePhaseTimeouts = {
|
|
40
|
+
initializeMs: 30_000,
|
|
41
|
+
newSessionMs: 30_000,
|
|
42
|
+
setModelMs: 30_000,
|
|
43
|
+
promptMs: 600_000,
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
/** The two adapter methods the turn drives — satisfied by the REAL claudeAdapter
|
|
47
|
+
* (emitted) in the LIVE runner and by recording stubs in the gate. */
|
|
48
|
+
export interface ProbeAdapterSeam {
|
|
49
|
+
buildSessionMeta(
|
|
50
|
+
params: { modelId: string; nativeModelId: string; config: ResolvedAcpConfig },
|
|
51
|
+
carrier: string | null,
|
|
52
|
+
): Record<string, unknown> | undefined;
|
|
53
|
+
enforceModel(params: {
|
|
54
|
+
connection: AcpConnectionLike;
|
|
55
|
+
acpSessionId: string;
|
|
56
|
+
modelId: string;
|
|
57
|
+
nativeModelId: string;
|
|
58
|
+
}): Promise<void>;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Production's enrichMcpServersWithEnvelope shape (config.ts). */
|
|
62
|
+
export type ProbeMcpEnricher = (
|
|
63
|
+
servers: ResolvedAcpConfig["mcpServers"],
|
|
64
|
+
envelope: { modelId?: string; piSessionId?: string },
|
|
65
|
+
) => ResolvedAcpConfig["mcpServers"];
|
|
66
|
+
|
|
67
|
+
/** The turn phases in production order. `initialize` failures are P0/I0 input
|
|
68
|
+
* (run-invalidating), the rest map to phase-qualified D readings. */
|
|
69
|
+
export type ProbeTurnPhase = "initialize" | "newSession" | "enforceModel" | "prompt";
|
|
70
|
+
|
|
71
|
+
/** A phase-attributed turn failure — the classifier needs WHICH wire step died,
|
|
72
|
+
* never just that the turn died. */
|
|
73
|
+
export class ProbePhaseError extends Error {
|
|
74
|
+
readonly phase: ProbeTurnPhase;
|
|
75
|
+
readonly timedOut: boolean;
|
|
76
|
+
|
|
77
|
+
constructor(phase: ProbeTurnPhase, cause: Error, timedOut: boolean) {
|
|
78
|
+
super(`${phase}: ${cause.message}`);
|
|
79
|
+
this.phase = phase;
|
|
80
|
+
this.timedOut = timedOut;
|
|
81
|
+
this.cause = cause;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
class PhaseTimeoutError extends Error {}
|
|
86
|
+
|
|
87
|
+
// Same guard shape as backend.ts withTimeout / smoke-acp-raw-turn-live: always
|
|
88
|
+
// clear the timer so a passing turn cannot pin the event loop on a stale timer.
|
|
89
|
+
function withPhaseTimeout<T>(label: string, p: Promise<T>, ms: number): Promise<T> {
|
|
90
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
91
|
+
const timeout = new Promise<never>((_, reject) => {
|
|
92
|
+
timer = setTimeout(() => reject(new PhaseTimeoutError(`${label} timed out after ${ms}ms`)), ms);
|
|
93
|
+
});
|
|
94
|
+
return Promise.race([p, timeout]).finally(() => {
|
|
95
|
+
if (timer) clearTimeout(timer);
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export interface ProbeTurnParams {
|
|
100
|
+
cwd: string;
|
|
101
|
+
modelId: string;
|
|
102
|
+
nativeModelId: string;
|
|
103
|
+
config: ResolvedAcpConfig;
|
|
104
|
+
/** Production carrier (adapter.loadCarrier result) — folded into `_meta` by
|
|
105
|
+
* buildSessionMeta so the probe's newSession matches production shape. */
|
|
106
|
+
carrier: string | null;
|
|
107
|
+
promptText: string;
|
|
108
|
+
adapter: ProbeAdapterSeam;
|
|
109
|
+
enrichMcpServers: ProbeMcpEnricher;
|
|
110
|
+
log: (event: ProbeEventName, payload?: Record<string, unknown>) => void;
|
|
111
|
+
timeouts?: ProbePhaseTimeouts;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export interface ProbeTurnResult {
|
|
115
|
+
acpSessionId: string;
|
|
116
|
+
stopReason: string | undefined;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
async function runPhase<T>(
|
|
120
|
+
phase: ProbeTurnPhase,
|
|
121
|
+
startEvent: ProbeEventName,
|
|
122
|
+
endEvent: ProbeEventName,
|
|
123
|
+
log: ProbeTurnParams["log"],
|
|
124
|
+
ms: number,
|
|
125
|
+
body: () => Promise<T>,
|
|
126
|
+
): Promise<T> {
|
|
127
|
+
log(startEvent, { timeoutMs: ms });
|
|
128
|
+
try {
|
|
129
|
+
const result = await withPhaseTimeout(phase, body(), ms);
|
|
130
|
+
log(endEvent, { ok: true });
|
|
131
|
+
return result;
|
|
132
|
+
} catch (err) {
|
|
133
|
+
const error = err instanceof Error ? err : new Error(String(err));
|
|
134
|
+
const timedOut = error instanceof PhaseTimeoutError;
|
|
135
|
+
log(endEvent, { ok: false, timedOut, error: error.message });
|
|
136
|
+
throw new ProbePhaseError(phase, error, timedOut);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Drive ONE production-shaped turn over an already-connected ACP connection:
|
|
142
|
+
*
|
|
143
|
+
* initialize → newSession → enforceModel(setSessionConfigOption) → prompt
|
|
144
|
+
*
|
|
145
|
+
* The connection seam is `AcpConnectionLike` — the same seam backend.ts drives —
|
|
146
|
+
* so the sameness gate runs this function over a recording fake with zero
|
|
147
|
+
* launch/IO coupling.
|
|
148
|
+
*/
|
|
149
|
+
export async function driveProbeTurn(connection: AcpConnectionLike, params: ProbeTurnParams): Promise<ProbeTurnResult> {
|
|
150
|
+
const t = params.timeouts ?? PROBE_PHASE_TIMEOUTS;
|
|
151
|
+
const log = params.log;
|
|
152
|
+
|
|
153
|
+
await runPhase("initialize", PROBE_EVENTS.initializeStart, PROBE_EVENTS.initializeEnd, log, t.initializeMs, () =>
|
|
154
|
+
connection.initialize({
|
|
155
|
+
protocolVersion: PROTOCOL_VERSION,
|
|
156
|
+
clientCapabilities: {},
|
|
157
|
+
clientInfo: { name: "entwurf", version: "s2d" },
|
|
158
|
+
}),
|
|
159
|
+
);
|
|
160
|
+
|
|
161
|
+
// _meta + wire servers exactly as backend.ts assembles them (S2g/§9-4): the
|
|
162
|
+
// injected production buildSessionMeta (omitted key when undefined) + the
|
|
163
|
+
// injected production envelope enrichment over the resolved config.
|
|
164
|
+
const sessionMeta = params.adapter.buildSessionMeta(
|
|
165
|
+
{ modelId: params.modelId, nativeModelId: params.nativeModelId, config: params.config },
|
|
166
|
+
params.carrier,
|
|
167
|
+
);
|
|
168
|
+
const wireMcpServers = params.enrichMcpServers(params.config.mcpServers, {
|
|
169
|
+
modelId: params.modelId,
|
|
170
|
+
piSessionId: process.env.PI_SESSION_ID?.trim() || undefined,
|
|
171
|
+
});
|
|
172
|
+
const newSessionArgs =
|
|
173
|
+
sessionMeta === undefined
|
|
174
|
+
? { cwd: params.cwd, mcpServers: wireMcpServers }
|
|
175
|
+
: { cwd: params.cwd, mcpServers: wireMcpServers, _meta: sessionMeta };
|
|
176
|
+
|
|
177
|
+
const created = await runPhase(
|
|
178
|
+
"newSession",
|
|
179
|
+
PROBE_EVENTS.newSessionStart,
|
|
180
|
+
PROBE_EVENTS.newSessionEnd,
|
|
181
|
+
log,
|
|
182
|
+
t.newSessionMs,
|
|
183
|
+
() => connection.newSession(newSessionArgs),
|
|
184
|
+
);
|
|
185
|
+
const acpSessionId = created?.sessionId;
|
|
186
|
+
if (!acpSessionId) {
|
|
187
|
+
throw new ProbePhaseError("newSession", new Error("newSession returned no sessionId"), false);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
await runPhase("enforceModel", PROBE_EVENTS.setModelStart, PROBE_EVENTS.setModelEnd, log, t.setModelMs, () =>
|
|
191
|
+
params.adapter.enforceModel({
|
|
192
|
+
connection,
|
|
193
|
+
acpSessionId,
|
|
194
|
+
modelId: params.modelId,
|
|
195
|
+
nativeModelId: params.nativeModelId,
|
|
196
|
+
}),
|
|
197
|
+
);
|
|
198
|
+
|
|
199
|
+
const promptResult = await runPhase("prompt", PROBE_EVENTS.promptStart, PROBE_EVENTS.promptEnd, log, t.promptMs, () =>
|
|
200
|
+
connection.prompt({
|
|
201
|
+
sessionId: acpSessionId,
|
|
202
|
+
prompt: [{ type: "text", text: params.promptText }],
|
|
203
|
+
}),
|
|
204
|
+
);
|
|
205
|
+
|
|
206
|
+
return { acpSessionId, stopReason: promptResult?.stopReason };
|
|
207
|
+
}
|
|
@@ -0,0 +1,464 @@
|
|
|
1
|
+
// §11-7-c B-name-snapshot seam — the PRODUCER half (docs/acp-backend-rail.md).
|
|
2
|
+
//
|
|
3
|
+
// The consumer half (probe-cli-target.ts preconditions, the event-log doors, the
|
|
4
|
+
// classifier ladder) was built and mutant-qualified first; this is the instrument
|
|
5
|
+
// it was specified against. The shim sits at CLAUDE_CODE_EXECUTABLE, spawns the
|
|
6
|
+
// REAL CLI the runner resolved, and tees the stream-json stdout so the CLI's own
|
|
7
|
+
// per-turn `system`/`init` tool-NAME set becomes an oracle that does not depend on
|
|
8
|
+
// model compliance (§11-7-b measured that stimulus tuning cannot reach delta-B).
|
|
9
|
+
//
|
|
10
|
+
// What it must NOT be is a second copy of upstream launch semantics. The runner
|
|
11
|
+
// already refused an ambient override, resolved the target through upstream
|
|
12
|
+
// `claudeCliPath()`, and asserted the native branch (absolute ∧ no script suffix ∧
|
|
13
|
+
// regular file ∧ X_OK). So the shim RESOLVES NOTHING: it execs exactly the path it
|
|
14
|
+
// was handed and reports that path plus its content hash, which is the fact the
|
|
15
|
+
// classifier verifies against the roster (§11-7-c condition 5 — a managed-policy
|
|
16
|
+
// env swap of the target can then never promote, it becomes a NAMED structural
|
|
17
|
+
// finding instead of an anonymous no-snapshot).
|
|
18
|
+
//
|
|
19
|
+
// Three properties are load-bearing and each one is a place this could silently
|
|
20
|
+
// lie, so each is stated here and proved by the fake-CLI matrix in
|
|
21
|
+
// check-probe-cli-shim:
|
|
22
|
+
//
|
|
23
|
+
// 1. BYTE TRANSPARENCY. Every byte that arrives is written downstream unchanged
|
|
24
|
+
// and in order. The NDJSON scanning is a SIDE observation over a bounded
|
|
25
|
+
// buffer — it never reframes, re-encodes, or re-chunks the stream. Line
|
|
26
|
+
// splitting is done on BYTES (0x0a), never on a decoded string, so a chunk
|
|
27
|
+
// boundary inside a multi-byte UTF-8 sequence cannot corrupt the passthrough
|
|
28
|
+
// or the framing.
|
|
29
|
+
//
|
|
30
|
+
// 2. THE SNAPSHOT APPEND HAPPENS INSIDE THE DOWNSTREAM WRITE CALLBACK. §11-7-c
|
|
31
|
+
// condition 6 makes the snapshot timestamp an INTERVAL — full line received ↔
|
|
32
|
+
// handed downstream — whose END has ONE SSOT: the event's own envelope
|
|
33
|
+
// `tsMs`. Appending inside the callback is what makes the single clock read
|
|
34
|
+
// that stamps the line BE the callback moment; the payload therefore carries
|
|
35
|
+
// only `receivedAtMs`, and the door holds `receivedAtMs ≤ tsMs`. Like the
|
|
36
|
+
// fixture's write-callback timing, this PLACEMENT is review-pinned rather
|
|
37
|
+
// than mutant-proven (the honesty carve-out recorded in §11-7-c).
|
|
38
|
+
//
|
|
39
|
+
// 3. THE SCRUB IS AN EXACT ALLOWLIST. `SHIM_SCRUB_ENV_VARS` (the single source
|
|
40
|
+
// in probe-cli-target.ts) leaves the child env otherwise byte-identical. A
|
|
41
|
+
// prefix scrub would delete operator env this probe has no claim on, and
|
|
42
|
+
// NOT scrubbing CLAUDE_CODE_EXECUTABLE would re-propagate the override to
|
|
43
|
+
// grandchildren — recursion, or a sub-agent measured through a second shim.
|
|
44
|
+
//
|
|
45
|
+
// It is ARGV-AGNOSTIC on purpose: `claudeCliPath()` has a second consumer
|
|
46
|
+
// (`claude auth logout`, acp-agent.js:841), so an invocation that is not a
|
|
47
|
+
// stream-json turn must be pure passthrough whose only log line is the boot
|
|
48
|
+
// marker. Nothing about argv, env, auth, or prompt bodies is ever logged — the
|
|
49
|
+
// only payload that leaves this process is the allowlisted init fields (tools,
|
|
50
|
+
// mcp_servers status, model) plus an ordinal and timings.
|
|
51
|
+
|
|
52
|
+
import { spawn } from "node:child_process";
|
|
53
|
+
import { appendFileSync, writeSync } from "node:fs";
|
|
54
|
+
import { constants as osConstants } from "node:os";
|
|
55
|
+
import { hashFileSha256, PROBE_SHIM_ENV, SHIM_SCRUB_ENV_VARS } from "./probe-cli-target.ts";
|
|
56
|
+
import { appendProbeEvent, PROBE_EVENTS } from "./probe-event-log.ts";
|
|
57
|
+
|
|
58
|
+
const NEWLINE = 0x0a;
|
|
59
|
+
|
|
60
|
+
/** Bound on the in-memory NDJSON framing buffer (§11-7-c condition 3: "a bounded
|
|
61
|
+
* in-memory line buffer"). A single stream-json line larger than this cannot be
|
|
62
|
+
* parsed without letting a hostile or pathological stream drive this process's
|
|
63
|
+
* memory, so the PARSE is skipped — the BYTES still pass through untouched,
|
|
64
|
+
* because passthrough never depends on the scanner. The contract consequence of
|
|
65
|
+
* a skipped parse is fail-closed by construction: a skipped init line yields
|
|
66
|
+
* zero bound candidates and a skipped prompt frame yields zero anchors, both of
|
|
67
|
+
* which are NAMED reading violations in the classifier, never a quiet promotion.
|
|
68
|
+
* 16 MiB is ~500x the largest init line this seam has measured (a ~30 KB name
|
|
69
|
+
* set), so reaching it means the stream stopped being stream-json. */
|
|
70
|
+
export const SHIM_MAX_LINE_BYTES = 16 * 1024 * 1024;
|
|
71
|
+
|
|
72
|
+
/** Signals forwarded to the child and then RE-RAISED on ourselves, so the parent's
|
|
73
|
+
* wait status carries the child's real (code, signal) instead of the shim's. */
|
|
74
|
+
const FORWARDED_SIGNALS: readonly NodeJS.Signals[] = ["SIGTERM", "SIGINT"];
|
|
75
|
+
|
|
76
|
+
/** Zero-length flush barrier. `write()`'s callback fires only after every queued
|
|
77
|
+
* chunk has reached the OS, so this is how the shim proves it lost no forwarded
|
|
78
|
+
* byte to `process.exit()`, which truncates pending async writes on a pipe. */
|
|
79
|
+
const FLUSH_BARRIER = Buffer.alloc(0);
|
|
80
|
+
|
|
81
|
+
interface InitSnapshotPayload {
|
|
82
|
+
tools: string[];
|
|
83
|
+
receivedAtMs: number;
|
|
84
|
+
mcpServers?: Array<{ name: string; status: string }>;
|
|
85
|
+
model?: string;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Byte-level NDJSON line framing over a buffer bounded in BOTH dimensions.
|
|
89
|
+
*
|
|
90
|
+
* Splitting on bytes rather than on a decoded string is not a micro-optimisation:
|
|
91
|
+
* a chunk boundary can fall inside a multi-byte UTF-8 sequence, and decoding each
|
|
92
|
+
* chunk independently would produce replacement characters that corrupt the line
|
|
93
|
+
* the parser then judges. Only COMPLETE lines are decoded.
|
|
94
|
+
*
|
|
95
|
+
* The partial line lives in ONE buffer that grows geometrically to a hard cap —
|
|
96
|
+
* not in an array of per-chunk pieces (adversarial review 2026-07-29). A piece
|
|
97
|
+
* list is bounded in BYTES and unbounded in OBJECTS: a peer writing one byte at a
|
|
98
|
+
* time reaches the byte cap holding sixteen million Buffer headers plus the array
|
|
99
|
+
* indexing them, so "a bounded in-memory line buffer" would have been false
|
|
100
|
+
* exactly where it mattered — under a hostile stream. One buffer makes the bound
|
|
101
|
+
* O(cap) in both dimensions, and the copy it costs is paid only by lines that
|
|
102
|
+
* actually span reads: a line whole inside its chunk is handed to the parser as a
|
|
103
|
+
* VIEW, with no copy and nothing retained. */
|
|
104
|
+
const INITIAL_LINE_CAPACITY = 64 * 1024;
|
|
105
|
+
/** Above this a completed line's buffer is released instead of kept for reuse —
|
|
106
|
+
* one big line must not leave the instrument holding that much for a whole turn. */
|
|
107
|
+
const RETAINED_LINE_CAPACITY = 1024 * 1024;
|
|
108
|
+
|
|
109
|
+
export class NdjsonLineScanner {
|
|
110
|
+
private buffer: Buffer = Buffer.alloc(0);
|
|
111
|
+
private length = 0;
|
|
112
|
+
private overflowed = false;
|
|
113
|
+
/** Lines whose parse was skipped because they exceeded the buffer bound. */
|
|
114
|
+
overflowCount = 0;
|
|
115
|
+
/** Allocations the CURRENT partial line has cost — the object half of the
|
|
116
|
+
* framing bound, made observable. Geometric growth keeps it logarithmic in the
|
|
117
|
+
* line's length however many reads it arrived in; per-read retention (a piece
|
|
118
|
+
* list, or a buffer regrown to the exact size each time) makes it linear in the
|
|
119
|
+
* number of reads, which is the bound a one-byte-at-a-time peer walks through. */
|
|
120
|
+
retainedAllocations = 0;
|
|
121
|
+
|
|
122
|
+
feed(chunk: Buffer, onLine: (line: Buffer) => void): void {
|
|
123
|
+
let start = 0;
|
|
124
|
+
for (;;) {
|
|
125
|
+
const nl = chunk.indexOf(NEWLINE, start);
|
|
126
|
+
if (nl === -1) {
|
|
127
|
+
this.absorb(chunk.subarray(start));
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
const piece = chunk.subarray(start, nl);
|
|
131
|
+
if (this.length === 0 && !this.overflowed) {
|
|
132
|
+
if (piece.length > SHIM_MAX_LINE_BYTES) this.overflowCount += 1;
|
|
133
|
+
else if (piece.length > 0) onLine(piece);
|
|
134
|
+
} else {
|
|
135
|
+
this.absorb(piece);
|
|
136
|
+
if (this.overflowed) this.overflowCount += 1;
|
|
137
|
+
else onLine(this.buffer.subarray(0, this.length));
|
|
138
|
+
}
|
|
139
|
+
this.reset();
|
|
140
|
+
start = nl + 1;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** Stream end. A line still OVER the bound when the stream closed never met a
|
|
145
|
+
* newline, so `feed` never counted it; without this the skip diagnostic would
|
|
146
|
+
* under-report exactly the unterminated-oversized shape. */
|
|
147
|
+
finalize(): void {
|
|
148
|
+
if (this.overflowed) this.overflowCount += 1;
|
|
149
|
+
this.reset();
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
private absorb(slice: Buffer): void {
|
|
153
|
+
if (this.overflowed || slice.length === 0) return;
|
|
154
|
+
const needed = this.length + slice.length;
|
|
155
|
+
if (needed > SHIM_MAX_LINE_BYTES) {
|
|
156
|
+
this.overflowed = true;
|
|
157
|
+
this.buffer = Buffer.alloc(0);
|
|
158
|
+
this.length = 0;
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
if (needed > this.buffer.length) {
|
|
162
|
+
let capacity = Math.max(this.buffer.length, INITIAL_LINE_CAPACITY);
|
|
163
|
+
while (capacity < needed) capacity *= 2;
|
|
164
|
+
const grown = Buffer.allocUnsafe(Math.min(capacity, SHIM_MAX_LINE_BYTES));
|
|
165
|
+
this.retainedAllocations += 1;
|
|
166
|
+
this.buffer.copy(grown, 0, 0, this.length);
|
|
167
|
+
this.buffer = grown;
|
|
168
|
+
}
|
|
169
|
+
slice.copy(this.buffer, this.length);
|
|
170
|
+
this.length = needed;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
private reset(): void {
|
|
174
|
+
this.length = 0;
|
|
175
|
+
this.overflowed = false;
|
|
176
|
+
this.retainedAllocations = 0;
|
|
177
|
+
if (this.buffer.length > RETAINED_LINE_CAPACITY) this.buffer = Buffer.alloc(0);
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function parseJsonObject(line: Buffer): Record<string, unknown> | undefined {
|
|
182
|
+
try {
|
|
183
|
+
const parsed: unknown = JSON.parse(line.toString("utf8"));
|
|
184
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return undefined;
|
|
185
|
+
return parsed as Record<string, unknown>;
|
|
186
|
+
} catch {
|
|
187
|
+
return undefined;
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/** A stream-json INPUT frame carrying a turn's prompt.
|
|
192
|
+
*
|
|
193
|
+
* Measured, not assumed (claude-agent-sdk 0.3.219): the SDK writes NDJSON to the
|
|
194
|
+
* CLI's stdin from two places — control traffic (`{"type":"control_request"…}` /
|
|
195
|
+
* `control_response`) and `streamInput`, which serializes each user message as
|
|
196
|
+
* `{"type":"user",…}`. Counting every stdin line would therefore count the
|
|
197
|
+
* initialize handshake as prompts and blow the exactly-one binding on every run;
|
|
198
|
+
* the prompt axis is the `user` frames alone. */
|
|
199
|
+
function isPromptFrame(line: Buffer): boolean {
|
|
200
|
+
return parseJsonObject(line)?.type === "user";
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/** The §11-7-c oracle line: `SDKSystemMessage` with `subtype: 'init'`, which
|
|
204
|
+
* sdk.d.ts:4412 defines as carrying `tools: string[]` and
|
|
205
|
+
* `mcp_servers: {name,status}[]`, re-emitted per turn (acp-agent.js:1573-1587).
|
|
206
|
+
*
|
|
207
|
+
* `tools` must be a real array of strings or this returns nothing. An init line
|
|
208
|
+
* whose name set is missing or mistyped would otherwise be reported as an EMPTY
|
|
209
|
+
* name set — and an empty set is exactly what the B-name-snapshot ladder reads as
|
|
210
|
+
* ABSENCE of the measured id. Fabricating an absence claim out of a malformed
|
|
211
|
+
* line is the one failure this instrument must never have, so the line simply
|
|
212
|
+
* does not become a candidate and the run fails closed on cardinality. */
|
|
213
|
+
function initSnapshotOf(line: Buffer, receivedAtMs: number): InitSnapshotPayload | undefined {
|
|
214
|
+
const obj = parseJsonObject(line);
|
|
215
|
+
if (obj === undefined || obj.type !== "system" || obj.subtype !== "init") return undefined;
|
|
216
|
+
if (!Array.isArray(obj.tools) || !obj.tools.every((t) => typeof t === "string")) return undefined;
|
|
217
|
+
const payload: InitSnapshotPayload = { tools: obj.tools as string[], receivedAtMs };
|
|
218
|
+
if (Array.isArray(obj.mcp_servers)) {
|
|
219
|
+
// Allowlist entries whose two fields are ALREADY strings rather than
|
|
220
|
+
// coercing: `String(someObject)` would put "[object Object]" into the
|
|
221
|
+
// evidence log as though the CLI had reported it. This fact never promotes
|
|
222
|
+
// anything, which is all the more reason not to manufacture it.
|
|
223
|
+
payload.mcpServers = obj.mcp_servers
|
|
224
|
+
.filter((entry): entry is { name: string; status: string } => {
|
|
225
|
+
if (typeof entry !== "object" || entry === null) return false;
|
|
226
|
+
const fields = entry as Record<string, unknown>;
|
|
227
|
+
return typeof fields.name === "string" && typeof fields.status === "string";
|
|
228
|
+
})
|
|
229
|
+
.map((entry) => ({ name: entry.name, status: entry.status }));
|
|
230
|
+
}
|
|
231
|
+
if (typeof obj.model === "string") payload.model = obj.model;
|
|
232
|
+
return payload;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
function abort(message: string, code: number): never {
|
|
236
|
+
// writeSync, not process.stderr.write. Node documents stream writes to a pipe
|
|
237
|
+
// as synchronous on Linux, so the truncation this avoids is latent here rather
|
|
238
|
+
// than live — but the NAMED-failure contract should not rest on a
|
|
239
|
+
// platform-specific guarantee about the one path that reports why the
|
|
240
|
+
// instrument could not run (adversarial review 2026-07-29). Partial writes are
|
|
241
|
+
// looped because write(2) may accept fewer bytes than offered.
|
|
242
|
+
const bytes = Buffer.from(`[probe-cli-shim] ${message}\n`, "utf8");
|
|
243
|
+
let written = 0;
|
|
244
|
+
while (written < bytes.length) {
|
|
245
|
+
try {
|
|
246
|
+
written += writeSync(2, bytes, written, bytes.length - written);
|
|
247
|
+
} catch {
|
|
248
|
+
break; // stderr is gone; the exit status still carries the fact
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
process.exit(code);
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/** One errno→status mapping for BOTH ways the target can be unusable — unreadable
|
|
255
|
+
* at hash time and unspawnable at exec time. Shell conventions (127 not-found,
|
|
256
|
+
* 126 not-executable) keep the status readable to whatever launched us, and using
|
|
257
|
+
* the same mapping on both paths means the operator reads one story about the
|
|
258
|
+
* target rather than two unrelated numbers. */
|
|
259
|
+
function exitStatusForErrno(code: string | undefined): number {
|
|
260
|
+
if (code === "ENOENT") return 127;
|
|
261
|
+
if (code === "EACCES") return 126;
|
|
262
|
+
return 70;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
function requiredShimEnv(name: string): string {
|
|
266
|
+
const value = process.env[name];
|
|
267
|
+
// The shim is probe-only: the runner sets all three vars together, so a
|
|
268
|
+
// missing one means this binary was reached by something other than the probe
|
|
269
|
+
// (a stale CLAUDE_CODE_EXECUTABLE in an operator shell, say). Failing loud
|
|
270
|
+
// beats silently exec-ing a target we cannot name in the log.
|
|
271
|
+
if (typeof value !== "string" || value.length === 0) {
|
|
272
|
+
abort(`missing required env ${name} — this shim is probe-only and its runner sets ${name}`, 70);
|
|
273
|
+
}
|
|
274
|
+
return value;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
export function runProbeCliShim(): void {
|
|
278
|
+
const targetPath = requiredShimEnv(PROBE_SHIM_ENV.target);
|
|
279
|
+
const eventLog = requiredShimEnv(PROBE_SHIM_ENV.eventLog);
|
|
280
|
+
const runId = requiredShimEnv(PROBE_SHIM_ENV.runId);
|
|
281
|
+
|
|
282
|
+
const emit = (event: (typeof PROBE_EVENTS)[keyof typeof PROBE_EVENTS], payload: Record<string, unknown>): void => {
|
|
283
|
+
appendProbeEvent(eventLog, runId, event, payload);
|
|
284
|
+
};
|
|
285
|
+
|
|
286
|
+
// Boot marker FIRST — before the spawn can fail. §11-7-c condition 7 makes a
|
|
287
|
+
// missing boot marker on an armed roster a NAMED structural finding
|
|
288
|
+
// (`snapshot-instrument-absent` / `snapshot-topology`), which is precisely how
|
|
289
|
+
// a managed-policy env application that replaced the shim inside the ACP child
|
|
290
|
+
// becomes visible instead of looking like "the CLI just did not re-emit init".
|
|
291
|
+
// The hash is taken HERE, by the process that actually execs it, because the
|
|
292
|
+
// classifier verifies this report against the roster's expected identity.
|
|
293
|
+
//
|
|
294
|
+
// A target that cannot be READ has no knowable identity, so there is no boot
|
|
295
|
+
// marker to write: emitting one with a placeholder hash would be fabricating
|
|
296
|
+
// exactly the fact condition 5 has the classifier verify. The absence is the
|
|
297
|
+
// honest report, and the consumer already names it (`snapshot-instrument-absent`
|
|
298
|
+
// → structural, P0), so this exits before the spawn rather than guessing.
|
|
299
|
+
let targetSha256: string;
|
|
300
|
+
try {
|
|
301
|
+
targetSha256 = hashFileSha256(targetPath);
|
|
302
|
+
} catch (err) {
|
|
303
|
+
const code = (err as NodeJS.ErrnoException).code;
|
|
304
|
+
abort(`cannot read exec target ${targetPath}: ${code ?? (err as Error).message}`, exitStatusForErrno(code));
|
|
305
|
+
}
|
|
306
|
+
emit(PROBE_EVENTS.shimBoot, { targetPath, targetSha256 });
|
|
307
|
+
|
|
308
|
+
// Exact-allowlist scrub (§11-7-c condition 4). Every other variable keeps its
|
|
309
|
+
// key AND its value byte-for-byte: under the runner's ambient-override refusal
|
|
310
|
+
// there is no prior operator value to restore, so deletion IS preservation.
|
|
311
|
+
const childEnv: NodeJS.ProcessEnv = { ...process.env };
|
|
312
|
+
for (const name of SHIM_SCRUB_ENV_VARS) delete childEnv[name];
|
|
313
|
+
|
|
314
|
+
// Same argv, same cwd (inherited — passing an explicit cwd would be a second
|
|
315
|
+
// copy of a fact we already hold), no shell. stderr is `inherit` rather than a
|
|
316
|
+
// copied pipe: handing the child our own fd 2 is exact passthrough with no
|
|
317
|
+
// buffering, no reordering, and nothing lost at exit, and the SDK reads a
|
|
318
|
+
// stderr tail for its own diagnostics.
|
|
319
|
+
const child = spawn(targetPath, process.argv.slice(2), {
|
|
320
|
+
env: childEnv,
|
|
321
|
+
stdio: ["pipe", "pipe", "inherit"],
|
|
322
|
+
windowsHide: true,
|
|
323
|
+
});
|
|
324
|
+
const childStdin = child.stdin;
|
|
325
|
+
const childStdout = child.stdout;
|
|
326
|
+
if (childStdin === null || childStdout === null) abort("child stdio pipes were not created", 70);
|
|
327
|
+
|
|
328
|
+
const stdinScanner = new NdjsonLineScanner();
|
|
329
|
+
const stdoutScanner = new NdjsonLineScanner();
|
|
330
|
+
let promptOrdinal = 0;
|
|
331
|
+
let finished = false;
|
|
332
|
+
|
|
333
|
+
// A closed downstream (the SDK gave up, the CLI died) is not this process's
|
|
334
|
+
// error to raise — it must not become an unhandled 'error' crash that changes
|
|
335
|
+
// the exit status the parent observes. But IGNORING it is not enough: the
|
|
336
|
+
// backpressure path parks the source until a 'drain' a dead stream will never
|
|
337
|
+
// emit, so a bare ignore turns a closed consumer into a HUNG instrument holding
|
|
338
|
+
// a live CLI open (adversarial review 2026-07-29). Losing stdout means the turn
|
|
339
|
+
// cannot be delivered at all, so unpark the source and tear the child down.
|
|
340
|
+
childStdin.on("error", (): void => {});
|
|
341
|
+
process.stdout.on("error", (): void => {
|
|
342
|
+
childStdout.resume();
|
|
343
|
+
if (!finished) child.kill("SIGTERM");
|
|
344
|
+
});
|
|
345
|
+
|
|
346
|
+
// --- stdin: SDK → shim → real CLI -------------------------------------
|
|
347
|
+
// The prompt anchor is stamped in the DOWNSTREAM write callback, i.e. when the
|
|
348
|
+
// bytes completing the frame reached the CLI's stdin — "fully passed to the
|
|
349
|
+
// CLI's stdin", not "we saw a newline". Only a successful callback stamps: an
|
|
350
|
+
// errored write must never claim a hand-off that did not happen (the same
|
|
351
|
+
// discipline the fixture applies to the wire marker).
|
|
352
|
+
process.stdin.on("data", (chunk: Buffer) => {
|
|
353
|
+
const ordinals: number[] = [];
|
|
354
|
+
stdinScanner.feed(chunk, (line) => {
|
|
355
|
+
if (isPromptFrame(line)) ordinals.push(++promptOrdinal);
|
|
356
|
+
});
|
|
357
|
+
const flushed = childStdin.write(chunk, (err) => {
|
|
358
|
+
if (err) return;
|
|
359
|
+
for (const ordinal of ordinals) emit(PROBE_EVENTS.shimPromptForwarded, { ordinal });
|
|
360
|
+
});
|
|
361
|
+
// Backpressure: stop READING upstream while the child's stdin is full,
|
|
362
|
+
// instead of letting Node's writable queue grow without bound.
|
|
363
|
+
if (!flushed) {
|
|
364
|
+
process.stdin.pause();
|
|
365
|
+
childStdin.once("drain", () => process.stdin.resume());
|
|
366
|
+
}
|
|
367
|
+
});
|
|
368
|
+
process.stdin.on("end", () => {
|
|
369
|
+
stdinScanner.finalize();
|
|
370
|
+
childStdin.end();
|
|
371
|
+
});
|
|
372
|
+
|
|
373
|
+
// --- stdout: real CLI → shim → SDK ------------------------------------
|
|
374
|
+
// ONE clock read per chunk is the receive stamp for every line that COMPLETES
|
|
375
|
+
// in that chunk: the line became whole when its newline arrived, which is this
|
|
376
|
+
// moment. The snapshot event is appended INSIDE the downstream write callback
|
|
377
|
+
// so the envelope `tsMs` the log door stamps IS the hand-off moment — the
|
|
378
|
+
// interval's single-SSOT end (§11-7-c condition 6).
|
|
379
|
+
childStdout.on("data", (chunk: Buffer) => {
|
|
380
|
+
const receivedAtMs = Date.now();
|
|
381
|
+
const snapshots: InitSnapshotPayload[] = [];
|
|
382
|
+
stdoutScanner.feed(chunk, (line) => {
|
|
383
|
+
const snapshot = initSnapshotOf(line, receivedAtMs);
|
|
384
|
+
if (snapshot !== undefined) snapshots.push(snapshot);
|
|
385
|
+
});
|
|
386
|
+
const flushed = process.stdout.write(chunk, (err) => {
|
|
387
|
+
if (err) return;
|
|
388
|
+
for (const snapshot of snapshots) emit(PROBE_EVENTS.shimInitSnapshot, { ...snapshot });
|
|
389
|
+
});
|
|
390
|
+
if (!flushed) {
|
|
391
|
+
childStdout.pause();
|
|
392
|
+
process.stdout.once("drain", () => childStdout.resume());
|
|
393
|
+
}
|
|
394
|
+
});
|
|
395
|
+
|
|
396
|
+
// --- lifecycle --------------------------------------------------------
|
|
397
|
+
const writeOverflowDiagnostic = (): void => {
|
|
398
|
+
const total = stdinScanner.overflowCount + stdoutScanner.overflowCount;
|
|
399
|
+
if (total === 0) return;
|
|
400
|
+
// Deliberately NOT an event in the shared log: the log's vocabulary is a
|
|
401
|
+
// closed contract the classifier judges on, and an unknown marker name is
|
|
402
|
+
// MALFORMED at the door. This sidecar is forensics only — the contract
|
|
403
|
+
// consequence of a skipped parse is already fail-closed (zero anchors or
|
|
404
|
+
// zero candidates, both NAMED readings).
|
|
405
|
+
try {
|
|
406
|
+
appendFileSync(
|
|
407
|
+
`${eventLog}.shim-diag`,
|
|
408
|
+
`${JSON.stringify({
|
|
409
|
+
runId,
|
|
410
|
+
pid: process.pid,
|
|
411
|
+
stdinLineParseSkipped: stdinScanner.overflowCount,
|
|
412
|
+
stdoutLineParseSkipped: stdoutScanner.overflowCount,
|
|
413
|
+
maxLineBytes: SHIM_MAX_LINE_BYTES,
|
|
414
|
+
})}\n`,
|
|
415
|
+
"utf8",
|
|
416
|
+
);
|
|
417
|
+
} catch {
|
|
418
|
+
// A diagnostic that cannot be written must not change the exit status.
|
|
419
|
+
}
|
|
420
|
+
};
|
|
421
|
+
|
|
422
|
+
const reRaise = (signal: NodeJS.Signals): void => {
|
|
423
|
+
// Restore the default disposition before signalling ourselves, or our own
|
|
424
|
+
// forwarding handler would swallow it and the parent would read a plain
|
|
425
|
+
// exit where the child actually died on a signal.
|
|
426
|
+
for (const forwarded of FORWARDED_SIGNALS) process.removeAllListeners(forwarded);
|
|
427
|
+
process.stdin.destroy();
|
|
428
|
+
process.kill(process.pid, signal);
|
|
429
|
+
// Only reached if this signal is ignored or blocked for us; do not hang.
|
|
430
|
+
const number = osConstants.signals[signal as keyof typeof osConstants.signals] ?? 0;
|
|
431
|
+
setTimeout(() => process.exit(number > 0 ? 128 + number : 1), 200);
|
|
432
|
+
};
|
|
433
|
+
|
|
434
|
+
const finish = (code: number | null, signal: NodeJS.Signals | null): void => {
|
|
435
|
+
if (finished) return;
|
|
436
|
+
finished = true;
|
|
437
|
+
writeOverflowDiagnostic();
|
|
438
|
+
process.stdout.write(FLUSH_BARRIER, () => {
|
|
439
|
+
if (signal !== null) {
|
|
440
|
+
reRaise(signal);
|
|
441
|
+
return;
|
|
442
|
+
}
|
|
443
|
+
process.exit(code ?? 0);
|
|
444
|
+
});
|
|
445
|
+
};
|
|
446
|
+
|
|
447
|
+
childStdout.on("end", () => stdoutScanner.finalize());
|
|
448
|
+
child.on("error", (err: NodeJS.ErrnoException) => {
|
|
449
|
+
// ENOENT/EACCES are NAMED here rather than swallowed: the runner already
|
|
450
|
+
// asserted the target is a present, executable regular file, so reaching
|
|
451
|
+
// this means the target moved under the pair — a fact the operator needs
|
|
452
|
+
// in plain words. Shell conventions for the two cases keep the exit status
|
|
453
|
+
// readable to whatever spawned us.
|
|
454
|
+
abort(`cannot execute ${targetPath}: ${err.code ?? err.message}`, exitStatusForErrno(err.code));
|
|
455
|
+
});
|
|
456
|
+
for (const signal of FORWARDED_SIGNALS) {
|
|
457
|
+
process.on(signal, () => {
|
|
458
|
+
if (!finished) child.kill(signal);
|
|
459
|
+
});
|
|
460
|
+
}
|
|
461
|
+
// 'close' rather than 'exit': it fires after the child's stdio streams are
|
|
462
|
+
// closed, so every byte the CLI wrote has already been scanned and forwarded.
|
|
463
|
+
child.on("close", (code, signal) => finish(code, signal));
|
|
464
|
+
}
|