@henols/vice-mcp 0.1.11 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -26,8 +26,9 @@
26
26
  // deliberately-killed instance, and writes the per-instance boot/crash log
27
27
  // D-23 preserves at the exact path shape the retiring bash supervisor used.
28
28
  import { spawn as nodeSpawn } from "node:child_process";
29
- import { mkdirSync, openSync, closeSync, existsSync } from "node:fs";
29
+ import { mkdirSync, mkdtempSync, openSync, closeSync, existsSync } from "node:fs";
30
30
  import { join, basename } from "node:path";
31
+ import { tmpdir } from "node:os";
31
32
  // Module-level: this file, not the caller, owns the single boolean --
32
33
  // synchronous check, synchronous set, released in a finally, with no
33
34
  // `await` between the check and the set.
@@ -124,7 +125,31 @@ export function buildViceArgs(port, { backend, mcpHost, binmonHost, viceArgsEnv,
124
125
  `reach it; the default of 127.0.0.1 is the safe posture for a host-native install, widen only ` +
125
126
  `when the MCP server itself runs in a container that must reach the host emulator\n`);
126
127
  }
127
- const args = ["-binarymonitor", "-binarymonitoraddress", `ip4://${host}:${port}`];
128
+ // Audit item I-2 (§4.2, FINDING-C1): a broker-launched stock x64sc used
129
+ // to boot with Drive8Type=0 (NONE) -- nothing answers unit 8, so
130
+ // LOAD"*",8,1 fails ?DEVICE NOT PRESENT ERROR and the entry-point
131
+ // checkpoint never hits. No stock MCP tool can correct this after boot
132
+ // (the 38-tool stock manifest has zero resource-set names by design),
133
+ // so the fix must be a launch-time flag. `-default` MUST be the very
134
+ // first element: it is VICE's reset-to-compiled-in-defaults instruction,
135
+ // not an inert "these are the baselines" no-op, so any flag emitted
136
+ // before it (including -drive8type) is silently clobbered back to its
137
+ // compiled-in value. `-drive8type 1541` therefore has to come
138
+ // immediately after `-default`, and -- per CLAUDE.md's documented
139
+ // constraint -- `-default` also has to come before `-binarymonitor` or
140
+ // the monitor never binds and the subsequent connect hangs in the
141
+ // backlog looking exactly like a wedge. Confirmed sufficient live in
142
+ // Phase 8.1's standalone probe (08.1-WALKTHROUGH-EVIDENCE.md §4):
143
+ // `resourceget "Drive8Type"` moved 0 -> 1541 and a `load` over the text
144
+ // monitor succeeded immediately. Deliberately NOT setting
145
+ // -drive8truedrive / Drive8TrueEmulation here: this build's own default
146
+ // already reads Drive8TrueEmulation=1 (same probe), so 08.2-RESEARCH.md's
147
+ // primary recommendation is that only -drive8type needs adding.
148
+ // Assumption A3 in that doc's Assumptions Log (some other stock build
149
+ // might default Drive8TrueEmulation to 0) is read and deliberately not
150
+ // pre-emptively defended against here; plan 03's live test is what would
151
+ // surface it if that assumption is ever wrong on a different build.
152
+ const args = ["-default", "-drive8type", "1541", "-binarymonitor", "-binarymonitoraddress", `ip4://${host}:${port}`];
128
153
  if (typeof remoteMonitorPort === "number") {
129
154
  if (host !== "127.0.0.1" && !warnedRemoteMonitorBindWidened) {
130
155
  warnedRemoteMonitorBindWidened = true;
@@ -154,7 +179,7 @@ export function buildViceArgs(port, { backend, mcpHost, binmonHost, viceArgsEnv,
154
179
  * spawning, so a bad configuration value is visible rather than silently
155
180
  * mis-parsed, exactly like the bash launcher's own logging discipline. */
156
181
  function spawnAndRecordInstance(reason, port, deps) {
157
- const spawnFn = deps.spawn ?? ((cmd, args) => nodeSpawn(cmd, args));
182
+ const spawnFn = deps.spawn ?? ((cmd, args, opts) => nodeSpawn(cmd, args, opts));
158
183
  const now = deps.now ?? (() => Date.now());
159
184
  const viceBin = deps.viceBin ?? process.env.VICE_BIN ?? "x64sc";
160
185
  const backend = deps.backend ?? "fork";
@@ -165,8 +190,52 @@ function spawnAndRecordInstance(reason, port, deps) {
165
190
  remoteMonitorPort: deps.remoteMonitorPort,
166
191
  });
167
192
  const log = deps.log ?? defaultLog;
168
- log(`vice-broker: launching ${viceBin} ${viceArgs.join(" ")}`);
169
- const child = spawnFn(viceBin, viceArgs);
193
+ // I-1 rider (audit §4.4, 08.2-02-PLAN.md Task 2): production stock
194
+ // launches used to set no scratch XDG_CONFIG_HOME and would read whatever
195
+ // vicerc the operator's own $HOME already carried -- shared with the
196
+ // operator's own VICE usage and with the fork build. For backend ===
197
+ // "stock" only, compute a fresh, isolated config dir with mkdtempSync
198
+ // (atomic creation, random suffix, 0700 permissions -- the primitive that
199
+ // makes a collision or a symlink-swap into the operator's real config
200
+ // unreachable) and pass it as a third options argument carrying `env`
201
+ // only. Never `shell: true`: the existing array-form spawn(viceBin,
202
+ // viceArgs) call avoids shell interpretation entirely and that property
203
+ // must survive this widening. For backend === "fork", spawnFn is called
204
+ // with NO third argument at all, so the fork path's observable behaviour
205
+ // stays bit-for-bit what it was (BACK-02 is a standing gate and the fork
206
+ // backend has been the sole production backend across all of v0.1.x).
207
+ //
208
+ // Scope boundary (do not remove this note): the production broker daemon
209
+ // always supplies its own deps.spawn / deps.spawnFactory, so the widened
210
+ // default wrapper above is dead code on the real launch paths. This
211
+ // function's job is only to COMPUTE the value at the one seam that should
212
+ // own it; the forwarding to nodeSpawn() happens at four further hops --
213
+ // makeLoggingSpawn() and maintainWarmFloorForRealBroker's inner
214
+ // stashingSpawn in vice-broker.mts, and withCrashSupervision()'s wrapper
215
+ // body and launchSupervised()'s defaultRealSpawn in this file. All four
216
+ // now forward the options argument (plan 08.2-06 closed them in this same
217
+ // phase, with a handleAcquire() composition test that omits
218
+ // buildColdSpawnFactory so an injected stub cannot fake the proof). If you
219
+ // add a fifth spawn hop, it must forward options too, or production stock
220
+ // launches silently lose their config isolation again.
221
+ //
222
+ // Scratch-dir lifetime: this function deliberately does NOT clean the
223
+ // directory up -- the spawned emulator process outlives this function's
224
+ // return and needs the directory for its whole lifetime. Per-launch
225
+ // scratch dirs therefore accumulate under the OS temp dir for the life of
226
+ // the host; this is a recorded trade-off, not an oversight. If reaping
227
+ // them is ever worth doing, the broker's own kill/recycle path is the
228
+ // component that would own it (it already knows when an instance's
229
+ // process has actually exited).
230
+ let spawnOptions;
231
+ let logLine = `vice-broker: launching ${viceBin} ${viceArgs.join(" ")}`;
232
+ if (backend === "stock") {
233
+ const scratchConfigDir = mkdtempSync(join(tmpdir(), "vice-broker-vicerc-"));
234
+ spawnOptions = { env: { ...process.env, XDG_CONFIG_HOME: scratchConfigDir } };
235
+ logLine += ` (XDG_CONFIG_HOME=${scratchConfigDir})`;
236
+ }
237
+ log(logLine);
238
+ const child = spawnOptions === undefined ? spawnFn(viceBin, viceArgs) : spawnFn(viceBin, viceArgs, spawnOptions);
170
239
  const record = {
171
240
  port,
172
241
  url: `http://127.0.0.1:${port}/mcp`,
@@ -422,6 +491,14 @@ function binmonRequest(commandType, requestId) {
422
491
  header[10] = commandType;
423
492
  return header;
424
493
  }
494
+ /** The wire's own "this is not a reply to any request I sent" sentinel
495
+ * (CLAUDE.md's Protocol constraint) -- REGISTER_INFO (0x31) arrives
496
+ * unsolicited at THIS id on every monitor open, and CHECKPOINT_INFO/STOPPED/
497
+ * RESUMED/JAM can too. A response-type byte alone is not enough to
498
+ * distinguish "an event that happens to share a type with a real reply" from
499
+ * an actual reply -- request-id is the only field the wire promises never
500
+ * collides between the two, which is exactly why demux must key on it. */
501
+ const BINMON_UNSOLICITED_REQUEST_ID = 0xffffffff;
425
502
  /**
426
503
  * WR-01: one PING (0x81) over the binary monitor, requiring a WELL-FORMED 0x81
427
504
  * reply -- STX, the expected api_version, response type 0x81, error code 0x00,
@@ -429,6 +506,23 @@ function binmonRequest(commandType, requestId) {
429
506
  * here for exactly the reason probeReady()'s own comment gives for the HTTP
430
507
  * route: a C64 can accept a connection before it has finished booting.
431
508
  *
509
+ * quick task 260818-obc (live-discovered): a NEW binmon connection ALWAYS
510
+ * emits an unsolicited REGISTER_INFO (0x31) frame at request-id 0xffffffff
511
+ * the instant it opens (CLAUDE.md's own Protocol constraint) -- BEFORE this
512
+ * probe's own PING reply ever arrives. The naive "the first 12 bytes ARE the
513
+ * reply" read this code used to do treated that event frame's OWN response-
514
+ * type byte (0x31) as a malformed PING reply and answered `false` forever,
515
+ * live-reproduced against a real crash-respawned stock x64sc: the respawn
516
+ * never left "launching" because THIS probe could never see it as ready,
517
+ * even though the emulator was genuinely up and answering fine underneath.
518
+ * The fix walks frame boundaries using each frame's own body-length field and
519
+ * discards every frame whose request-id is the unsolicited sentinel (or
520
+ * simply is not this probe's own id) rather than assuming the first frame
521
+ * on the wire is the reply -- the same "demux by request-id, never by
522
+ * arrival order" discipline CLAUDE.md's Protocol constraint already requires
523
+ * of every OTHER binmon consumer in this tree (stock-protocol.ts's
524
+ * ViceMonitorClient chief among them).
525
+ *
432
526
  * Then EXIT (0xaa), unconditionally, before closing -- because the PING ITSELF
433
527
  * HALTS THE MACHINE. Any inbound byte does (docs/phase0-binmon-findings.md §4,
434
528
  * and CR-02, which fixed the same omission in the connect handshake). A
@@ -470,31 +564,52 @@ async function defaultBinmonProbe(port, timeoutMs) {
470
564
  });
471
565
  socket.on("data", (chunk) => {
472
566
  buffer = Buffer.concat([buffer, chunk]);
473
- if (buffer.length < BINMON_RESPONSE_HEADER_LEN)
474
- return;
475
- const wellFormed = buffer[0] === BINMON_STX &&
476
- buffer[1] === BINMON_API_VERSION &&
477
- buffer[6] === BINMON_CMD_PING &&
478
- buffer[7] === 0x00 &&
479
- buffer.readUInt32LE(8) === BINMON_PROBE_REQUEST_ID;
480
- if (!wellFormed) {
481
- finish(false);
567
+ // Walk complete frames off the front of the buffer -- never assume the
568
+ // first BINMON_RESPONSE_HEADER_LEN bytes on the wire are this probe's
569
+ // own reply (see this function's own header comment on why an
570
+ // unsolicited event frame can and does arrive first in practice).
571
+ for (;;) {
572
+ if (buffer.length < BINMON_RESPONSE_HEADER_LEN)
573
+ return; // wait for more data
574
+ const bodyLen = buffer.readUInt32LE(2);
575
+ const frameLen = BINMON_RESPONSE_HEADER_LEN + bodyLen;
576
+ if (buffer.length < frameLen)
577
+ return; // header seen, body still incoming
578
+ const responseType = buffer[6];
579
+ const errorCode = buffer[7];
580
+ const requestId = buffer.readUInt32LE(8);
581
+ const stxOk = buffer[0] === BINMON_STX && buffer[1] === BINMON_API_VERSION;
582
+ if (!stxOk) {
583
+ finish(false);
584
+ return;
585
+ }
586
+ if (requestId === BINMON_UNSOLICITED_REQUEST_ID || requestId !== BINMON_PROBE_REQUEST_ID) {
587
+ // Not a reply to anything this probe sent (an unsolicited event, or
588
+ // a stale reply to a previous probe's own request id) -- discard
589
+ // this one frame only and keep walking the rest of the buffer.
590
+ buffer = buffer.subarray(frameLen);
591
+ continue;
592
+ }
593
+ if (responseType !== BINMON_CMD_PING || errorCode !== 0x00) {
594
+ finish(false);
595
+ return;
596
+ }
597
+ // Resume the machine this probe's own PING halted, then close GRACEFULLY:
598
+ // socket.end(data, cb) writes the EXIT and then sends FIN, so the bytes
599
+ // are delivered before the connection goes away. A bare write() followed
600
+ // by destroy() can discard them (destroy may RST), which would leave the
601
+ // instance "ready" and frozen -- the exact outcome the EXIT exists to
602
+ // prevent. The resume is best-effort in its OUTCOME, though: a failed
603
+ // resume must not turn a READY instance into a not-ready one, since the
604
+ // emulator demonstrably answered, which is what this function reports on.
605
+ try {
606
+ socket.end(binmonRequest(BINMON_CMD_EXIT, BINMON_PROBE_REQUEST_ID + 1), () => finish(true));
607
+ }
608
+ catch {
609
+ finish(true);
610
+ }
482
611
  return;
483
612
  }
484
- // Resume the machine this probe's own PING halted, then close GRACEFULLY:
485
- // socket.end(data, cb) writes the EXIT and then sends FIN, so the bytes
486
- // are delivered before the connection goes away. A bare write() followed
487
- // by destroy() can discard them (destroy may RST), which would leave the
488
- // instance "ready" and frozen -- the exact outcome the EXIT exists to
489
- // prevent. The resume is best-effort in its OUTCOME, though: a failed
490
- // resume must not turn a READY instance into a not-ready one, since the
491
- // emulator demonstrably answered, which is what this function reports on.
492
- try {
493
- socket.end(binmonRequest(BINMON_CMD_EXIT, BINMON_PROBE_REQUEST_ID + 1), () => finish(true));
494
- }
495
- catch {
496
- finish(true);
497
- }
498
613
  });
499
614
  });
500
615
  }
@@ -849,8 +964,11 @@ async function handleExit(reason, port, deps) {
849
964
  }
850
965
  /** The single exit-listener installation point in the whole module tree.
851
966
  * Wraps `baseSpawn` (a plain spawn function of the same shape
852
- * `(command, args) => ChildProcess` every launch path already threads
853
- * through) so the returned spawn function, when called, attaches a
967
+ * `(command, args, options?) => ChildProcess` every launch path already
968
+ * threads through -- the third `options` argument is load-bearing: it
969
+ * carries the scratch XDG_CONFIG_HOME that isolates a stock launch from the
970
+ * operator's real vicerc, and this wrapper MUST forward it) so the returned
971
+ * spawn function, when called, attaches a
854
972
  * one-shot "exit" listener that drives handleExit() above -- the SAME
855
973
  * respawn/give-up/deliberate-teardown resolution launchSupervised()'s own
856
974
  * relaunch path already uses. Returns the child object baseSpawn produced,
@@ -866,8 +984,13 @@ async function handleExit(reason, port, deps) {
866
984
  * a second inline listener, is what keeps the "exactly one installation
867
985
  * point" invariant a structural gate (broker-launch.test.ts) can hold. */
868
986
  export function withCrashSupervision(reason, port, baseSpawn, deps) {
869
- return (cmd, args) => {
870
- const child = baseSpawn(cmd, args);
987
+ // I-1 rider (08.2-06-PLAN.md, Task 1): forwards a third options argument
988
+ // in the BODY, not just the type -- this is the hop that matters most,
989
+ // because it wraps every real launch path (cold acquire, warm floor, and
990
+ // every respawn). A type-only widening would still silently drop a
991
+ // caller's options at this call site.
992
+ return (cmd, args, options) => {
993
+ const child = baseSpawn(cmd, args, options);
871
994
  child.once("exit", () => {
872
995
  void handleExit(reason, port, deps);
873
996
  });
@@ -914,9 +1037,18 @@ function launchSupervised(reason, port, deps, crashTimes, backoffMs, remoteMonit
914
1037
  const logFileName = `${basename(viceBin)}-${Date.now()}-e${epoch}.log`;
915
1038
  const logPath = join(logDir, logFileName);
916
1039
  const logRelPath = `logs/${logFileName}`;
917
- const defaultRealSpawn = (cmd, args) => {
1040
+ // I-1 rider (08.2-06-PLAN.md, Task 1): forwards a third options argument
1041
+ // and MERGES it with the per-instance log stdio -- caller options
1042
+ // spread FIRST, `stdio` set LAST, so the per-instance log fd always
1043
+ // wins. Never the other order: a caller-supplied `stdio` would silently
1044
+ // redirect a crash-respawn's output away from the log file the epoch
1045
+ // record names, and the forensic per-instance log (D-23) would point at
1046
+ // a file that received nothing. Without this fix, a stock instance that
1047
+ // crashes and respawns comes back reading the operator's real `vicerc`
1048
+ // even though its original launch was isolated.
1049
+ const defaultRealSpawn = (cmd, args, options) => {
918
1050
  const fd = openSync(logPath, "a");
919
- return nodeSpawn(cmd, args, { stdio: ["ignore", fd, fd] });
1051
+ return nodeSpawn(cmd, args, { ...options, stdio: ["ignore", fd, fd] });
920
1052
  };
921
1053
  const baseSpawn = deps.spawnFactory ? deps.spawnFactory(port) : (deps.spawn ?? defaultRealSpawn);
922
1054
  const wrappedSpawn = withCrashSupervision(reason, port, baseSpawn, deps);
@@ -184,14 +184,22 @@ function writeBrokerRecordFile(stateDir, record) {
184
184
  * closure and the log's path relative to supervisorDir (the epoch
185
185
  * record's own `log` field). Shared by both launch paths -- a cold
186
186
  * acquire and warm-floor maintenance -- so there is exactly one place that
187
- * opens a launch log fd. */
187
+ * opens a launch log fd.
188
+ *
189
+ * I-1 rider (08.2-06-PLAN.md, Task 2): the returned `spawn` now also
190
+ * forwards a caller options object (audit item I-1), MERGING it into the
191
+ * object handed to nodeSpawn() -- caller options spread FIRST, `stdio` set
192
+ * LAST, so the launch log fd always wins over any caller-supplied `stdio`.
193
+ * Merging in the other order would silently redirect a launch's output
194
+ * away from the per-instance log file the epoch record names, breaking
195
+ * D-23's forensic logs while appearing to work. */
188
196
  function makeLoggingSpawn(logDir) {
189
197
  mkdirSync(logDir, { recursive: true });
190
198
  const viceBinForLog = basename(process.env.VICE_BIN ?? "x64sc");
191
199
  const logName = `${viceBinForLog}-${Date.now()}.log`;
192
200
  const logFd = openSync(join(logDir, logName), "a");
193
201
  return {
194
- spawn: (cmd, cmdArgs) => nodeSpawn(cmd, cmdArgs, { stdio: ["ignore", logFd, logFd] }),
202
+ spawn: (cmd, cmdArgs, options) => nodeSpawn(cmd, cmdArgs, { ...options, stdio: ["ignore", logFd, logFd] }),
195
203
  logRelPath: `logs/${logName}`,
196
204
  };
197
205
  }
@@ -684,8 +692,12 @@ function maintainWarmFloorForRealBroker(stateDir, state, backend) {
684
692
  spawnFactory: (port) => {
685
693
  const supervisorDir = join(stateDir, String(port));
686
694
  const { spawn, logRelPath } = makeLoggingSpawn(join(supervisorDir, "logs"));
687
- const stashingSpawn = (cmd, args) => {
688
- const child = spawn(cmd, args);
695
+ // I-1 rider (08.2-06-PLAN.md, Task 2): forwards a third options
696
+ // argument -- this is a SECOND, independent dropper on the
697
+ // warm-floor arm; fixing only makeLoggingSpawn above would leave
698
+ // this arm's own scratch XDG_CONFIG_HOME dropped right here.
699
+ const stashingSpawn = (cmd, args, options) => {
700
+ const child = spawn(cmd, args, options);
689
701
  // Stash the log path where onLaunched (fired synchronously right
690
702
  // after this returns, still within the SAME maintainWarmFloor()
691
703
  // call -- at most one launch per call, per the serialised-warming
@@ -720,6 +732,15 @@ function maintainWarmFloorForRealBroker(stateDir, state, backend) {
720
732
  log: (line) => process.stderr.write(`${line}\n`),
721
733
  });
722
734
  }
735
+ /** Exported ONLY so a test can drive the warm-floor arm's REAL spawn
736
+ * composition (this function's own makeLoggingSpawn()+stashingSpawn+
737
+ * withCrashSupervision() closure above) through the built artifact, the
738
+ * same escape-hatch pattern `_superviseDepsFor` already establishes for the
739
+ * respawn composition -- see vice-broker-acquire.test.ts's I-1 composition
740
+ * tests (08.2-06-PLAN.md, Task 3), which call this directly with no spawn
741
+ * override so the warm floor's own independent `stashingSpawn` dropper
742
+ * cannot hide behind an injected stub. */
743
+ export const _maintainWarmFloorForRealBroker = maintainWarmFloorForRealBroker;
723
744
  /** Releases a grant and identity-verified-kills its instance -- but ONLY
724
745
  * when the port's CURRENT occupant is proven to be the SAME process this
725
746
  * grant was actually issued for (its own recorded `pid`, set at grant time