@henols/vice-mcp 0.2.2 → 0.2.3

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.
Files changed (77) hide show
  1. package/README.md +2 -2
  2. package/THIRD-PARTY-NOTICES.md +422 -1
  3. package/anno-bank.ts +171 -0
  4. package/anno-cli.ts +1674 -99
  5. package/anno-enum-gen.ts +416 -30
  6. package/anno-export-asm.ts +1175 -89
  7. package/anno-graphics.ts +338 -0
  8. package/anno-hazard-report.ts +1367 -0
  9. package/anno-import.ts +495 -0
  10. package/anno-join.ts +480 -0
  11. package/anno-provenance-ledger.ts +472 -0
  12. package/anno-register.ts +159 -0
  13. package/anno-store-export.ts +661 -0
  14. package/anno-store.ts +518 -2
  15. package/anno-tools.ts +1169 -16
  16. package/anno-types.ts +275 -2
  17. package/backend-detect.mts +124 -312
  18. package/build.ts +3 -1
  19. package/capture-predicate.ts +597 -0
  20. package/channel-lock.ts +349 -0
  21. package/evid-ingest.ts +217 -0
  22. package/evid-reconcile.ts +316 -0
  23. package/host-tool-client.ts +430 -0
  24. package/incident-record.ts +23 -12
  25. package/install-resources.ts +29 -13
  26. package/memmap-lookup.ts +285 -0
  27. package/package.json +27 -8
  28. package/prg-image.ts +1 -2
  29. package/repo-root.ts +87 -3
  30. package/resources/backend-detect.mjs +98 -236
  31. package/resources/broker-control.mjs +189 -16
  32. package/resources/broker-epoch.mjs +1 -1
  33. package/resources/broker-kill.mjs +8 -2
  34. package/resources/broker-launch.mjs +365 -210
  35. package/resources/broker-state.mjs +64 -18
  36. package/resources/container-guard.mjs +1 -1
  37. package/resources/ghidra-project.mjs +790 -0
  38. package/resources/host-tool.mjs +2561 -0
  39. package/resources/vice-broker.mjs +330 -184
  40. package/resources/vice-launcher.sh +127 -9
  41. package/stock-address.ts +1 -1
  42. package/stock-condition.ts +1 -1
  43. package/stock-connect.ts +9 -5
  44. package/stock-derived.ts +29 -37
  45. package/stock-diagnose.ts +200 -36
  46. package/stock-dispatch.ts +179 -77
  47. package/stock-handler.ts +1 -1
  48. package/stock-paths.ts +18 -14
  49. package/stock-petscii.ts +1 -1
  50. package/stock-protocol.ts +1 -1
  51. package/stock-recycle.ts +83 -2
  52. package/stock-reproducible-run.ts +811 -0
  53. package/stock-run-until.ts +100 -1
  54. package/stock-symbols.ts +4 -4
  55. package/stock-timing.ts +1 -1
  56. package/stop-oracle.ts +167 -0
  57. package/text-capability-probe.ts +660 -0
  58. package/text-connect.ts +157 -0
  59. package/text-protocol.ts +810 -0
  60. package/text-tools.ts +778 -0
  61. package/textmon-backtrace.ts +385 -0
  62. package/textmon-cpuhistory.ts +335 -0
  63. package/textmon-memmap.ts +494 -0
  64. package/textmon-profile.ts +458 -0
  65. package/textmon-registers.ts +748 -0
  66. package/tools-manifest.stock.json +864 -3
  67. package/vice-broker-client.ts +189 -42
  68. package/vice-errors.ts +268 -0
  69. package/vice-proxy.ts +339 -2144
  70. package/vsf-slice.ts +640 -0
  71. package/anno-d64.ts +0 -310
  72. package/capability-registry.ts +0 -390
  73. package/refresh-manifest.ts +0 -124
  74. package/tools-manifest.json +0 -1223
  75. package/vice-probe.ts +0 -278
  76. package/vice-sync.ts +0 -336
  77. package/vice.ts +0 -772
@@ -2,7 +2,7 @@
2
2
  // Compiled by `tsc` from broker-control.mts. Edit the TypeScript source and rebuild;
3
3
  // changes made directly to this file are silently overwritten by the next build, and are never
4
4
  // deployed to the host on their own -- install-resources.mjs copies THIS file's on-disk contents
5
- // verbatim to tools/, so an edit made only here reaches the host but is lost on the very next
5
+ // verbatim to .c64-re-tools/bin/, so an edit made only here reaches the host but is lost on the very next
6
6
  // rebuild.
7
7
  // broker-control.mts
8
8
  //
@@ -48,6 +48,91 @@ const MAX_LINE_BYTES = 65536;
48
48
  * and T-02-18's prohibition on wedge/hang vocabulary in this file's
49
49
  * monitor-op refusals. */
50
50
  const MONITOR_OWNERSHIP_DENIAL = "monitor_claim/monitor_release may only target the grant this connection itself holds";
51
+ /** Resolves the `channel` field on a `monitor_claim`/`monitor_release`
52
+ * request line (plan 41-03, D-14): an ABSENT field means `binary`
53
+ * deliberately -- a broker restarted mid-phase against a client that
54
+ * predates this field keeps working (backward compatibility, this plan's
55
+ * own must-have). An unrecognised NON-EMPTY value is `bad_request`, never a
56
+ * silent fallback and never cast -- the caller below names both accepted
57
+ * values in the refusal message. */
58
+ function resolveMonitorChannel(raw) {
59
+ if (raw === undefined)
60
+ return "binary";
61
+ if (raw === "binary" || raw === "text")
62
+ return raw;
63
+ return "bad_request";
64
+ }
65
+ // ---------------------------------------------------------------------------
66
+ // Phase 33, plan 33-06 (REPRO-05, D-15, T-33-03/T-33-04): the launch-profile
67
+ // narrowing site.
68
+ //
69
+ // THIS IS THE ONE PLACE `profile` IS NARROWED. Do not re-derive this check
70
+ // anywhere else -- not in vice-broker.mts, not in broker-launch.mts, not in
71
+ // the container-side client. A second copy is how one of them ends up
72
+ // accepting a shape the other refuses.
73
+ //
74
+ // WHY IT HAS TO EXIST AT ALL: `ControlRequest` above carries an index
75
+ // signature, so *anything* a container writes on the wire parses into it. The
76
+ // profile then feeds buildViceArgs(), i.e. an `execve(x64sc, argv)` on the
77
+ // HOST. An unvalidated `profile` is therefore an argv-construction surface
78
+ // across a trust boundary, not merely a typing inconvenience.
79
+ //
80
+ // WHY UNKNOWN KEYS ARE REFUSED BY NAME rather than dropped: a silently
81
+ // accepted typo means a caller asked for warp, got an unwarped instance, and
82
+ // received a confident success. That is the same undetectable-lie failure
83
+ // D-16 exists to prevent one layer down, and it is why the message below
84
+ // names the offending key -- the by-name unexpected-argument discipline the
85
+ // tool handlers already use (RUN_UNTIL_KEYS' own convention).
86
+ //
87
+ // WHAT MUST NEVER BE ADDED HERE: a passthrough string, an `extraArgs`, or any
88
+ // key whose VALUE reaches argv. `profile` maps to exactly two literal flag
89
+ // tokens (`-console`, `-warp`) and to nothing else (T-33-04). `VICE_ARGS`
90
+ // stays the single, deliberate operator-only whole-argv override.
91
+ // ---------------------------------------------------------------------------
92
+ /** The complete accepted key set -- the ONE binding list this narrowing
93
+ * checks against, so adding a knob to LaunchProfile without adding it here
94
+ * refuses the knob rather than silently widening the boundary. */
95
+ const LAUNCH_PROFILE_KEYS = Object.freeze(["warp", "headless"]);
96
+ const LAUNCH_PROFILE_SHAPE = `an object with optional boolean keys ${LAUNCH_PROFILE_KEYS.join("/")}, or absent`;
97
+ /** Narrows an untrusted `profile` field off the wire. Never throws; answers a
98
+ * discriminated result so the caller writes the existing `bad_request` error
99
+ * shape rather than needing a try/catch at the protocol boundary.
100
+ *
101
+ * Rules, in the order they are applied:
102
+ * - `undefined` (key absent) and `null` -> `ok` with `undefined`. Both mean
103
+ * profile-less, which is byte-identically today's behaviour.
104
+ * - a PLAIN object (arrays and every other non-plain value refused) whose
105
+ * keys are a subset of LAUNCH_PROFILE_KEYS and whose PRESENT values are
106
+ * booleans -> `ok` with that object.
107
+ * - anything else -> `ok: false`, with a message naming the offending value
108
+ * (or key) and the accepted shape. Never coerced, never silently dropped:
109
+ * `"yes"`, `1` and `"warp"` are refusals, not truthy warp requests. */
110
+ export function normaliseLaunchProfile(raw) {
111
+ if (raw === undefined || raw === null)
112
+ return { ok: true, profile: undefined };
113
+ if (typeof raw !== "object" || Array.isArray(raw)) {
114
+ // Arrays are specifically excluded: `typeof [] === "object"` in JS, so
115
+ // without the Array.isArray() arm a JSON array would reach the key walk
116
+ // below and pass it vacuously (an empty array has no own keys).
117
+ return { ok: false, message: `profile must be ${LAUNCH_PROFILE_SHAPE}; got ${JSON.stringify(raw) ?? String(raw)}` };
118
+ }
119
+ const entries = Object.entries(raw);
120
+ const unknownKeys = entries.filter(([key]) => !LAUNCH_PROFILE_KEYS.includes(key)).map(([key]) => key);
121
+ if (unknownKeys.length > 0) {
122
+ return { ok: false, message: `profile has unknown key(s) ${unknownKeys.join(", ")}; accepted shape is ${LAUNCH_PROFILE_SHAPE}` };
123
+ }
124
+ const profile = {};
125
+ for (const [key, value] of entries) {
126
+ if (typeof value !== "boolean") {
127
+ return { ok: false, message: `profile.${key} must be a boolean; got ${JSON.stringify(value) ?? String(value)}` };
128
+ }
129
+ if (key === "warp")
130
+ profile.warp = value;
131
+ if (key === "headless")
132
+ profile.headless = value;
133
+ }
134
+ return { ok: true, profile };
135
+ }
51
136
  export function resolveControlPort(override) {
52
137
  if (typeof override === "number")
53
138
  return override;
@@ -74,6 +159,21 @@ function writeLine(socket, obj) {
74
159
  socket.write(`${JSON.stringify(obj)}\n`);
75
160
  }
76
161
  }
162
+ /** Phase 34, plan 34-01: writes a `host_tool` SUCCESS response line -- the
163
+ * object host-tool.mts's runHostTool() produced, whatever shape that is
164
+ * (`{ ok: true, ... }` or its own `{ ok: false, message }` refusal). This is
165
+ * deliberately NOT `writeLine()`/`ControlResponse`: the host-tool response
166
+ * shape is host-tool.mts's own contract, not one more `ControlResponse`
167
+ * variant this module would otherwise have to keep in sync with a sibling
168
+ * module's allowlist. A REJECTED onHostTool() promise never reaches this
169
+ * function -- it is answered through the ordinary `writeLine()`/`error`
170
+ * path instead, so every protocol-level failure still goes through one
171
+ * shape. */
172
+ function writeHostToolLine(socket, obj) {
173
+ if (socket.writable) {
174
+ socket.write(`${JSON.stringify(obj)}\n`);
175
+ }
176
+ }
77
177
  function defaultRequestId(prefix) {
78
178
  return `${prefix}-${process.pid}-${Date.now()}`;
79
179
  }
@@ -217,7 +317,7 @@ function attachControlProtocol(server, opts, pendingAcquires) {
217
317
  * callback with the same request id instead of silently dropping the
218
318
  * grant it produced.
219
319
  */
220
- function attemptAcquire(requestId) {
320
+ function attemptAcquire(requestId, profile) {
221
321
  // Half one: a queued entry whose owning socket is already gone is
222
322
  // settled immediately, WITHOUT ever calling onAcquire() -- this is
223
323
  // what keeps a retried drain pass from performing a real, ownerless
@@ -225,7 +325,12 @@ function attachControlProtocol(server, opts, pendingAcquires) {
225
325
  if (socket.destroyed)
226
326
  return Promise.resolve(true);
227
327
  return opts
228
- .onAcquire(requestId)
328
+ // Phase 33, plan 33-06: the profile is threaded through THIS shared
329
+ // helper, which both the immediate first attempt and every later
330
+ // drainPendingAcquires() retry go through -- so a request that
331
+ // queued behind an in-flight launch is retried later with the
332
+ // profile it was MADE with, never with a profile-less one.
333
+ .onAcquire(requestId, profile)
229
334
  .then((outcome) => {
230
335
  if (outcome.ok) {
231
336
  // Half two: the pre-check above ran before this call; a
@@ -245,6 +350,14 @@ function attachControlProtocol(server, opts, pendingAcquires) {
245
350
  url: outcome.grant.url,
246
351
  epoch_file: outcome.grant.epochFile,
247
352
  supervisor_dir: outcome.grant.supervisorDir,
353
+ // D-15; tightened by plan 41-05 (D-16): key omitted entirely
354
+ // when absent -- the fork case only now. A stock grant whose
355
+ // second (text-monitor) port allocation failed never reaches
356
+ // this line at all: acquirePortAndLaunch() fails the WHOLE
357
+ // acquire (`no_free_text_port`) before any grant is produced,
358
+ // so "absent" no longer needs to cover that case. Never a
359
+ // fabricated 0 or null standing in for "no port".
360
+ ...(outcome.grant.remoteMonitorPort === undefined ? {} : { remote_monitor_port: outcome.grant.remoteMonitorPort }),
248
361
  });
249
362
  return true;
250
363
  }
@@ -289,11 +402,52 @@ function attachControlProtocol(server, opts, pendingAcquires) {
289
402
  socket.destroy();
290
403
  return;
291
404
  }
292
- if (req.op === "acquire") {
405
+ // Phase 34, plan 34-01 (SEAM-01): dispatched FIRST in the chain, before
406
+ // "acquire" -- so the ordering reads as the requirement does. Dispatch
407
+ // here is on EXACT STRING EQUALITY, never fallthrough, so branch order
408
+ // does not itself change which requests reach attemptAcquire() -- what
409
+ // actually makes this branch unable to touch lease state is that
410
+ // opts.onHostTool is its OWN callback (see StartControlListenerOptions'
411
+ // own comment), never composed from onAcquire/onRelease/onRecycle/
412
+ // onStatus/onHostState/onMonitorClaim/onMonitorRelease.
413
+ if (req.op === "host_tool") {
414
+ opts
415
+ .onHostTool(req)
416
+ .then((result) => {
417
+ if (!socket.destroyed)
418
+ writeHostToolLine(socket, result);
419
+ })
420
+ .catch(() => {
421
+ if (!socket.destroyed) {
422
+ writeLine(socket, { kind: "error", code: "internal", message: "host_tool threw" });
423
+ }
424
+ });
425
+ }
426
+ else if (req.op === "acquire") {
293
427
  const requestId = typeof req.id === "string" && req.id !== "" ? req.id : defaultRequestId("req");
294
- void attemptAcquire(requestId).then((settled) => {
428
+ // Phase 33, plan 33-06 (T-33-03): narrow BEFORE attemptAcquire, so a
429
+ // malformed profile never reaches onAcquire and therefore never
430
+ // reaches the port allocator, a spawn, or argv construction. A
431
+ // refusal also does NOT enqueue -- the request is answered and
432
+ // dropped, never retried on a later drain pass with the same bad
433
+ // shape.
434
+ const normalised = normaliseLaunchProfile(req.profile);
435
+ if (!normalised.ok) {
436
+ writeLine(socket, { kind: "error", code: "bad_request", message: normalised.message });
437
+ return;
438
+ }
439
+ const profile = normalised.profile;
440
+ // 33 review WR-03's profile-is-stock-only refusal lived here: it
441
+ // refused `profile.warp`/`profile.headless` when this broker's
442
+ // resolved backend had no `-warp`/`-console` route at all, so a
443
+ // caller learned a knob would be silently ignored rather than
444
+ // getting a confident grant with no effect. FORKRM-01 (plan 52-06):
445
+ // there is one backend now and it always has that route, so the
446
+ // condition this refused can no longer occur -- deleted rather than
447
+ // left as a check against a value that can never disagree.
448
+ void attemptAcquire(requestId, profile).then((settled) => {
295
449
  if (!settled) {
296
- enqueueAcquire(pendingAcquires, { requestId, attempt: () => attemptAcquire(requestId) });
450
+ enqueueAcquire(pendingAcquires, { requestId, attempt: () => attemptAcquire(requestId, profile) });
297
451
  }
298
452
  });
299
453
  }
@@ -354,7 +508,6 @@ function attachControlProtocol(server, opts, pendingAcquires) {
354
508
  started_at: hs.startedAt,
355
509
  node_version: hs.nodeVersion,
356
510
  vice_bin: hs.viceBin,
357
- warm_floor: hs.warmFloor,
358
511
  max_instances: hs.maxInstances,
359
512
  base_port: hs.basePort,
360
513
  backend: hs.backend,
@@ -370,15 +523,25 @@ function attachControlProtocol(server, opts, pendingAcquires) {
370
523
  writeLine(socket, { kind: "error", code: "denied", message: MONITOR_OWNERSHIP_DENIAL });
371
524
  return;
372
525
  }
526
+ const channel = resolveMonitorChannel(req.channel);
527
+ if (channel === "bad_request") {
528
+ writeLine(socket, {
529
+ kind: "error",
530
+ code: "bad_request",
531
+ message: `monitor_claim: unrecognised channel ${JSON.stringify(req.channel)} -- accepted values are "binary" and "text"`,
532
+ });
533
+ return;
534
+ }
373
535
  const requestId = typeof req.id === "string" && req.id !== "" ? req.id : defaultRequestId("claim");
374
- const outcome = opts.onMonitorClaim(requestId, targetId);
536
+ const outcome = opts.onMonitorClaim(requestId, targetId, channel);
375
537
  if (outcome.ok) {
376
538
  writeLine(socket, { kind: "monitor_claimed" });
377
539
  }
378
540
  else if (outcome.code === "monitor_owned") {
379
- // Ownership conflict, named by holder -- deliberately worded to
380
- // never suggest the emulator itself has stopped answering
381
- // (T-02-18; the plan's own grep gate polices this).
541
+ // Ownership conflict, named by holder AND channel (plan 41-03,
542
+ // D-14) -- deliberately worded to never suggest the emulator
543
+ // itself has stopped answering (T-02-18; the plan's own grep gate
544
+ // polices this).
382
545
  //
383
546
  // WR-08 (broker side): `holder` is REQUIRED by MonitorClaimOutcome for
384
547
  // this code, but this handler runs inside socket.on("data") with no
@@ -386,13 +549,14 @@ function attachControlProtocol(server, opts, pendingAcquires) {
386
549
  // TypeError out of the control listener and take the broker process
387
550
  // with it -- a type contract is not a runtime guarantee at a wire
388
551
  // boundary. The fallback names the holder as unknown rather than
389
- // fabricating one, matching what the container-side client now does
390
- // with a malformed holder payload.
391
- const holder = outcome.holder ?? { grantId: "unknown", claimedAt: 0, pid: null };
552
+ // fabricating one (matching what the container-side client now does
553
+ // with a malformed holder payload), and defaults `channel` to the
554
+ // channel THIS request asked for -- never a fabricated third value.
555
+ const holder = outcome.holder ?? { grantId: "unknown", claimedAt: 0, pid: null, channel };
392
556
  writeLine(socket, {
393
557
  kind: "error",
394
558
  code: "monitor_owned",
395
- message: `instance already has a monitor client (grant ${holder.grantId}, claimed at ${holder.claimedAt}) -- this is an ownership conflict, not an emulator failure`,
559
+ message: `instance already has a monitor client on the ${holder.channel} channel (grant ${holder.grantId}, claimed at ${holder.claimedAt}) -- this is an ownership conflict, not an emulator failure`,
396
560
  holder,
397
561
  });
398
562
  }
@@ -410,8 +574,17 @@ function attachControlProtocol(server, opts, pendingAcquires) {
410
574
  writeLine(socket, { kind: "error", code: "denied", message: MONITOR_OWNERSHIP_DENIAL });
411
575
  return;
412
576
  }
577
+ const channel = resolveMonitorChannel(req.channel);
578
+ if (channel === "bad_request") {
579
+ writeLine(socket, {
580
+ kind: "error",
581
+ code: "bad_request",
582
+ message: `monitor_release: unrecognised channel ${JSON.stringify(req.channel)} -- accepted values are "binary" and "text"`,
583
+ });
584
+ return;
585
+ }
413
586
  const requestId = typeof req.id === "string" && req.id !== "" ? req.id : defaultRequestId("release-monitor");
414
- const outcome = opts.onMonitorRelease(requestId, targetId);
587
+ const outcome = opts.onMonitorRelease(requestId, targetId, channel);
415
588
  if (outcome.ok) {
416
589
  writeLine(socket, { kind: "monitor_released" });
417
590
  }
@@ -2,7 +2,7 @@
2
2
  // Compiled by `tsc` from broker-epoch.mts. Edit the TypeScript source and rebuild;
3
3
  // changes made directly to this file are silently overwritten by the next build, and are never
4
4
  // deployed to the host on their own -- install-resources.mjs copies THIS file's on-disk contents
5
- // verbatim to tools/, so an edit made only here reaches the host but is lost on the very next
5
+ // verbatim to .c64-re-tools/bin/, so an edit made only here reaches the host but is lost on the very next
6
6
  // rebuild.
7
7
  // broker-epoch.mts
8
8
  //
@@ -2,7 +2,7 @@
2
2
  // Compiled by `tsc` from broker-kill.mts. Edit the TypeScript source and rebuild;
3
3
  // changes made directly to this file are silently overwritten by the next build, and are never
4
4
  // deployed to the host on their own -- install-resources.mjs copies THIS file's on-disk contents
5
- // verbatim to tools/, so an edit made only here reaches the host but is lost on the very next
5
+ // verbatim to .c64-re-tools/bin/, so an edit made only here reaches the host but is lost on the very next
6
6
  // rebuild.
7
7
  // broker-kill.mts
8
8
  //
@@ -325,7 +325,13 @@ export function startupBanner() {
325
325
  "vice-broker: nohup/setsid/systemd -- this launcher does not offer a --detach flag.",
326
326
  ];
327
327
  if (process.env.VICE_BROKER_SPARES !== undefined) { // banner-only presence check (D-25/P-13) -- never reads the value
328
- lines.push("vice-broker: NOTE -- the VICE_BROKER_SPARES environment variable is set and is IGNORED; it was retired with no alias or fallback. Use VICE_BROKER_WARM_FLOOR instead.");
328
+ lines.push(
329
+ // Plan 41-05 (folded todo): this note's own former "use the warm-floor
330
+ // knob instead" replacement is ITSELF retired along with the warm
331
+ // floor -- pointing an operator at a second dead knob would be worse
332
+ // than pointing at none. VICE now launches strictly on demand, on the
333
+ // first request; there is no warming knob left to name.
334
+ "vice-broker: NOTE -- the VICE_BROKER_SPARES environment variable is set and is IGNORED; it was retired with no alias or fallback. There is no replacement -- VICE now launches strictly on demand, on the first request.");
329
335
  }
330
336
  return lines.join("\n");
331
337
  }