@henols/vice-mcp 0.1.9 → 0.1.11

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.
@@ -0,0 +1,396 @@
1
+ // GENERATED FILE -- DO NOT EDIT.
2
+ // Compiled by `tsc` from backend-detect.mts. Edit the TypeScript source and rebuild;
3
+ // changes made directly to this file are silently overwritten by the next build, and are never
4
+ // deployed to the host on their own -- install-resources.mjs copies THIS file's on-disk contents
5
+ // verbatim to tools/, so an edit made only here reaches the host but is lost on the very next
6
+ // rebuild.
7
+ // backend-detect.mts
8
+ //
9
+ // The ONE place that decides which VICE build a binary is, and the ONE
10
+ // reader of VICE_BACKEND anywhere in this tree (D-01). Everything else that
11
+ // needs to know "fork or stock" -- broker-launch.mts's buildViceArgs(),
12
+ // vice-broker.mts's startup wiring, and plan 02-08's later connect handshake
13
+ // -- calls resolvedBackend() below and threads its answer down, exactly like
14
+ // vice.ts's mcpHost() documents for its own container-versus-host question:
15
+ // a SECOND independent reader of the same signal is a bug waiting to
16
+ // happen the moment one copy is updated and the other is not (mcpHost()'s
17
+ // own "three inlined copies" incident is the exact regression this file
18
+ // exists to keep from recurring here).
19
+ //
20
+ // WHAT NOT TO DO:
21
+ // - Do not call resolvedBackend()/probeBackend() per acquire, per launch,
22
+ // or per connect. The broker resolves the backend exactly ONCE, at
23
+ // process startup (vice-broker.mts's run()), and passes the resolved
24
+ // value down through every real launch call site -- see this module's
25
+ // own module-level memo below, which exists as a second line of defence
26
+ // against an accidental extra call, not as the PRIMARY mechanism (that
27
+ // is the caller only ever invoking this once).
28
+ // - Do not call this from inside broker-launch.mts's `inFlight`
29
+ // single-owner launch guard. This is a possibly-blocking child-process
30
+ // spawn (probeBackend()'s --help probe); anything that can block inside
31
+ // that synchronous check-and-set window is the exact failure class the
32
+ // 2026-08-01 triple-launch outage came from (D-03/T-02-25).
33
+ // - Do not add a trial-launch fallback (launch the binary for real and
34
+ // watch what happens) as a second detection mechanism. The `--help`
35
+ // probe below runs entirely outside any launch-guarded critical section
36
+ // BY CONSTRUCTION -- a trial launch would not. It would also depend on
37
+ // RESEARCH.md's assumption A1 (stock's argument parser rejects an
38
+ // unknown flag rather than ignoring it), which is UNVERIFIED against a
39
+ // real stock binary; see docs/phase2-backend-probe-evidence.md.
40
+ //
41
+ // ENVIRONMENT CONSTRAINT (2026-08-13, explicit user scope override): no real
42
+ // stock or fork VICE binary is reachable from the environment this plan
43
+ // executed in, and the user's own ruling for this plan is "we can't do
44
+ // tests with deciding what vice is". Every test in backend-detect.test.ts
45
+ // therefore drives this module's OVERRIDE path, its on-disk CACHE lifecycle,
46
+ // and classifyHelpOutput()'s STRING-PARSING logic against fixture strings
47
+ // authored in the test file -- never a real spawned binary. The `--help`
48
+ // discriminator itself (does a real stock build's --help output actually
49
+ // contain "-binarymonitor" and omit "-mcpserver", the way classifyHelpOutput()
50
+ // below assumes) is recorded as an OPEN, not a VERIFIED, question in
51
+ // docs/phase2-backend-probe-evidence.md section 2 -- that document's verdict
52
+ // is deliberately left standing; nothing in this file's own tests attempts to
53
+ // resolve it, and no fixture string anywhere in this tree should ever be
54
+ // presented as real captured output from either build. See the follow-up
55
+ // todo tracked under .planning/todos/pending/ for what a real-hardware run
56
+ // must still confirm.
57
+ import { spawnSync } from "node:child_process";
58
+ import { existsSync, readFileSync, writeFileSync, chmodSync, renameSync, mkdirSync, statSync, } from "node:fs";
59
+ import { join, resolve as resolvePath } from "node:path";
60
+ // ---------------------------------------------------------------------------
61
+ // classifyHelpOutput() -- pure string classification, no I/O at all.
62
+ // ---------------------------------------------------------------------------
63
+ /** Matches on the literal flag tokens D-02 names as the discriminator: the
64
+ * fork's `-mcpserver` flag versus stock's `-binarymonitor`-only surface.
65
+ * `"fork"` wins when BOTH tokens appear (the fork's own VICE tree is a 3.10
66
+ * checkout and accepts both flags) -- checked FIRST, deliberately, so a
67
+ * build that advertises both is classified by the flag that actually makes
68
+ * it the fork, not merely "also has stock's flag too". `"unknown"` when
69
+ * NEITHER token appears -- a real --help transcript that does not match
70
+ * either shape, a probe that spawned nothing at all (empty text), or
71
+ * anything else this function was never taught to recognise. Never throws;
72
+ * pure function of the text it is given. */
73
+ export function classifyHelpOutput(text) {
74
+ const hasFork = text.includes("-mcpserver");
75
+ const hasStock = text.includes("-binarymonitor");
76
+ if (hasFork)
77
+ return "fork";
78
+ if (hasStock)
79
+ return "stock";
80
+ return "unknown";
81
+ }
82
+ // ---------------------------------------------------------------------------
83
+ // probeBackend() -- the --help probe. spawnSync only, argv array, shell:
84
+ // false, never a shell string and never string interpolation of binPath
85
+ // into a command line (T-02-03's mitigation). Bounded by a 5000ms timeout
86
+ // with kill-on-timeout so a hostile or hung binary cannot stall broker
87
+ // startup (T-02-25's second mitigation half).
88
+ // ---------------------------------------------------------------------------
89
+ const PROBE_TIMEOUT_MS = 5000;
90
+ /** `--help` first, falling back to `-help` then `-?` ONLY when a run exits
91
+ * non-zero with EMPTY combined output -- a run that exits non-zero but
92
+ * still printed something (some builds write usage to stderr and exit 1) is
93
+ * already usable and is not retried further. */
94
+ const HELP_FLAG_CANDIDATES = ["--help", "-help", "-?"];
95
+ function defaultSpawnHelp(binPath, flag) {
96
+ try {
97
+ const result = spawnSync(binPath, [flag], {
98
+ encoding: "utf8",
99
+ timeout: PROBE_TIMEOUT_MS,
100
+ killSignal: "SIGKILL",
101
+ });
102
+ const text = `${result.stdout ?? ""}${result.stderr ?? ""}`;
103
+ return { text, exitedZero: result.status === 0 };
104
+ }
105
+ catch {
106
+ return { text: "", exitedZero: false };
107
+ }
108
+ }
109
+ /** Runs the fallback ladder above against `binPath` and classifies whatever
110
+ * text the LAST attempted flag produced. Never throws -- every failure mode
111
+ * (spawn error, timeout, empty output, an exit code the caller does not
112
+ * recognise) flows through to classifyHelpOutput() as ordinary text, which
113
+ * itself never throws either. */
114
+ export function probeBackend(binPath, deps = {}) {
115
+ const spawnHelp = deps.spawnHelp ?? defaultSpawnHelp;
116
+ let text = "";
117
+ for (const flag of HELP_FLAG_CANDIDATES) {
118
+ const outcome = spawnHelp(binPath, flag);
119
+ text = outcome.text;
120
+ if (outcome.exitedZero || text.trim() !== "")
121
+ break;
122
+ }
123
+ return classifyHelpOutput(text);
124
+ }
125
+ function isPlainObject(value) {
126
+ return typeof value === "object" && value !== null && !Array.isArray(value);
127
+ }
128
+ function cachePathFor(supervisorDir) {
129
+ return join(supervisorDir, "backend.json");
130
+ }
131
+ /** Reads and narrows the cache file at the boundary with isPlainObject()-
132
+ * style checks, never a cast -- absent, unreadable, unparseable, or
133
+ * wrong-shaped (missing/mistyped required fields) all collapse to `null`,
134
+ * treated identically as a cache MISS, never as an error this function
135
+ * surfaces to its caller. */
136
+ function readCacheRecord(supervisorDir) {
137
+ let raw;
138
+ try {
139
+ raw = readFileSync(cachePathFor(supervisorDir), "utf8");
140
+ }
141
+ catch {
142
+ return null;
143
+ }
144
+ let parsed;
145
+ try {
146
+ parsed = JSON.parse(raw);
147
+ }
148
+ catch {
149
+ return null;
150
+ }
151
+ if (!isPlainObject(parsed))
152
+ return null;
153
+ if (typeof parsed.resolvedPath !== "string" ||
154
+ typeof parsed.mtimeMs !== "number" ||
155
+ typeof parsed.sizeBytes !== "number" ||
156
+ (parsed.backend !== "fork" && parsed.backend !== "stock")) {
157
+ return null;
158
+ }
159
+ const record = {
160
+ version: 1,
161
+ resolvedPath: parsed.resolvedPath,
162
+ mtimeMs: parsed.mtimeMs,
163
+ sizeBytes: parsed.sizeBytes,
164
+ backend: parsed.backend,
165
+ probedAt: typeof parsed.probedAt === "string" ? parsed.probedAt : "",
166
+ };
167
+ if (typeof parsed.versionQuad === "string")
168
+ record.versionQuad = parsed.versionQuad;
169
+ if (typeof parsed.cpuHistoryAvailable === "boolean")
170
+ record.cpuHistoryAvailable = parsed.cpuHistoryAvailable;
171
+ return record;
172
+ }
173
+ /** Tmp-sibling -> chmod 0600 -> content -> rename, the SAME atomic-write
174
+ * discipline refresh-manifest.ts's writeManifestAtomic() and vice-broker.mts's
175
+ * writeBrokerRecordFile() both already use -- a crash mid-write can only ever
176
+ * leave a stray tmp sibling behind, never a truncated or empty file at the
177
+ * real cache path that a later read would wrongly accept. */
178
+ function writeCacheRecordAtomic(supervisorDir, record) {
179
+ mkdirSync(supervisorDir, { recursive: true });
180
+ const finalPath = cachePathFor(supervisorDir);
181
+ const tmpPath = `${finalPath}.tmp-${process.pid}-${Date.now()}`;
182
+ writeFileSync(tmpPath, "");
183
+ chmodSync(tmpPath, 0o600);
184
+ writeFileSync(tmpPath, JSON.stringify(record, null, 2) + "\n");
185
+ renameSync(tmpPath, finalPath);
186
+ }
187
+ function defaultResolveBinPath(bin, env) {
188
+ if (bin.includes("/")) {
189
+ const abs = resolvePath(bin);
190
+ return existsSync(abs) ? abs : null;
191
+ }
192
+ const pathEnv = env.PATH ?? "";
193
+ for (const dir of pathEnv.split(":")) {
194
+ if (!dir)
195
+ continue;
196
+ const candidate = join(dir, bin);
197
+ if (existsSync(candidate))
198
+ return candidate;
199
+ }
200
+ return null;
201
+ }
202
+ function defaultStat(resolvedPath) {
203
+ try {
204
+ const st = statSync(resolvedPath);
205
+ return { mtimeMs: st.mtimeMs, sizeBytes: st.size };
206
+ }
207
+ catch {
208
+ return null;
209
+ }
210
+ }
211
+ function defaultLog(line) {
212
+ process.stderr.write(`${line}\n`);
213
+ }
214
+ // Memoised answer for the probe/cache path ONLY -- the override path
215
+ // (VICE_BACKEND set) is always answered fresh, on every call, straight from
216
+ // the environment, and never touches this memo (an explicit override can
217
+ // legitimately differ from call to call within, e.g., a test process driving
218
+ // many scenarios; the detected-backend answer for a fixed binary cannot).
219
+ // This is what makes resolvedBackend() answer "once per long-running
220
+ // process" for the case that actually spawns something, while never
221
+ // requiring a caller to somehow signal "this is a fresh scenario" the way
222
+ // container-guard.mts's isInsideContainer() asks callers to pass explicit
223
+ // deps to bypass ITS OWN memo -- here, the override/no-override distinction
224
+ // already IS that signal.
225
+ let memoisedResult = null;
226
+ // D-06: gates the "detected backend X for binary Y" stderr note so a
227
+ // long-running broker (or a test suite driving resolvedBackend() many times)
228
+ // emits it at most once per process -- repo-root.ts's warnedEnvOutsideFrom/
229
+ // warnedNoMarkerFound pattern, reused here verbatim.
230
+ let warnedBackendUnset = false;
231
+ function emitDetectedNote(result, viceBin, log) {
232
+ if (warnedBackendUnset)
233
+ return;
234
+ warnedBackendUnset = true;
235
+ log(`vice-broker: detected backend "${result.backend}" for ${viceBin} (source: ${result.source}) -- ` +
236
+ `set VICE_BACKEND=stock or VICE_BACKEND=fork to override this detection explicitly`);
237
+ }
238
+ /** Test-only escape hatch: clears the in-process memo and the D-06
239
+ * one-time-note gate. Never called by any real production code path --
240
+ * vice-broker.mts calls resolvedBackend() exactly once per real process
241
+ * lifetime and has no reason to ever reset it; this exists solely so
242
+ * backend-detect.test.ts can drive many distinct scenarios (cache hit, cache
243
+ * miss, indeterminate, ...) in one shared test process without one
244
+ * scenario's memoised answer contaminating the next -- mirroring
245
+ * broker-launch.test.ts's own discipline of restoring module-level state
246
+ * between test cases, made explicit here rather than left to careful test
247
+ * ordering, since this module's memo (unlike buildViceArgs()'s one-time
248
+ * note) has no natural "always widens the same way" ordering to exploit. */
249
+ export function resetResolvedBackendForTests() {
250
+ memoisedResult = null;
251
+ warnedBackendUnset = false;
252
+ }
253
+ /** Honours VICE_BACKEND FIRST, returning immediately without spawning
254
+ * anything when it names `stock` or `fork` (BACK-01: one optional config
255
+ * value switches backends, no code edit). Otherwise consults the on-disk
256
+ * cache (when `supervisorDir` is given and the binary's current
257
+ * `{ resolvedPath, mtimeMs, sizeBytes }` all match the stored record); on a
258
+ * miss, probes via probeBackend() and writes the cache. Memoises the
259
+ * probe/cache answer in a module-level variable so a long-running process
260
+ * resolves once (see the memo's own comment above for what "once" means
261
+ * here). Never throws: a probe that classifies "unknown" (including a
262
+ * spawn failure or a timeout, both of which probeBackend() already reduces
263
+ * to "unknown") returns a defined `{ backend: "fork", source:
264
+ * "indeterminate", ... }` outcome instead -- "fork" because that is the
265
+ * pre-Phase-2 behaviour every existing install already has, so an
266
+ * undetectable binary degrades to what already worked rather than to
267
+ * nothing. */
268
+ /** WR-05: the ONE place `binPath`/`binPathResolved` are derived, so the four
269
+ * return paths below cannot disagree about what "the binary" means. A resolved
270
+ * absolute path when there is one; the configured name, flagged as unresolved,
271
+ * when there is not. */
272
+ function binPathFields(resolvedPath, viceBin) {
273
+ return resolvedPath !== null ? { binPath: resolvedPath, binPathResolved: true } : { binPath: viceBin, binPathResolved: false };
274
+ }
275
+ export function resolvedBackend(deps = {}) {
276
+ const env = deps.env ?? process.env;
277
+ const viceBin = deps.viceBin ?? env.VICE_BIN ?? "x64sc";
278
+ // A direct read of the real environment on the right of this ternary
279
+ // (rather than the generic `env` local above) is deliberate: this file is
280
+ // grep-gated, tree-wide, as the ONE place that ever names this variable
281
+ // directly against the real environment -- `deps.env` (the test-injection
282
+ // seam) still takes precedence when supplied, exactly like every other
283
+ // field on this options object.
284
+ const override = deps.env ? deps.env.VICE_BACKEND : process.env.VICE_BACKEND;
285
+ const resolveBinPath = deps.resolveBinPath ?? defaultResolveBinPath;
286
+ if (override === "stock" || override === "fork") {
287
+ // WR-05: an explicit backend override still resolves the PATH, so
288
+ // `vice_ping` reports a real file rather than the bare name. This is a
289
+ // filesystem lookup only -- existsSync per PATH entry -- and NEVER a spawn,
290
+ // so the override path keeps its "answered fresh, straight from the
291
+ // environment, spawns nothing" property.
292
+ return { backend: override, source: "override", ...binPathFields(resolveBinPath(viceBin, env), viceBin) };
293
+ }
294
+ if (memoisedResult !== null)
295
+ return memoisedResult;
296
+ const log = deps.log ?? defaultLog;
297
+ const stat = deps.stat ?? defaultStat;
298
+ const probe = deps.probe ?? ((bin) => probeBackend(bin));
299
+ const now = deps.now ?? (() => Date.now());
300
+ const resolvedPath = resolveBinPath(viceBin, env);
301
+ const identity = resolvedPath ? stat(resolvedPath) : null;
302
+ const cacheEligible = resolvedPath !== null && identity !== null && typeof deps.supervisorDir === "string";
303
+ if (cacheEligible) {
304
+ const cached = readCacheRecord(deps.supervisorDir);
305
+ if (cached &&
306
+ cached.resolvedPath === resolvedPath &&
307
+ cached.mtimeMs === identity.mtimeMs &&
308
+ cached.sizeBytes === identity.sizeBytes) {
309
+ const result = { backend: cached.backend, source: "cache", ...binPathFields(resolvedPath, viceBin) };
310
+ memoisedResult = result;
311
+ emitDetectedNote(result, viceBin, log);
312
+ return result;
313
+ }
314
+ }
315
+ const verdict = probe(viceBin);
316
+ if (verdict === "unknown") {
317
+ const note = `vice-broker: could not determine whether ${viceBin} is the stock or fork VICE build -- ` +
318
+ `its --help output matched neither the -mcpserver nor the -binarymonitor discriminator. ` +
319
+ `Set VICE_BACKEND=stock or VICE_BACKEND=fork explicitly.`;
320
+ log(note);
321
+ const result = { backend: "fork", source: "indeterminate", ...binPathFields(resolvedPath, viceBin), note };
322
+ memoisedResult = result;
323
+ return result;
324
+ }
325
+ if (cacheEligible) {
326
+ writeCacheRecordAtomic(deps.supervisorDir, {
327
+ version: 1,
328
+ resolvedPath: resolvedPath,
329
+ mtimeMs: identity.mtimeMs,
330
+ sizeBytes: identity.sizeBytes,
331
+ backend: verdict,
332
+ probedAt: new Date(now()).toISOString(),
333
+ });
334
+ }
335
+ const result = { backend: verdict, source: "probe", ...binPathFields(resolvedPath, viceBin) };
336
+ memoisedResult = result;
337
+ emitDetectedNote(result, viceBin, log);
338
+ return result;
339
+ }
340
+ /** Reads whatever capability answers (BACK-04) are on record for `binPath`
341
+ * -- `null` when there is no cache at all, the binary cannot be resolved, the
342
+ * record on file names a DIFFERENT resolved binary, or nothing has been
343
+ * recorded for this binary yet. Never throws. */
344
+ export function readCapabilityRecord(binPath, deps = {}) {
345
+ if (typeof deps.supervisorDir !== "string")
346
+ return null;
347
+ const env = deps.env ?? process.env;
348
+ const resolveBinPath = deps.resolveBinPath ?? defaultResolveBinPath;
349
+ const resolvedPath = resolveBinPath(binPath, env);
350
+ if (!resolvedPath)
351
+ return null;
352
+ const existing = readCacheRecord(deps.supervisorDir);
353
+ if (!existing || existing.resolvedPath !== resolvedPath)
354
+ return null;
355
+ if (existing.versionQuad === undefined && existing.cpuHistoryAvailable === undefined)
356
+ return null;
357
+ const stale = deps.observedVersionQuad !== undefined &&
358
+ existing.versionQuad !== undefined &&
359
+ existing.versionQuad !== deps.observedVersionQuad;
360
+ return { versionQuad: existing.versionQuad, cpuHistoryAvailable: existing.cpuHistoryAvailable, stale };
361
+ }
362
+ /** Attaches `{ versionQuad, cpuHistoryAvailable }` to the EXISTING backend
363
+ * verdict already on record for `binPath`'s resolved identity -- a no-op,
364
+ * never a throw, when there is no such matching record yet (no supervisorDir
365
+ * given, the binary cannot be resolved or stat'd, or the cache names a
366
+ * different binary or has no verdict at all). This function never invents a
367
+ * backend verdict of its own: it can only EXTEND a record resolvedBackend()
368
+ * already wrote, since a `--help` probe has no way to observe a version
369
+ * quad and this function must not silently fabricate the field it did not
370
+ * observe either. */
371
+ export function writeCapabilityRecord(binPath, capability, deps = {}) {
372
+ if (typeof deps.supervisorDir !== "string")
373
+ return;
374
+ const env = deps.env ?? process.env;
375
+ const resolveBinPath = deps.resolveBinPath ?? defaultResolveBinPath;
376
+ const stat = deps.stat ?? defaultStat;
377
+ const resolvedPath = resolveBinPath(binPath, env);
378
+ if (!resolvedPath)
379
+ return;
380
+ const identity = stat(resolvedPath);
381
+ if (!identity)
382
+ return;
383
+ const existing = readCacheRecord(deps.supervisorDir);
384
+ if (!existing || existing.resolvedPath !== resolvedPath)
385
+ return;
386
+ writeCacheRecordAtomic(deps.supervisorDir, {
387
+ version: 1,
388
+ resolvedPath,
389
+ mtimeMs: identity.mtimeMs,
390
+ sizeBytes: identity.sizeBytes,
391
+ backend: existing.backend,
392
+ probedAt: existing.probedAt,
393
+ versionQuad: capability.versionQuad,
394
+ cpuHistoryAvailable: capability.cpuHistoryAvailable,
395
+ });
396
+ }
@@ -7,13 +7,19 @@
7
7
  // broker-control.mts
8
8
  //
9
9
  // N / D-01 (plan 01, tracer): the framing, the token gate, and acquire/
10
- // release. THIS PLAN (05) completes the message set: recycle, status,
10
+ // release. Plan 05 (task 1) completed the message set: recycle, status,
11
11
  // host_state, the arrival-ordered pending-acquire structure, and the
12
- // kernel-enforced singleton guard's low-level bind primitive. The
13
- // subsystem's FIRST network listener: a TCP control plane replacing the
14
- // bash broker's requests/grants/denials/leases directory tree entirely. One
15
- // JSON object per line; the connection open IS the claim, connection close
16
- // IS the release (T-01.6.2-01 through -09).
12
+ // kernel-enforced singleton guard's low-level bind primitive. THIS PLAN's
13
+ // task 2 adds a SEVENTH and EIGHTH op, `monitor_claim`/`monitor_release`
14
+ // (BROK-02/PROTO-08, D-13): exclusive ownership of an instance's raw binmon
15
+ // socket, enforced here rather than left to a client-side heuristic --
16
+ // stock VICE services exactly one binmon client, and a second connect()
17
+ // produces no reply and no EOF, so the refusal must happen BEFORE any
18
+ // second dial is ever attempted. The subsystem's FIRST network listener: a
19
+ // TCP control plane replacing the bash broker's requests/grants/denials/
20
+ // leases directory tree entirely. One JSON object per line; the connection
21
+ // open IS the claim, connection close IS the release (T-01.6.2-01 through
22
+ // -09).
17
23
  //
18
24
  // Wire format confirmed at plan 01's blocking checkpoint:decision
19
25
  // (2026-08-03, `as-specified`, no amendments -- see .planning/RE-FINDINGS.md
@@ -34,6 +40,14 @@ export function newControlToken() {
34
40
  return randomBytes(32).toString("hex");
35
41
  }
36
42
  const MAX_LINE_BYTES = 65536;
43
+ /** CR-03: the one refusal wording for a target-naming op whose `target_id` is
44
+ * not the grant the asking connection itself holds. Deliberately worded as an
45
+ * authorisation refusal and NOT as an ownership conflict between two
46
+ * legitimate holders (`monitor_owned`, which names a holder) and never as an
47
+ * emulator fault -- see attachControlProtocol()'s own ownsTarget() comment,
48
+ * and T-02-18's prohibition on wedge/hang vocabulary in this file's
49
+ * monitor-op refusals. */
50
+ const MONITOR_OWNERSHIP_DENIAL = "monitor_claim/monitor_release may only target the grant this connection itself holds";
37
51
  export function resolveControlPort(override) {
38
52
  if (typeof override === "number")
39
53
  return override;
@@ -147,6 +161,32 @@ function attachControlProtocol(server, opts, pendingAcquires) {
147
161
  // Per-connection error handling isolates one peer's failure from
148
162
  // every other connection and from the server itself (T-01.6.2-06).
149
163
  });
164
+ /**
165
+ * CR-03 (code review 2026-08-13). THE per-connection ownership predicate
166
+ * every target-naming op is gated on -- the same rule `recycle` has
167
+ * enforced since T-01.6.2-31, now shared rather than copied.
168
+ *
169
+ * Before this existed, `monitor_claim`/`monitor_release` took `target_id`
170
+ * from the request and passed it straight through, so any connection
171
+ * holding the per-boot control token (which every container-side proxy
172
+ * sharing this broker does) could name ANOTHER session's grant id.
173
+ * vice-broker.mts's handleMonitorClaim() uses that id as BOTH the target
174
+ * and the claiming identity, and handleMonitorRelease()'s "only the
175
+ * holder may release" check compared the request against itself -- so
176
+ * session B could lock session A out of its own monitor socket, or
177
+ * RELEASE A's live claim, after which a third client was free to dial the
178
+ * same single-client binmon socket. That is precisely the unserviced-
179
+ * backlog state D-13 exists to prevent and that CLAUDE.md says must never
180
+ * be reachable.
181
+ *
182
+ * WHAT NOT TO DO: never add another op that acts on a caller-supplied
183
+ * `target_id` without gating it here first. The grant a connection holds
184
+ * is the ONLY identity this protocol has -- `target_id` is a request
185
+ * field, not a credential.
186
+ */
187
+ function ownsTarget(targetId) {
188
+ return requestIdForThisConnection !== null && targetId === requestIdForThisConnection;
189
+ }
150
190
  /** Attempts one acquire over THIS connection/socket, writing the
151
191
  * terminal response (grant or a non-queueing error) when settled, or
152
192
  * enqueueing itself and returning unsettled when a launch is already in
@@ -272,8 +312,10 @@ function attachControlProtocol(server, opts, pendingAcquires) {
272
312
  // holds. This check happens here, before onRecycle() is ever
273
313
  // called, so a mismatched target never reaches the kill discipline
274
314
  // and never signals anything -- an injected signal recorder stays
275
- // empty for this case.
276
- if (requestIdForThisConnection === null || targetId !== requestIdForThisConnection) {
315
+ // empty for this case. Now expressed through the SAME ownsTarget()
316
+ // predicate monitor_claim/monitor_release use (CR-03), so the three
317
+ // target-naming ops cannot drift apart.
318
+ if (!ownsTarget(targetId)) {
277
319
  writeLine(socket, {
278
320
  kind: "error",
279
321
  code: "denied",
@@ -315,8 +357,68 @@ function attachControlProtocol(server, opts, pendingAcquires) {
315
357
  warm_floor: hs.warmFloor,
316
358
  max_instances: hs.maxInstances,
317
359
  base_port: hs.basePort,
360
+ backend: hs.backend,
318
361
  });
319
362
  }
363
+ else if (req.op === "monitor_claim") {
364
+ const targetId = typeof req.target_id === "string" ? req.target_id : "";
365
+ if (targetId === "") {
366
+ writeLine(socket, { kind: "error", code: "bad_request", message: "monitor_claim requires target_id" });
367
+ return;
368
+ }
369
+ if (!ownsTarget(targetId)) {
370
+ writeLine(socket, { kind: "error", code: "denied", message: MONITOR_OWNERSHIP_DENIAL });
371
+ return;
372
+ }
373
+ const requestId = typeof req.id === "string" && req.id !== "" ? req.id : defaultRequestId("claim");
374
+ const outcome = opts.onMonitorClaim(requestId, targetId);
375
+ if (outcome.ok) {
376
+ writeLine(socket, { kind: "monitor_claimed" });
377
+ }
378
+ else if (outcome.code === "monitor_owned") {
379
+ // Ownership conflict, named by holder -- deliberately worded to
380
+ // never suggest the emulator itself has stopped answering
381
+ // (T-02-18; the plan's own grep gate polices this).
382
+ //
383
+ // WR-08 (broker side): `holder` is REQUIRED by MonitorClaimOutcome for
384
+ // this code, but this handler runs inside socket.on("data") with no
385
+ // try/catch above it, so a producer that ever omitted it would throw a
386
+ // TypeError out of the control listener and take the broker process
387
+ // with it -- a type contract is not a runtime guarantee at a wire
388
+ // boundary. The fallback names the holder as unknown rather than
389
+ // fabricating one, matching what the container-side client now does
390
+ // with a malformed holder payload.
391
+ const holder = outcome.holder ?? { grantId: "unknown", claimedAt: 0, pid: null };
392
+ writeLine(socket, {
393
+ kind: "error",
394
+ code: "monitor_owned",
395
+ message: `instance already has a monitor client (grant ${holder.grantId}, claimed at ${holder.claimedAt}) -- this is an ownership conflict, not an emulator failure`,
396
+ holder,
397
+ });
398
+ }
399
+ else {
400
+ writeLine(socket, { kind: "error", code: outcome.code, message: `monitor_claim failed: ${outcome.code}` });
401
+ }
402
+ }
403
+ else if (req.op === "monitor_release") {
404
+ const targetId = typeof req.target_id === "string" ? req.target_id : "";
405
+ if (targetId === "") {
406
+ writeLine(socket, { kind: "error", code: "bad_request", message: "monitor_release requires target_id" });
407
+ return;
408
+ }
409
+ if (!ownsTarget(targetId)) {
410
+ writeLine(socket, { kind: "error", code: "denied", message: MONITOR_OWNERSHIP_DENIAL });
411
+ return;
412
+ }
413
+ const requestId = typeof req.id === "string" && req.id !== "" ? req.id : defaultRequestId("release-monitor");
414
+ const outcome = opts.onMonitorRelease(requestId, targetId);
415
+ if (outcome.ok) {
416
+ writeLine(socket, { kind: "monitor_released" });
417
+ }
418
+ else {
419
+ writeLine(socket, { kind: "error", code: outcome.code, message: `monitor_release refused: ${outcome.code}` });
420
+ }
421
+ }
320
422
  else {
321
423
  writeLine(socket, { kind: "error", code: "bad_request", message: `unknown op: ${String(req.op)}` });
322
424
  }