@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.
@@ -21,9 +21,10 @@
21
21
  // never-cache-a-negative-result" section.
22
22
  //
23
23
  // MUST NOT import hostpath.ts: the host-path consumer set is closed to
24
- // four production modules by vice-mcp-selector-docs.test.mjs's assertion 4,
25
- // and host-path message text stays in vice-proxy.mjs, which is already on
26
- // that list.
24
+ // exactly five production modules (containerpath.ts, install-resources.ts,
25
+ // stock-paths.ts, vice-proxy.ts, vice-sync.ts), pinned by
26
+ // hostpath-consumers.test.ts, and host-path message text stays in
27
+ // vice-proxy.ts, which is already on that list.
27
28
  import { readFileSync } from "node:fs";
28
29
  import { randomUUID } from "node:crypto";
29
30
  import { join, resolve } from "node:path";
@@ -43,7 +44,7 @@ import { supervisorDir } from "./repo-root.ts";
43
44
  // and importing it would pull `hostpath.ts` into this module, which this
44
45
  // file's own header (lines 23-26) forbids and which the host-path
45
46
  // consumer-set assertion polices.
46
- import { mcpHost } from "./vice.ts";
47
+ import { mcpHost, ViceError } from "./vice.ts";
47
48
 
48
49
  // -------------------------------------------------------------- request ids
49
50
  //
@@ -509,7 +510,13 @@ export type ControlFailureKind =
509
510
  | "denied"
510
511
  | "no_free_port"
511
512
  | "at_capacity"
512
- | "internal";
513
+ | "internal"
514
+ // Plan 05 (BROK-02/PROTO-08): the broker's own ControlErrorCode gained
515
+ // this member for the ownership-conflict outcome; duplicated here for the
516
+ // same reason every other member already is (this client and the broker
517
+ // run in separate processes -- the shared surface is the wire format, not
518
+ // a TypeScript type).
519
+ | "monitor_owned";
513
520
 
514
521
  export type ControlAcquireResult = { ok: true; grant: AcquireGrant } | { ok: false; kind: ControlFailureKind; message: string };
515
522
 
@@ -543,12 +550,94 @@ interface ControlHostStateFields {
543
550
  warm_floor: number;
544
551
  max_instances: number;
545
552
  base_port: number;
553
+ /** WR-04: the backend verdict the BROKER resolved -- the authoritative one,
554
+ * since it is what decided the emulator's launch argv. `null` when the broker
555
+ * predates this field or sent something unrecognised: absent evidence, kept
556
+ * strictly distinct from a definite disagreement, so a mismatch check can
557
+ * refuse only on the latter. */
558
+ backend: "fork" | "stock" | null;
546
559
  }
547
560
 
548
561
  export type ControlHostStateResult =
549
562
  | { ok: true; hostState: ControlHostStateFields }
550
563
  | { ok: false; kind: ControlFailureKind; message: string };
551
564
 
565
+ /** Plan 05 (BROK-02/PROTO-08, D-13): the current monitor-socket holder's own
566
+ * identity, named in a `monitor_owned` refusal -- field-for-field the same
567
+ * shape the broker's own MonitorHolder carries (broker-control.mts), minus
568
+ * nothing (pid included, matching GrantRecord's own convention this whole
569
+ * mechanism mirrors). */
570
+ export interface MonitorClaimHolder {
571
+ grantId: string;
572
+ claimedAt: number;
573
+ pid: number | null;
574
+ }
575
+
576
+ export interface ClaimMonitorOptions {
577
+ targetId: string;
578
+ timeoutMs?: number;
579
+ }
580
+
581
+ export interface ReleaseMonitorOptions {
582
+ targetId: string;
583
+ timeoutMs?: number;
584
+ }
585
+
586
+ /** Discriminated claim outcome (plan 05): `monitor_owned` is kept STRICTLY
587
+ * separate from `timeout` -- conflating "someone else holds it" with "the
588
+ * broker did not answer" would reintroduce exactly the ambiguity PROTO-08
589
+ * exists to remove. Never thrown; a caller that wants to raise instead
590
+ * should construct a MonitorOwnershipError from this outcome's own fields
591
+ * (see that class's own header comment). */
592
+ export type ClaimMonitorOutcome =
593
+ | { ok: true }
594
+ | { ok: false; reason: "monitor_owned"; holder: MonitorClaimHolder }
595
+ // "denied" (CR-03): the broker's control plane refused because the grant
596
+ // named is not the one THIS connection holds. In a correct client that is
597
+ // unreachable -- stockConnect() always claims the grant its own session
598
+ // acquired -- so it is carried as its own reason rather than collapsed into
599
+ // "internal", where a wiring bug would be indistinguishable from a broker
600
+ // fault. Kept strictly distinct from "monitor_owned", which is a conflict
601
+ // between two LEGITIMATE holders.
602
+ | { ok: false; reason: "timeout" | "unauthorized" | "bad_request" | "denied" | "internal" };
603
+
604
+ export type ReleaseMonitorOutcome = { ok: true } | { ok: false; reason: "timeout" | "unauthorized" | "bad_request" | "denied" | "internal" };
605
+
606
+ export interface MonitorOwnershipErrorOptions {
607
+ holderGrantId?: string;
608
+ holderClaimedAt?: number;
609
+ port?: number;
610
+ }
611
+
612
+ /** Thrown (by a caller that prefers to raise rather than branch on
613
+ * ClaimMonitorOutcome) when `monitor_claim` is refused because a DIFFERENT
614
+ * grant already holds this instance's monitor socket (plan 05, PROTO-08,
615
+ * D-13). Names the holding grant and the port plainly, as an ownership
616
+ * conflict -- a state the broker itself enforced, distinct from an emulator
617
+ * that has stopped answering, and NOT a state the vice-wedge-triage skill's
618
+ * opening move should ever be misdirected by.
619
+ *
620
+ * The claim this error reports on a refusal is made BEFORE any binmon
621
+ * connect() is ever attempted: stock VICE services exactly one binmon
622
+ * client, and a second connect() produces no reply and no EOF, so a refusal
623
+ * arriving only after dialling would be byte-for-byte indistinguishable
624
+ * from a wedge (PROTO-08). Claiming first means this refusal is a JSON
625
+ * response on a control-plane socket that already works, and the second
626
+ * client never dials the binmon port at all (D-13). */
627
+ export class MonitorOwnershipError extends ViceError {
628
+ holderGrantId?: string;
629
+ holderClaimedAt?: number;
630
+ port?: number;
631
+
632
+ constructor(message: string, { holderGrantId, holderClaimedAt, port }: MonitorOwnershipErrorOptions = {}) {
633
+ super(message);
634
+ this.name = "MonitorOwnershipError";
635
+ this.holderGrantId = holderGrantId;
636
+ this.holderClaimedAt = holderClaimedAt;
637
+ this.port = port;
638
+ }
639
+ }
640
+
552
641
  /** Per-call deadline override -- matches PollOptions's own established shape
553
642
  * above (pollGrant()/pollRecycleAck() already take an optional `timeoutMs`
554
643
  * this same way). The MODULE-LEVEL constant (ACQUIRE_TIMEOUT_MS etc.) is the
@@ -570,6 +659,15 @@ export interface BrokerControlSession {
570
659
  recycle(targetId: string, opts?: ControlDeadlineOptions): Promise<ControlRecycleResult>;
571
660
  status(opts?: ControlDeadlineOptions): Promise<ControlStatusResult>;
572
661
  hostState(opts?: ControlDeadlineOptions): Promise<ControlHostStateResult>;
662
+ /** Claims exclusive ownership of an instance's monitor socket BEFORE any
663
+ * binmon connect() is attempted (plan 05, PROTO-08, D-13) -- see
664
+ * MonitorOwnershipError's own header comment for why claiming first is
665
+ * the only way this refusal can ever be distinguishable from a wedge. */
666
+ claimMonitor(opts: ClaimMonitorOptions): Promise<ClaimMonitorOutcome>;
667
+ /** Releases a previously claimed monitor socket. Tolerates a broker that
668
+ * has already cleared the record (release/recycle/process-exit all clear
669
+ * it broker-side) -- a second release is `ok: true`, not an error. */
670
+ releaseMonitor(opts: ReleaseMonitorOptions): Promise<ReleaseMonitorOutcome>;
573
671
  }
574
672
 
575
673
  export interface OpenBrokerControlOptions {
@@ -580,6 +678,49 @@ export type OpenBrokerControlOutcome =
580
678
  | { ok: true; session: BrokerControlSession }
581
679
  | { ok: false; kind: ControlFailureKind; message: string; target?: string };
582
680
 
681
+ /** The backend-agnostic coordinate set a session which ALREADY holds a
682
+ * broker grant hands to anything that needs to dial the instance that grant
683
+ * names (plan 02-09, PROTO-08, D-13). Declared here, beside
684
+ * BrokerControlSession and openBrokerControl(), because it is
685
+ * backend-agnostic -- the fork path does not consume it only because
686
+ * forwardToVice() reads activeInstance() from the same module (vice.ts)
687
+ * that owns the state, not because this shape is stock-specific.
688
+ *
689
+ * `targetId` is the GRANT ID, not the port -- the exact same value
690
+ * vice-proxy.ts's own `controlSession.recycle(grantId)` call site passes.
691
+ * `brokerControl` is the SAME control session the grant was acquired
692
+ * through; a stock handler must claim its monitor socket on this session,
693
+ * never on one it opened itself (see stock-dispatch.ts's own
694
+ * ensureStockSession() header comment for why a second acquisition would
695
+ * break the claim-before-dial guarantee this type exists to preserve). */
696
+ export interface HeldLease {
697
+ host: string;
698
+ port: number;
699
+ targetId: string;
700
+ brokerControl: BrokerControlSession;
701
+ /** CR-06: THIS instance's own epoch.json, in the CONSUMER's view of the
702
+ * filesystem (i.e. already containerized -- vice-proxy.ts fills it from
703
+ * activeInstance().epochFile, which adoptGrant() set from the containerized
704
+ * grant). This is the reconnect-identity baseline stock-connect.ts's
705
+ * stockReconnect() proves machine identity against. NOT optional: with it
706
+ * absent, stockReconnect() reports a FALSE MachineRestartedError on every
707
+ * transient socket drop ("treat every result since the previous call as
708
+ * void"), because identity that cannot be proven is treated as not proven.
709
+ * Empty string means genuinely no epoch evidence exists, which is that same
710
+ * unprovable case stated explicitly rather than by omission. */
711
+ epochFile: string;
712
+ /** CR-06: the TOP-LEVEL supervisor directory -- the one holding
713
+ * `backend.json`, i.e. the same directory `broker.json` is read from
714
+ * (brokerRootDir()). Deliberately NOT the grant's own per-instance
715
+ * `supervisor_dir` (`<stateDir>/<port>`), which holds epoch.json and would
716
+ * make backend-detect.mts's capability cache look in a directory that never
717
+ * has a record in it -- a silent permanent miss. Empty string disables the
718
+ * capability cache (every connect re-probes), matching
719
+ * backend-detect.mts's own documented degradation for an omitted
720
+ * supervisorDir. */
721
+ supervisorDir: string;
722
+ }
723
+
583
724
  /** One in-flight request's settlement callback -- pushed onto the session's
584
725
  * FIFO pending queue in sendAndAwaitLine() below, and shifted off it by
585
726
  * EXACTLY ONE of: a response line arriving (createSession()'s own "data"
@@ -595,7 +736,23 @@ interface PendingLineEntry {
595
736
  handle(line: Record<string, unknown> | null, brokerGone: boolean): void;
596
737
  }
597
738
 
598
- type RawLineOutcome = { ok: true; line: Record<string, unknown> } | { ok: false; kind: ControlFailureKind; message: string };
739
+ // `holder` is optional and populated ONLY when `kind` is "monitor_owned" --
740
+ // every other failure kind leaves it undefined, matching broker-control.mts's
741
+ // own error variant this outcome mirrors (plan 05: extends the existing
742
+ // generic failure shape rather than a parallel channel for the one kind
743
+ // that needs an extra field).
744
+ type RawLineOutcome = { ok: true; line: Record<string, unknown> } | { ok: false; kind: ControlFailureKind; message: string; holder?: MonitorClaimHolder };
745
+
746
+ /** Never-throw extraction of a `holder` payload from untrusted wire input --
747
+ * absent or malformed input answers `undefined`, never a partially-filled
748
+ * object (plan 05's own never-throw-on-untrusted-input posture, matching
749
+ * this file's own header comment on broker.json reads). */
750
+ function extractHolder(raw: unknown): MonitorClaimHolder | undefined {
751
+ if (typeof raw !== "object" || raw === null) return undefined;
752
+ const h = raw as Record<string, unknown>;
753
+ if (typeof h.grantId !== "string" || typeof h.claimedAt !== "number") return undefined;
754
+ return { grantId: h.grantId, claimedAt: h.claimedAt, pid: typeof h.pid === "number" ? h.pid : null };
755
+ }
599
756
 
600
757
  /** Builds the session object wrapping an already-CONNECTED socket. Wires the
601
758
  * newline framing (buffer, split on "\n", one entry-per-response FIFO
@@ -675,10 +832,16 @@ function createSession(socket: Socket, token: string): BrokerControlSession {
675
832
  }
676
833
  if (line.kind === "error") {
677
834
  const code = typeof line.code === "string" ? (line.code as ControlFailureKind) : "internal";
835
+ // Plan 05: forward `holder` verbatim ONLY for monitor_owned --
836
+ // every other error code carries no such field on the wire, and
837
+ // extractHolder() itself never invents one from absent/malformed
838
+ // input.
839
+ const holder = code === "monitor_owned" ? extractHolder(line.holder) : undefined;
678
840
  resolvePromise({
679
841
  ok: false,
680
842
  kind: code,
681
843
  message: typeof line.message === "string" ? line.message : "openBrokerControl: broker reported an error",
844
+ holder,
682
845
  });
683
846
  return;
684
847
  }
@@ -794,11 +957,74 @@ function createSession(socket: Socket, token: string): BrokerControlSession {
794
957
  warm_floor: Number(line.warm_floor),
795
958
  max_instances: Number(line.max_instances),
796
959
  base_port: Number(line.base_port),
960
+ // WR-04: narrowed at the boundary, never cast -- anything other than the
961
+ // two known verdicts reads as `null` ("this broker did not tell us"),
962
+ // which callers must treat as absent evidence rather than agreement.
963
+ backend: line.backend === "fork" || line.backend === "stock" ? line.backend : null,
797
964
  },
798
965
  };
799
966
  }
800
967
 
801
- return { acquire, release, recycle, status, hostState };
968
+ /** Claims exclusive ownership of `opts.targetId`'s monitor socket, sending
969
+ * `{ op: "monitor_claim", id, target_id, token }` through the SAME
970
+ * `sendAndAwaitLine()` path -- the same session, the same token, the same
971
+ * newline-delimited JSON discipline every other op uses; no second
972
+ * control connection is ever opened, and this function never dials the
973
+ * binmon port itself, on success OR on failure (plan 05, PROTO-08, D-13
974
+ * -- see MonitorOwnershipError's own header comment for why the claim is
975
+ * made BEFORE any binmon connect()). `timeout` is reported distinctly
976
+ * from `monitor_owned`: a timeout means the broker did not answer, never
977
+ * that someone else owns the socket. */
978
+ async function claimMonitor(opts: ClaimMonitorOptions): Promise<ClaimMonitorOutcome> {
979
+ const requestId = newRequestId();
980
+ const raw = await sendAndAwaitLine({ op: "monitor_claim", id: requestId, target_id: opts.targetId, token }, opts.timeoutMs ?? ACQUIRE_TIMEOUT_MS);
981
+ if (!raw.ok) {
982
+ if (raw.kind === "deadline") return { ok: false, reason: "timeout" };
983
+ // WR-08: the `monitor_owned` REASON survives even when the wire's own
984
+ // `holder` payload is absent or malformed. This used to be
985
+ // `raw.kind === "monitor_owned" && raw.holder`, so a partially-malformed
986
+ // refusal collapsed to `{ ok: false, reason: "internal" }` -- stockConnect()
987
+ // then threw a generic ViceError, convertHandshakeError() produced "stock
988
+ // handshake failed (...)", and the ownership-conflict framing T-02-14
989
+ // requires (and MonitorOwnershipError exists to preserve) was lost. The
990
+ // broker has told us WHICH state this is; not being able to name the
991
+ // holder does not make it a different state. Holder fields default to
992
+ // "unknown"/0/null so the wording still reads as an ownership conflict
993
+ // rather than an emulator fault -- never fabricated as a plausible grant
994
+ // id, which would be worse than admitting it is unknown.
995
+ if (raw.kind === "monitor_owned") {
996
+ return { ok: false, reason: "monitor_owned", holder: raw.holder ?? { grantId: "unknown", claimedAt: 0, pid: null } };
997
+ }
998
+ if (raw.kind === "unauthorized" || raw.kind === "bad_request" || raw.kind === "denied") return { ok: false, reason: raw.kind };
999
+ return { ok: false, reason: "internal" };
1000
+ }
1001
+ if (raw.line.kind !== "monitor_claimed") {
1002
+ return { ok: false, reason: "internal" };
1003
+ }
1004
+ return { ok: true };
1005
+ }
1006
+
1007
+ /** Releases a previously claimed monitor socket, sending
1008
+ * `{ op: "monitor_release", id, target_id, token }` over the SAME
1009
+ * session. Tolerates a broker that has already cleared the record (the
1010
+ * broker's own onMonitorRelease answers `ok: true` for an already-cleared
1011
+ * target) -- this function never retries and never opens a second
1012
+ * connection. */
1013
+ async function releaseMonitor(opts: ReleaseMonitorOptions): Promise<ReleaseMonitorOutcome> {
1014
+ const requestId = newRequestId();
1015
+ const raw = await sendAndAwaitLine({ op: "monitor_release", id: requestId, target_id: opts.targetId, token }, opts.timeoutMs ?? ACQUIRE_TIMEOUT_MS);
1016
+ if (!raw.ok) {
1017
+ if (raw.kind === "deadline") return { ok: false, reason: "timeout" };
1018
+ if (raw.kind === "unauthorized" || raw.kind === "bad_request" || raw.kind === "denied") return { ok: false, reason: raw.kind };
1019
+ return { ok: false, reason: "internal" };
1020
+ }
1021
+ if (raw.line.kind !== "monitor_released") {
1022
+ return { ok: false, reason: "internal" };
1023
+ }
1024
+ return { ok: true };
1025
+ }
1026
+
1027
+ return { acquire, release, recycle, status, hostState, claimMonitor, releaseMonitor };
802
1028
  }
803
1029
 
804
1030
  /** Opens ONE session against the control plane: reads broker.json ONCE for
package/vice-proxy.ts CHANGED
@@ -105,15 +105,16 @@ import { hostPath, SET_ENV_HINT } from "./hostpath.ts";
105
105
  // own host-local coordinates before useInstance() ever adopts them (this
106
106
  // task, quick-260801-ccn). Consuming this from the proxy -- rather than
107
107
  // hand-translating a host path here -- is what keeps the host-path consumer
108
- // set closed to a fixed, traced list (vice-mcp-selector-docs.test.mjs's
109
- // assertion 4, amended by this task to include containerpath.ts itself as
110
- // a fifth, sibling consumer of hostpath.mjs's own knowledge).
108
+ // set closed to a fixed, traced list of exactly five production modules
109
+ // (containerpath.ts, install-resources.ts, stock-paths.ts, vice-proxy.ts,
110
+ // vice-sync.ts), pinned by hostpath-consumers.test.ts.
111
111
  import { containerizeRecord } from "./containerpath.ts";
112
112
  // The container-side half of the on-demand broker protocol (Phase 01.2).
113
113
  // This module deliberately does NOT import hostpath.mjs itself -- the
114
- // host-path consumer set stays closed to four production modules
115
- // (vice-mcp-selector-docs.test.mjs's assertion 4), and this file is already
116
- // on that list, so any broker-related host path text is built HERE.
114
+ // host-path consumer set stays closed to exactly five production modules
115
+ // (containerpath.ts, install-resources.ts, stock-paths.ts, vice-proxy.ts,
116
+ // vice-sync.ts), pinned by hostpath-consumers.test.ts, and this file is
117
+ // already on that list, so any broker-related host path text is built HERE.
117
118
  // Tasks 1+2 (this plan) swap acquisition, release AND recycle onto the TCP
118
119
  // control session (openBrokerControl()/BrokerControlSession, plan 06's
119
120
  // completed client) -- writeRequest/createLease/touchLease/releaseLease/
@@ -133,6 +134,7 @@ import {
133
134
  type BrokerLivenessResult,
134
135
  type BrokerControlSession,
135
136
  type ControlFailureKind,
137
+ type HeldLease,
136
138
  } from "./vice-broker-client.ts";
137
139
  // The recycle path's own incident record (plan 01.3-01) -- written BEFORE
138
140
  // anything is killed (D-17), never through any network call of its own.
@@ -166,9 +168,24 @@ import type { StandardSchemaWithJSON } from "@mastra/core/schema";
166
168
  // A real, already-resolved transitive dependency of @mastra/mcp (Plan 01's
167
169
  // Task 2 note) -- deliberately NOT added to package.json directly.
168
170
  import { CallToolRequestSchema } from "@modelcontextprotocol/sdk/types.js";
171
+ // Plan 02-10: this file's own backend-detection and stock-dispatch consumer
172
+ // edits. Both are namespace imports, deliberately -- keeps every reference to
173
+ // their exported members's names down to the ONE call site each below (this
174
+ // file's own grep-gated single-occurrence acceptance criteria), rather than a
175
+ // named import whose binding is textually repeated at both the import line
176
+ // and every call site.
177
+ import * as backendDetect from "./backend-detect.mts";
178
+ import * as stockDispatch from "./stock-dispatch.ts";
169
179
 
170
180
  const HERE_DIR = dirname(fileURLToPath(import.meta.url));
171
181
 
182
+ // D-01/BACK-01: the backend is settled exactly ONCE here, at module scope --
183
+ // never re-settled per tool or per call. Every seam below (manifest
184
+ // selection, the tools construction loop's dispatch choice, and the final
185
+ // ready log line) reads THIS constant, never the raw environment variable
186
+ // and never a second detection call.
187
+ const ACTIVE_BACKEND = backendDetect.resolvedBackend();
188
+
172
189
  // -------------------------------------------------------------- JSON-RPC
173
190
  //
174
191
  // The boundary types every handler below reads or produces. `params` and
@@ -390,10 +407,13 @@ const DIAGNOSE_TOOL: ToolDefinition = {
390
407
  },
391
408
  };
392
409
 
410
+ // Edit 1 (plan 02-10): delegates to stock-dispatch.ts's own selector function
411
+ // -- the ONE manifest site this file keeps, now backend-aware. The existing
412
+ // malformed-manifest fallbacks in readManifestTools() below are untouched: a
413
+ // missing or unreadable stock manifest still answers tools/list with an
414
+ // empty array rather than crashing the server.
393
415
  function manifestPath(): string {
394
- return process.env.VICE_TOOLS_MANIFEST
395
- ? resolve(process.env.VICE_TOOLS_MANIFEST)
396
- : join(HERE_DIR, "tools-manifest.json");
416
+ return stockDispatch.manifestPathForBackend(ACTIVE_BACKEND.backend, HERE_DIR, process.env.VICE_TOOLS_MANIFEST);
397
417
  }
398
418
 
399
419
  function readManifestTools(): ToolInfo[] {
@@ -2131,11 +2151,56 @@ function containerizeGrant(grant: Record<string, unknown>): Record<string, unkno
2131
2151
  * that earlier decision needs to know it no longer applies, not that it was
2132
2152
  * quietly dropped.
2133
2153
  */
2134
- type BrokerLeaseResult = { ok: true } | { ok: false; message: string };
2154
+ // Edit 2 (plan 02-10, D-13/PROTO-08): `ok: true` now carries a `lease:
2155
+ // HeldLease | null` -- `null` only for the VICE_MCP_URL override branch
2156
+ // below, where there is no broker control session to claim a monitor socket
2157
+ // through. Every failure branch is untouched.
2158
+ type BrokerLeaseResult = { ok: true; lease: HeldLease | null } | { ok: false; message: string };
2159
+
2160
+ /**
2161
+ * Builds the HeldLease a stock handler needs from state read FRESH on every
2162
+ * call -- activeInstance() and grantId -- never memoised here:
2163
+ * handleGrantedInstanceUnreachable() overwrites both on a replacement
2164
+ * acquisition, and a cached lease would keep pointing at the retired
2165
+ * instance. `host` is the hostname of the active instance's ALREADY
2166
+ * containerized `url` (containerizeGrant()'s own loopback rewrite already
2167
+ * owns host/container translation -- reading its result here is reuse, not
2168
+ * re-derivation). `port` is activeInstance().port (the broker allocates one
2169
+ * port per instance and passes it to -binarymonitoraddress on the stock
2170
+ * backend, per plan 02-03). `targetId` is grantId -- the same value
2171
+ * controlSession.recycle(grantId) already sends on the wire. Called only
2172
+ * from the two success returns below that hold a control session.
2173
+ */
2174
+ function buildHeldLease(session: BrokerControlSession): HeldLease {
2175
+ const { url, port, epochFile } = activeInstance();
2176
+ // WR-06: `new URL(url).hostname` returns a BRACKETED literal for IPv6
2177
+ // ("[::1]"), which net.connect() will not accept -- so the brackets are
2178
+ // stripped here, at the one place the dial host is derived, rather than by
2179
+ // every eventual consumer. Deliberately not a general URL-parsing helper: the
2180
+ // bracket form is the single documented WHATWG-URL quirk this seam meets.
2181
+ const host = new URL(url).hostname.replace(/^\[(.+)\]$/, "$1");
2182
+ // CR-06: `epochFile` and `supervisorDir` are what make the stock handshake's
2183
+ // two BACK-04/reconnect mechanisms actually live on the real path -- before
2184
+ // this, no production call ever passed StockConnectDeps, so `baselineEpoch`
2185
+ // was always null (making stockReconnect() throw a FALSE
2186
+ // MachineRestartedError on every transient drop) and the capability cache
2187
+ // was never read or written.
2188
+ //
2189
+ // Two DIFFERENT directories, deliberately, and not interchangeable:
2190
+ // - epochFile is THIS instance's own `<stateDir>/<port>/epoch.json`, read
2191
+ // fresh from activeInstance() like every other field here (adoptGrant()
2192
+ // put the CONTAINERIZED path there, so it is already in this process's
2193
+ // view of the filesystem -- no second translation here).
2194
+ // - supervisorDir is the TOP-LEVEL `.vice-supervisor`, where backend.json
2195
+ // lives, resolved through brokerRootDir() -- the SAME resolver
2196
+ // broker.json is read from, never a locally re-derived path (the
2197
+ // "re-deriving a cross-cutting seam locally" anti-pattern).
2198
+ return { host, port, targetId: grantId ?? "", brokerControl: session, epochFile, supervisorDir: brokerRootDir() };
2199
+ }
2135
2200
 
2136
2201
  async function ensureBrokerLease(): Promise<BrokerLeaseResult> {
2137
- if (controlSession) return { ok: true };
2138
- if (process.env.VICE_MCP_URL) return { ok: true }; // explicit override -- broker never contacted
2202
+ if (controlSession) return { ok: true, lease: buildHeldLease(controlSession) };
2203
+ if (process.env.VICE_MCP_URL) return { ok: true, lease: null }; // explicit override -- broker never contacted, nothing to claim a monitor socket through
2139
2204
 
2140
2205
  // Classify liveness FIRST, before ever opening a connection (C10).
2141
2206
  // never_started and stale both return their message immediately, with no
@@ -2184,6 +2249,57 @@ async function ensureBrokerLease(): Promise<BrokerLeaseResult> {
2184
2249
  }
2185
2250
  const session = opened.session;
2186
2251
 
2252
+ // WR-04: the FIRST thing done on a freshly opened control session -- before an
2253
+ // emulator is allocated -- is to ask the broker which backend IT resolved, and
2254
+ // refuse if it disagrees with this process's own verdict.
2255
+ //
2256
+ // Why this check has to exist: `ACTIVE_BACKEND` is resolved at module scope
2257
+ // from resolvedBackend(), against the CONTAINER's filesystem, with no
2258
+ // supervisorDir and therefore no cache -- while the emulator it describes is
2259
+ // launched by the broker on the HOST, from the broker's own independent
2260
+ // resolvedBackend({ supervisorDir }). In the normal devcontainer topology the
2261
+ // container has no x64sc at all, so the proxy classifies `unknown` and
2262
+ // degrades to `{ backend: "fork", source: "indeterminate" }`. If the host
2263
+ // binary is stock, the proxy would otherwise advertise the fork's full
2264
+ // manifest and forward HTTP at a binary-monitor port -- and nothing would ever
2265
+ // report the disagreement; it would surface as an inexplicable transport
2266
+ // failure on the first real tool call. D-01's "one reader" property holds per
2267
+ // PROCESS but not across this pair, and this is the seam where the pair first
2268
+ // meets.
2269
+ //
2270
+ // Refusing (rather than adapting) is deliberate: the advertised tool list was
2271
+ // already built at startup from ACTIVE_BACKEND and answered to the client, so
2272
+ // this process cannot re-decide its own surface here. VICE_BACKEND remains the
2273
+ // explicit fix, and it must be set for BOTH processes.
2274
+ //
2275
+ // Absent evidence is NOT disagreement: a broker that does not report a backend
2276
+ // (older build, or an unrecognised value) leaves `backend: null`, and a
2277
+ // hostState() call that fails at all is not allowed to block an acquire. Only
2278
+ // a definite, named mismatch refuses.
2279
+ const brokerState = await session.hostState();
2280
+ if (brokerState.ok && brokerState.hostState.backend !== null && brokerState.hostState.backend !== ACTIVE_BACKEND.backend) {
2281
+ const brokerBackend = brokerState.hostState.backend;
2282
+ await session.release();
2283
+ return {
2284
+ ok: false,
2285
+ message:
2286
+ `vice: backend mismatch between this MCP server and the broker that owns the emulator. This process ` +
2287
+ `resolved "${ACTIVE_BACKEND.backend}" (source: ${ACTIVE_BACKEND.source}, binary: ${ACTIVE_BACKEND.binPath}) while the ` +
2288
+ `broker resolved "${brokerBackend}" (binary: ${brokerState.hostState.vice_bin}) -- and the broker's verdict is the ` +
2289
+ `authoritative one, because it is what the emulator was actually launched with. The two backends speak ` +
2290
+ `different protocols on that port, so proceeding would send ${ACTIVE_BACKEND.backend === "fork" ? "HTTP at a binary-monitor port" : "binary-monitor frames at an HTTP endpoint"}. ` +
2291
+ `This normally means the MCP server runs where the emulator binary is not (a container), so its own detection ` +
2292
+ `could not see it. Set VICE_BACKEND=${brokerBackend} for THIS process as well -- it must be set for both -- ` +
2293
+ `and restart the MCP server so its advertised tool list matches.`,
2294
+ };
2295
+ }
2296
+ if (!brokerState.ok) {
2297
+ console.error(
2298
+ `vice-proxy: could not read the broker's own backend verdict (${brokerState.kind}: ${brokerState.message}) -- ` +
2299
+ `proceeding with this process's own verdict "${ACTIVE_BACKEND.backend}" (source: ${ACTIVE_BACKEND.source}); a mismatch, if any, will not be detected`,
2300
+ );
2301
+ }
2302
+
2187
2303
  const result = await session.acquire();
2188
2304
  if (!result.ok) {
2189
2305
  // No grant is coming for this session -- nothing to hold the connection
@@ -2208,7 +2324,7 @@ async function ensureBrokerLease(): Promise<BrokerLeaseResult> {
2208
2324
  adoptGrant({ ...result.grant });
2209
2325
  viceSession = null; // re-baseline: the next ensureViceSession() reads the GRANTED instance's own epoch file
2210
2326
  controlSession = session;
2211
- return { ok: true };
2327
+ return { ok: true, lease: buildHeldLease(session) };
2212
2328
  }
2213
2329
 
2214
2330
  /**
@@ -3006,14 +3122,74 @@ function buildViceTool(def: ToolDefinition, run: (args: Record<string, unknown>)
3006
3122
  // function runs at read time. A manifest hot-reload mid-session is
3007
3123
  // therefore no longer picked up until the proxy restarts; the manifest is
3008
3124
  // regenerated by a manual, rare build step, never mid-session in practice.
3125
+ // Edit 3 (plan 02-10, D-09): the runner each manifest tool's own execute()
3126
+ // closes over is chosen by ACTIVE_BACKEND, decided ONCE above, never
3127
+ // per-tool or per-call. The first ternary arm below is byte-identical to
3128
+ // every prior plan's own forwarding call, unchanged.
3129
+ // The second arm (the OTHER backend) passes ensureBrokerLease itself as the
3130
+ // injected LeaseProvider (no locally-built acquisition wrapping it -- there
3131
+ // is exactly one acquisition function in this file, and this arm calls the
3132
+ // SAME one the first arm's own lease check already calls), plus this file's
3133
+ // own already-settled binary path so the health-check tool on that path can
3134
+ // answer BACK-03 without ever re-detecting anything itself.
3135
+ /**
3136
+ * The ONE backend-aware registration seam (D-09). CR-07 (code review
3137
+ * 2026-08-13) is why it is a function rather than a ternary inlined in the
3138
+ * manifest loop: the loop was backend-aware, but the three synthetic tools
3139
+ * registered straight after it were NOT, and `tools/list` is served from this
3140
+ * same object -- so on the stock backend the advertised surface was `vice_ping`
3141
+ * PLUS `vice_result_continue`, `vice_recycle` and `vice_diagnose`, and two of
3142
+ * those three ran the fork's HTTP transport against a port speaking the binary
3143
+ * monitor. `handleDiagnose()` reaches ensureViceSession() /
3144
+ * gatherCheckpointTrapEvidence() / gatherBracketEvidence(); `handleRecycle()`
3145
+ * reaches gatherWedgeEvidence(). Both go through call()/forwardToVice(). That
3146
+ * is a direct D-09 violation ("the stock path must never fall through to the
3147
+ * fork's HTTP forward"), and `vice_diagnose` is the wedge-triage skill's
3148
+ * documented opening move -- so its output on stock was HTTP failure text
3149
+ * dressed as emulator diagnosis. The pre-existing structural test could not
3150
+ * catch it: it only checked that no code LINE pairs the string "stock" with
3151
+ * `forwardToVice`, which this arrangement satisfied while still reaching that
3152
+ * transport.
3153
+ *
3154
+ * On the stock backend every tool registered through here is answered by
3155
+ * dispatchStock -- which either has a table entry for the name or REFUSES BY
3156
+ * NAME. There is no third path and no fall-through, which is D-09's whole
3157
+ * point.
3158
+ *
3159
+ * WHAT NOT TO DO: never register a tool whose runner can reach `call()` /
3160
+ * `forwardToVice()` / `ensureViceSession()` without going through this
3161
+ * function. The one legitimate exception is a runner that touches no transport
3162
+ * at all (`vice_result_continue`, which only reads this proxy's own
3163
+ * CONTINUATION_STORE) -- and that exception is asserted, by name, in
3164
+ * stock-dispatch.test.ts's structural section rather than left to judgement.
3165
+ */
3166
+ function buildBackendAwareTool(def: ToolDefinition, forkRun: (args: Record<string, unknown>) => Promise<ToolCallResult>) {
3167
+ return ACTIVE_BACKEND.backend === "fork"
3168
+ ? buildViceTool(def, forkRun)
3169
+ : buildViceTool(def, (args) =>
3170
+ stockDispatch.dispatchStock(def.name, args, {
3171
+ ensureLease: ensureBrokerLease,
3172
+ resolvedBinaryPath: ACTIVE_BACKEND.binPath,
3173
+ resolvedBinaryPathIsResolved: ACTIVE_BACKEND.binPathResolved,
3174
+ }),
3175
+ );
3176
+ }
3177
+
3009
3178
  const tools: Record<string, ReturnType<typeof buildViceTool>> = {};
3010
3179
  for (const def of readManifestTools()) {
3011
3180
  if (DENY_LIST.includes(def.name)) continue;
3012
- tools[def.name] = buildViceTool(def, (args) => forwardToVice(def.name, args));
3181
+ tools[def.name] = buildBackendAwareTool(def, (args) => forwardToVice(def.name, args));
3013
3182
  }
3183
+ // Backend-INDEPENDENT by construction: handleResultContinue() is served
3184
+ // entirely from this proxy's own CONTINUATION_STORE and opens no socket of any
3185
+ // kind, so it is correct on either backend and is deliberately NOT routed
3186
+ // through dispatchStock (which would refuse the continuation mechanism itself).
3014
3187
  tools[RESULT_CONTINUE_TOOL.name] = buildViceTool(RESULT_CONTINUE_TOOL, (args) => Promise.resolve(handleResultContinue(args)));
3015
- tools[RECYCLE_TOOL.name] = buildViceTool(RECYCLE_TOOL, (args) => handleRecycle(args));
3016
- tools[DIAGNOSE_TOOL.name] = buildViceTool(DIAGNOSE_TOOL, (args) => handleDiagnose(args));
3188
+ // Backend-AWARE (CR-07): both of these gather evidence over the fork's HTTP
3189
+ // transport, so on stock they are refused by name rather than advertised and
3190
+ // then failed at the wire.
3191
+ tools[RECYCLE_TOOL.name] = buildBackendAwareTool(RECYCLE_TOOL, (args) => handleRecycle(args));
3192
+ tools[DIAGNOSE_TOOL.name] = buildBackendAwareTool(DIAGNOSE_TOOL, (args) => handleDiagnose(args));
3017
3193
 
3018
3194
  const server = new MCPServer({ name: "vice", version: PROXY_VERSION, tools });
3019
3195
  await server.startStdio();
@@ -3090,4 +3266,13 @@ server.getServer().setRequestHandler(CallToolRequestSchema, async (request) => {
3090
3266
  }
3091
3267
  });
3092
3268
 
3093
- console.error(`vice-proxy: ready, forwarding to ${activeInstance().url} (port ${activeInstance().port})`);
3269
+ // Log-line correction (plan 02-10): the fork arm is byte-identical to every
3270
+ // prior plan. The stock arm cannot yet name a real instance/port -- no
3271
+ // acquisition has happened at process startup, only lazily on the first
3272
+ // tools/call -- so it names the backend and the binary-monitor target
3273
+ // instead of a coordinate pair that does not exist yet.
3274
+ console.error(
3275
+ ACTIVE_BACKEND.backend === "fork"
3276
+ ? `vice-proxy: ready, forwarding to ${activeInstance().url} (port ${activeInstance().port})`
3277
+ : `vice-proxy: ready, stock backend active -- dispatching to a broker-claimed binary-monitor instance (resolved binary: ${ACTIVE_BACKEND.binPath})`,
3278
+ );