@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.
- package/README.md +15 -0
- package/THIRD-PARTY-NOTICES.md +113 -0
- package/backend-detect.mts +595 -0
- package/build.ts +1 -0
- package/disasm-decoder.ts +248 -0
- package/disasm-opcodes.ts +464 -0
- package/disasm-renderer.ts +306 -0
- package/package.json +27 -2
- package/refresh-manifest.ts +23 -2
- package/resources/backend-detect.mjs +396 -0
- package/resources/broker-control.mjs +110 -8
- package/resources/broker-kill.mjs +114 -103
- package/resources/broker-launch.mjs +334 -19
- package/resources/broker-state.mjs +11 -0
- package/resources/vice-broker.mjs +185 -14
- package/stock-address.ts +219 -0
- package/stock-checkpoints.ts +794 -0
- package/stock-condition.ts +636 -0
- package/stock-connect.ts +427 -0
- package/stock-derived.ts +122 -0
- package/stock-disassemble.ts +252 -0
- package/stock-dispatch.ts +640 -0
- package/stock-execution.ts +327 -0
- package/stock-handler.ts +175 -0
- package/stock-input.ts +274 -0
- package/stock-machine.ts +357 -0
- package/stock-memory.ts +323 -0
- package/stock-paths.ts +191 -0
- package/stock-petscii.ts +143 -0
- package/stock-protocol.ts +2057 -0
- package/stock-registers.ts +324 -0
- package/stock-runstate.ts +104 -0
- package/tools-manifest.json +1 -9
- package/tools-manifest.stock.json +841 -0
- package/vice-broker-client.ts +233 -7
- package/vice-proxy.ts +202 -17
package/vice-broker-client.ts
CHANGED
|
@@ -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
|
-
//
|
|
25
|
-
//
|
|
26
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
109
|
-
//
|
|
110
|
-
//
|
|
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
|
|
115
|
-
// (
|
|
116
|
-
//
|
|
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
|
-
|
|
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] =
|
|
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
|
-
|
|
3016
|
-
|
|
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
|
-
|
|
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
|
+
);
|