@henols/vice-mcp 0.1.9 → 0.1.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,427 @@
1
+ #!/usr/bin/env node
2
+ // stock-connect.ts
3
+ //
4
+ // The ONE place that performs the stock connect handshake: claim the
5
+ // monitor socket from the broker (BEFORE any TCP dial), open a
6
+ // ViceMonitorClient, assert the wire's api_version, read the connected
7
+ // build's identity via VICE_INFO, settle its version-gated capabilities
8
+ // exactly once per binary (BACK-04 -- at connect time, not at first use),
9
+ // and detect whether the machine underneath a reconnect is the SAME machine
10
+ // this client originally handshook with.
11
+ //
12
+ // WHY THIS FILE EXISTS: stock-protocol.ts's ViceMonitorClient deliberately
13
+ // answers only "this socket died" (see its own header comment on D-11) --
14
+ // it never decides whether a freshly reconnected socket belongs to the same
15
+ // emulator process. backend-detect.mts's capability cache is written once
16
+ // per binary by a --help probe that cannot observe a version quad at all
17
+ // (that file's own header comment says as much). Something has to sit
18
+ // between those two files and turn "a claimed, connected,
19
+ // api_version-checked socket" into "a named build with settled
20
+ // capabilities, whose continued identity across a reconnect is provable" --
21
+ // this file is that seam.
22
+ //
23
+ // WHAT NOT TO DO:
24
+ // - Never re-derive an epoch or restart heuristic here. vice.ts's
25
+ // MachineRestartedError is the ONE restart-error type this whole module
26
+ // tree uses (D-11); reuse it, do not define a second one.
27
+ // - Never dial the binmon port before claimMonitor() has succeeded --
28
+ // stock VICE services exactly one client, and a refused claim must
29
+ // arrive as a JSON response on a working control-plane socket, never as
30
+ // a connect() that silently sits unserviced in the backlog (PROTO-08,
31
+ // D-13, vice-broker-client.ts's own MonitorOwnershipError header
32
+ // comment).
33
+ import { ViceMonitorClient, CommandType, ErrorCode, StockProtocolError } from "./stock-protocol.ts";
34
+ import { readCapabilityRecord, writeCapabilityRecord, type CapabilityDeps } from "./backend-detect.mts";
35
+ import { MachineRestartedError, ViceError, readEpoch, type EpochResult } from "./vice.ts";
36
+ import {
37
+ MonitorOwnershipError,
38
+ type ClaimMonitorOptions,
39
+ type ClaimMonitorOutcome,
40
+ type ReleaseMonitorOptions,
41
+ type ReleaseMonitorOutcome,
42
+ } from "./vice-broker-client.ts";
43
+
44
+ // ---------------------------------------------------------------------------
45
+ // Broker control surface this handshake needs -- deliberately narrower than
46
+ // the full BrokerControlSession (acquire/release/recycle/status/hostState):
47
+ // this file only ever claims and releases a monitor socket, never opens or
48
+ // closes the acquire-level lease itself. Any real BrokerControlSession
49
+ // satisfies this structurally; tests inject a minimal stub instead of the
50
+ // whole session.
51
+ // ---------------------------------------------------------------------------
52
+
53
+ export interface StockConnectBrokerControl {
54
+ claimMonitor(opts: ClaimMonitorOptions): Promise<ClaimMonitorOutcome>;
55
+ releaseMonitor(opts: ReleaseMonitorOptions): Promise<ReleaseMonitorOutcome>;
56
+ }
57
+
58
+ // ---------------------------------------------------------------------------
59
+ // Capabilities -- BACK-04's version-gated answer set. Today this is just
60
+ // CPUHISTORY_GET's three-way outcome (docs/phase0-binmon-findings.md §5,
61
+ // D-10): 0x00 OK on the 3.10 fork, 0x83 INVALID_TYPE (opcode absent) on
62
+ // stock 3.9, 0x8f CMD_FAILURE (compiled without support) as the distinct
63
+ // third case. A later plan adding a second version-gated opcode extends this
64
+ // type, not a parallel mechanism.
65
+ // ---------------------------------------------------------------------------
66
+
67
+ export type CpuHistoryCapability = "available" | "absent" | "not_compiled_in";
68
+
69
+ export interface StockCapabilities {
70
+ cpuHistory: CpuHistoryCapability;
71
+ }
72
+
73
+ /** CPUHISTORY_GET's count field is read as uint32 by this handshake's own
74
+ * request body but VICE stores it internally in a uint16_t
75
+ * (monitor_binary.c:1492) -- any count >= 65536 wraps silently server-side.
76
+ * Clamp client-side rather than ever sending an unclamped value. This
77
+ * handshake only ever probes with count 0 (it wants the capability answer,
78
+ * not any history), but the clamp is a general guard for any future caller
79
+ * of this same request shape. */
80
+ const CPU_HISTORY_MAX_COUNT = 65535;
81
+
82
+ /** WR-02/WR-12: bounded at BOTH ends, and against non-finite input. A bare
83
+ * `Math.min(count, 65535)` clamped only the documented uint16 wrap and passed
84
+ * negatives and NaN straight through to `body.writeUInt32LE(count, 1)`, which
85
+ * THROWS for either. This function's own doc comment above advertises it as "a
86
+ * general guard for any future caller of this same request shape" -- and that
87
+ * caller is exactly the one who will hand it unvalidated input, so the guard
88
+ * has to hold for the whole numeric domain, not just the upper bound. A
89
+ * fractional count truncates rather than throwing: the wire field is an
90
+ * integer, and 0 is the safe floor (this handshake's own probe uses it). */
91
+ export function clampCpuHistoryCount(count: number): number {
92
+ if (!Number.isFinite(count)) return 0;
93
+ return Math.min(Math.max(Math.trunc(count), 0), CPU_HISTORY_MAX_COUNT);
94
+ }
95
+
96
+ /** Sends CPUHISTORY_GET (0x86) with memspace=main and a zero, clamped count,
97
+ * and maps the wire outcome to CpuHistoryCapability's three-way answer --
98
+ * 0x00 OK -> "available", 0x83 INVALID_TYPE -> "absent" (the pre-3.10 case),
99
+ * 0x8f CMD_FAILURE -> "not_compiled_in" (the distinct compiled-without-
100
+ * support case). Any other rejection (a timeout, a closed socket, an
101
+ * unrecognized error code) is not this function's to interpret -- it
102
+ * propagates unchanged. */
103
+ async function probeCpuHistory(client: ViceMonitorClient): Promise<CpuHistoryCapability> {
104
+ const count = clampCpuHistoryCount(0);
105
+ const body = Buffer.alloc(5);
106
+ body[0] = 0x00; // memspace: main
107
+ body.writeUInt32LE(count, 1);
108
+ try {
109
+ await client.send(CommandType.CpuHistoryGet, body);
110
+ return "available";
111
+ } catch (err) {
112
+ if (err instanceof StockProtocolError) {
113
+ if (err.errorCode === ErrorCode.InvalidType) return "absent"; // 0x83 -- opcode absent, pre-3.10
114
+ if (err.errorCode === ErrorCode.CmdFailure) return "not_compiled_in"; // 0x8f -- compiled without support
115
+ }
116
+ throw err;
117
+ }
118
+ }
119
+
120
+ /** Gates the CPUHISTORY_GET probe behind backend-detect.mts's own capability
121
+ * cache (BACK-04): a record whose stored versionQuad matches the one this
122
+ * handshake just observed short-circuits the probe entirely; a miss, a
123
+ * stale record (different versionQuad -- the binary was swapped), or the
124
+ * absence of a `binPath` to key on all fall through to a fresh probe, whose
125
+ * answer is then written back exactly once. This function never invents a
126
+ * backend verdict -- writeCapabilityRecord() itself is a no-op unless
127
+ * backend-detect.mts's own resolvedBackend() has already written a matching
128
+ * record for this binary (see that function's own header comment). */
129
+ async function resolveCapabilities(client: ViceMonitorClient, versionQuad: string, deps: StockConnectDeps): Promise<StockCapabilities> {
130
+ const readCap = deps.readCapabilityRecordFn ?? readCapabilityRecord;
131
+ const writeCap = deps.writeCapabilityRecordFn ?? writeCapabilityRecord;
132
+
133
+ if (deps.binPath) {
134
+ const capDeps: CapabilityDeps = { supervisorDir: deps.supervisorDir, observedVersionQuad: versionQuad };
135
+ const existing = readCap(deps.binPath, capDeps);
136
+ if (existing && !existing.stale && existing.cpuHistoryAvailable !== undefined) {
137
+ // backend-detect.mts's own cache schema stores only a boolean --
138
+ // "absent" and "not_compiled_in" both collapse to `false` there,
139
+ // since both mean "never attempt CPUHISTORY_GET again"; they differ
140
+ // only in WHY. A cache hit cannot recover which of the two it
141
+ // originally was, so it reports the more common non-3.10 case
142
+ // ("absent") rather than re-probing to find out -- re-probing on
143
+ // every connect is exactly what BACK-04 exists to avoid.
144
+ return { cpuHistory: existing.cpuHistoryAvailable ? "available" : "absent" };
145
+ }
146
+ }
147
+
148
+ const cpuHistory = await probeCpuHistory(client);
149
+ if (deps.binPath) {
150
+ writeCap(deps.binPath, { versionQuad, cpuHistoryAvailable: cpuHistory === "available" }, { supervisorDir: deps.supervisorDir });
151
+ }
152
+ return { cpuHistory };
153
+ }
154
+
155
+ // ---------------------------------------------------------------------------
156
+ // stockConnect() -- the handshake itself.
157
+ // ---------------------------------------------------------------------------
158
+
159
+ export interface StockConnectDeps {
160
+ /** The binary this handshake is connected to -- the SAME key
161
+ * backend-detect.mts's cache is written under. Omitted entirely disables
162
+ * the capability cache (every connect re-probes, never persisted). */
163
+ binPath?: string;
164
+ /** Same meaning as backend-detect.mts's own `supervisorDir` option: a
165
+ * caller-supplied string, never re-derived here (this file must not
166
+ * become a second, driftable copy of "where is .vice-supervisor"). */
167
+ supervisorDir?: string;
168
+ /** Path to this instance's own epoch.json (broker-epoch.mts's own writer),
169
+ * used ONLY as the reconnect baseline/comparison (Task 2). Omitted
170
+ * entirely means identity across a reconnect can never be proven -- see
171
+ * stockReconnect()'s own header comment. */
172
+ epochPath?: string;
173
+ readCapabilityRecordFn?: typeof readCapabilityRecord;
174
+ writeCapabilityRecordFn?: typeof writeCapabilityRecord;
175
+ readEpochFn?: typeof readEpoch;
176
+ }
177
+
178
+ export interface StockConnectOptions {
179
+ host: string;
180
+ port: number;
181
+ targetId: string;
182
+ brokerControl: StockConnectBrokerControl;
183
+ deps?: StockConnectDeps;
184
+ }
185
+
186
+ export interface StockConnectSession {
187
+ client: ViceMonitorClient;
188
+ versionQuad: string;
189
+ capabilities: StockCapabilities;
190
+ host: string;
191
+ port: number;
192
+ targetId: string;
193
+ brokerControl: StockConnectBrokerControl;
194
+ deps: StockConnectDeps;
195
+ /** This instance's epoch, as read at connect time -- `null` when no
196
+ * epoch evidence could be read at all (deps.epochPath omitted, absent, or
197
+ * unreadable). Consumed only by stockReconnect() (Task 2). */
198
+ baselineEpoch: number | null;
199
+ }
200
+
201
+ async function safeDisconnect(client: ViceMonitorClient): Promise<void> {
202
+ try {
203
+ await client.disconnect();
204
+ } catch {
205
+ // disconnect() itself never throws in stock-protocol.ts's own
206
+ // implementation, but this handshake's own failure-cleanup path must
207
+ // never itself fail on the way out.
208
+ }
209
+ }
210
+
211
+ /**
212
+ * CR-02 (code review 2026-08-13). THE load-bearing counterpart to every
213
+ * command this handshake sends. docs/phase0-binmon-findings.md §4, read from
214
+ * VICE's own source, is explicit: `monitor_check_binary()` calls
215
+ * `monitor_startup_trap()` on ANY INBOUND BYTE (monitor_binary.c:281), and
216
+ * that check runs every vsync -- so the bare `PING` (0x81) below halts the
217
+ * emulated C64 within roughly one frame and emits `STOPPED` (0x62). `EXIT`
218
+ * (0xaa) is the ONLY thing that resumes it.
219
+ *
220
+ * Before this function existed, nothing in this tree ever sent 0xaa: the
221
+ * first `vice_ping` on the stock backend froze the machine and left it frozen
222
+ * for the life of the held session, with a `STOPPED` event nobody consumed --
223
+ * exactly the "stopped advancing / not wedged / merely paused" state
224
+ * `vice-wedge-triage` exists to disambiguate, manufactured by the health
225
+ * check itself.
226
+ *
227
+ * WHAT NOT TO DO: never add a command sequence to this file (or to any future
228
+ * stock handler) that leaves the machine halted. The invariant is that a
229
+ * handshake returns the machine to the run state it found it in. That is why
230
+ * the success-path call below is INSIDE the try: a handshake that cannot
231
+ * prove it resumed the machine is a FAILED handshake, routed through the same
232
+ * disconnect-and-release cleanup as any other step, never a success that
233
+ * silently leaves the C64 frozen.
234
+ *
235
+ * The `RESUMED` (0x63) event VICE emits alongside the EXIT reply arrives at
236
+ * request id 0xffffffff and is routed to ViceMonitorClient's 'event' channel
237
+ * by the request-id-first demux -- deliberately not awaited here: keying on
238
+ * it would mean waiting on an unsolicited frame, which is precisely what that
239
+ * demux forbids resolving a request with.
240
+ */
241
+ async function resumeMachine(client: ViceMonitorClient): Promise<void> {
242
+ await client.send(CommandType.Exit);
243
+ }
244
+
245
+ /** Best-effort resume for the FAILURE path only: the machine may already be
246
+ * halted by whichever command got through before the failure, and this
247
+ * handshake must not leave it that way if it can help it. Every error is
248
+ * swallowed -- the original handshake failure is what the caller must see
249
+ * (WR-07's own concern), never a cleanup error replacing it. Skipped entirely
250
+ * when the socket is already gone, since there is nothing to send through. */
251
+ async function safeResume(client: ViceMonitorClient): Promise<void> {
252
+ if (!client.connected) return;
253
+ try {
254
+ await resumeMachine(client);
255
+ } catch (err) {
256
+ console.error(`stockConnect: best-effort resume (EXIT 0xaa) after a failed handshake did not complete: ${String(err)}`);
257
+ }
258
+ }
259
+
260
+ /**
261
+ * The one connect handshake for the stock path, in load-bearing order:
262
+ *
263
+ * 1. claimMonitor() -- BEFORE any socket is opened (PROTO-08, D-13). A
264
+ * `monitor_owned` refusal rejects with MonitorOwnershipError naming the
265
+ * holder; a `timeout` refusal rejects distinctly (the broker did not
266
+ * answer, which is not "someone else owns it").
267
+ * 2. Open a ViceMonitorClient against host:port.
268
+ * 3. Assert the protocol version by sending one PING (0x81).
269
+ * stock-protocol.ts's own parser validates api_version on every frame
270
+ * it decodes -- a mismatch surfaces as a StockFramingError straight out
271
+ * of `client.send()`, which this function propagates as a fatal
272
+ * handshake failure rather than re-deriving the check.
273
+ * 4. Send VICE_INFO (0x85) and read the version quad.
274
+ * 5. Gate capabilities via resolveCapabilities() above.
275
+ * 6. Record this instance's epoch (deps.epochPath) as the reconnect
276
+ * baseline (Task 2) -- absence is normal here (D-3's own posture) and
277
+ * becomes significant only at stockReconnect() time.
278
+ * 7. Send EXIT (0xaa) to RESUME the machine step 3's PING halted (CR-02).
279
+ * Non-optional: see resumeMachine()'s own header comment. This handshake
280
+ * returns the emulator to the run state it found it in, or fails.
281
+ *
282
+ * Every failure path releases the monitor claim before propagating -- a
283
+ * handshake that fails at any step must never leave the instance claimed --
284
+ * and best-effort resumes the machine first, so a failed handshake does not
285
+ * leave a frozen C64 behind either.
286
+ */
287
+ export async function stockConnect({ host, port, targetId, brokerControl, deps = {} }: StockConnectOptions): Promise<StockConnectSession> {
288
+ const claimOutcome = await brokerControl.claimMonitor({ targetId });
289
+ if (!claimOutcome.ok) {
290
+ if (claimOutcome.reason === "monitor_owned") {
291
+ throw new MonitorOwnershipError(
292
+ `stockConnect: monitor for target ${targetId} on port ${port} is already claimed by grant ${claimOutcome.holder.grantId}`,
293
+ { holderGrantId: claimOutcome.holder.grantId, holderClaimedAt: claimOutcome.holder.claimedAt, port },
294
+ );
295
+ }
296
+ // "timeout" (the broker did not answer) is kept strictly distinct from
297
+ // "monitor_owned" (someone else holds it) -- never conflated, matching
298
+ // vice-broker-client.ts's own MonitorOwnershipError header comment.
299
+ throw new ViceError(`stockConnect: monitor claim for target ${targetId} failed (${claimOutcome.reason})`, { code: claimOutcome.reason });
300
+ }
301
+
302
+ const client = new ViceMonitorClient();
303
+ // CR-02: flipped immediately BEFORE step 7's resume is attempted, so the
304
+ // failure path below never sends a SECOND EXIT for a resume that already
305
+ // failed on its own (which would stack a second full timeout on top of the
306
+ // first, and re-report the same problem twice).
307
+ let resumeAttempted = false;
308
+
309
+ try {
310
+ await client.connect(host, port);
311
+
312
+ // Step 3: api_version assertion. A non-0x02 api_version rejects this
313
+ // send() call directly with a StockFramingError naming the observed
314
+ // value -- see this function's own header comment. NOTE (CR-02): this
315
+ // single byte HALTS the emulated machine (any inbound byte does --
316
+ // docs/phase0-binmon-findings.md §4); step 7's EXIT is what undoes it.
317
+ await client.send(CommandType.Ping);
318
+
319
+ // Step 4: build identity.
320
+ const infoResponse = await client.send(CommandType.ViceInfo);
321
+ if (infoResponse.type !== "vice_info") {
322
+ throw new ViceError(`stockConnect: VICE_INFO reply for target ${targetId} had unexpected shape "${infoResponse.type}"`);
323
+ }
324
+ const versionQuad = infoResponse.versionString;
325
+
326
+ // Step 5: settle version-gated capabilities, once per binary.
327
+ const capabilities = await resolveCapabilities(client, versionQuad, deps);
328
+
329
+ // Step 6: record the reconnect baseline (Task 2). Absence is normal --
330
+ // matches vice.ts's own readEpoch()/D-3 posture -- and is not an error
331
+ // here; it becomes significant only inside stockReconnect().
332
+ const readEpochFn = deps.readEpochFn ?? readEpoch;
333
+ const baselineRecord: EpochResult | null = deps.epochPath ? readEpochFn(deps.epochPath) : null;
334
+ const baselineEpoch = baselineRecord && baselineRecord.present ? baselineRecord.epoch : null;
335
+
336
+ // Step 7 (CR-02): resume the machine the PING in step 3 halted. LAST, and
337
+ // inside the try -- see resumeMachine()'s own header comment for why a
338
+ // failure here must fail the whole handshake rather than return a session
339
+ // whose emulator is frozen.
340
+ resumeAttempted = true;
341
+ await resumeMachine(client);
342
+
343
+ return { client, versionQuad, capabilities, host, port, targetId, brokerControl, deps, baselineEpoch };
344
+ } catch (err) {
345
+ // Every failure path releases the claim before propagating -- a
346
+ // handshake that fails at any step must not leave the instance locked --
347
+ // and, CR-02, tries to leave the machine RUNNING on the way out too.
348
+ if (!resumeAttempted) await safeResume(client);
349
+ await safeDisconnect(client);
350
+ // WR-07: the release must never REPLACE the original failure. Before this,
351
+ // a bare `await brokerControl.releaseMonitor(...)` sat between the catch and
352
+ // the throw, so a rejecting release substituted its own error for the real
353
+ // handshake cause (an api-version mismatch, a timeout, a closed socket) and
354
+ // the caller lost it entirely. Its `{ ok: false, reason }` outcome was
355
+ // discarded too, so a FAILED release was silent -- the instance stayed
356
+ // claimed while the caller was told the handshake failed for an unrelated
357
+ // reason. Both outcomes are now reported on stderr and neither can
358
+ // displace `err`.
359
+ try {
360
+ const released = await brokerControl.releaseMonitor({ targetId });
361
+ if (!released.ok) {
362
+ console.error(
363
+ `stockConnect: monitor release for target ${targetId} after a failed handshake was refused (${released.reason}) -- the instance may still be claimed`,
364
+ );
365
+ }
366
+ } catch (releaseErr) {
367
+ console.error(`stockConnect: monitor release for target ${targetId} after a failed handshake threw: ${String(releaseErr)}`);
368
+ }
369
+ throw err;
370
+ }
371
+ }
372
+
373
+ /** Normal counterpart to stockConnect()'s claim: disconnects the socket and
374
+ * releases the monitor claim together, so a caller never ends up holding
375
+ * one without the other. This is the "success path" release alongside
376
+ * stockConnect()'s own failure-path release above. */
377
+ export async function stockDisconnect(session: StockConnectSession): Promise<void> {
378
+ await safeDisconnect(session.client);
379
+ await session.brokerControl.releaseMonitor({ targetId: session.targetId });
380
+ }
381
+
382
+ // ---------------------------------------------------------------------------
383
+ // stockReconnect() -- Task 2: reconnect-with-identity-check.
384
+ // ---------------------------------------------------------------------------
385
+
386
+ export interface StockReconnectOptions {
387
+ lastToolCall?: string | null;
388
+ }
389
+
390
+ /**
391
+ * Reconnects against the SAME target this session originally handshook
392
+ * with, proving identity via the per-instance epoch file (deps.epochPath)
393
+ * BEFORE running the handshake again. Three failure meanings, three
394
+ * distinct types -- conflating any two of them is the regression this
395
+ * comment exists to prevent:
396
+ *
397
+ * - StockRequestTimeoutError (stock-protocol.ts): "connected but silent."
398
+ * - StockConnectionClosedError (stock-protocol.ts): "this socket died."
399
+ * - MachineRestartedError (vice.ts, reused -- never redefined here):
400
+ * "the machine under you is not the machine you handshook with," or its
401
+ * identity across the reconnect could not be proven at all (no epoch
402
+ * evidence either way is treated the same as proven-different -- D-3's
403
+ * own "identity that cannot be proven is treated as not proven").
404
+ *
405
+ * On a proven match, this function re-runs the FULL handshake (stockConnect
406
+ * again) rather than merely re-dialling: re-reading VICE_INFO and
407
+ * re-validating the capability record against the freshly observed version
408
+ * quad means a restart that swapped the underlying binary never inherits
409
+ * the old build's capability answers (resolveCapabilities()'s own staleness
410
+ * check above).
411
+ */
412
+ export async function stockReconnect(session: StockConnectSession, { lastToolCall = null }: StockReconnectOptions = {}): Promise<StockConnectSession> {
413
+ const readEpochFn = session.deps.readEpochFn ?? readEpoch;
414
+ const current: EpochResult | null = session.deps.epochPath ? readEpochFn(session.deps.epochPath) : null;
415
+ const currentEpoch = current && current.present ? current.epoch : null;
416
+ const baselineEpoch = session.baselineEpoch;
417
+
418
+ if (baselineEpoch === null || currentEpoch === null || currentEpoch !== baselineEpoch) {
419
+ throw new MachineRestartedError(
420
+ `stockConnect: reconnect to target ${session.targetId} could not prove machine identity across the reconnect ` +
421
+ `(baseline epoch ${String(baselineEpoch)}, current epoch ${String(currentEpoch)})`,
422
+ { baselineEpoch, currentEpoch, where: "stock-connect.ts:stockReconnect", lastToolCall },
423
+ );
424
+ }
425
+
426
+ return stockConnect({ host: session.host, port: session.port, targetId: session.targetId, brokerControl: session.brokerControl, deps: session.deps });
427
+ }
@@ -0,0 +1,122 @@
1
+ #!/usr/bin/env node
2
+ // stock-derived.ts
3
+ //
4
+ // THE derived-tool leaf (DERIV-07). This file owns the one thing
5
+ // stock-dispatch.ts does not: the CONTAINER-PATH DISCIPLINE for a stock tool
6
+ // whose answer is computed CLIENT-SIDE rather than fetched from the wire.
7
+ // It holds STOCK_DERIVED_TOOLS (the data-only registry, D-03),
8
+ // derivedContainerPath() (D-01/D-02), DerivedToolError, and the
9
+ // DerivedPureHandler type. withDerivedTool() itself lives beside
10
+ // withStockSession() in stock-dispatch.ts -- see this plan's
11
+ // plan_decision_module_split block for why the adapter and this leaf are two
12
+ // files rather than one (withDerivedTool() must delegate to
13
+ // ensureStockSession(), which lives in stock-dispatch.ts; putting the
14
+ // adapter here would close a runtime cycle stock-dispatch.ts -> stock-derived.ts
15
+ // -> stock-dispatch.ts).
16
+ //
17
+ // WHY THIS FILE EXISTS: this is the MIRROR IMAGE of stock-paths.ts's D-17
18
+ // hazard. There, NOT translating an emulator-side path is the bug -- four
19
+ // tools carry a filename stock VICE opens on the HOST, and stock-paths.ts's
20
+ // whole job is making sure that translation happens. HERE, translating a
21
+ // CLIENT-SIDE-DERIVED path is the bug: rewriteArguments() runs INSIDE the
22
+ // fork-forwarding function at vice-proxy.ts:2773, before call() -- a derived
23
+ // tool sitting behind call() would receive HOST-translated paths and act on
24
+ // them INSIDE THE CONTAINER (ROADMAP Phase 4 Notes, CLAUDE.md). The
25
+ // derived-tool seam this file anchors exists so a derived tool's handler is
26
+ // reached BEFORE that fork-forwarding function ever runs
27
+ // rewriteArguments() at all.
28
+ //
29
+ // SECOND CONSUMER, named now so Phase 5's edit is a one-liner:
30
+ // gatherWedgeEvidence() (vice-proxy.ts:1343) calls rewriteArguments() itself
31
+ // at line 1367. On the stock backend, PERFORMING that translation becomes
32
+ // the bug -- its own comment inverts. Phase 5 criterion 5 owns that fix; it
33
+ // is deliberately NOT repointed here (it is currently unreachable on stock
34
+ // anyway: handleRecycle() is backend-aware and refused by name after CR-07,
35
+ // and vice_display_screenshot does not exist on stock until Phase 5).
36
+ //
37
+ // WHAT NOT TO DO:
38
+ // - Never `import` hostpath.ts from this file, or from any module listed
39
+ // in STOCK_DERIVED_TOOLS' implementations -- hostpath-consumers.test.ts
40
+ // fails the build if you do.
41
+ // - Never `import` vice-proxy.ts, and never call rewriteArguments().
42
+ // - Never add a client-side-derived tool to stock-paths.ts's
43
+ // STOCK_EMULATOR_SIDE_PATH_TOOLS -- that file's own header already warns
44
+ // against exactly this.
45
+ // - Never build a second dispatch table or a fall-through (D-03, Phase 2
46
+ // D-09). Derived-ness is a property of which adapter wraps a handler,
47
+ // never a routing decision -- there is still exactly one
48
+ // STOCK_DISPATCH_TABLE and exactly one dispatchStock( call site in
49
+ // vice-proxy.ts.
50
+ // - Never re-implement session acquisition here -- withDerivedTool() in
51
+ // stock-dispatch.ts delegates to the one ensureStockSession() the 25
52
+ // direct tools use.
53
+ import { ViceError, type ViceErrorOptions } from "./vice.ts";
54
+ import type { StockToolResult } from "./stock-handler.ts";
55
+ import type { StockDispatchDeps } from "./stock-dispatch.ts";
56
+
57
+ /** The one error type this module ever throws -- never a bare Error,
58
+ * matching vice.ts's established ViceError hierarchy (stock-address.ts's
59
+ * StockAddressError and stock-paths.ts's StockPathError are the sibling
60
+ * precedents). */
61
+ export class DerivedToolError extends ViceError {
62
+ constructor(message: string, options: ViceErrorOptions = {}) {
63
+ super(message, options);
64
+ this.name = "DerivedToolError";
65
+ }
66
+ }
67
+
68
+ // ---------------------------------------------------------------------------
69
+ // STOCK_DERIVED_TOOLS -- the data-only registry (D-03). This set is DATA and
70
+ // is NEVER consulted to ROUTE a call -- it is consulted only to refuse an
71
+ // undeclared tool (derivedContainerPath(), withDerivedTool()) and to answer
72
+ // Phase 8's "which tools are derived" question. There is still exactly one
73
+ // STOCK_DISPATCH_TABLE; a tool's presence here changes nothing about how it
74
+ // is dispatched, only which adapter wraps its handler.
75
+ // ---------------------------------------------------------------------------
76
+
77
+ export const STOCK_DERIVED_TOOLS: ReadonlySet<string> = new Set([
78
+ "vice_disassemble", // Phase 4, DERIV-07's first consumer (04-05) -- client-side 6510 disassembler
79
+ ]);
80
+
81
+ /**
82
+ * The handler shape for a derived tool that needs NO session (D-04). It
83
+ * receives no session argument at all, so it structurally cannot reach the
84
+ * wire -- there is no `session.client` to send anything through. `deps` is
85
+ * threaded down for anything the handler needs beyond the session (matching
86
+ * StockSessionHandler's own `deps` parameter).
87
+ *
88
+ * `StockDispatchDeps` is imported `type`-only from stock-dispatch.ts -- under
89
+ * verbatimModuleSyntax an `import type` erases completely at compile time,
90
+ * so it creates no runtime cycle even though stock-dispatch.ts imports THIS
91
+ * file's other exports at runtime (exactly the arrangement stock-handler.ts
92
+ * already uses and documents for the same reason).
93
+ */
94
+ export type DerivedPureHandler = (args: Record<string, unknown>, deps: StockDispatchDeps) => Promise<StockToolResult>;
95
+
96
+ // ---------------------------------------------------------------------------
97
+ // derivedContainerPath() -- the container-path discipline (D-01/D-02).
98
+ // ---------------------------------------------------------------------------
99
+
100
+ /**
101
+ * Returns `containerPath` UNCHANGED, having consulted nothing -- no
102
+ * `isInsideContainer()` branch, no `hostPathCandidates()`, no
103
+ * `tryHostPaths()`, no environment variable. Its entire behaviour is "return
104
+ * the container path", and its entire value is that it is the ONE named seam
105
+ * a derived tool routes an output path through -- so a future reviewer (or
106
+ * hostpath-consumers.test.ts's asserted-absence scan) has something concrete
107
+ * to point at when checking that no translation ever happened.
108
+ *
109
+ * Refuses any `toolName` not declared in STOCK_DERIVED_TOOLS -- the same
110
+ * refuse-if-not-declared shape as stock-paths.ts's withEmulatorSidePath() --
111
+ * so a handler cannot opt itself into derived treatment without being
112
+ * declared in the registry above.
113
+ */
114
+ export function derivedContainerPath(toolName: string, containerPath: string): string {
115
+ if (!STOCK_DERIVED_TOOLS.has(toolName)) {
116
+ throw new DerivedToolError(
117
+ `derivedContainerPath: ${toolName} is not declared in STOCK_DERIVED_TOOLS -- only a tool listed there may ` +
118
+ `route a path through this function.`,
119
+ );
120
+ }
121
+ return containerPath;
122
+ }