@henols/vice-mcp 0.2.1 → 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 (108) hide show
  1. package/README.md +4 -3
  2. package/THIRD-PARTY-NOTICES.md +423 -25
  3. package/{r2000-acme-ident.ts → anno-acme-ident.ts} +13 -13
  4. package/anno-bank.ts +171 -0
  5. package/anno-cli.ts +3040 -0
  6. package/{r2000-confidence.ts → anno-confidence.ts} +22 -22
  7. package/anno-coverage.ts +2465 -0
  8. package/anno-derive.ts +590 -0
  9. package/anno-details.ts +169 -0
  10. package/anno-enum-gen.ts +919 -0
  11. package/anno-export-asm.ts +2396 -0
  12. package/anno-graphics.ts +338 -0
  13. package/anno-hazard-report.ts +1367 -0
  14. package/anno-import.ts +495 -0
  15. package/anno-index.ts +150 -0
  16. package/anno-join.ts +480 -0
  17. package/{r2000-memmap-render.ts → anno-memmap-render.ts} +236 -95
  18. package/anno-provenance-ledger.ts +472 -0
  19. package/{r2000-regbits-gen.ts → anno-regbits-gen.ts} +20 -15
  20. package/{r2000-regbits.json → anno-regbits.json} +2 -2
  21. package/anno-register.ts +399 -0
  22. package/anno-store-export.ts +661 -0
  23. package/anno-store.ts +4002 -0
  24. package/anno-symbols.ts +266 -0
  25. package/anno-tools.ts +3264 -0
  26. package/anno-types.ts +1909 -0
  27. package/backend-detect.mts +124 -312
  28. package/block-class.ts +201 -0
  29. package/build.ts +4 -2
  30. package/capture-predicate.ts +597 -0
  31. package/channel-lock.ts +349 -0
  32. package/disasm-decoder.ts +14 -14
  33. package/disasm-opcodes.ts +4 -4
  34. package/disasm-renderer.ts +2 -2
  35. package/evid-ingest.ts +217 -0
  36. package/evid-reconcile.ts +316 -0
  37. package/host-tool-client.ts +430 -0
  38. package/hostpath.ts +1 -1
  39. package/incident-record.ts +23 -12
  40. package/install-resources.ts +30 -14
  41. package/memmap-lookup.ts +285 -0
  42. package/package.json +48 -23
  43. package/prg-image.ts +118 -0
  44. package/repo-root.ts +107 -8
  45. package/resources/backend-detect.mjs +98 -236
  46. package/resources/broker-control.mjs +189 -16
  47. package/resources/broker-epoch.mjs +1 -1
  48. package/resources/broker-kill.mjs +8 -2
  49. package/resources/broker-launch.mjs +373 -214
  50. package/resources/broker-state.mjs +64 -18
  51. package/resources/container-guard.mjs +1 -1
  52. package/resources/ghidra-project.mjs +790 -0
  53. package/resources/host-tool.mjs +2561 -0
  54. package/resources/vice-broker.mjs +330 -184
  55. package/resources/vice-launcher.sh +130 -12
  56. package/stock-address.ts +6 -6
  57. package/stock-cia.ts +2 -2
  58. package/stock-condition.ts +8 -8
  59. package/stock-connect.ts +10 -6
  60. package/stock-derived.ts +29 -37
  61. package/stock-diagnose.ts +200 -36
  62. package/stock-dispatch.ts +200 -68
  63. package/stock-execution.ts +5 -3
  64. package/stock-handler.ts +1 -1
  65. package/stock-input.ts +9 -9
  66. package/stock-machine.ts +17 -6
  67. package/stock-paths.ts +18 -14
  68. package/stock-petscii.ts +1 -1
  69. package/stock-protocol.ts +17 -12
  70. package/stock-recycle.ts +83 -2
  71. package/stock-registers.ts +54 -29
  72. package/stock-reproducible-run.ts +811 -0
  73. package/stock-run-until.ts +100 -1
  74. package/stock-sprites.ts +3 -3
  75. package/stock-symbols.ts +13 -13
  76. package/stock-timing.ts +2 -2
  77. package/stock-vicii.ts +1 -1
  78. package/stop-oracle.ts +167 -0
  79. package/text-capability-probe.ts +660 -0
  80. package/text-connect.ts +157 -0
  81. package/text-protocol.ts +810 -0
  82. package/text-tools.ts +778 -0
  83. package/textmon-backtrace.ts +385 -0
  84. package/textmon-cpuhistory.ts +335 -0
  85. package/textmon-memmap.ts +494 -0
  86. package/textmon-profile.ts +458 -0
  87. package/textmon-registers.ts +748 -0
  88. package/tools-manifest.stock.json +864 -3
  89. package/version.ts +1 -1
  90. package/vice-broker-client.ts +189 -42
  91. package/vice-errors.ts +268 -0
  92. package/vice-proxy.ts +392 -2175
  93. package/vsf-slice.ts +640 -0
  94. package/capability-registry.ts +0 -388
  95. package/r2000-cli.ts +0 -1103
  96. package/r2000-d64.ts +0 -310
  97. package/r2000-enum-gen.ts +0 -574
  98. package/r2000-launch.ts +0 -357
  99. package/r2000-mcp-client.ts +0 -596
  100. package/r2000-project.ts +0 -190
  101. package/r2000-symbols.ts +0 -388
  102. package/r2000-tools.ts +0 -914
  103. package/r2000-verify.ts +0 -184
  104. package/refresh-manifest.ts +0 -124
  105. package/tools-manifest.json +0 -1223
  106. package/vice-probe.ts +0 -278
  107. package/vice-sync.ts +0 -336
  108. package/vice.ts +0 -772
package/repo-root.ts CHANGED
@@ -39,6 +39,19 @@
39
39
  // `scripts/` segment. Branch 4's hop count below moved from four levels to
40
40
  // three to match. Branches 1-3 are depth-independent (an env var check, then
41
41
  // a `.git` ancestor walk) and needed no change.
42
+ //
43
+ // FOURTH MOVE (phase 16-04, 2026-08-23): the module directory relocated
44
+ // again, from `.claude/mcp/vice/` to `src/mcp/vice/` (packaging and repo
45
+ // shape). `src`/`mcp`/`vice` is the same three path segments below the
46
+ // repository root as the old `.claude`/`mcp`/`vice` shape -- this is
47
+ // reviewed and confirmed, not assumed, from the segment count itself. Branch
48
+ // 4's hop count was therefore reviewed and deliberately left unchanged: this
49
+ // move relocates the same flat, three-segment shape elsewhere directly under
50
+ // the root, which is a different move from nesting authored sources one
51
+ // level deeper INSIDE this directory (which would make four, and IS what
52
+ // branches 1-3's depth-independence and branch 4's fixed hop count would not
53
+ // survive). See repo-root.test.ts's own standing caution for that
54
+ // distinction, drawn explicitly there for the first time by this move.
42
55
  import { existsSync } from "node:fs";
43
56
  import { fileURLToPath } from "node:url";
44
57
  import { dirname, join, resolve, sep } from "node:path";
@@ -62,6 +75,8 @@ let warnedNoMarkerFound = false;
62
75
  export interface RepoRootOptions {
63
76
  from?: string;
64
77
  env?: NodeJS.ProcessEnv;
78
+ /** Test seam for the marker walk; production uses node:fs existsSync. */
79
+ exists?: (path: string) => boolean;
65
80
  }
66
81
 
67
82
  /**
@@ -84,7 +99,7 @@ function isInside(child: string, parent: string): boolean {
84
99
  * Claude Code exports for the workspace it is driving. This is the ONLY
85
100
  * branch that is correct when this module is consumed as an installed
86
101
  * plugin: the MCP's own files then live under the plugin install dir
87
- * (e.g. `~/.claude/plugins/<marketplace>/<plugin>/.claude/mcp/vice/`),
102
+ * (e.g. `~/.claude/plugins/<marketplace>/<plugin>/src/mcp/vice/`),
88
103
  * NOT inside the project the user is working in, so neither the
89
104
  * `from`-relative `.git` walk (branch 2, which would find the plugin's
90
105
  * OWN checkout) nor a CONTAINER_WORKSPACE_PATH containment check
@@ -108,13 +123,13 @@ function isInside(child: string, parent: string): boolean {
108
123
  * be exactly the quiet-wrong-answer failure class this file exists to
109
124
  * prevent, so this path emits a one-time stderr note naming both paths.
110
125
  * 4. Otherwise, three levels up from `from`, with a one-time stderr note.
111
- * Last resort only -- three levels is what `<root>/.claude/mcp/<server>/`
126
+ * Last resort only -- three levels is what `<root>/src/mcp/<server>/`
112
127
  * implies. In this repo branch 4 never actually runs (there is always a
113
128
  * `.git` ancestor), which is exactly why the paired synthetic test in
114
129
  * repo-root.test.ts is the only thing that would catch a wrong hop
115
130
  * count here.
116
131
  */
117
- export function repoRoot({ from = HERE, env = process.env }: RepoRootOptions = {}): string {
132
+ export function repoRoot({ from = HERE, env = process.env, exists = existsSync }: RepoRootOptions = {}): string {
118
133
  // Branch 0 (plugin-consumption signal): Claude Code sets CLAUDE_PROJECT_DIR
119
134
  // to the root of the workspace it is driving. When this module runs as an
120
135
  // installed plugin its own files sit outside that workspace, so this is the
@@ -134,7 +149,7 @@ export function repoRoot({ from = HERE, env = process.env }: RepoRootOptions = {
134
149
 
135
150
  let dir = resolve(from);
136
151
  while (true) {
137
- if (existsSync(join(dir, ".git"))) {
152
+ if (exists(join(dir, ".git"))) {
138
153
  return dir;
139
154
  }
140
155
  const parent = dirname(dir);
@@ -160,18 +175,102 @@ export function repoRoot({ from = HERE, env = process.env }: RepoRootOptions = {
160
175
  const fallback = resolve(from, "..", "..", "..");
161
176
  console.error(
162
177
  `warn: could not find a .git ancestor above ${from} and CONTAINER_WORKSPACE_PATH is not set -- ` +
163
- `falling back to three levels up (${fallback}), the shape <root>/.claude/mcp/<server>/ implies. ` +
178
+ `falling back to three levels up (${fallback}), the shape <root>/src/mcp/<server>/ implies. ` +
164
179
  `This is a last resort; if it's wrong, set CONTAINER_WORKSPACE_PATH or run from inside a git repo.`
165
180
  );
166
181
  }
167
182
  return resolve(from, "..", "..", "..");
168
183
  }
169
184
 
185
+ /** The ONE definition of the tool-written root every writer in this codebase
186
+ * ultimately derives its location from (D-33, 2026-09-08 clean-break
187
+ * consolidation): `join(repoRoot(...), ".c64-re-tools")`.
188
+ *
189
+ * CORRECTED 2026-09-08 (gap `G-40-1`; see
190
+ * .planning/notes/ghidra-dot-path-check-semantics.md): this comment used to
191
+ * claim (a) that the Ghidra runs directory was among the writers resolving
192
+ * through THIS function, and (b) that the literal string below had exactly
193
+ * one non-comment occurrence in the codebase. Both were false when written,
194
+ * and neither was ever measured before being written down. The corrected
195
+ * picture:
196
+ *
197
+ * - FIVE files call `toolsDir()`/`supervisorDir()` directly, across FOUR
198
+ * distinct subdirectories: incident-record.ts (`incidents`),
199
+ * stock-paths.ts (`snapshots`), vice-proxy.ts (`bin`, reading back the
200
+ * deployed launcher path), and vice.ts + vice-broker-client.ts, both via
201
+ * `supervisorDir()` (`supervisor`, the broker state directory -- one
202
+ * writer, two readers).
203
+ * - FOUR files cannot import this container-side module at all, so each
204
+ * joins `".c64-re-tools"` with its own trailing segment(s) directly,
205
+ * matching this function's shape by CONVENTION, never by shared code:
206
+ * install-resources.ts's `installTargetDir()` (`bin` -- module-cycle
207
+ * avoidance, since THIS file's own bottom-of-module call invokes it),
208
+ * vice-broker.mts's `parseArgs()` state-dir fallback (`supervisor` --
209
+ * host-bound, compiled separately by build.ts), host-tool.mts's
210
+ * `oracle.run` scratch directory (`runs/oracle` -- host-bound), and
211
+ * ghidra-project.mts's `ghidraRunsRoot()`/`ghidraRunsRealRoot()`
212
+ * (`runs/ghidra` -- host-bound, reached through a symlinked alias
213
+ * handle, see below). Every one of these four must keep its literal
214
+ * equal to `join(toolsDir(...), <same segments>)`, by convention, or the
215
+ * two halves of this codebase silently disagree on where the root is.
216
+ *
217
+ * The Ghidra runs root is emphatically NOT, and never was, one of the five
218
+ * files that call this function directly -- it is host-bound
219
+ * (ghidra-project.mts) and cannot import this file at all.
220
+ *
221
+ * The literal string ".c64-re-tools" therefore has exactly 6 non-comment
222
+ * occurrences in this codebase, across 5 files. repo-root.test.ts's census
223
+ * gate reads BOTH the count and this file list straight out of this
224
+ * sentence and the bullet list below -- never duplicated by hand a second
225
+ * time in the test -- and compares both against the real tree, with a
226
+ * planted-violation control proving the comparison predicate actually
227
+ * fires. That is the mechanism that stops this specific claim going false
228
+ * again (threat `T-40-10-02`):
229
+ * - repo-root.ts -- this definition, the line below (1)
230
+ * - install-resources.ts -- `installTargetDir()`'s `bin` join (1)
231
+ * - vice-broker.mts -- `parseArgs()`'s state-dir fallback, BOTH branches
232
+ * of one ternary on the same line (2)
233
+ * - ghidra-project.mts -- `GHIDRA_RUNS_HANDLE_TARGET`, the alias handle's
234
+ * relative symlink target (1)
235
+ * - host-tool.mts -- `oracle.run`'s scratch-directory join (1)
236
+ *
237
+ * The Ghidra alias handle: a non-dotted sibling of this root
238
+ * (`<repoRoot>/c64-re-tools`, no leading dot), a symlink whose RELATIVE
239
+ * target is this root's own directory name, minted host-side by the broker
240
+ * at startup (vice-broker.mts) and re-asserted, idempotently, as a
241
+ * precondition by ghidra-project.mts's `ensureGhidraRunsHandle()` on every
242
+ * resolve -- refused BY NAME, never repaired, when something unexpected
243
+ * already sits at the handle path. It exists because Ghidra's own
244
+ * project-location refusal binds the ABSOLUTIZED path argument it is
245
+ * handed (`ProjectLocator` calls `java.io.File.getAbsolutePath()`, never
246
+ * `getCanonicalPath()` -- MEASURED from the class's own bytecode -- so it
247
+ * absolutizes a relative argument but does not resolve a symlink), so a
248
+ * tool that refuses a dot-prefixed segment in the path it is HANDED can
249
+ * still be pointed, indirectly, at bytes that live physically inside this
250
+ * one root. The superseded method that produced the original overstated
251
+ * claim was running this project's OWN dot-segment-refusal check
252
+ * (`hasDotPrefixedSegment()`) against a synthetic string -- which observes
253
+ * this project, never Ghidra. See
254
+ * .planning/notes/ghidra-dot-path-check-semantics.md for the full live
255
+ * measurement against real Ghidra 12.1.3.
256
+ *
257
+ * This is a clean break, not a migration: no code path falls back to any of
258
+ * the five previous locations when the new one is absent, and there is no
259
+ * opt-back-in environment variable. A pre-existing tree at one of the old
260
+ * locations is simply left on disk, unread, for the user to delete by hand.
261
+ * `VICE_POOL_DIR` / `VICE_EPOCH_FILE` / `VICE_SUPERVISOR_DIR` /
262
+ * `VICE_INCIDENTS_DIR` are unaffected by this move -- they still override
263
+ * their respective resolved default, exactly as before; only the DEFAULT
264
+ * moved. */
265
+ export function toolsDir(opts: RepoRootOptions = {}): string {
266
+ return join(repoRoot(opts), ".c64-re-tools");
267
+ }
268
+
170
269
  /** The one shared directory name every module in this skill reads/writes
171
- * host-synchronised state through -- `join(repoRoot(...), ".vice-supervisor")`,
172
- * so the literal directory name also has exactly one definition. */
270
+ * host-synchronised state through -- `join(toolsDir(...), "supervisor")`,
271
+ * a subdirectory of the single tool-written root `toolsDir()` owns. */
173
272
  export function supervisorDir(opts: RepoRootOptions = {}): string {
174
- return join(repoRoot(opts), ".vice-supervisor");
273
+ return join(toolsDir(opts), "supervisor");
175
274
  }
176
275
 
177
276
  // Fires once per process, on whatever entry point happens to import THIS
@@ -2,126 +2,53 @@
2
2
  // Compiled by `tsc` from backend-detect.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
  // backend-detect.mts
8
8
  //
9
- // The ONE place that decides which VICE build a binary is, and the ONE
10
- // reader of VICE_BACKEND anywhere in this tree (D-01). Everything else that
11
- // needs to know "fork or stock" -- broker-launch.mts's buildViceArgs(),
12
- // vice-broker.mts's startup wiring, and plan 02-08's later connect handshake
13
- // -- calls resolvedBackend() below and threads its answer down, exactly like
14
- // vice.ts's mcpHost() documents for its own container-versus-host question:
15
- // a SECOND independent reader of the same signal is a bug waiting to
16
- // happen the moment one copy is updated and the other is not (mcpHost()'s
17
- // own "three inlined copies" incident is the exact regression this file
18
- // exists to keep from recurring here).
9
+ // FORKRM-01 (plan 52-06): this file used to be the ONE place that decided
10
+ // which VICE build a binary is (the fork's `-mcpserver` flag versus stock's
11
+ // `-binarymonitor`-only surface) and the ONE reader of an environment
12
+ // override naming a backend, anywhere in this tree. There is only one
13
+ // backend now, so there is nothing left to detect between: the `--help`
14
+ // probe (its own spawn, its fork/stock/unknown classifier) and the
15
+ // environment override are both deleted outright, not merely unreachable.
19
16
  //
20
- // WHAT NOT TO DO:
21
- // - Do not call resolvedBackend()/probeBackend() per acquire, per launch,
22
- // or per connect. The broker resolves the backend exactly ONCE, at
23
- // process startup (vice-broker.mts's run()), and passes the resolved
24
- // value down through every real launch call site -- see this module's
25
- // own module-level memo below, which exists as a second line of defence
26
- // against an accidental extra call, not as the PRIMARY mechanism (that
27
- // is the caller only ever invoking this once).
28
- // - Do not call this from inside broker-launch.mts's `inFlight`
29
- // single-owner launch guard. This is a possibly-blocking child-process
30
- // spawn (probeBackend()'s --help probe); anything that can block inside
31
- // that synchronous check-and-set window is the exact failure class the
32
- // 2026-08-01 triple-launch outage came from (D-03/T-02-25).
33
- // - Do not add a trial-launch fallback (launch the binary for real and
34
- // watch what happens) as a second detection mechanism. The `--help`
35
- // probe below runs entirely outside any launch-guarded critical section
36
- // BY CONSTRUCTION -- a trial launch would not. It would also depend on
37
- // RESEARCH.md's assumption A1 (stock's argument parser rejects an
38
- // unknown flag rather than ignoring it), which is UNVERIFIED against a
39
- // real stock binary; see docs/phase2-backend-probe-evidence.md.
17
+ // What SURVIVES, and why:
18
+ // - Binary-path resolution (`resolveBinPath`/`binPathFields`, WR-05) --
19
+ // `vice_ping`'s `resolvedBinaryPath` field and host-tool.mts's sibling
20
+ // binary resolution (`c1541`/`petcat`) both still need "which file did
21
+ // you actually mean", independent of any backend concept.
22
+ // - The on-disk identity/capability cache (BACK-04) -- it still records
23
+ // something worth caching once the backend verdict is gone: the version
24
+ // quad and CPU-history capability a live connect handshake (plan 02-08,
25
+ // stock-connect.ts) attaches to a resolved binary's identity
26
+ // (`resolvedPath`/`mtimeMs`/`sizeBytes`). `readCapabilityRecord()`/
27
+ // `writeCapabilityRecord()` are unchanged in shape; only the cache
28
+ // record's `backend` field is gone, since it never meant anything once
29
+ // there was nothing to distinguish.
30
+ //
31
+ // `ViceBackend` is NARROWED to a single literal (`"stock"`), not deleted:
32
+ // text-capability-probe.ts and stock-connect.ts (both out of this plan's
33
+ // scope -- plan 52-07 owns text-capability-probe.ts's own registry-shaped
34
+ // fork references) still import it as a type-only import, and deleting it
35
+ // outright would break their compile for no benefit this plan is scoped to
36
+ // deliver.
40
37
  //
41
- // ENVIRONMENT CONSTRAINT (2026-08-13, explicit user scope override): no real
42
- // stock or fork VICE binary is reachable from the environment this plan
43
- // executed in, and the user's own ruling for this plan is "we can't do
44
- // tests with deciding what vice is". Every test in backend-detect.test.ts
45
- // therefore drives this module's OVERRIDE path, its on-disk CACHE lifecycle,
46
- // and classifyHelpOutput()'s STRING-PARSING logic against fixture strings
47
- // authored in the test file -- never a real spawned binary. The `--help`
48
- // discriminator itself (does a real stock build's --help output actually
49
- // contain "-binarymonitor" and omit "-mcpserver", the way classifyHelpOutput()
50
- // below assumes) is recorded as an OPEN, not a VERIFIED, question in
51
- // docs/phase2-backend-probe-evidence.md section 2 -- that document's verdict
52
- // is deliberately left standing; nothing in this file's own tests attempts to
53
- // resolve it, and no fixture string anywhere in this tree should ever be
54
- // presented as real captured output from either build. See the follow-up
55
- // todo tracked under .planning/todos/pending/ for what a real-hardware run
56
- // must still confirm.
57
- import { spawnSync } from "node:child_process";
38
+ // WHAT NOT TO DO:
39
+ // - Do not reintroduce an environment-variable backend override, a
40
+ // --help probe, or any other backend-discrimination mechanism. There is
41
+ // one binary shape now; a second detection mechanism would be solving a
42
+ // problem that no longer exists.
43
+ // - Do not call resolvedBackend() from inside broker-launch.mts's
44
+ // `inFlight` single-owner launch guard. This still performs filesystem
45
+ // I/O (a stat call and, on a fresh or changed identity, a cache write);
46
+ // anything that can block inside that synchronous check-and-set window
47
+ // is the exact failure class the 2026-08-01 triple-launch outage came
48
+ // from (D-03/T-02-25) -- unchanged reasoning from before this plan, only
49
+ // the mechanism inside resolvedBackend() changed.
58
50
  import { existsSync, readFileSync, writeFileSync, chmodSync, renameSync, mkdirSync, statSync, } from "node:fs";
59
51
  import { join, resolve as resolvePath } from "node:path";
60
- // ---------------------------------------------------------------------------
61
- // classifyHelpOutput() -- pure string classification, no I/O at all.
62
- // ---------------------------------------------------------------------------
63
- /** Matches on the literal flag tokens D-02 names as the discriminator: the
64
- * fork's `-mcpserver` flag versus stock's `-binarymonitor`-only surface.
65
- * `"fork"` wins when BOTH tokens appear (the fork's own VICE tree is a 3.10
66
- * checkout and accepts both flags) -- checked FIRST, deliberately, so a
67
- * build that advertises both is classified by the flag that actually makes
68
- * it the fork, not merely "also has stock's flag too". `"unknown"` when
69
- * NEITHER token appears -- a real --help transcript that does not match
70
- * either shape, a probe that spawned nothing at all (empty text), or
71
- * anything else this function was never taught to recognise. Never throws;
72
- * pure function of the text it is given. */
73
- export function classifyHelpOutput(text) {
74
- const hasFork = text.includes("-mcpserver");
75
- const hasStock = text.includes("-binarymonitor");
76
- if (hasFork)
77
- return "fork";
78
- if (hasStock)
79
- return "stock";
80
- return "unknown";
81
- }
82
- // ---------------------------------------------------------------------------
83
- // probeBackend() -- the --help probe. spawnSync only, argv array, shell:
84
- // false, never a shell string and never string interpolation of binPath
85
- // into a command line (T-02-03's mitigation). Bounded by a 5000ms timeout
86
- // with kill-on-timeout so a hostile or hung binary cannot stall broker
87
- // startup (T-02-25's second mitigation half).
88
- // ---------------------------------------------------------------------------
89
- const PROBE_TIMEOUT_MS = 5000;
90
- /** `--help` first, falling back to `-help` then `-?` ONLY when a run exits
91
- * non-zero with EMPTY combined output -- a run that exits non-zero but
92
- * still printed something (some builds write usage to stderr and exit 1) is
93
- * already usable and is not retried further. */
94
- const HELP_FLAG_CANDIDATES = ["--help", "-help", "-?"];
95
- function defaultSpawnHelp(binPath, flag) {
96
- try {
97
- const result = spawnSync(binPath, [flag], {
98
- encoding: "utf8",
99
- timeout: PROBE_TIMEOUT_MS,
100
- killSignal: "SIGKILL",
101
- });
102
- const text = `${result.stdout ?? ""}${result.stderr ?? ""}`;
103
- return { text, exitedZero: result.status === 0 };
104
- }
105
- catch {
106
- return { text: "", exitedZero: false };
107
- }
108
- }
109
- /** Runs the fallback ladder above against `binPath` and classifies whatever
110
- * text the LAST attempted flag produced. Never throws -- every failure mode
111
- * (spawn error, timeout, empty output, an exit code the caller does not
112
- * recognise) flows through to classifyHelpOutput() as ordinary text, which
113
- * itself never throws either. */
114
- export function probeBackend(binPath, deps = {}) {
115
- const spawnHelp = deps.spawnHelp ?? defaultSpawnHelp;
116
- let text = "";
117
- for (const flag of HELP_FLAG_CANDIDATES) {
118
- const outcome = spawnHelp(binPath, flag);
119
- text = outcome.text;
120
- if (outcome.exitedZero || text.trim() !== "")
121
- break;
122
- }
123
- return classifyHelpOutput(text);
124
- }
125
52
  /** The client-side capability-decision schema version stamped into every
126
53
  * capability record this module writes (see BackendCacheRecord's own field
127
54
  * comment). BUMP THIS whenever the client's capability probing or the
@@ -165,8 +92,7 @@ function readCacheRecord(supervisorDir) {
165
92
  return null;
166
93
  if (typeof parsed.resolvedPath !== "string" ||
167
94
  typeof parsed.mtimeMs !== "number" ||
168
- typeof parsed.sizeBytes !== "number" ||
169
- (parsed.backend !== "fork" && parsed.backend !== "stock")) {
95
+ typeof parsed.sizeBytes !== "number") {
170
96
  return null;
171
97
  }
172
98
  const record = {
@@ -174,7 +100,6 @@ function readCacheRecord(supervisorDir) {
174
100
  resolvedPath: parsed.resolvedPath,
175
101
  mtimeMs: parsed.mtimeMs,
176
102
  sizeBytes: parsed.sizeBytes,
177
- backend: parsed.backend,
178
103
  probedAt: typeof parsed.probedAt === "string" ? parsed.probedAt : "",
179
104
  };
180
105
  if (typeof parsed.versionQuad === "string")
@@ -186,10 +111,10 @@ function readCacheRecord(supervisorDir) {
186
111
  return record;
187
112
  }
188
113
  /** Tmp-sibling -> chmod 0600 -> content -> rename, the SAME atomic-write
189
- * discipline refresh-manifest.ts's writeManifestAtomic() and vice-broker.mts's
190
- * writeBrokerRecordFile() both already use -- a crash mid-write can only ever
191
- * leave a stray tmp sibling behind, never a truncated or empty file at the
192
- * real cache path that a later read would wrongly accept. */
114
+ * discipline vice-broker.mts's writeBrokerRecordFile() already uses -- a
115
+ * crash mid-write can only ever leave a stray tmp sibling behind, never a
116
+ * truncated or empty file at the real cache path that a later read would
117
+ * wrongly accept. */
193
118
  function writeCacheRecordAtomic(supervisorDir, record) {
194
119
  mkdirSync(supervisorDir, { recursive: true });
195
120
  const finalPath = cachePathFor(supervisorDir);
@@ -223,133 +148,73 @@ function defaultStat(resolvedPath) {
223
148
  return null;
224
149
  }
225
150
  }
226
- function defaultLog(line) {
227
- process.stderr.write(`${line}\n`);
228
- }
229
- // Memoised answer for the probe/cache path ONLY -- the override path
230
- // (VICE_BACKEND set) is always answered fresh, on every call, straight from
231
- // the environment, and never touches this memo (an explicit override can
232
- // legitimately differ from call to call within, e.g., a test process driving
233
- // many scenarios; the detected-backend answer for a fixed binary cannot).
234
- // This is what makes resolvedBackend() answer "once per long-running
235
- // process" for the case that actually spawns something, while never
236
- // requiring a caller to somehow signal "this is a fresh scenario" the way
237
- // container-guard.mts's isInsideContainer() asks callers to pass explicit
238
- // deps to bypass ITS OWN memo -- here, the override/no-override distinction
239
- // already IS that signal.
151
+ // Memoised answer -- a long-running process (the real broker) resolves once
152
+ // per process lifetime; a test suite driving many scenarios calls
153
+ // resetResolvedBackendForTests() between them (see that function's own
154
+ // comment).
240
155
  let memoisedResult = null;
241
- // D-06: gates the "detected backend X for binary Y" stderr note so a
242
- // long-running broker (or a test suite driving resolvedBackend() many times)
243
- // emits it at most once per process -- repo-root.ts's warnedEnvOutsideFrom/
244
- // warnedNoMarkerFound pattern, reused here verbatim.
245
- let warnedBackendUnset = false;
246
- function emitDetectedNote(result, viceBin, log) {
247
- if (warnedBackendUnset)
248
- return;
249
- warnedBackendUnset = true;
250
- log(`vice-broker: detected backend "${result.backend}" for ${viceBin} (source: ${result.source}) -- ` +
251
- `set VICE_BACKEND=stock or VICE_BACKEND=fork to override this detection explicitly`);
252
- }
253
- /** Test-only escape hatch: clears the in-process memo and the D-06
254
- * one-time-note gate. Never called by any real production code path --
255
- * vice-broker.mts calls resolvedBackend() exactly once per real process
256
- * lifetime and has no reason to ever reset it; this exists solely so
257
- * backend-detect.test.ts can drive many distinct scenarios (cache hit, cache
258
- * miss, indeterminate, ...) in one shared test process without one
259
- * scenario's memoised answer contaminating the next -- mirroring
260
- * broker-launch.test.ts's own discipline of restoring module-level state
261
- * between test cases, made explicit here rather than left to careful test
262
- * ordering, since this module's memo (unlike buildViceArgs()'s one-time
263
- * note) has no natural "always widens the same way" ordering to exploit. */
156
+ /** Test-only escape hatch: clears the in-process memo. Never called by any
157
+ * real production code path -- vice-broker.mts calls resolvedBackend()
158
+ * exactly once per real process lifetime and has no reason to ever reset it;
159
+ * this exists solely so backend-detect.test.ts can drive many distinct
160
+ * scenarios (fresh identity, matching cached identity, changed identity, ...)
161
+ * in one shared test process without one scenario's memoised answer
162
+ * contaminating the next. */
264
163
  export function resetResolvedBackendForTests() {
265
164
  memoisedResult = null;
266
- warnedBackendUnset = false;
267
165
  }
268
- /** Honours VICE_BACKEND FIRST, returning immediately without spawning
269
- * anything when it names `stock` or `fork` (BACK-01: one optional config
270
- * value switches backends, no code edit). Otherwise consults the on-disk
271
- * cache (when `supervisorDir` is given and the binary's current
272
- * `{ resolvedPath, mtimeMs, sizeBytes }` all match the stored record); on a
273
- * miss, probes via probeBackend() and writes the cache. Memoises the
274
- * probe/cache answer in a module-level variable so a long-running process
275
- * resolves once (see the memo's own comment above for what "once" means
276
- * here). Never throws: a probe that classifies "unknown" (including a
277
- * spawn failure or a timeout, both of which probeBackend() already reduces
278
- * to "unknown") returns a defined `{ backend: "fork", source:
279
- * "indeterminate", ... }` outcome instead -- "fork" because that is the
280
- * pre-Phase-2 behaviour every existing install already has, so an
281
- * undetectable binary degrades to what already worked rather than to
282
- * nothing. */
283
- /** WR-05: the ONE place `binPath`/`binPathResolved` are derived, so the four
284
- * return paths below cannot disagree about what "the binary" means. A resolved
285
- * absolute path when there is one; the configured name, flagged as unresolved,
286
- * when there is not. */
166
+ /** WR-05: the ONE place `binPath`/`binPathResolved` are derived, so a
167
+ * resolved absolute path when there is one, or the configured name flagged
168
+ * as unresolved when there is not, is computed identically everywhere this
169
+ * file returns it. */
287
170
  function binPathFields(resolvedPath, viceBin) {
288
171
  return resolvedPath !== null ? { binPath: resolvedPath, binPathResolved: true } : { binPath: viceBin, binPathResolved: false };
289
172
  }
173
+ /** Resolves the emulator binary's identity and (re)initialises the on-disk
174
+ * identity/capability cache record for it when a `supervisorDir` is given.
175
+ * Memoises the answer in a module-level variable so a long-running process
176
+ * resolves once (see the memo's own comment above for what "once" means
177
+ * here). Never throws.
178
+ *
179
+ * FORKRM-01: there is nothing left to detect -- `backend` is always
180
+ * `"stock"`. What this function still does is identity resolution (WR-05's
181
+ * `binPath`/`binPathResolved`) and cache bookkeeping for BACK-04's capability
182
+ * record: when the resolved binary's identity (`resolvedPath`/`mtimeMs`/
183
+ * `sizeBytes`) does not match whatever is already on file -- no record at
184
+ * all, or a record describing a DIFFERENT binary (replaced in place, or a
185
+ * fresh install) -- a fresh identity record is written, with no stale
186
+ * capability fields carried over from a different binary's answer. A
187
+ * matching identity is left untouched, so a capability answer already
188
+ * recorded for THIS binary survives. */
290
189
  export function resolvedBackend(deps = {}) {
190
+ if (memoisedResult !== null)
191
+ return memoisedResult;
291
192
  const env = deps.env ?? process.env;
292
193
  const viceBin = deps.viceBin ?? env.VICE_BIN ?? "x64sc";
293
- // A direct read of the real environment on the right of this ternary
294
- // (rather than the generic `env` local above) is deliberate: this file is
295
- // grep-gated, tree-wide, as the ONE place that ever names this variable
296
- // directly against the real environment -- `deps.env` (the test-injection
297
- // seam) still takes precedence when supplied, exactly like every other
298
- // field on this options object.
299
- const override = deps.env ? deps.env.VICE_BACKEND : process.env.VICE_BACKEND;
300
194
  const resolveBinPath = deps.resolveBinPath ?? defaultResolveBinPath;
301
- if (override === "stock" || override === "fork") {
302
- // WR-05: an explicit backend override still resolves the PATH, so
303
- // `vice_ping` reports a real file rather than the bare name. This is a
304
- // filesystem lookup only -- existsSync per PATH entry -- and NEVER a spawn,
305
- // so the override path keeps its "answered fresh, straight from the
306
- // environment, spawns nothing" property.
307
- return { backend: override, source: "override", ...binPathFields(resolveBinPath(viceBin, env), viceBin) };
308
- }
309
- if (memoisedResult !== null)
310
- return memoisedResult;
311
- const log = deps.log ?? defaultLog;
312
195
  const stat = deps.stat ?? defaultStat;
313
- const probe = deps.probe ?? ((bin) => probeBackend(bin));
314
196
  const now = deps.now ?? (() => Date.now());
315
197
  const resolvedPath = resolveBinPath(viceBin, env);
316
198
  const identity = resolvedPath ? stat(resolvedPath) : null;
317
199
  const cacheEligible = resolvedPath !== null && identity !== null && typeof deps.supervisorDir === "string";
318
200
  if (cacheEligible) {
319
- const cached = readCacheRecord(deps.supervisorDir);
320
- if (cached &&
321
- cached.resolvedPath === resolvedPath &&
322
- cached.mtimeMs === identity.mtimeMs &&
323
- cached.sizeBytes === identity.sizeBytes) {
324
- const result = { backend: cached.backend, source: "cache", ...binPathFields(resolvedPath, viceBin) };
325
- memoisedResult = result;
326
- emitDetectedNote(result, viceBin, log);
327
- return result;
201
+ const existing = readCacheRecord(deps.supervisorDir);
202
+ const identityMatches = existing !== null &&
203
+ existing.resolvedPath === resolvedPath &&
204
+ existing.mtimeMs === identity.mtimeMs &&
205
+ existing.sizeBytes === identity.sizeBytes;
206
+ if (!identityMatches) {
207
+ writeCacheRecordAtomic(deps.supervisorDir, {
208
+ version: 1,
209
+ resolvedPath: resolvedPath,
210
+ mtimeMs: identity.mtimeMs,
211
+ sizeBytes: identity.sizeBytes,
212
+ probedAt: new Date(now()).toISOString(),
213
+ });
328
214
  }
329
215
  }
330
- const verdict = probe(viceBin);
331
- if (verdict === "unknown") {
332
- const note = `vice-broker: could not determine whether ${viceBin} is the stock or fork VICE build -- ` +
333
- `its --help output matched neither the -mcpserver nor the -binarymonitor discriminator. ` +
334
- `Set VICE_BACKEND=stock or VICE_BACKEND=fork explicitly.`;
335
- log(note);
336
- const result = { backend: "fork", source: "indeterminate", ...binPathFields(resolvedPath, viceBin), note };
337
- memoisedResult = result;
338
- return result;
339
- }
340
- if (cacheEligible) {
341
- writeCacheRecordAtomic(deps.supervisorDir, {
342
- version: 1,
343
- resolvedPath: resolvedPath,
344
- mtimeMs: identity.mtimeMs,
345
- sizeBytes: identity.sizeBytes,
346
- backend: verdict,
347
- probedAt: new Date(now()).toISOString(),
348
- });
349
- }
350
- const result = { backend: verdict, source: "probe", ...binPathFields(resolvedPath, viceBin) };
216
+ const result = { backend: "stock", ...binPathFields(resolvedPath, viceBin) };
351
217
  memoisedResult = result;
352
- emitDetectedNote(result, viceBin, log);
353
218
  return result;
354
219
  }
355
220
  /** Reads whatever capability answers (BACK-04) are on record for `binPath`
@@ -383,15 +248,13 @@ export function readCapabilityRecord(binPath, deps = {}) {
383
248
  result.capabilitySchema = existing.capabilitySchema;
384
249
  return result;
385
250
  }
386
- /** Attaches `{ versionQuad, cpuHistoryAvailable }` to the EXISTING backend
387
- * verdict already on record for `binPath`'s resolved identity -- a no-op,
388
- * never a throw, when there is no such matching record yet (no supervisorDir
251
+ /** Attaches `{ versionQuad, cpuHistoryAvailable }` to the EXISTING identity
252
+ * record already on file for `binPath`'s resolved identity -- a no-op, never
253
+ * a throw, when there is no such matching record yet (no supervisorDir
389
254
  * given, the binary cannot be resolved or stat'd, or the cache names a
390
- * different binary or has no verdict at all). This function never invents a
391
- * backend verdict of its own: it can only EXTEND a record resolvedBackend()
392
- * already wrote, since a `--help` probe has no way to observe a version
393
- * quad and this function must not silently fabricate the field it did not
394
- * observe either. */
255
+ * different binary or has no record at all). This function never invents an
256
+ * identity record of its own: it can only EXTEND a record resolvedBackend()
257
+ * already wrote. */
395
258
  export function writeCapabilityRecord(binPath, capability, deps = {}) {
396
259
  if (typeof deps.supervisorDir !== "string")
397
260
  return;
@@ -412,7 +275,6 @@ export function writeCapabilityRecord(binPath, capability, deps = {}) {
412
275
  resolvedPath,
413
276
  mtimeMs: identity.mtimeMs,
414
277
  sizeBytes: identity.sizeBytes,
415
- backend: existing.backend,
416
278
  probedAt: existing.probedAt,
417
279
  versionQuad: capability.versionQuad,
418
280
  cpuHistoryAvailable: capability.cpuHistoryAvailable,