@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.
- package/README.md +2 -2
- package/THIRD-PARTY-NOTICES.md +422 -1
- package/anno-bank.ts +171 -0
- package/anno-cli.ts +1736 -163
- package/anno-confidence.ts +2 -2
- package/anno-derive.ts +6 -6
- package/anno-details.ts +4 -4
- package/anno-enum-gen.ts +416 -30
- package/anno-export-asm.ts +1211 -126
- package/anno-graphics.ts +338 -0
- package/anno-hazard-report.ts +1367 -0
- package/anno-import.ts +495 -0
- package/anno-index.ts +8 -8
- package/anno-join.ts +480 -0
- package/anno-memmap-render.ts +22 -21
- package/anno-provenance-ledger.ts +472 -0
- package/anno-regbits-gen.ts +13 -13
- package/anno-register.ts +159 -0
- package/anno-store-export.ts +661 -0
- package/anno-store.ts +635 -124
- package/anno-symbols.ts +7 -7
- package/anno-tools.ts +1169 -16
- package/anno-types.ts +313 -40
- package/backend-detect.mts +124 -312
- package/build.ts +3 -1
- package/capture-predicate.ts +597 -0
- package/channel-lock.ts +349 -0
- package/evid-ingest.ts +217 -0
- package/evid-reconcile.ts +316 -0
- package/host-tool-client.ts +430 -0
- package/incident-record.ts +23 -12
- package/install-resources.ts +29 -13
- package/memmap-lookup.ts +285 -0
- package/package.json +27 -8
- package/prg-image.ts +1 -2
- package/repo-root.ts +87 -3
- package/resources/backend-detect.mjs +98 -236
- package/resources/broker-control.mjs +220 -54
- package/resources/broker-epoch.mjs +7 -8
- package/resources/broker-kill.mjs +36 -31
- package/resources/broker-launch.mjs +511 -374
- package/resources/broker-state.mjs +69 -24
- package/resources/container-guard.mjs +1 -1
- package/resources/ghidra-project.mjs +790 -0
- package/resources/host-tool.mjs +2533 -0
- package/resources/vice-broker.mjs +434 -290
- package/resources/vice-launcher.sh +127 -9
- package/stock-address.ts +1 -1
- package/stock-condition.ts +1 -1
- package/stock-connect.ts +9 -5
- package/stock-derived.ts +29 -37
- package/stock-diagnose.ts +200 -36
- package/stock-dispatch.ts +179 -77
- package/stock-handler.ts +1 -1
- package/stock-paths.ts +18 -14
- package/stock-petscii.ts +1 -1
- package/stock-protocol.ts +1 -1
- package/stock-recycle.ts +83 -2
- package/stock-reproducible-run.ts +811 -0
- package/stock-run-until.ts +100 -1
- package/stock-symbols.ts +4 -4
- package/stock-timing.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/vice-broker-client.ts +253 -108
- package/vice-errors.ts +268 -0
- package/vice-proxy.ts +339 -2144
- package/vsf-slice.ts +640 -0
- package/anno-d64.ts +0 -310
- package/capability-registry.ts +0 -390
- 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/text-protocol.ts
ADDED
|
@@ -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
|
+
}
|