@henols/vice-mcp 0.2.2 → 0.2.4

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 (84) hide show
  1. package/README.md +2 -2
  2. package/THIRD-PARTY-NOTICES.md +422 -1
  3. package/anno-bank.ts +171 -0
  4. package/anno-cli.ts +1736 -163
  5. package/anno-confidence.ts +2 -2
  6. package/anno-derive.ts +6 -6
  7. package/anno-details.ts +4 -4
  8. package/anno-enum-gen.ts +416 -30
  9. package/anno-export-asm.ts +1211 -126
  10. package/anno-graphics.ts +338 -0
  11. package/anno-hazard-report.ts +1367 -0
  12. package/anno-import.ts +495 -0
  13. package/anno-index.ts +8 -8
  14. package/anno-join.ts +480 -0
  15. package/anno-memmap-render.ts +22 -21
  16. package/anno-provenance-ledger.ts +472 -0
  17. package/anno-regbits-gen.ts +13 -13
  18. package/anno-register.ts +159 -0
  19. package/anno-store-export.ts +661 -0
  20. package/anno-store.ts +635 -124
  21. package/anno-symbols.ts +7 -7
  22. package/anno-tools.ts +1169 -16
  23. package/anno-types.ts +313 -40
  24. package/backend-detect.mts +124 -312
  25. package/build.ts +3 -1
  26. package/capture-predicate.ts +597 -0
  27. package/channel-lock.ts +349 -0
  28. package/evid-ingest.ts +217 -0
  29. package/evid-reconcile.ts +316 -0
  30. package/host-tool-client.ts +430 -0
  31. package/incident-record.ts +23 -12
  32. package/install-resources.ts +29 -13
  33. package/memmap-lookup.ts +285 -0
  34. package/package.json +27 -8
  35. package/prg-image.ts +1 -2
  36. package/repo-root.ts +87 -3
  37. package/resources/backend-detect.mjs +98 -236
  38. package/resources/broker-control.mjs +220 -54
  39. package/resources/broker-epoch.mjs +7 -8
  40. package/resources/broker-kill.mjs +36 -31
  41. package/resources/broker-launch.mjs +511 -374
  42. package/resources/broker-state.mjs +69 -24
  43. package/resources/container-guard.mjs +1 -1
  44. package/resources/ghidra-project.mjs +790 -0
  45. package/resources/host-tool.mjs +2533 -0
  46. package/resources/vice-broker.mjs +434 -290
  47. package/resources/vice-launcher.sh +127 -9
  48. package/stock-address.ts +1 -1
  49. package/stock-condition.ts +1 -1
  50. package/stock-connect.ts +9 -5
  51. package/stock-derived.ts +29 -37
  52. package/stock-diagnose.ts +200 -36
  53. package/stock-dispatch.ts +179 -77
  54. package/stock-handler.ts +1 -1
  55. package/stock-paths.ts +18 -14
  56. package/stock-petscii.ts +1 -1
  57. package/stock-protocol.ts +1 -1
  58. package/stock-recycle.ts +83 -2
  59. package/stock-reproducible-run.ts +811 -0
  60. package/stock-run-until.ts +100 -1
  61. package/stock-symbols.ts +4 -4
  62. package/stock-timing.ts +1 -1
  63. package/stop-oracle.ts +167 -0
  64. package/text-capability-probe.ts +660 -0
  65. package/text-connect.ts +157 -0
  66. package/text-protocol.ts +810 -0
  67. package/text-tools.ts +778 -0
  68. package/textmon-backtrace.ts +385 -0
  69. package/textmon-cpuhistory.ts +335 -0
  70. package/textmon-memmap.ts +494 -0
  71. package/textmon-profile.ts +458 -0
  72. package/textmon-registers.ts +748 -0
  73. package/tools-manifest.stock.json +864 -3
  74. package/vice-broker-client.ts +253 -108
  75. package/vice-errors.ts +268 -0
  76. package/vice-proxy.ts +339 -2144
  77. package/vsf-slice.ts +640 -0
  78. package/anno-d64.ts +0 -310
  79. package/capability-registry.ts +0 -390
  80. package/refresh-manifest.ts +0 -124
  81. package/tools-manifest.json +0 -1223
  82. package/vice-probe.ts +0 -278
  83. package/vice-sync.ts +0 -336
  84. package/vice.ts +0 -772
@@ -0,0 +1,810 @@
1
+ #!/usr/bin/env node
2
+ // text-protocol.ts
3
+ //
4
+ // THE ONE place that frames the `-remotemonitor` TEXT-monitor wire's bytes --
5
+ // nothing else in this tree decodes text-monitor bytes. This is the text-wire
6
+ // sibling of stock-protocol.ts's ViceMonitorClient (the binary-monitor
7
+ // client): same private-field shape (#socket, #buffer, bound
8
+ // #onData/#onClose/#onError handlers, a `connected` getter), same
9
+ // concat-then-decode-once discipline, same "never leak a live socket on a
10
+ // second connect()" rule -- but the text wire has NO length-prefixed frame,
11
+ // NO request-id multiplexing, and NO api_version byte. One command is
12
+ // outstanding at a time; the reply is framed by a literal, human-readable
13
+ // prompt string VICE writes when it is ready for the next command. Because
14
+ // of that, this class deliberately DROPS stock-protocol.ts's
15
+ // #pending/#nextRequestId/#settledRing/#settledSet machinery entirely --
16
+ // there is nothing to correlate a reply against beyond "is a command
17
+ // outstanding right now".
18
+ //
19
+ // Plan 41-01: Task 1 shipped the happy path plus a quiescence window pulled
20
+ // forward from Task 2's original scope -- this task's own live acceptance
21
+ // criterion (a real `device c:` round trip against genuine stock VICE) was
22
+ // MEASURED to fail without it. A fresh connection's very FIRST command reply
23
+ // can arrive as TWO separate TCP chunks: a residual leading prompt
24
+ // (`(C:$xxxx) `) with no command output yet attached, then the real output.
25
+ // A resolve-on-first-tail-match design mistakes the first chunk alone for a
26
+ // complete (empty) response -- exactly the shape Control 2 (a tail match
27
+ // that is not really final) exists to catch, occurring naturally rather than
28
+ // needing to be planted. See the plan SUMMARY for the measured repro. Task 2
29
+ // adds: the passive banner drain (D-13(b)) for bytes arriving with no
30
+ // command outstanding, enforcement of TEXT_MAX_BUFFERED_LEN, and the full
31
+ // deterministic test suite (including a controlled RED/GREEN demonstration
32
+ // of both planted controls).
33
+ //
34
+ // WHAT NOT TO DO:
35
+ // - Never re-implement text-wire framing in a dispatcher, a tool handler,
36
+ // or channel-lock.ts -- this module is the ONE place it happens.
37
+ // - Never call command() outside withTextChannelLock() (plan 41-02,
38
+ // CHAN-04) -- command() itself refuses when channel-lock.ts's mutex is
39
+ // not currently held by the text channel, so a call site that bypasses
40
+ // withTextChannelLock() is refused, not silently allowed through.
41
+ // - Never widen stock-protocol.ts to also speak text. The binary and text
42
+ // wires are structurally different protocols (length-prefixed frames
43
+ // with a request-id demux vs. a free-text prompt terminator with no
44
+ // multiplexing at all) and belong in separate modules, mirroring this
45
+ // project's existing binary/text file split.
46
+ // - Never accept a caller-supplied, free-text command string anywhere in
47
+ // this module's public surface. VICE's text monitor accepts arbitrary
48
+ // monitor commands and is UNAUTHENTICATED -- it can `load` and `save`
49
+ // host files, exactly like the security caveat broker-launch.mts's own
50
+ // bind-widening warning already states for this same channel. Every
51
+ // outbound command must come from TEXT_COMMAND_ALLOWLIST below;
52
+ // command() refuses anything else BY NAME, before a single byte reaches
53
+ // the socket (D-01). The ONE stated, bounded exception (D-42-1, plan
54
+ // 42-04): TEXT_COMMAND_PARAM_SPECS lets exactly three of those eight
55
+ // verbs also carry a caller-chosen value, but that value is always a
56
+ // typed, bounded number -- never a string, never a rest-of-line
57
+ // passthrough, never a `params` field -- validated and rendered by
58
+ // buildTextCommand(), the ONE place such a string is built, and
59
+ // accepted as dialable only when isDialableTextCommandForVerb()'s own
60
+ // re-render round trip reproduces it byte-for-byte. This does not
61
+ // widen what "free-text command" means above; it narrows one bounded
62
+ // numeric slot per verb.
63
+ // - Never frame a response by a timeout, a byte count, or any fallback
64
+ // that hands back a plausible-looking partial payload. A response that
65
+ // cannot be honestly framed refuses by name (TextFramingError), naming
66
+ // what was actually observed -- never a guess.
67
+ import { EventEmitter } from "node:events";
68
+ import net from "node:net";
69
+
70
+ import { ViceError } from "./vice-errors.ts";
71
+ import { acquireChannelLock, currentChannelLockHolder } from "./channel-lock.ts";
72
+
73
+ // ---------------------------------------------------------------------------
74
+ // The prompt terminator and the closed command allowlist.
75
+ // ---------------------------------------------------------------------------
76
+
77
+ /**
78
+ * Matches the trailing `(C:$xxxx) ` prompt VICE's text monitor writes once it
79
+ * has finished producing a command's output and is ready for the next one.
80
+ * Anchored at the buffer's TAIL ($) deliberately: a command whose own output
81
+ * happens to CONTAIN prompt-shaped text partway through (the second planted
82
+ * control this phase's criterion names) must never be mistaken for the real
83
+ * terminator merely because the pattern occurs somewhere in the stream --
84
+ * only a match at the true end counts.
85
+ */
86
+ export const PROMPT_RE = /\(C:\$[0-9A-Fa-f]{4}\)\s*$/;
87
+
88
+ /**
89
+ * The closed set of verb strings this project may send over the text
90
+ * channel (D-01/D-02). `device c:` and `warp on`/`warp off` reach `tools/
91
+ * list` as their own MCP tools; the remaining five (`memmapshow`,
92
+ * `prof flat`, `chis`, `bt`, `io`) are reachable ONLY in-process through this
93
+ * allowlist -- covered by this file's own framing controls, but not yet
94
+ * exposed as tools -- until a later plan lands their owning text-format
95
+ * parsers. Frozen: a ninth entry is a conscious edit here, never a
96
+ * speculative widening, and never a free-text field accepting anything
97
+ * outside this list.
98
+ *
99
+ * `prof on`/`prof off` (plan 42-09, a conscious, measured widening, not a
100
+ * speculative one): live-testing `prof flat` against a genuinely fresh
101
+ * stock instance MEASURED that VICE's own profiler defaults to off --
102
+ * `prof flat` alone returns `"No profiling data available. Start profiling
103
+ * with \"prof on\"."`, never real rows, until `prof on` is issued first on
104
+ * that same machine. No handler in this tree issues `prof on` today (a
105
+ * real, separately-tracked production gap -- `vice_profile_flat` cannot
106
+ * yet produce real data against a freshly launched instance); these two
107
+ * verbs are added here ONLY so the live opt-in suite can toggle the
108
+ * profiler on before proving `prof flat`'s own parsing path, and off again
109
+ * afterward, leaving the toggle itself reachable but unused elsewhere.
110
+ *
111
+ * `memmapzap` (plan 43-01, a conscious, measured widening, not a speculative
112
+ * one): clears VICE's accumulated memory-access map (`mon_memmap_zap()`'s
113
+ * `memset()`, per the VICE Manual and `mon_memmap.c`) so a runtime-evidence
114
+ * measurement bracket starts from nothing rather than inheriting whatever
115
+ * `memmapshow` has accumulated since the instance booted. This is what lets
116
+ * the evidence layer arm a fresh bracket immediately before a run instead of
117
+ * subtracting a prior baseline after the fact. The sibling verb `memmapsave`
118
+ * is deliberately NOT added here -- it writes a host file, and this
119
+ * allowlist's own membership test (`text-protocol.test.ts`) refuses any
120
+ * entry whose name matches the pattern that spells the words `load` or
121
+ * `save`, the same file-touching-verb rule `device c:`, `warp on/off`,
122
+ * `memmapshow`, `prof flat`, `chis`, `bt`, `io` and `prof on/off` already
123
+ * satisfy.
124
+ */
125
+ export const TEXT_COMMAND_ALLOWLIST = Object.freeze([
126
+ "device c:",
127
+ "warp on",
128
+ "warp off",
129
+ "memmapshow",
130
+ "prof flat",
131
+ "chis",
132
+ "bt",
133
+ "io",
134
+ "prof on",
135
+ "prof off",
136
+ "memmapzap",
137
+ ] as const);
138
+
139
+ export type TextCommand = (typeof TEXT_COMMAND_ALLOWLIST)[number];
140
+
141
+ // ---------------------------------------------------------------------------
142
+ // Parameterized commands (D-42-1, plan 42-04). Three of the eight
143
+ // allowlisted verbs -- "chis", "prof flat", "io" -- were captured on the
144
+ // real wire carrying a caller-chosen value ("chis 4", "prof flat 5",
145
+ // "io $d020" -- see fixtures/textmon/{cpu-history,flat-profile,
146
+ // register-decode}-stock.json's own "command" field), so a bare literal
147
+ // alone cannot reach them meaningfully. TEXT_COMMAND_ALLOWLIST above is NOT
148
+ // widened for this: it stays exactly the eight frozen literals, and its own
149
+ // membership assertion is unaffected. TEXT_COMMAND_PARAM_SPECS is a SIBLING
150
+ // table describing, for the subset of verbs that take one, the bounded
151
+ // typed value each accepts and the ONE renderer that turns a validated
152
+ // value into the exact command string VICE was captured accepting.
153
+ // ---------------------------------------------------------------------------
154
+
155
+ /** The parameter kind a spec entry declares. "count" bounds a decimal
156
+ * row/entry count (chis, prof flat), rendered as the verb, one space, and
157
+ * the decimal digits with no padding and no leading zero. "address" bounds
158
+ * a 16-bit machine address (io), rendered as the verb, one space, a dollar
159
+ * sign, and exactly four lowercase hex digits, zero-padded -- the exact
160
+ * form the committed register-decode-stock fixture was captured with. */
161
+ export type TextCommandParamKind = "count" | "address";
162
+
163
+ export interface TextCommandParamSpec {
164
+ readonly kind: TextCommandParamKind;
165
+ readonly min: number;
166
+ readonly max: number;
167
+ readonly render: (value: number) => string;
168
+ }
169
+
170
+ function renderCountParam(verb: string, value: number): string {
171
+ return `${verb} ${value}`;
172
+ }
173
+
174
+ function renderAddressParam(verb: string, value: number): string {
175
+ return `${verb} $${value.toString(16).padStart(4, "0")}`;
176
+ }
177
+
178
+ /**
179
+ * Frozen, per-verb parameter specs (D-42-1). Keyed by the verb exactly as
180
+ * it appears in TEXT_COMMAND_ALLOWLIST above. A verb with no entry here
181
+ * takes no parameter -- it keeps dialing its bare frozen literal, unchanged
182
+ * (the three no-parameter verbs -- "device c:", "warp on", "warp off" --
183
+ * are deliberately absent). The count bound is 1 through 65535: one because
184
+ * a zero-row request is not a request, and 65535 because that is the same
185
+ * 16-bit domain the CPU-history count lives in on this machine
186
+ * (CPUHISTORY_GET's own count field, monitor_binary.c:1492). The address
187
+ * bound is 0 through 65535, the full 16-bit machine address space.
188
+ */
189
+ export const TEXT_COMMAND_PARAM_SPECS: Readonly<Record<string, TextCommandParamSpec>> = Object.freeze({
190
+ chis: Object.freeze({
191
+ kind: "count",
192
+ min: 1,
193
+ max: 65535,
194
+ render: (value: number) => renderCountParam("chis", value),
195
+ }),
196
+ "prof flat": Object.freeze({
197
+ kind: "count",
198
+ min: 1,
199
+ max: 65535,
200
+ render: (value: number) => renderCountParam("prof flat", value),
201
+ }),
202
+ io: Object.freeze({
203
+ kind: "address",
204
+ min: 0,
205
+ max: 65535,
206
+ render: (value: number) => renderAddressParam("io", value),
207
+ }),
208
+ } satisfies Record<string, TextCommandParamSpec>);
209
+
210
+ function isSafeIntegerNumber(value: unknown): value is number {
211
+ return typeof value === "number" && Number.isSafeInteger(value);
212
+ }
213
+
214
+ export type BuildTextCommandResult = { readonly ok: true; readonly command: string } | { readonly ok: false; readonly message: string };
215
+
216
+ /**
217
+ * THE ONE place a parameterized text-monitor command string is ever
218
+ * constructed (D-42-1). Looks `verb` up in TEXT_COMMAND_PARAM_SPECS and
219
+ * refuses BY NAME when it has no entry -- never falls through to a bare
220
+ * concatenation. Validates `value` as a number, a safe integer, and within
221
+ * the spec's inclusive bounds; a string that merely looks numeric is
222
+ * refused too -- the parameter's type is a number, and accepting a string
223
+ * here would be the first step back toward a free-text field. Returns a
224
+ * discriminated result rather than throwing (mirrors textmon-memmap.ts's
225
+ * D-42-3 discipline for the same reason): a refusal is a value the caller
226
+ * renders to the user, not an exception whose meaning a catch block has to
227
+ * guess.
228
+ */
229
+ export function buildTextCommand(verb: string, value: unknown): BuildTextCommandResult {
230
+ const spec = (TEXT_COMMAND_PARAM_SPECS as Record<string, TextCommandParamSpec | undefined>)[verb];
231
+ if (!spec) {
232
+ return {
233
+ ok: false,
234
+ message: `text-protocol: "${verb}" has no parameterized form -- refusing rather than concatenating a value onto an unrecognised verb`,
235
+ };
236
+ }
237
+ if (!isSafeIntegerNumber(value) || value < spec.min || value > spec.max) {
238
+ return {
239
+ ok: false,
240
+ message: `text-protocol: "${verb}" requires an integer between ${spec.min} and ${spec.max} (got ${JSON.stringify(value)})`,
241
+ };
242
+ }
243
+ return { ok: true, command: spec.render(value) };
244
+ }
245
+
246
+ /** Strict decimal-digits-only match for a "count" parameter's candidate
247
+ * text -- never Number()'s own permissive parsing (which accepts leading/
248
+ * trailing whitespace, a leading "+", scientific notation, etc.), because
249
+ * the ROUND TRIP below depends on rejecting anything the renderer itself
250
+ * would never have produced. */
251
+ function parseCountParamText(text: string): number | null {
252
+ if (!/^[0-9]+$/.test(text)) return null;
253
+ const n = Number(text);
254
+ return Number.isSafeInteger(n) ? n : null;
255
+ }
256
+
257
+ /** Strict `$` plus one-or-more hex-digit match for an "address" parameter's
258
+ * candidate text. Case-insensitive on input (parseInt handles that), but
259
+ * the round trip below still rejects an uppercase-hex rendering, because
260
+ * the RE-RENDER is always lowercase and compared with strict equality. */
261
+ function parseAddressParamText(text: string): number | null {
262
+ const match = /^\$([0-9A-Fa-f]+)$/.exec(text);
263
+ if (!match) return null;
264
+ const n = parseInt(match[1]!, 16);
265
+ return Number.isSafeInteger(n) ? n : null;
266
+ }
267
+
268
+ /**
269
+ * True when `cmd` is byte-identical to what verb's own renderer produces
270
+ * for the value `cmd` claims to carry (D-42-1). Implemented by extracting
271
+ * the candidate parameter text after the verb and its single separating
272
+ * space, parsing it back to a number under the spec's own kind, re-
273
+ * rendering through the same renderer, and comparing the result to `cmd`
274
+ * with strict equality. This round trip -- never a hand-written pattern --
275
+ * is what makes the accepted set exactly the canonical forms and rejects a
276
+ * doubled space, a trailing space, a leading zero, an uppercase rendering
277
+ * and an appended second parameter, without any of those needing to be
278
+ * enumerated individually.
279
+ */
280
+ export function isDialableTextCommandForVerb(verb: string, cmd: string): boolean {
281
+ const spec = (TEXT_COMMAND_PARAM_SPECS as Record<string, TextCommandParamSpec | undefined>)[verb];
282
+ if (!spec) return false;
283
+ const prefix = `${verb} `;
284
+ if (!cmd.startsWith(prefix)) return false;
285
+ const paramText = cmd.slice(prefix.length);
286
+ const parsed = spec.kind === "address" ? parseAddressParamText(paramText) : parseCountParamText(paramText);
287
+ if (parsed === null || parsed < spec.min || parsed > spec.max) return false;
288
+ return spec.render(parsed) === cmd;
289
+ }
290
+
291
+ /** The ONE place a string is checked against the dialable command set --
292
+ * either an exact TEXT_COMMAND_ALLOWLIST literal, or a spec verb's own
293
+ * canonical parameterized rendering (D-42-1). A plain boolean, not a type
294
+ * predicate over TextCommand: the dialable set is now larger than that
295
+ * eight-member union, since a parameterized command is a distinct runtime
296
+ * string TextCommand's own literal union does not (and should not) name. */
297
+ export function isAllowlistedTextCommand(cmd: string): boolean {
298
+ if ((TEXT_COMMAND_ALLOWLIST as readonly string[]).includes(cmd)) return true;
299
+ for (const verb of Object.keys(TEXT_COMMAND_PARAM_SPECS)) {
300
+ if (cmd.startsWith(`${verb} `) && isDialableTextCommandForVerb(verb, cmd)) return true;
301
+ }
302
+ return false;
303
+ }
304
+
305
+ /** Any carriage return, line feed, or other C0 control character. Defense in
306
+ * depth: every TEXT_COMMAND_ALLOWLIST entry above is already a fixed literal
307
+ * with none of these, so this can never actually fire against a bare
308
+ * allowlisted command -- but as of D-42-1 (plan 42-04) isAllowlistedTextCommand()
309
+ * also accepts a RENDERED parameterized command, so this check is no longer
310
+ * merely hypothetical defense in depth for that path: a bug in a spec's
311
+ * render() function, or in isDialableTextCommandForVerb()'s own round trip,
312
+ * is now a real way a control character could reach this far, and this
313
+ * check is what still stops it before a single byte is written. It also
314
+ * guards any future allowlist entry the same way it always did. (canon-
315
+ * referral breadcrumb: generic command injection is `/gsd-secure-phase`
316
+ * canon, not re-litigated here). */
317
+ const FORBIDDEN_COMMAND_CHARS_RE = /[\r\n\x00-\x1f]/;
318
+
319
+ /**
320
+ * Thrown when a text-monitor response cannot be honestly framed -- an
321
+ * accumulated buffer that exceeded TEXT_MAX_BUFFERED_LEN with no prompt in
322
+ * sight (Task 2 enforces this; the type ships now so Task 2 needs no new
323
+ * export). Never thrown for a byte-count or timeout reason alone; always
324
+ * names what was actually observed. A subclass of ViceError, not a bare
325
+ * Error, so callers already switching on ViceError's shape keep working.
326
+ */
327
+ export interface TextFramingErrorOptions {
328
+ observedBytes?: number;
329
+ outstandingCommand?: string | null;
330
+ }
331
+
332
+ export class TextFramingError extends ViceError {
333
+ observedBytes?: number;
334
+ outstandingCommand?: string | null;
335
+
336
+ constructor(message: string, { observedBytes, outstandingCommand }: TextFramingErrorOptions = {}) {
337
+ super(message);
338
+ this.name = "TextFramingError";
339
+ this.observedBytes = observedBytes;
340
+ this.outstandingCommand = outstandingCommand ?? null;
341
+ }
342
+ }
343
+
344
+ // ---------------------------------------------------------------------------
345
+ // Accumulation cap and quiescence window (constants ship now; Task 2 wires
346
+ // enforcement of both into TextMonitorClient's data handler).
347
+ // ---------------------------------------------------------------------------
348
+
349
+ /**
350
+ * Upper bound on accumulated-but-not-yet-framed bytes for a single
351
+ * outstanding command. Re-derived from real captured text-monitor output
352
+ * rather than copied from stock-protocol.ts's MAX_BUFFERED_LEN, which bounds
353
+ * a DIFFERENT wire (length-prefixed binary frames) with a different worst
354
+ * case: the largest real fixture committed under fixtures/textmon/
355
+ * (`access-map-stock.txt`, `memmapshow`'s own output) is ~1.62 MiB. 4 MiB
356
+ * gives generous headroom above that measured ceiling while still refusing
357
+ * genuinely unbounded accumulation (the DoS shape this cap exists to bound)
358
+ * -- see text-protocol.test.ts's own assertion (Task 2) that this constant
359
+ * is strictly greater than the largest fixture found on disk, so it can
360
+ * never be tightened below real observed output without going red.
361
+ */
362
+ export const TEXT_MAX_BUFFERED_LEN = 4 * 1024 * 1024;
363
+
364
+ /**
365
+ * How long a buffer must stay silent, after a tail match against PROMPT_RE,
366
+ * before that match is accepted as the real terminator rather than
367
+ * prompt-shaped text occurring mid-stream (the second planted control this
368
+ * phase's criterion names, wired in Task 2). VICE writes the real prompt as
369
+ * the very last thing it sends for a completed command, so a genuine
370
+ * terminator is never immediately followed by more command-output bytes --
371
+ * 50ms is comfortably above ordinary same-host loopback latency while
372
+ * staying well under the per-command cost every caller of this class
373
+ * already accepts. Overridable via VICE_TEXT_QUIESCENCE_MS for a slower host
374
+ * or a deliberately stressed test. Residual risk, stated rather than
375
+ * hidden: a response written in bursts with wire-level backpressure longer
376
+ * than this window would be accepted early, and no measurement on file
377
+ * covers inter-chunk timing on a response near TEXT_MAX_BUFFERED_LEN's own
378
+ * ceiling.
379
+ */
380
+ export const TEXT_QUIESCENCE_MS: number = (() => {
381
+ const raw = process.env.VICE_TEXT_QUIESCENCE_MS;
382
+ if (raw === undefined || raw === "") return 50;
383
+ const n = Number(raw);
384
+ return Number.isFinite(n) && n >= 0 ? n : 50;
385
+ })();
386
+
387
+ /** Byte-safe tail scan for the prompt terminator. Deliberately decodes only a
388
+ * short, fixed-size tail window through `latin1` (a 1-byte-to-1-code-unit
389
+ * mapping that never throws and never depends on where a multi-byte UTF-8
390
+ * sequence happens to be split) -- the prompt itself is pure ASCII, so this
391
+ * can never miss a real terminator and can never be confused by a UTF-8
392
+ * continuation byte living in the tail window. The AUTHORITATIVE decode of
393
+ * the full payload still happens exactly once, with `utf8`, on the complete
394
+ * assembled buffer, after a match is accepted as final -- see
395
+ * #finishPending() below. */
396
+ function bufferEndsWithPrompt(buf: Buffer): boolean {
397
+ const windowLen = Math.min(buf.length, 32);
398
+ const tail = buf.subarray(buf.length - windowLen).toString("latin1");
399
+ return PROMPT_RE.test(tail);
400
+ }
401
+
402
+ // ---------------------------------------------------------------------------
403
+ // withTextChannelLock() -- the text channel's ONE acquire seam for
404
+ // channel-lock.ts's mutex (plan 41-02, CHAN-04, D-07).
405
+ // ---------------------------------------------------------------------------
406
+
407
+ export interface WithTextChannelLockOptions {
408
+ /** Test-only override of channel-lock.ts's acquire bound. Production call
409
+ * sites never set this -- they always take channel-lock.ts's own
410
+ * CHANNEL_LOCK_ACQUIRE_TIMEOUT_MS default. */
411
+ timeoutMs?: number;
412
+ }
413
+
414
+ /**
415
+ * The text channel's ONE acquire seam for channel-lock.ts's mutex. Acquires
416
+ * `channel: "text"`, runs `fn`, and releases in a `finally` so a throwing
417
+ * `fn` still releases -- matching stock-dispatch.ts's `withChannelLockHeld()`
418
+ * on the binary side exactly, and satisfying D-07's requirement that
419
+ * `text-protocol.ts` and `stock-dispatch.ts` both import the one primitive.
420
+ *
421
+ * Every real text-monitor command MUST be issued from inside this function:
422
+ * `command()` below refuses, by name, whenever channel-lock.ts's mutex is
423
+ * not currently held by the text channel -- so a call site that forgets to
424
+ * acquire is refused rather than silently bypassing the authority (D-07's
425
+ * "cannot be silently bypassed" requirement).
426
+ */
427
+ export async function withTextChannelLock<T>(operation: string, fn: () => Promise<T>, opts: WithTextChannelLockOptions = {}): Promise<T> {
428
+ const handle = await acquireChannelLock({ channel: "text", operation, timeoutMs: opts.timeoutMs });
429
+ try {
430
+ return await fn();
431
+ } finally {
432
+ handle.release();
433
+ }
434
+ }
435
+
436
+ interface PendingTextCommand {
437
+ resolve: (value: string) => void;
438
+ reject: (reason: unknown) => void;
439
+ // D-42-1 (plan 42-04): a dialable command is no longer only ever a
440
+ // TextCommand literal -- isAllowlistedTextCommand() also accepts a
441
+ // spec verb's own canonical parameterized rendering, a distinct runtime
442
+ // string TextCommand's closed union does not (and should not) name.
443
+ command: string;
444
+ }
445
+
446
+ export interface TextMonitorClientOptions {
447
+ /** Test-only override of the quiescence window, taking precedence over
448
+ * both the module-level TEXT_QUIESCENCE_MS constant and its own
449
+ * VICE_TEXT_QUIESCENCE_MS env override -- lets a test exercise the
450
+ * mid-stream-prompt case (Control 2) without waiting out a real-world
451
+ * window or mutating process.env. */
452
+ quiescenceMs?: number;
453
+ }
454
+
455
+ export interface TextConnectSocketOptions {
456
+ timeoutMs?: number;
457
+ }
458
+
459
+ export interface TextCommandOptions {
460
+ timeoutMs?: number;
461
+ }
462
+
463
+ // ---------------------------------------------------------------------------
464
+ // TextMonitorClient
465
+ // ---------------------------------------------------------------------------
466
+
467
+ /**
468
+ * Raw text-monitor socket client: connect/disconnect and a single
469
+ * outstanding command() at a time, framed by PROMPT_RE plus a quiescence
470
+ * window -- accumulate raw Buffers (never decode per chunk), and accept a
471
+ * tail match against PROMPT_RE as final only once no further bytes arrive
472
+ * within the quiescence window (see TEXT_QUIESCENCE_MS's own header comment
473
+ * for why this is not optional even for the plain happy path). Task 2 adds
474
+ * the passive banner drain (D-13(b)) and the accumulation cap on top of this
475
+ * shape without changing it structurally.
476
+ *
477
+ * D-13(a): connect() never reads or waits for a connect banner -- stock's
478
+ * text monitor sends ZERO bytes on connect, so there is nothing to frame or
479
+ * race. Waiting would hang for the full connect timeout on every single
480
+ * connection.
481
+ */
482
+ export class TextMonitorClient extends EventEmitter {
483
+ #socket: net.Socket | null = null;
484
+ #buffer: Buffer = Buffer.alloc(0);
485
+ #port: number | null = null;
486
+ #closed = false;
487
+ #pending: PendingTextCommand | null = null;
488
+ #quiescenceTimer: NodeJS.Timeout | null = null;
489
+ #quiescenceMs: number;
490
+ /** Task 2 (D-13(b)): incremented every time a passively-arriving,
491
+ * no-command-outstanding banner is drained -- never used to resolve a
492
+ * later command's promise, only counted and emitted on `banner`. */
493
+ bannerFramesDrained = 0;
494
+ #onDataBound = (chunk: Buffer) => this.#onData(chunk);
495
+ #onCloseBound = () => this.#onClose();
496
+ #onErrorBound = (err: Error) => this.#onError(err);
497
+
498
+ constructor({ quiescenceMs }: TextMonitorClientOptions = {}) {
499
+ super();
500
+ this.#quiescenceMs = quiescenceMs ?? TEXT_QUIESCENCE_MS;
501
+ }
502
+
503
+ get connected(): boolean {
504
+ return this.#socket != null && !this.#socket.destroyed;
505
+ }
506
+
507
+ /** Whether a command is currently outstanding on this connection -- exposed
508
+ * so text-connect.ts and any future channel-lock integration can observe
509
+ * idle-vs-busy state without reaching into a private field. */
510
+ get hasOutstandingCommand(): boolean {
511
+ return this.#pending !== null;
512
+ }
513
+
514
+ connect(host: string, port: number, { timeoutMs = 5000 }: TextConnectSocketOptions = {}): Promise<void> {
515
+ // Mirrors stock-protocol.ts's WR-13(b) fix: refuse to connect over a
516
+ // socket that is still live, rather than silently overwriting #socket
517
+ // and leaking the previous socket and its listeners. A reconnect must go
518
+ // through disconnect() first.
519
+ if (this.#socket != null && !this.#socket.destroyed) {
520
+ return Promise.reject(
521
+ new ViceError(
522
+ `connect to ${host}:${port} refused: this client already holds a live socket to port ${this.#port} -- ` +
523
+ `call disconnect() first (stock VICE services exactly one text-monitor client)`,
524
+ ),
525
+ );
526
+ }
527
+
528
+ return new Promise((resolve, reject) => {
529
+ const socket = net.createConnection({ host, port });
530
+
531
+ const onConnect = () => {
532
+ clearTimeout(timer);
533
+ socket.removeListener("error", onConnectError);
534
+ this.#socket = socket;
535
+ this.#buffer = Buffer.alloc(0);
536
+ this.#port = port;
537
+ this.#closed = false;
538
+ socket.on("data", this.#onDataBound);
539
+ socket.on("close", this.#onCloseBound);
540
+ socket.on("error", this.#onErrorBound);
541
+ // D-13(a): resolve immediately -- never read or wait for a connect
542
+ // banner. Stock's text monitor sends zero bytes on connect.
543
+ resolve();
544
+ };
545
+ const onConnectError = (err: Error) => {
546
+ clearTimeout(timer);
547
+ reject(err);
548
+ };
549
+ const timer = setTimeout(() => {
550
+ socket.removeListener("connect", onConnect);
551
+ socket.removeListener("error", onConnectError);
552
+ // Mirrors stock-protocol.ts's WR-13(a) fix: destroy() can itself
553
+ // deliver an 'error' for this socket. Both prior listeners are
554
+ // already removed, so a no-op listener is attached for the socket's
555
+ // remaining lifetime -- this socket is abandoned, nothing left to
556
+ // report, but the event still needs somewhere to land.
557
+ socket.on("error", () => {
558
+ /* abandoned socket -- swallow */
559
+ });
560
+ socket.destroy();
561
+ reject(new ViceError(`connect to ${host}:${port} timed out after ${timeoutMs}ms`));
562
+ }, timeoutMs);
563
+
564
+ socket.once("connect", onConnect);
565
+ socket.once("error", onConnectError);
566
+ });
567
+ }
568
+
569
+ /**
570
+ * Issue exactly one text-monitor command and resolve with its complete,
571
+ * prompt-framed response (the prompt itself stripped). Refuses any
572
+ * argument that is not in TEXT_COMMAND_ALLOWLIST, and refuses any argument
573
+ * containing a CR, LF, or C0 control character, BY NAME, before a single
574
+ * byte is written (D-01). Only one command may be outstanding at a time --
575
+ * the text protocol is not multiplexed.
576
+ */
577
+ command(cmd: string, _opts: TextCommandOptions = {}): Promise<string> {
578
+ // Checked BEFORE the allowlist membership check, deliberately: every
579
+ // TEXT_COMMAND_ALLOWLIST entry is already clean of these characters, so
580
+ // ordering it first makes this refusal reachable and testable in its own
581
+ // right (a string carrying an embedded control character is refused
582
+ // BY THAT REASON, not merely folded into the generic non-allowlisted
583
+ // refusal) while changing nothing about which strings are ultimately
584
+ // accepted.
585
+ if (FORBIDDEN_COMMAND_CHARS_RE.test(cmd)) {
586
+ return Promise.reject(
587
+ new ViceError(`text-protocol: refusing command ${JSON.stringify(cmd)} containing a CR, LF, or C0 control character`),
588
+ );
589
+ }
590
+ if (!isAllowlistedTextCommand(cmd)) {
591
+ return Promise.reject(
592
+ new ViceError(
593
+ `text-protocol: refusing non-allowlisted command ${JSON.stringify(cmd)} -- every outbound text-monitor ` +
594
+ `command must come from TEXT_COMMAND_ALLOWLIST (D-01)`,
595
+ ),
596
+ );
597
+ }
598
+ // D-07 (plan 41-02, CHAN-04): a text command issued without the text
599
+ // channel holding channel-lock.ts's mutex is a defect, not a variant --
600
+ // refusing it BY NAME is what keeps the serialization authority from
601
+ // being silently bypassable by a call site that forgot to route through
602
+ // withTextChannelLock(). Checked before the connection-state checks
603
+ // below: holding halt authority is a prerequisite for issuing ANY
604
+ // command, independent of whether a socket happens to be connected.
605
+ const lockHolder = currentChannelLockHolder();
606
+ if (lockHolder === null || lockHolder.channel !== "text") {
607
+ return Promise.reject(
608
+ new ViceError(
609
+ `text-protocol: refusing command ${JSON.stringify(cmd)} -- the text channel does not currently hold ` +
610
+ `channel-lock.ts's halt authority; every text-monitor command must be issued from inside ` +
611
+ `withTextChannelLock()`,
612
+ ),
613
+ );
614
+ }
615
+ if (this.#closed || !this.connected || !this.#socket) {
616
+ return Promise.reject(new ViceError("text-protocol: cannot send, the text-monitor connection is not open"));
617
+ }
618
+ if (this.#pending) {
619
+ return Promise.reject(new ViceError("text-protocol: a command is already outstanding on this connection"));
620
+ }
621
+
622
+ const socket = this.#socket;
623
+ return new Promise<string>((resolve, reject) => {
624
+ this.#pending = { resolve, reject, command: cmd };
625
+ socket.write(`${cmd}\n`);
626
+ });
627
+ }
628
+
629
+ /** Tears down the socket and removes the bound listeners. Any outstanding
630
+ * command is rejected rather than left to hang. A second disconnect() on
631
+ * an already-torn-down client is a harmless no-op. */
632
+ disconnect(): Promise<void> {
633
+ if (this.#quiescenceTimer) {
634
+ clearTimeout(this.#quiescenceTimer);
635
+ this.#quiescenceTimer = null;
636
+ }
637
+ const pending = this.#pending;
638
+ this.#pending = null;
639
+ if (pending) {
640
+ pending.reject(new ViceError("text-protocol: connection closed while a command was outstanding"));
641
+ }
642
+
643
+ const socket = this.#socket;
644
+ this.#socket = null;
645
+ this.#buffer = Buffer.alloc(0);
646
+ this.#closed = true;
647
+ if (!socket) {
648
+ return Promise.resolve();
649
+ }
650
+ socket.removeListener("data", this.#onDataBound);
651
+ socket.removeListener("close", this.#onCloseBound);
652
+ socket.removeListener("error", this.#onErrorBound);
653
+ return new Promise((resolve) => {
654
+ socket.once("close", () => resolve());
655
+ socket.destroy();
656
+ });
657
+ }
658
+
659
+ #onData(chunk: Buffer): void {
660
+ // Concat raw Buffers, never decode per chunk -- this alone is what
661
+ // survives a prompt split across two socket chunks (Control 1):
662
+ // matching happens against the TAIL of the accumulated buffer, which is
663
+ // agnostic to where the chunk boundary fell.
664
+ this.#buffer = Buffer.concat([this.#buffer, chunk]);
665
+
666
+ if (this.#quiescenceTimer) {
667
+ // More bytes arrived before the quiescence window elapsed for a
668
+ // PREVIOUS tail match -- that match was not really final (Control 2's
669
+ // own shape: prompt-shaped text mid-stream, or -- as measured live --
670
+ // a residual leading prompt arriving as its own chunk ahead of the
671
+ // real output). Cancel the timer and re-evaluate from scratch below
672
+ // against the now-larger buffer.
673
+ clearTimeout(this.#quiescenceTimer);
674
+ this.#quiescenceTimer = null;
675
+ }
676
+
677
+ if (!this.#pending) {
678
+ // D-13(b): a passively-arriving banner (e.g. a binary-owned
679
+ // checkpoint-hit notification pushed to this same text console) with
680
+ // no command outstanding. WR-01: this path must apply the SAME
681
+ // quiescence discipline as the pending-command branch below --
682
+ // prompt-shaped text can occur mid-banner exactly as it can
683
+ // mid-command-response (Control 2's own shape), and draining
684
+ // immediately on the first tail match risks splitting one logical
685
+ // banner into two events, or -- worse -- leaking a banner's true
686
+ // trailing bytes into an unrelated command's response buffer if a
687
+ // command is issued in the narrow window between the false match and
688
+ // the banner's real tail arriving. Arm the quiescence window and only
689
+ // drain once a tail match SURVIVES it with no further bytes arriving.
690
+ if (bufferEndsWithPrompt(this.#buffer)) {
691
+ this.#quiescenceTimer = setTimeout(() => {
692
+ this.#quiescenceTimer = null;
693
+ this.#finishBanner();
694
+ }, this.#quiescenceMs);
695
+ if (typeof this.#quiescenceTimer.unref === "function") this.#quiescenceTimer.unref();
696
+ return;
697
+ }
698
+ this.#checkCap();
699
+ return;
700
+ }
701
+
702
+ if (bufferEndsWithPrompt(this.#buffer)) {
703
+ // A tail match. Arm the quiescence window rather than resolving
704
+ // immediately: only a match that SURVIVES the window (no further
705
+ // bytes arrive) is accepted as the genuine terminator.
706
+ this.#quiescenceTimer = setTimeout(() => {
707
+ this.#quiescenceTimer = null;
708
+ this.#finishPending();
709
+ }, this.#quiescenceMs);
710
+ if (typeof this.#quiescenceTimer.unref === "function") this.#quiescenceTimer.unref();
711
+ return;
712
+ }
713
+
714
+ this.#checkCap();
715
+ }
716
+
717
+ /** Finalizes the currently outstanding command against the buffer accrued
718
+ * so far: decodes the ENTIRE assembled buffer with `utf8` exactly once
719
+ * (never per chunk -- the encoding-split control depends on this), strips
720
+ * the trailing prompt, and resolves. */
721
+ #finishPending(): void {
722
+ const pending = this.#pending;
723
+ if (!pending) return;
724
+ this.#pending = null;
725
+ const raw = this.#buffer;
726
+ this.#buffer = Buffer.alloc(0);
727
+ const decoded = raw.toString("utf8");
728
+ const payload = decoded.replace(PROMPT_RE, "");
729
+ pending.resolve(payload);
730
+ }
731
+
732
+ /** Finalizes a passively-drained banner (D-13(b)) against the buffer
733
+ * accrued so far, mirroring #finishPending()'s discipline for the
734
+ * pending-command path: only invoked after a tail match has survived the
735
+ * quiescence window with no further bytes arriving in between (WR-01) --
736
+ * never on the first tail match alone. Never resolves or touches
737
+ * #pending; a banner is passive output, not a command reply. */
738
+ #finishBanner(): void {
739
+ const raw = this.#buffer;
740
+ this.#buffer = Buffer.alloc(0);
741
+ this.bannerFramesDrained += 1;
742
+ this.emit("banner", raw.toString("utf8"));
743
+ }
744
+
745
+ /** Enforces TEXT_MAX_BUFFERED_LEN against accumulated-but-not-yet-framed
746
+ * bytes, whether a command is outstanding or a banner is being drained.
747
+ * Exceeding the cap with no prompt in sight is a refusal (TextFramingError
748
+ * naming the byte count and the outstanding command, if any) -- never a
749
+ * truncated payload handed back as if it were complete. */
750
+ #checkCap(): void {
751
+ if (this.#buffer.length <= TEXT_MAX_BUFFERED_LEN) return;
752
+ const observedBytes = this.#buffer.length;
753
+ const outstandingCommand = this.#pending?.command ?? null;
754
+ this.#buffer = Buffer.alloc(0);
755
+ const err = new TextFramingError(
756
+ `text-protocol: accumulated buffer exceeded TEXT_MAX_BUFFERED_LEN (${TEXT_MAX_BUFFERED_LEN}) with no prompt in sight` +
757
+ (outstandingCommand ? ` while "${outstandingCommand}" was outstanding` : " while draining a banner"),
758
+ { observedBytes, outstandingCommand },
759
+ );
760
+ const pending = this.#pending;
761
+ this.#pending = null;
762
+ if (pending) {
763
+ pending.reject(err);
764
+ } else {
765
+ // IN-01: no command is outstanding when this fires, so there is no
766
+ // promise to reject -- "desync" is the only signal. This deliberately
767
+ // mirrors stock-protocol.ts's own "desync" convention for its binary
768
+ // ViceMonitorClient: neither of this class's two current production
769
+ // consumers (text-connect.ts, text-tools.ts) attach a listener, exactly
770
+ // like the binary client's own production call sites today. That is a
771
+ // known diagnosability gap (a genuinely desynced text channel produces
772
+ // no operator-visible signal until the next real command is issued
773
+ // against it), not an oversight -- left unconsumed BY DESIGN, pending a
774
+ // future plan that exposes the banner/desync stream to a caller. A
775
+ // fix that wires a listener onto only this class, asymmetric with the
776
+ // binary client's identical convention, is explicitly NOT wanted here.
777
+ this.emit("desync", err);
778
+ }
779
+ }
780
+
781
+ #onClose(): void {
782
+ if (this.#quiescenceTimer) {
783
+ clearTimeout(this.#quiescenceTimer);
784
+ this.#quiescenceTimer = null;
785
+ }
786
+ const pending = this.#pending;
787
+ this.#pending = null;
788
+ this.#socket = null;
789
+ this.#buffer = Buffer.alloc(0);
790
+ this.#closed = true;
791
+ if (pending) {
792
+ pending.reject(new ViceError("text-protocol: connection closed while a command was outstanding"));
793
+ }
794
+ this.emit("close");
795
+ }
796
+
797
+ #onError(err: Error): void {
798
+ if (this.#quiescenceTimer) {
799
+ clearTimeout(this.#quiescenceTimer);
800
+ this.#quiescenceTimer = null;
801
+ }
802
+ const pending = this.#pending;
803
+ this.#pending = null;
804
+ this.#closed = true;
805
+ if (pending) {
806
+ pending.reject(err);
807
+ }
808
+ this.emit("transport-error", err);
809
+ }
810
+ }