@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.
- package/README.md +4 -3
- package/THIRD-PARTY-NOTICES.md +423 -25
- package/{r2000-acme-ident.ts → anno-acme-ident.ts} +13 -13
- package/anno-bank.ts +171 -0
- package/anno-cli.ts +3040 -0
- package/{r2000-confidence.ts → anno-confidence.ts} +22 -22
- package/anno-coverage.ts +2465 -0
- package/anno-derive.ts +590 -0
- package/anno-details.ts +169 -0
- package/anno-enum-gen.ts +919 -0
- package/anno-export-asm.ts +2396 -0
- package/anno-graphics.ts +338 -0
- package/anno-hazard-report.ts +1367 -0
- package/anno-import.ts +495 -0
- package/anno-index.ts +150 -0
- package/anno-join.ts +480 -0
- package/{r2000-memmap-render.ts → anno-memmap-render.ts} +236 -95
- package/anno-provenance-ledger.ts +472 -0
- package/{r2000-regbits-gen.ts → anno-regbits-gen.ts} +20 -15
- package/{r2000-regbits.json → anno-regbits.json} +2 -2
- package/anno-register.ts +399 -0
- package/anno-store-export.ts +661 -0
- package/anno-store.ts +4002 -0
- package/anno-symbols.ts +266 -0
- package/anno-tools.ts +3264 -0
- package/anno-types.ts +1909 -0
- package/backend-detect.mts +124 -312
- package/block-class.ts +201 -0
- package/build.ts +4 -2
- package/capture-predicate.ts +597 -0
- package/channel-lock.ts +349 -0
- package/disasm-decoder.ts +14 -14
- package/disasm-opcodes.ts +4 -4
- package/disasm-renderer.ts +2 -2
- package/evid-ingest.ts +217 -0
- package/evid-reconcile.ts +316 -0
- package/host-tool-client.ts +430 -0
- package/hostpath.ts +1 -1
- package/incident-record.ts +23 -12
- package/install-resources.ts +30 -14
- package/memmap-lookup.ts +285 -0
- package/package.json +48 -23
- package/prg-image.ts +118 -0
- package/repo-root.ts +107 -8
- package/resources/backend-detect.mjs +98 -236
- package/resources/broker-control.mjs +189 -16
- package/resources/broker-epoch.mjs +1 -1
- package/resources/broker-kill.mjs +8 -2
- package/resources/broker-launch.mjs +373 -214
- package/resources/broker-state.mjs +64 -18
- package/resources/container-guard.mjs +1 -1
- package/resources/ghidra-project.mjs +790 -0
- package/resources/host-tool.mjs +2561 -0
- package/resources/vice-broker.mjs +330 -184
- package/resources/vice-launcher.sh +130 -12
- package/stock-address.ts +6 -6
- package/stock-cia.ts +2 -2
- package/stock-condition.ts +8 -8
- package/stock-connect.ts +10 -6
- package/stock-derived.ts +29 -37
- package/stock-diagnose.ts +200 -36
- package/stock-dispatch.ts +200 -68
- package/stock-execution.ts +5 -3
- package/stock-handler.ts +1 -1
- package/stock-input.ts +9 -9
- package/stock-machine.ts +17 -6
- package/stock-paths.ts +18 -14
- package/stock-petscii.ts +1 -1
- package/stock-protocol.ts +17 -12
- package/stock-recycle.ts +83 -2
- package/stock-registers.ts +54 -29
- package/stock-reproducible-run.ts +811 -0
- package/stock-run-until.ts +100 -1
- package/stock-sprites.ts +3 -3
- package/stock-symbols.ts +13 -13
- package/stock-timing.ts +2 -2
- package/stock-vicii.ts +1 -1
- package/stop-oracle.ts +167 -0
- package/text-capability-probe.ts +660 -0
- package/text-connect.ts +157 -0
- package/text-protocol.ts +810 -0
- package/text-tools.ts +778 -0
- package/textmon-backtrace.ts +385 -0
- package/textmon-cpuhistory.ts +335 -0
- package/textmon-memmap.ts +494 -0
- package/textmon-profile.ts +458 -0
- package/textmon-registers.ts +748 -0
- package/tools-manifest.stock.json +864 -3
- package/version.ts +1 -1
- package/vice-broker-client.ts +189 -42
- package/vice-errors.ts +268 -0
- package/vice-proxy.ts +392 -2175
- package/vsf-slice.ts +640 -0
- package/capability-registry.ts +0 -388
- package/r2000-cli.ts +0 -1103
- package/r2000-d64.ts +0 -310
- package/r2000-enum-gen.ts +0 -574
- package/r2000-launch.ts +0 -357
- package/r2000-mcp-client.ts +0 -596
- package/r2000-project.ts +0 -190
- package/r2000-symbols.ts +0 -388
- package/r2000-tools.ts +0 -914
- package/r2000-verify.ts +0 -184
- package/refresh-manifest.ts +0 -124
- package/tools-manifest.json +0 -1223
- package/vice-probe.ts +0 -278
- package/vice-sync.ts +0 -336
- 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
|
|
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
|
|
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 (
|
|
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
|
|
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(
|
|
172
|
-
*
|
|
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(
|
|
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
|
-
//
|
|
10
|
-
//
|
|
11
|
-
//
|
|
12
|
-
//
|
|
13
|
-
//
|
|
14
|
-
//
|
|
15
|
-
//
|
|
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
|
-
//
|
|
21
|
-
// -
|
|
22
|
-
//
|
|
23
|
-
//
|
|
24
|
-
//
|
|
25
|
-
//
|
|
26
|
-
//
|
|
27
|
-
//
|
|
28
|
-
//
|
|
29
|
-
//
|
|
30
|
-
//
|
|
31
|
-
//
|
|
32
|
-
//
|
|
33
|
-
//
|
|
34
|
-
//
|
|
35
|
-
//
|
|
36
|
-
//
|
|
37
|
-
//
|
|
38
|
-
//
|
|
39
|
-
//
|
|
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
|
-
//
|
|
42
|
-
//
|
|
43
|
-
//
|
|
44
|
-
//
|
|
45
|
-
//
|
|
46
|
-
//
|
|
47
|
-
//
|
|
48
|
-
//
|
|
49
|
-
//
|
|
50
|
-
//
|
|
51
|
-
//
|
|
52
|
-
//
|
|
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
|
|
190
|
-
*
|
|
191
|
-
*
|
|
192
|
-
*
|
|
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
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
//
|
|
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
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
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
|
-
/**
|
|
269
|
-
*
|
|
270
|
-
*
|
|
271
|
-
*
|
|
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
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
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
|
|
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
|
|
387
|
-
*
|
|
388
|
-
*
|
|
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
|
|
391
|
-
*
|
|
392
|
-
* already wrote
|
|
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,
|