@henols/vice-mcp 0.1.9 → 0.1.11
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +15 -0
- package/THIRD-PARTY-NOTICES.md +113 -0
- package/backend-detect.mts +595 -0
- package/build.ts +1 -0
- package/disasm-decoder.ts +248 -0
- package/disasm-opcodes.ts +464 -0
- package/disasm-renderer.ts +306 -0
- package/package.json +27 -2
- package/refresh-manifest.ts +23 -2
- package/resources/backend-detect.mjs +396 -0
- package/resources/broker-control.mjs +110 -8
- package/resources/broker-kill.mjs +114 -103
- package/resources/broker-launch.mjs +334 -19
- package/resources/broker-state.mjs +11 -0
- package/resources/vice-broker.mjs +185 -14
- package/stock-address.ts +219 -0
- package/stock-checkpoints.ts +794 -0
- package/stock-condition.ts +636 -0
- package/stock-connect.ts +427 -0
- package/stock-derived.ts +122 -0
- package/stock-disassemble.ts +252 -0
- package/stock-dispatch.ts +640 -0
- package/stock-execution.ts +327 -0
- package/stock-handler.ts +175 -0
- package/stock-input.ts +274 -0
- package/stock-machine.ts +357 -0
- package/stock-memory.ts +323 -0
- package/stock-paths.ts +191 -0
- package/stock-petscii.ts +143 -0
- package/stock-protocol.ts +2057 -0
- package/stock-registers.ts +324 -0
- package/stock-runstate.ts +104 -0
- package/tools-manifest.json +1 -9
- package/tools-manifest.stock.json +841 -0
- package/vice-broker-client.ts +233 -7
- package/vice-proxy.ts +202 -17
|
@@ -0,0 +1,396 @@
|
|
|
1
|
+
// GENERATED FILE -- DO NOT EDIT.
|
|
2
|
+
// Compiled by `tsc` from backend-detect.mts. Edit the TypeScript source and rebuild;
|
|
3
|
+
// changes made directly to this file are silently overwritten by the next build, and are never
|
|
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
|
|
6
|
+
// rebuild.
|
|
7
|
+
// backend-detect.mts
|
|
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).
|
|
19
|
+
//
|
|
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.
|
|
40
|
+
//
|
|
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";
|
|
58
|
+
import { existsSync, readFileSync, writeFileSync, chmodSync, renameSync, mkdirSync, statSync, } from "node:fs";
|
|
59
|
+
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
|
+
function isPlainObject(value) {
|
|
126
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
127
|
+
}
|
|
128
|
+
function cachePathFor(supervisorDir) {
|
|
129
|
+
return join(supervisorDir, "backend.json");
|
|
130
|
+
}
|
|
131
|
+
/** Reads and narrows the cache file at the boundary with isPlainObject()-
|
|
132
|
+
* style checks, never a cast -- absent, unreadable, unparseable, or
|
|
133
|
+
* wrong-shaped (missing/mistyped required fields) all collapse to `null`,
|
|
134
|
+
* treated identically as a cache MISS, never as an error this function
|
|
135
|
+
* surfaces to its caller. */
|
|
136
|
+
function readCacheRecord(supervisorDir) {
|
|
137
|
+
let raw;
|
|
138
|
+
try {
|
|
139
|
+
raw = readFileSync(cachePathFor(supervisorDir), "utf8");
|
|
140
|
+
}
|
|
141
|
+
catch {
|
|
142
|
+
return null;
|
|
143
|
+
}
|
|
144
|
+
let parsed;
|
|
145
|
+
try {
|
|
146
|
+
parsed = JSON.parse(raw);
|
|
147
|
+
}
|
|
148
|
+
catch {
|
|
149
|
+
return null;
|
|
150
|
+
}
|
|
151
|
+
if (!isPlainObject(parsed))
|
|
152
|
+
return null;
|
|
153
|
+
if (typeof parsed.resolvedPath !== "string" ||
|
|
154
|
+
typeof parsed.mtimeMs !== "number" ||
|
|
155
|
+
typeof parsed.sizeBytes !== "number" ||
|
|
156
|
+
(parsed.backend !== "fork" && parsed.backend !== "stock")) {
|
|
157
|
+
return null;
|
|
158
|
+
}
|
|
159
|
+
const record = {
|
|
160
|
+
version: 1,
|
|
161
|
+
resolvedPath: parsed.resolvedPath,
|
|
162
|
+
mtimeMs: parsed.mtimeMs,
|
|
163
|
+
sizeBytes: parsed.sizeBytes,
|
|
164
|
+
backend: parsed.backend,
|
|
165
|
+
probedAt: typeof parsed.probedAt === "string" ? parsed.probedAt : "",
|
|
166
|
+
};
|
|
167
|
+
if (typeof parsed.versionQuad === "string")
|
|
168
|
+
record.versionQuad = parsed.versionQuad;
|
|
169
|
+
if (typeof parsed.cpuHistoryAvailable === "boolean")
|
|
170
|
+
record.cpuHistoryAvailable = parsed.cpuHistoryAvailable;
|
|
171
|
+
return record;
|
|
172
|
+
}
|
|
173
|
+
/** Tmp-sibling -> chmod 0600 -> content -> rename, the SAME atomic-write
|
|
174
|
+
* discipline refresh-manifest.ts's writeManifestAtomic() and vice-broker.mts's
|
|
175
|
+
* writeBrokerRecordFile() both already use -- a crash mid-write can only ever
|
|
176
|
+
* leave a stray tmp sibling behind, never a truncated or empty file at the
|
|
177
|
+
* real cache path that a later read would wrongly accept. */
|
|
178
|
+
function writeCacheRecordAtomic(supervisorDir, record) {
|
|
179
|
+
mkdirSync(supervisorDir, { recursive: true });
|
|
180
|
+
const finalPath = cachePathFor(supervisorDir);
|
|
181
|
+
const tmpPath = `${finalPath}.tmp-${process.pid}-${Date.now()}`;
|
|
182
|
+
writeFileSync(tmpPath, "");
|
|
183
|
+
chmodSync(tmpPath, 0o600);
|
|
184
|
+
writeFileSync(tmpPath, JSON.stringify(record, null, 2) + "\n");
|
|
185
|
+
renameSync(tmpPath, finalPath);
|
|
186
|
+
}
|
|
187
|
+
function defaultResolveBinPath(bin, env) {
|
|
188
|
+
if (bin.includes("/")) {
|
|
189
|
+
const abs = resolvePath(bin);
|
|
190
|
+
return existsSync(abs) ? abs : null;
|
|
191
|
+
}
|
|
192
|
+
const pathEnv = env.PATH ?? "";
|
|
193
|
+
for (const dir of pathEnv.split(":")) {
|
|
194
|
+
if (!dir)
|
|
195
|
+
continue;
|
|
196
|
+
const candidate = join(dir, bin);
|
|
197
|
+
if (existsSync(candidate))
|
|
198
|
+
return candidate;
|
|
199
|
+
}
|
|
200
|
+
return null;
|
|
201
|
+
}
|
|
202
|
+
function defaultStat(resolvedPath) {
|
|
203
|
+
try {
|
|
204
|
+
const st = statSync(resolvedPath);
|
|
205
|
+
return { mtimeMs: st.mtimeMs, sizeBytes: st.size };
|
|
206
|
+
}
|
|
207
|
+
catch {
|
|
208
|
+
return null;
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
function defaultLog(line) {
|
|
212
|
+
process.stderr.write(`${line}\n`);
|
|
213
|
+
}
|
|
214
|
+
// Memoised answer for the probe/cache path ONLY -- the override path
|
|
215
|
+
// (VICE_BACKEND set) is always answered fresh, on every call, straight from
|
|
216
|
+
// the environment, and never touches this memo (an explicit override can
|
|
217
|
+
// legitimately differ from call to call within, e.g., a test process driving
|
|
218
|
+
// many scenarios; the detected-backend answer for a fixed binary cannot).
|
|
219
|
+
// This is what makes resolvedBackend() answer "once per long-running
|
|
220
|
+
// process" for the case that actually spawns something, while never
|
|
221
|
+
// requiring a caller to somehow signal "this is a fresh scenario" the way
|
|
222
|
+
// container-guard.mts's isInsideContainer() asks callers to pass explicit
|
|
223
|
+
// deps to bypass ITS OWN memo -- here, the override/no-override distinction
|
|
224
|
+
// already IS that signal.
|
|
225
|
+
let memoisedResult = null;
|
|
226
|
+
// D-06: gates the "detected backend X for binary Y" stderr note so a
|
|
227
|
+
// long-running broker (or a test suite driving resolvedBackend() many times)
|
|
228
|
+
// emits it at most once per process -- repo-root.ts's warnedEnvOutsideFrom/
|
|
229
|
+
// warnedNoMarkerFound pattern, reused here verbatim.
|
|
230
|
+
let warnedBackendUnset = false;
|
|
231
|
+
function emitDetectedNote(result, viceBin, log) {
|
|
232
|
+
if (warnedBackendUnset)
|
|
233
|
+
return;
|
|
234
|
+
warnedBackendUnset = true;
|
|
235
|
+
log(`vice-broker: detected backend "${result.backend}" for ${viceBin} (source: ${result.source}) -- ` +
|
|
236
|
+
`set VICE_BACKEND=stock or VICE_BACKEND=fork to override this detection explicitly`);
|
|
237
|
+
}
|
|
238
|
+
/** Test-only escape hatch: clears the in-process memo and the D-06
|
|
239
|
+
* one-time-note gate. Never called by any real production code path --
|
|
240
|
+
* vice-broker.mts calls resolvedBackend() exactly once per real process
|
|
241
|
+
* lifetime and has no reason to ever reset it; this exists solely so
|
|
242
|
+
* backend-detect.test.ts can drive many distinct scenarios (cache hit, cache
|
|
243
|
+
* miss, indeterminate, ...) in one shared test process without one
|
|
244
|
+
* scenario's memoised answer contaminating the next -- mirroring
|
|
245
|
+
* broker-launch.test.ts's own discipline of restoring module-level state
|
|
246
|
+
* between test cases, made explicit here rather than left to careful test
|
|
247
|
+
* ordering, since this module's memo (unlike buildViceArgs()'s one-time
|
|
248
|
+
* note) has no natural "always widens the same way" ordering to exploit. */
|
|
249
|
+
export function resetResolvedBackendForTests() {
|
|
250
|
+
memoisedResult = null;
|
|
251
|
+
warnedBackendUnset = false;
|
|
252
|
+
}
|
|
253
|
+
/** Honours VICE_BACKEND FIRST, returning immediately without spawning
|
|
254
|
+
* anything when it names `stock` or `fork` (BACK-01: one optional config
|
|
255
|
+
* value switches backends, no code edit). Otherwise consults the on-disk
|
|
256
|
+
* cache (when `supervisorDir` is given and the binary's current
|
|
257
|
+
* `{ resolvedPath, mtimeMs, sizeBytes }` all match the stored record); on a
|
|
258
|
+
* miss, probes via probeBackend() and writes the cache. Memoises the
|
|
259
|
+
* probe/cache answer in a module-level variable so a long-running process
|
|
260
|
+
* resolves once (see the memo's own comment above for what "once" means
|
|
261
|
+
* here). Never throws: a probe that classifies "unknown" (including a
|
|
262
|
+
* spawn failure or a timeout, both of which probeBackend() already reduces
|
|
263
|
+
* to "unknown") returns a defined `{ backend: "fork", source:
|
|
264
|
+
* "indeterminate", ... }` outcome instead -- "fork" because that is the
|
|
265
|
+
* pre-Phase-2 behaviour every existing install already has, so an
|
|
266
|
+
* undetectable binary degrades to what already worked rather than to
|
|
267
|
+
* nothing. */
|
|
268
|
+
/** WR-05: the ONE place `binPath`/`binPathResolved` are derived, so the four
|
|
269
|
+
* return paths below cannot disagree about what "the binary" means. A resolved
|
|
270
|
+
* absolute path when there is one; the configured name, flagged as unresolved,
|
|
271
|
+
* when there is not. */
|
|
272
|
+
function binPathFields(resolvedPath, viceBin) {
|
|
273
|
+
return resolvedPath !== null ? { binPath: resolvedPath, binPathResolved: true } : { binPath: viceBin, binPathResolved: false };
|
|
274
|
+
}
|
|
275
|
+
export function resolvedBackend(deps = {}) {
|
|
276
|
+
const env = deps.env ?? process.env;
|
|
277
|
+
const viceBin = deps.viceBin ?? env.VICE_BIN ?? "x64sc";
|
|
278
|
+
// A direct read of the real environment on the right of this ternary
|
|
279
|
+
// (rather than the generic `env` local above) is deliberate: this file is
|
|
280
|
+
// grep-gated, tree-wide, as the ONE place that ever names this variable
|
|
281
|
+
// directly against the real environment -- `deps.env` (the test-injection
|
|
282
|
+
// seam) still takes precedence when supplied, exactly like every other
|
|
283
|
+
// field on this options object.
|
|
284
|
+
const override = deps.env ? deps.env.VICE_BACKEND : process.env.VICE_BACKEND;
|
|
285
|
+
const resolveBinPath = deps.resolveBinPath ?? defaultResolveBinPath;
|
|
286
|
+
if (override === "stock" || override === "fork") {
|
|
287
|
+
// WR-05: an explicit backend override still resolves the PATH, so
|
|
288
|
+
// `vice_ping` reports a real file rather than the bare name. This is a
|
|
289
|
+
// filesystem lookup only -- existsSync per PATH entry -- and NEVER a spawn,
|
|
290
|
+
// so the override path keeps its "answered fresh, straight from the
|
|
291
|
+
// environment, spawns nothing" property.
|
|
292
|
+
return { backend: override, source: "override", ...binPathFields(resolveBinPath(viceBin, env), viceBin) };
|
|
293
|
+
}
|
|
294
|
+
if (memoisedResult !== null)
|
|
295
|
+
return memoisedResult;
|
|
296
|
+
const log = deps.log ?? defaultLog;
|
|
297
|
+
const stat = deps.stat ?? defaultStat;
|
|
298
|
+
const probe = deps.probe ?? ((bin) => probeBackend(bin));
|
|
299
|
+
const now = deps.now ?? (() => Date.now());
|
|
300
|
+
const resolvedPath = resolveBinPath(viceBin, env);
|
|
301
|
+
const identity = resolvedPath ? stat(resolvedPath) : null;
|
|
302
|
+
const cacheEligible = resolvedPath !== null && identity !== null && typeof deps.supervisorDir === "string";
|
|
303
|
+
if (cacheEligible) {
|
|
304
|
+
const cached = readCacheRecord(deps.supervisorDir);
|
|
305
|
+
if (cached &&
|
|
306
|
+
cached.resolvedPath === resolvedPath &&
|
|
307
|
+
cached.mtimeMs === identity.mtimeMs &&
|
|
308
|
+
cached.sizeBytes === identity.sizeBytes) {
|
|
309
|
+
const result = { backend: cached.backend, source: "cache", ...binPathFields(resolvedPath, viceBin) };
|
|
310
|
+
memoisedResult = result;
|
|
311
|
+
emitDetectedNote(result, viceBin, log);
|
|
312
|
+
return result;
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
const verdict = probe(viceBin);
|
|
316
|
+
if (verdict === "unknown") {
|
|
317
|
+
const note = `vice-broker: could not determine whether ${viceBin} is the stock or fork VICE build -- ` +
|
|
318
|
+
`its --help output matched neither the -mcpserver nor the -binarymonitor discriminator. ` +
|
|
319
|
+
`Set VICE_BACKEND=stock or VICE_BACKEND=fork explicitly.`;
|
|
320
|
+
log(note);
|
|
321
|
+
const result = { backend: "fork", source: "indeterminate", ...binPathFields(resolvedPath, viceBin), note };
|
|
322
|
+
memoisedResult = result;
|
|
323
|
+
return result;
|
|
324
|
+
}
|
|
325
|
+
if (cacheEligible) {
|
|
326
|
+
writeCacheRecordAtomic(deps.supervisorDir, {
|
|
327
|
+
version: 1,
|
|
328
|
+
resolvedPath: resolvedPath,
|
|
329
|
+
mtimeMs: identity.mtimeMs,
|
|
330
|
+
sizeBytes: identity.sizeBytes,
|
|
331
|
+
backend: verdict,
|
|
332
|
+
probedAt: new Date(now()).toISOString(),
|
|
333
|
+
});
|
|
334
|
+
}
|
|
335
|
+
const result = { backend: verdict, source: "probe", ...binPathFields(resolvedPath, viceBin) };
|
|
336
|
+
memoisedResult = result;
|
|
337
|
+
emitDetectedNote(result, viceBin, log);
|
|
338
|
+
return result;
|
|
339
|
+
}
|
|
340
|
+
/** Reads whatever capability answers (BACK-04) are on record for `binPath`
|
|
341
|
+
* -- `null` when there is no cache at all, the binary cannot be resolved, the
|
|
342
|
+
* record on file names a DIFFERENT resolved binary, or nothing has been
|
|
343
|
+
* recorded for this binary yet. Never throws. */
|
|
344
|
+
export function readCapabilityRecord(binPath, deps = {}) {
|
|
345
|
+
if (typeof deps.supervisorDir !== "string")
|
|
346
|
+
return null;
|
|
347
|
+
const env = deps.env ?? process.env;
|
|
348
|
+
const resolveBinPath = deps.resolveBinPath ?? defaultResolveBinPath;
|
|
349
|
+
const resolvedPath = resolveBinPath(binPath, env);
|
|
350
|
+
if (!resolvedPath)
|
|
351
|
+
return null;
|
|
352
|
+
const existing = readCacheRecord(deps.supervisorDir);
|
|
353
|
+
if (!existing || existing.resolvedPath !== resolvedPath)
|
|
354
|
+
return null;
|
|
355
|
+
if (existing.versionQuad === undefined && existing.cpuHistoryAvailable === undefined)
|
|
356
|
+
return null;
|
|
357
|
+
const stale = deps.observedVersionQuad !== undefined &&
|
|
358
|
+
existing.versionQuad !== undefined &&
|
|
359
|
+
existing.versionQuad !== deps.observedVersionQuad;
|
|
360
|
+
return { versionQuad: existing.versionQuad, cpuHistoryAvailable: existing.cpuHistoryAvailable, stale };
|
|
361
|
+
}
|
|
362
|
+
/** Attaches `{ versionQuad, cpuHistoryAvailable }` to the EXISTING backend
|
|
363
|
+
* verdict already on record for `binPath`'s resolved identity -- a no-op,
|
|
364
|
+
* never a throw, when there is no such matching record yet (no supervisorDir
|
|
365
|
+
* given, the binary cannot be resolved or stat'd, or the cache names a
|
|
366
|
+
* different binary or has no verdict at all). This function never invents a
|
|
367
|
+
* backend verdict of its own: it can only EXTEND a record resolvedBackend()
|
|
368
|
+
* already wrote, since a `--help` probe has no way to observe a version
|
|
369
|
+
* quad and this function must not silently fabricate the field it did not
|
|
370
|
+
* observe either. */
|
|
371
|
+
export function writeCapabilityRecord(binPath, capability, deps = {}) {
|
|
372
|
+
if (typeof deps.supervisorDir !== "string")
|
|
373
|
+
return;
|
|
374
|
+
const env = deps.env ?? process.env;
|
|
375
|
+
const resolveBinPath = deps.resolveBinPath ?? defaultResolveBinPath;
|
|
376
|
+
const stat = deps.stat ?? defaultStat;
|
|
377
|
+
const resolvedPath = resolveBinPath(binPath, env);
|
|
378
|
+
if (!resolvedPath)
|
|
379
|
+
return;
|
|
380
|
+
const identity = stat(resolvedPath);
|
|
381
|
+
if (!identity)
|
|
382
|
+
return;
|
|
383
|
+
const existing = readCacheRecord(deps.supervisorDir);
|
|
384
|
+
if (!existing || existing.resolvedPath !== resolvedPath)
|
|
385
|
+
return;
|
|
386
|
+
writeCacheRecordAtomic(deps.supervisorDir, {
|
|
387
|
+
version: 1,
|
|
388
|
+
resolvedPath,
|
|
389
|
+
mtimeMs: identity.mtimeMs,
|
|
390
|
+
sizeBytes: identity.sizeBytes,
|
|
391
|
+
backend: existing.backend,
|
|
392
|
+
probedAt: existing.probedAt,
|
|
393
|
+
versionQuad: capability.versionQuad,
|
|
394
|
+
cpuHistoryAvailable: capability.cpuHistoryAvailable,
|
|
395
|
+
});
|
|
396
|
+
}
|
|
@@ -7,13 +7,19 @@
|
|
|
7
7
|
// broker-control.mts
|
|
8
8
|
//
|
|
9
9
|
// N / D-01 (plan 01, tracer): the framing, the token gate, and acquire/
|
|
10
|
-
// release.
|
|
10
|
+
// release. Plan 05 (task 1) completed the message set: recycle, status,
|
|
11
11
|
// host_state, the arrival-ordered pending-acquire structure, and the
|
|
12
|
-
// kernel-enforced singleton guard's low-level bind primitive.
|
|
13
|
-
//
|
|
14
|
-
//
|
|
15
|
-
//
|
|
16
|
-
//
|
|
12
|
+
// kernel-enforced singleton guard's low-level bind primitive. THIS PLAN's
|
|
13
|
+
// task 2 adds a SEVENTH and EIGHTH op, `monitor_claim`/`monitor_release`
|
|
14
|
+
// (BROK-02/PROTO-08, D-13): exclusive ownership of an instance's raw binmon
|
|
15
|
+
// socket, enforced here rather than left to a client-side heuristic --
|
|
16
|
+
// stock VICE services exactly one binmon client, and a second connect()
|
|
17
|
+
// produces no reply and no EOF, so the refusal must happen BEFORE any
|
|
18
|
+
// second dial is ever attempted. The subsystem's FIRST network listener: a
|
|
19
|
+
// TCP control plane replacing the bash broker's requests/grants/denials/
|
|
20
|
+
// leases directory tree entirely. One JSON object per line; the connection
|
|
21
|
+
// open IS the claim, connection close IS the release (T-01.6.2-01 through
|
|
22
|
+
// -09).
|
|
17
23
|
//
|
|
18
24
|
// Wire format confirmed at plan 01's blocking checkpoint:decision
|
|
19
25
|
// (2026-08-03, `as-specified`, no amendments -- see .planning/RE-FINDINGS.md
|
|
@@ -34,6 +40,14 @@ export function newControlToken() {
|
|
|
34
40
|
return randomBytes(32).toString("hex");
|
|
35
41
|
}
|
|
36
42
|
const MAX_LINE_BYTES = 65536;
|
|
43
|
+
/** CR-03: the one refusal wording for a target-naming op whose `target_id` is
|
|
44
|
+
* not the grant the asking connection itself holds. Deliberately worded as an
|
|
45
|
+
* authorisation refusal and NOT as an ownership conflict between two
|
|
46
|
+
* legitimate holders (`monitor_owned`, which names a holder) and never as an
|
|
47
|
+
* emulator fault -- see attachControlProtocol()'s own ownsTarget() comment,
|
|
48
|
+
* and T-02-18's prohibition on wedge/hang vocabulary in this file's
|
|
49
|
+
* monitor-op refusals. */
|
|
50
|
+
const MONITOR_OWNERSHIP_DENIAL = "monitor_claim/monitor_release may only target the grant this connection itself holds";
|
|
37
51
|
export function resolveControlPort(override) {
|
|
38
52
|
if (typeof override === "number")
|
|
39
53
|
return override;
|
|
@@ -147,6 +161,32 @@ function attachControlProtocol(server, opts, pendingAcquires) {
|
|
|
147
161
|
// Per-connection error handling isolates one peer's failure from
|
|
148
162
|
// every other connection and from the server itself (T-01.6.2-06).
|
|
149
163
|
});
|
|
164
|
+
/**
|
|
165
|
+
* CR-03 (code review 2026-08-13). THE per-connection ownership predicate
|
|
166
|
+
* every target-naming op is gated on -- the same rule `recycle` has
|
|
167
|
+
* enforced since T-01.6.2-31, now shared rather than copied.
|
|
168
|
+
*
|
|
169
|
+
* Before this existed, `monitor_claim`/`monitor_release` took `target_id`
|
|
170
|
+
* from the request and passed it straight through, so any connection
|
|
171
|
+
* holding the per-boot control token (which every container-side proxy
|
|
172
|
+
* sharing this broker does) could name ANOTHER session's grant id.
|
|
173
|
+
* vice-broker.mts's handleMonitorClaim() uses that id as BOTH the target
|
|
174
|
+
* and the claiming identity, and handleMonitorRelease()'s "only the
|
|
175
|
+
* holder may release" check compared the request against itself -- so
|
|
176
|
+
* session B could lock session A out of its own monitor socket, or
|
|
177
|
+
* RELEASE A's live claim, after which a third client was free to dial the
|
|
178
|
+
* same single-client binmon socket. That is precisely the unserviced-
|
|
179
|
+
* backlog state D-13 exists to prevent and that CLAUDE.md says must never
|
|
180
|
+
* be reachable.
|
|
181
|
+
*
|
|
182
|
+
* WHAT NOT TO DO: never add another op that acts on a caller-supplied
|
|
183
|
+
* `target_id` without gating it here first. The grant a connection holds
|
|
184
|
+
* is the ONLY identity this protocol has -- `target_id` is a request
|
|
185
|
+
* field, not a credential.
|
|
186
|
+
*/
|
|
187
|
+
function ownsTarget(targetId) {
|
|
188
|
+
return requestIdForThisConnection !== null && targetId === requestIdForThisConnection;
|
|
189
|
+
}
|
|
150
190
|
/** Attempts one acquire over THIS connection/socket, writing the
|
|
151
191
|
* terminal response (grant or a non-queueing error) when settled, or
|
|
152
192
|
* enqueueing itself and returning unsettled when a launch is already in
|
|
@@ -272,8 +312,10 @@ function attachControlProtocol(server, opts, pendingAcquires) {
|
|
|
272
312
|
// holds. This check happens here, before onRecycle() is ever
|
|
273
313
|
// called, so a mismatched target never reaches the kill discipline
|
|
274
314
|
// and never signals anything -- an injected signal recorder stays
|
|
275
|
-
// empty for this case.
|
|
276
|
-
|
|
315
|
+
// empty for this case. Now expressed through the SAME ownsTarget()
|
|
316
|
+
// predicate monitor_claim/monitor_release use (CR-03), so the three
|
|
317
|
+
// target-naming ops cannot drift apart.
|
|
318
|
+
if (!ownsTarget(targetId)) {
|
|
277
319
|
writeLine(socket, {
|
|
278
320
|
kind: "error",
|
|
279
321
|
code: "denied",
|
|
@@ -315,8 +357,68 @@ function attachControlProtocol(server, opts, pendingAcquires) {
|
|
|
315
357
|
warm_floor: hs.warmFloor,
|
|
316
358
|
max_instances: hs.maxInstances,
|
|
317
359
|
base_port: hs.basePort,
|
|
360
|
+
backend: hs.backend,
|
|
318
361
|
});
|
|
319
362
|
}
|
|
363
|
+
else if (req.op === "monitor_claim") {
|
|
364
|
+
const targetId = typeof req.target_id === "string" ? req.target_id : "";
|
|
365
|
+
if (targetId === "") {
|
|
366
|
+
writeLine(socket, { kind: "error", code: "bad_request", message: "monitor_claim requires target_id" });
|
|
367
|
+
return;
|
|
368
|
+
}
|
|
369
|
+
if (!ownsTarget(targetId)) {
|
|
370
|
+
writeLine(socket, { kind: "error", code: "denied", message: MONITOR_OWNERSHIP_DENIAL });
|
|
371
|
+
return;
|
|
372
|
+
}
|
|
373
|
+
const requestId = typeof req.id === "string" && req.id !== "" ? req.id : defaultRequestId("claim");
|
|
374
|
+
const outcome = opts.onMonitorClaim(requestId, targetId);
|
|
375
|
+
if (outcome.ok) {
|
|
376
|
+
writeLine(socket, { kind: "monitor_claimed" });
|
|
377
|
+
}
|
|
378
|
+
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).
|
|
382
|
+
//
|
|
383
|
+
// WR-08 (broker side): `holder` is REQUIRED by MonitorClaimOutcome for
|
|
384
|
+
// this code, but this handler runs inside socket.on("data") with no
|
|
385
|
+
// try/catch above it, so a producer that ever omitted it would throw a
|
|
386
|
+
// TypeError out of the control listener and take the broker process
|
|
387
|
+
// with it -- a type contract is not a runtime guarantee at a wire
|
|
388
|
+
// 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 };
|
|
392
|
+
writeLine(socket, {
|
|
393
|
+
kind: "error",
|
|
394
|
+
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`,
|
|
396
|
+
holder,
|
|
397
|
+
});
|
|
398
|
+
}
|
|
399
|
+
else {
|
|
400
|
+
writeLine(socket, { kind: "error", code: outcome.code, message: `monitor_claim failed: ${outcome.code}` });
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
else if (req.op === "monitor_release") {
|
|
404
|
+
const targetId = typeof req.target_id === "string" ? req.target_id : "";
|
|
405
|
+
if (targetId === "") {
|
|
406
|
+
writeLine(socket, { kind: "error", code: "bad_request", message: "monitor_release requires target_id" });
|
|
407
|
+
return;
|
|
408
|
+
}
|
|
409
|
+
if (!ownsTarget(targetId)) {
|
|
410
|
+
writeLine(socket, { kind: "error", code: "denied", message: MONITOR_OWNERSHIP_DENIAL });
|
|
411
|
+
return;
|
|
412
|
+
}
|
|
413
|
+
const requestId = typeof req.id === "string" && req.id !== "" ? req.id : defaultRequestId("release-monitor");
|
|
414
|
+
const outcome = opts.onMonitorRelease(requestId, targetId);
|
|
415
|
+
if (outcome.ok) {
|
|
416
|
+
writeLine(socket, { kind: "monitor_released" });
|
|
417
|
+
}
|
|
418
|
+
else {
|
|
419
|
+
writeLine(socket, { kind: "error", code: outcome.code, message: `monitor_release refused: ${outcome.code}` });
|
|
420
|
+
}
|
|
421
|
+
}
|
|
320
422
|
else {
|
|
321
423
|
writeLine(socket, { kind: "error", code: "bad_request", message: `unknown op: ${String(req.op)}` });
|
|
322
424
|
}
|