@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,2533 @@
1
+ // GENERATED FILE -- DO NOT EDIT.
2
+ // Compiled by `tsc` from host-tool.mts. Edit the TypeScript source and rebuild;
3
+ // changes made directly to this file are silently overwritten by the next build, and are never
4
+ // deployed to the host on their own -- install-resources.mjs copies THIS file's on-disk contents
5
+ // verbatim to .c64-re-tools/bin/, so an edit made only here reaches the host but is lost on the very next
6
+ // rebuild.
7
+ // host-tool.mts
8
+ //
9
+ // This is the host-bound executor for the host-tool control op. A
10
+ // container-side caller (host-tool-client.ts) reaches this module over
11
+ // broker-control.mts's `host_tool` op -- never directly -- and this module is
12
+ // the ONE place that turns an untrusted wire request into a real child
13
+ // process on the HOST, outside any container. Motivated by the project
14
+ // owner's own rule, recorded once a skill script's own binaries (acme, and
15
+ // later dxa/Ghidra/c1541/petcat/cartconv) turned out to need to live
16
+ // host-side: a skill script runs container-side and there is no container
17
+ // PATH to find them on.
18
+ //
19
+ // THIS IS THE ONE AUTHORITATIVE PLACE for three things, none of which may be
20
+ // re-derived anywhere else:
21
+ // - the typed per-tool allowlist (HOST_TOOL_IDS / HOST_TOOL_ARG_KEYS /
22
+ // normaliseHostToolRequest()) -- mirrors broker-control.mts's own
23
+ // normaliseLaunchProfile() discipline: refuse unknown keys BY NAME, never
24
+ // coerce a type, never drop a key silently;
25
+ // - server-side argv construction (buildHostToolArgv()) -- argv is built
26
+ // ENTIRELY from typed, already-narrowed fields; a wire array or a wire
27
+ // string never reaches argv (T-34-01);
28
+ // - the async child-process invocation and its result digest (runHostTool())
29
+ // -- spawned via node:child_process's async `spawn`, never `spawnSync`,
30
+ // never a shell string, and bounded by a per-invocation timeout (T-34-02,
31
+ // T-34-05).
32
+ //
33
+ // WHAT NOT TO DO, each naming the prohibition it guards (recorded because a
34
+ // fast sweep of this file could plausibly re-add any one of them):
35
+ // - No generic wire op that accepts a raw argv array or a raw command
36
+ // string for a host tool -- argv is constructed server-side from typed
37
+ // fields only (T-34-01).
38
+ // - No shell-form child process on any host-tool path: no command
39
+ // interpreter, no interpolated command string (T-34-02).
40
+ // - No host-tool output written outside the bind-mounted workspace tree --
41
+ // resolveWorkspacePath() is the only place a wire-supplied path becomes a
42
+ // real path. BOTH the workspace root and the candidate go through the
43
+ // same ancestor-realpath walk (realpathOfNearestExisting(), mirroring
44
+ // anno-types.ts's storePathWithinWorkspace() and its own incident
45
+ // history by name) before the prefix comparison, and the comparison is
46
+ // over the WALKED (real) paths, never the lexical join -- a purely
47
+ // lexical path.resolve() + startsWith() check is exactly what a live
48
+ // symlink test found: a symlink planted inside the workspace defeated
49
+ // it, satisfying the prefix check lexically while a real write through
50
+ // it landed outside the root. This covers EVERY path-bearing wire field
51
+ // on every tool, not only the ones present when this file was first
52
+ // written: acme.build's source/outDir AND each entry of its includes
53
+ // array, and ghidra.analyze's importPath AND its preScript/postScript.
54
+ // Two residuals recorded beside the guarantee, not hidden past it: the
55
+ // check-then-open window between this decision and the child process's
56
+ // own open is NOT closed here -- the child is a third-party binary
57
+ // handed a path string, so there is no descriptor-based route to making
58
+ // the check and the open one operation (T-34-52, accepted). And the
59
+ // comparison is byte-wise over the resolved strings with no Unicode
60
+ // normalisation, so two spellings differing only in normalisation form
61
+ // are two distinct paths here (same residual anno-confinement.test.ts
62
+ // records for the same comparison). A third residual, recorded rather
63
+ // than hidden: because the return value is now the REAL path, on a
64
+ // host whose workspace root is itself reached through a symlink the
65
+ // response `path` need not match any member of hostRootCandidates(),
66
+ // and containerPath() throws rather than passing an untranslatable
67
+ // path through -- HOST_WORKSPACE_PATH naming the real root is the
68
+ // pre-existing mitigation; this is a recorded limit, not a widened
69
+ // hostpath.ts consumer set.
70
+ // - No inline byte payload on a host-tool response, at any result size --
71
+ // every result crosses as `{ path, sha256, byteLength }`, never bytes.
72
+ // - No second copy of a tool's argv construction -- buildHostToolArgv() is
73
+ // the one place.
74
+ // - No synchronous child-process call on any path reachable from the
75
+ // broker process -- runHostTool() awaits an async spawn only.
76
+ // - No unbounded host-tool child process -- every invocation is bounded by
77
+ // a timeout that kills the child and reports a refusal on expiry.
78
+ //
79
+ // This module must never be added to package.json's `files[]` (mirrors
80
+ // broker-control.mts's own precedent: shipped only as its compiled
81
+ // `resources/host-tool.mjs` artifact, added to build.ts's HOST_BOUND_ARTIFACTS
82
+ // and tsconfig.build.json's include[] in the same commit as this file).
83
+ //
84
+ // This module's first SIBLING import. `ghidra-project.mjs` is a VALUE
85
+ // import (not type-only) because the rule must be enforced where
86
+ // `analyzeHeadless` is actually spawned -- inside the broker process --
87
+ // which is why `ghidra-project.mts` ships as a compiled `resources/*.mjs`
88
+ // artifact exactly like this file does. A `.mjs`-specifier value import
89
+ // only resolves once both siblings are compiled into resources/ (the same
90
+ // reason host-tool.test.ts reaches THIS module as the committed artifact,
91
+ // not the unbuilt source). The dot-segment rule and the per-run project
92
+ // location are NEVER copied here -- this module reaches them through the
93
+ // one place that owns them.
94
+ import { spawn } from "node:child_process";
95
+ import { createHash } from "node:crypto";
96
+ import { cpSync, existsSync, lstatSync, mkdirSync, readFileSync, readlinkSync, realpathSync, rmSync, writeFileSync } from "node:fs";
97
+ import { basename, dirname, isAbsolute, join, resolve as resolvePath, sep } from "node:path";
98
+ import { fileURLToPath } from "node:url";
99
+ import { resolveGhidraProject, buildAnalyzeHeadlessArgv, hasDotPrefixedSegment, installedLanguageIds, GHIDRA_STOCK_6502_LANGUAGE_FILES, GHIDRA_IMPORT_ROUTES, importRouteBaseAddr, LANGUAGE_ID_PATTERN, LOADER_BASE_ADDR_PATTERN, RUN_ID_PATTERN, } from "./ghidra-project.mjs";
100
+ // This module's SECOND sibling import. `resolvedBackend()` is the ONE
101
+ // place that decides which x64sc build is on this host (backend-detect.mts's
102
+ // own header) -- findSiblingBinary() below resolves c1541/petcat as siblings
103
+ // of THAT resolved binary rather than by a bare-name spawn, which a host
104
+ // carrying both a stock and a fork build (MEASURED live on this project's
105
+ // own dev host: /usr/local/bin/x64sc is the fork, /usr/bin/x64sc is genuine
106
+ // stock, and $PATH resolves the fork first) would otherwise silently answer
107
+ // with whichever build's directory happens to sort first. A VALUE import,
108
+ // exactly like ghidra-project.mjs above, for the same reason: it is invoked
109
+ // where the sibling binary is actually resolved, inside this process.
110
+ // resolvedBackend() is itself memoised at module scope (backend-detect.mts's
111
+ // own `memoisedResult`) and this project's broker already calls it once at
112
+ // startup before the control listener binds (vice-broker.mts's run()) -- for
113
+ // the control-plane route this call below is therefore always a cache hit,
114
+ // never a second probe. The host route (no broker in the loop) has no such
115
+ // warm memo and pays one `--help` probe per invocation, mirroring the
116
+ // existing, already-accepted cost vice-broker.mts's own startup call pays
117
+ // once per broker lifetime -- never re-probed per c1541.* call within the
118
+ // SAME process, per findSiblingBinary()'s own memo below.
119
+ import { resolvedBackend } from "./backend-detect.mjs";
120
+ // This module's own directory, used ONLY to compute the vendored dxa
121
+ // binary's fixed path. Never an environment-variable override: dxa is
122
+ // vendored AND built by this project (unlike ACME_BIN/GHIDRA_HOME, which
123
+ // name a HOST PREREQUISITE a user installs anywhere), so an override could
124
+ // only ever select a binary this project did not build and did not pin --
125
+ // a substitution this seam must never allow.
126
+ const HERE = dirname(fileURLToPath(import.meta.url));
127
+ export const HOST_TOOL_IDS = Object.freeze([
128
+ "acme.build",
129
+ "ghidra.analyze",
130
+ "oracle.probe",
131
+ "oracle.run",
132
+ "dxa.disassemble",
133
+ "ghidra.installExtension",
134
+ "c1541.bam",
135
+ "c1541.dir",
136
+ "c1541.entry",
137
+ "c1541.chain",
138
+ "c1541.read",
139
+ "petcat.decode",
140
+ ]);
141
+ /** Per-tool accepted argument-key lists, built with `Object.create(null)`
142
+ * (the same prototype-null idiom vsf-slice.mjs uses) so no prototype key can
143
+ * ever resolve to a value here even if a future caller indexed it with an
144
+ * untrusted string directly -- belt-and-suspenders alongside the
145
+ * array-membership check above, which is what actually guards the lookup
146
+ * below.
147
+ *
148
+ * `ghidra.analyze`'s accepted keys carry no raw argv array and no raw
149
+ * command string. Per field (corrected from an earlier version of this
150
+ * comment, which wrongly claimed all four already flowed through a
151
+ * resolver -- that was false for the two script fields until it was fixed):
152
+ * `runId` is a bare name, never a path, and flows through
153
+ * `resolveGhidraProject()`'s own per-run-directory resolution; `importPath`,
154
+ * `preScript` and `postScript` each flow through `resolveWorkspacePath()` --
155
+ * the SAME workspace-boundary resolver `acme.build`'s `source`/`outDir`/
156
+ * `includes` use -- before any of the four ever reaches argv.
157
+ * `buildAnalyzeHeadlessArgv()` (ghidra-project.mts) also independently
158
+ * re-checks `preScript`/`postScript` for a parent-directory path segment,
159
+ * exactly as it already re-checks `projectLocation` for a dot-prefixed
160
+ * segment -- so both rules hold even for a caller that constructed these
161
+ * fields itself and skipped this module's own resolution sites entirely.
162
+ *
163
+ * `ghidra.analyze` gains `processor` (promoted from optional to required
164
+ * after early testing showed a caller must always name the language) -- a
165
+ * REQUIRED, non-path, language-id string. It is deliberately absent from
166
+ * `HOST_TOOL_PATH_ARG_KEYS` below and never flows through
167
+ * `resolveWorkspacePath()`; it is validated against `LANGUAGE_ID_PATTERN`
168
+ * instead (ghidra-project.mts). `ghidra.installExtension`'s two keys:
169
+ * `sourceDir` (workspace-relative, path-bearing) and `moduleName` (a
170
+ * non-path name validated against `RUN_ID_PATTERN`'s anchored shape, exactly
171
+ * like `ghidra.analyze`'s own `runId`). */
172
+ export const HOST_TOOL_ARG_KEYS = Object.freeze(Object.assign(Object.create(null), {
173
+ "acme.build": Object.freeze(["source", "outDir", "format", "setpc", "defines", "includes", "noReport"]),
174
+ // Seven new fields close a measured seam-argv surface gap -- importRoute
175
+ // (required), loaderBaseAddr, noanalysis, scriptPath, entrypointsPath,
176
+ // exportPath, expectedClassificationLines.
177
+ // "dataRangesPath" is the ONE new field added after that -- an OPTIONAL
178
+ // path-bearing field naming a range file for the new DataRangeSeed.java
179
+ // pre-script. A run omitting it is accepted exactly as before this
180
+ // field existed: a first pass without graphics feedback must keep
181
+ // working unchanged.
182
+ "ghidra.analyze": Object.freeze([
183
+ "runId",
184
+ "importPath",
185
+ "processor",
186
+ "importRoute",
187
+ "loaderBaseAddr",
188
+ "noanalysis",
189
+ "scriptPath",
190
+ "preScript",
191
+ "postScript",
192
+ "entrypointsPath",
193
+ "exportPath",
194
+ "expectedClassificationLines",
195
+ "dataRangesPath",
196
+ ]),
197
+ // EMPTY -- the oracle's location is host-side configuration only
198
+ // (resolveOracleCommand(), below), never a wire value. No
199
+ // caller-supplied value may ever select what the host executes, even
200
+ // framed as merely reconfiguring an already-allowlisted tool.
201
+ "oracle.probe": Object.freeze([]),
202
+ "oracle.run": Object.freeze(["source"]),
203
+ // Frozen exactly as originally specified -- five path-bearing keys plus
204
+ // the one enum key (`imageKind`), never re-derived from
205
+ // ResolvedDxaDisassemblePaths below.
206
+ "dxa.disassemble": Object.freeze(["image", "imageKind", "entrypointsPath", "datablocksPath", "labelsPath", "outDir"]),
207
+ // `sourceDir` is the vendored extension tree; `moduleName` names the
208
+ // install target directory under <GHIDRA_HOME>/Ghidra/Extensions/.
209
+ "ghidra.installExtension": Object.freeze(["sourceDir", "moduleName"]),
210
+ // `image` is the `.d64` these five capabilities read; `outDir` defaults
211
+ // to `dirname(imagePath)`, exactly as `dxa.disassemble`'s own default
212
+ // does. `name` (entry/chain/read) is a CBM filename or glob pattern --
213
+ // never a path, never resolved through `resolveWorkspacePath()` (see
214
+ // HOST_TOOL_PATH_ARG_KEYS below).
215
+ "c1541.bam": Object.freeze(["image", "outDir"]),
216
+ "c1541.dir": Object.freeze(["image", "outDir"]),
217
+ "c1541.entry": Object.freeze(["image", "name", "outDir"]),
218
+ "c1541.chain": Object.freeze(["image", "name", "outDir"]),
219
+ "c1541.read": Object.freeze(["image", "name", "outDir"]),
220
+ // No dialect key here or anywhere else in this module -- the BASIC
221
+ // dialect is a fixed literal inside buildHostToolArgv()'s own
222
+ // petcat.decode branch, never a wire field. A caller has no way to
223
+ // request one, let alone a wrong one.
224
+ "petcat.decode": Object.freeze(["image", "outDir"]),
225
+ }));
226
+ /** The answer to ONE question -- which accepted argument keys, per tool,
227
+ * name a filesystem path and therefore MUST pass `resolveWorkspacePath()`
228
+ * before ever reaching argv. Built with the SAME
229
+ * `Object.freeze(Object.assign(Object.create(null), ...))` idiom
230
+ * `HOST_TOOL_ARG_KEYS` above uses. Consumed by `host-tool.test.ts`'s
231
+ * data-driven census, never by production code -- the census is what makes
232
+ * "no argv passthrough anywhere" a mechanism rather than three point fixes:
233
+ * a key added here without a matching resolution site is what the test
234
+ * proves, a key ADDED to `HOST_TOOL_ARG_KEYS` without being classified HERE
235
+ * (as path-bearing or not) is what the test's both-directions completeness
236
+ * check catches.
237
+ *
238
+ * Deliberately NOT included: `ghidra.analyze`'s `runId`. It is a validated
239
+ * opaque id bounded by its own anchored pattern (`RUN_ID_PATTERN`,
240
+ * ghidra-project.mts), turned into a path only by `resolveGhidraProject()`
241
+ * -- a DIFFERENT mechanism with its own guard, not `resolveWorkspacePath()`.
242
+ * `oracle.probe`'s entry is empty because that tool accepts no arguments at
243
+ * all.
244
+ *
245
+ * `ghidra.analyze`'s `processor` is deliberately NOT listed here -- it is a
246
+ * language-id string, not a path, and is validated against
247
+ * `LANGUAGE_ID_PATTERN` instead. `ghidra.installExtension`'s `sourceDir` IS
248
+ * path-bearing; `moduleName` is deliberately absent for the same reason
249
+ * `ghidra.analyze`'s own `runId` is: a validated opaque name
250
+ * (`RUN_ID_PATTERN`) turned into a path segment only inside
251
+ * `runHostTool()`'s own resolution branch below, never through
252
+ * `resolveWorkspacePath()`. */
253
+ export const HOST_TOOL_PATH_ARG_KEYS = Object.freeze(Object.assign(Object.create(null), {
254
+ "acme.build": Object.freeze(["source", "outDir", "includes"]),
255
+ // scriptPath/entrypointsPath/exportPath join the pre-existing three --
256
+ // each resolved through resolveWorkspacePath() in runHostTool()'s ghidra
257
+ // branch, exactly like importPath/preScript/postScript already are.
258
+ // "dataRangesPath" joins the pre-existing six -- resolved through
259
+ // resolveWorkspacePath() in runHostTool()'s ghidra branch, exactly like
260
+ // every other script-adjacent path field.
261
+ "ghidra.analyze": Object.freeze(["importPath", "preScript", "postScript", "scriptPath", "entrypointsPath", "exportPath", "dataRangesPath"]),
262
+ "oracle.probe": Object.freeze([]),
263
+ "oracle.run": Object.freeze(["source"]),
264
+ // `imageKind` is deliberately absent -- it is a two-member enum, not a
265
+ // path, and is the one key HOST_TOOL_ARG_KEYS_REMAINDER (host-tool.test.ts)
266
+ // classifies for this tool.
267
+ "dxa.disassemble": Object.freeze(["image", "entrypointsPath", "datablocksPath", "labelsPath", "outDir"]),
268
+ "ghidra.installExtension": Object.freeze(["sourceDir"]),
269
+ // `image`/`outDir` are path-bearing on all five ids; `name`
270
+ // (entry/chain/read) is deliberately absent here -- it is a CBM
271
+ // filename/glob, not a path, and is the one key each of those three
272
+ // tools' own `HOST_TOOL_ARG_KEYS_REMAINDER` entry (host-tool.test.ts)
273
+ // classifies. `c1541.bam`/`c1541.dir` have no non-path keys at all, so
274
+ // their own remainder entries are empty.
275
+ "c1541.bam": Object.freeze(["image", "outDir"]),
276
+ "c1541.dir": Object.freeze(["image", "outDir"]),
277
+ "c1541.entry": Object.freeze(["image", "outDir"]),
278
+ "c1541.chain": Object.freeze(["image", "outDir"]),
279
+ "c1541.read": Object.freeze(["image", "outDir"]),
280
+ // Both of `petcat.decode`'s accepted keys are path-bearing -- there is
281
+ // no non-path key at all, so its own HOST_TOOL_ARG_KEYS_REMAINDER entry
282
+ // (host-tool.test.ts) is empty.
283
+ "petcat.decode": Object.freeze(["image", "outDir"]),
284
+ }));
285
+ const HOST_TOOL_SHAPE = `an object with a "tool" field naming one of ${HOST_TOOL_IDS.map((t) => JSON.stringify(t)).join(", ")}, and an optional "args" object`;
286
+ function isPlainObject(value) {
287
+ return typeof value === "object" && value !== null && !Array.isArray(value);
288
+ }
289
+ function describe(value) {
290
+ try {
291
+ const json = JSON.stringify(value);
292
+ return json === undefined ? String(value) : json;
293
+ }
294
+ catch {
295
+ return String(value);
296
+ }
297
+ }
298
+ /** THIS IS THE ONE PLACE a `host_tool` request is narrowed. Never throws;
299
+ * answers a discriminated result naming the offending value or key AND the
300
+ * accepted shape, exactly as broker-control.mts's normaliseLaunchProfile()
301
+ * does. Never coerces a type (a string "true" for `noReport` is refused, not
302
+ * converted) and never drops a key silently. */
303
+ export function normaliseHostToolRequest(raw) {
304
+ if (!isPlainObject(raw)) {
305
+ return { ok: false, message: `host_tool request must be ${HOST_TOOL_SHAPE}; got ${describe(raw)}` };
306
+ }
307
+ const toolRaw = raw.tool;
308
+ if (typeof toolRaw !== "string" || toolRaw === "") {
309
+ return {
310
+ ok: false,
311
+ message: `host_tool request "tool" field must be a non-empty string naming one of ${HOST_TOOL_IDS.join(", ")}; got ${describe(toolRaw)}`,
312
+ };
313
+ }
314
+ if (!HOST_TOOL_IDS.includes(toolRaw)) {
315
+ return { ok: false, message: `unknown host_tool "tool" value ${describe(toolRaw)}; accepted values are ${HOST_TOOL_IDS.join(", ")}` };
316
+ }
317
+ const tool = toolRaw;
318
+ const argsRaw = raw.args;
319
+ if (argsRaw !== undefined && !isPlainObject(argsRaw)) {
320
+ return { ok: false, message: `host_tool "args" must be a plain object or absent; got ${describe(argsRaw)}` };
321
+ }
322
+ const argsObj = argsRaw ?? {};
323
+ const acceptedKeys = HOST_TOOL_ARG_KEYS[tool];
324
+ const acceptedShape = acceptedKeys.length > 0 ? `an object with optional key(s) ${acceptedKeys.join("/")}` : "an object with no accepted keys -- this tool takes no arguments";
325
+ const unknownKeys = Object.keys(argsObj).filter((key) => !acceptedKeys.includes(key));
326
+ if (unknownKeys.length > 0) {
327
+ return { ok: false, message: `host_tool "${tool}" args has unknown key(s) ${unknownKeys.join(", ")}; accepted shape is ${acceptedShape}` };
328
+ }
329
+ if (tool === "acme.build") {
330
+ const source = argsObj.source;
331
+ if (typeof source !== "string" || source === "") {
332
+ return { ok: false, message: `host_tool "acme.build" requires a non-empty string "source"; got ${describe(source)}` };
333
+ }
334
+ const args = { source };
335
+ if ("outDir" in argsObj) {
336
+ const outDir = argsObj.outDir;
337
+ if (typeof outDir !== "string" || outDir === "") {
338
+ return { ok: false, message: `host_tool "acme.build" args.outDir must be a non-empty string; got ${describe(outDir)}` };
339
+ }
340
+ args.outDir = outDir;
341
+ }
342
+ if ("format" in argsObj) {
343
+ const format = argsObj.format;
344
+ if (typeof format !== "string" || format === "") {
345
+ return { ok: false, message: `host_tool "acme.build" args.format must be a non-empty string; got ${describe(format)}` };
346
+ }
347
+ args.format = format;
348
+ }
349
+ if ("setpc" in argsObj) {
350
+ const setpc = argsObj.setpc;
351
+ if (typeof setpc !== "string" || setpc === "") {
352
+ return { ok: false, message: `host_tool "acme.build" args.setpc must be a non-empty string; got ${describe(setpc)}` };
353
+ }
354
+ args.setpc = setpc;
355
+ }
356
+ if ("defines" in argsObj) {
357
+ const defines = argsObj.defines;
358
+ if (!Array.isArray(defines) || !defines.every((d) => typeof d === "string")) {
359
+ return { ok: false, message: `host_tool "acme.build" args.defines must be an array of strings; got ${describe(defines)}` };
360
+ }
361
+ args.defines = defines;
362
+ }
363
+ if ("includes" in argsObj) {
364
+ const includes = argsObj.includes;
365
+ // An empty-string entry is refused here rather than silently skipped
366
+ // or forwarded to resolveWorkspacePath() -- the same "must be an
367
+ // array of strings" message, tightened to reject the one string
368
+ // value that would otherwise slip through as "an array of strings"
369
+ // while carrying no real path.
370
+ if (!Array.isArray(includes) || !includes.every((i) => typeof i === "string" && i !== "")) {
371
+ return { ok: false, message: `host_tool "acme.build" args.includes must be an array of strings; got ${describe(includes)}` };
372
+ }
373
+ args.includes = includes;
374
+ }
375
+ if ("noReport" in argsObj) {
376
+ const noReport = argsObj.noReport;
377
+ if (typeof noReport !== "boolean") {
378
+ return { ok: false, message: `host_tool "acme.build" args.noReport must be a boolean; got ${describe(noReport)}` };
379
+ }
380
+ args.noReport = noReport;
381
+ }
382
+ return { ok: true, request: { tool, args } };
383
+ }
384
+ if (tool === "ghidra.analyze") {
385
+ const runIdRaw = argsObj.runId;
386
+ if (typeof runIdRaw !== "string" || runIdRaw === "") {
387
+ return { ok: false, message: `host_tool "ghidra.analyze" requires a non-empty string "runId"; got ${describe(runIdRaw)}` };
388
+ }
389
+ const importPathRaw = argsObj.importPath;
390
+ if (typeof importPathRaw !== "string" || importPathRaw === "") {
391
+ return { ok: false, message: `host_tool "ghidra.analyze" requires a non-empty string "importPath"; got ${describe(importPathRaw)}` };
392
+ }
393
+ // REQUIRED, non-defaulted -- the correction stated on the interface
394
+ // above. Refused absent, empty, non-string, and non-matching, each
395
+ // naming the field and the accepted shape; re-validated independently
396
+ // inside buildAnalyzeHeadlessArgv() (ghidra-project.mts) so the rule
397
+ // holds for a caller that bypassed this narrowing entirely. Byte-exact,
398
+ // case-sensitive comparison -- never case-folded.
399
+ const processorRaw = argsObj.processor;
400
+ if (typeof processorRaw !== "string" || processorRaw === "" || !LANGUAGE_ID_PATTERN.test(processorRaw)) {
401
+ return {
402
+ ok: false,
403
+ message: `host_tool "ghidra.analyze" requires a non-empty "processor" string matching ${LANGUAGE_ID_PATTERN.source} (a colon-separated Ghidra language id, alphanumeric-and-underscore segments, no path separator, no dot, length-capped); got ${describe(processorRaw)}`,
404
+ };
405
+ }
406
+ // REQUIRED, non-defaulted -- exact membership of a frozen two-member
407
+ // array, never a string passed through to argv. The loader itself
408
+ // ("BinaryLoader") is a fixed literal and never a wire field at all.
409
+ const importRouteRaw = argsObj.importRoute;
410
+ if (typeof importRouteRaw !== "string" || !GHIDRA_IMPORT_ROUTES.includes(importRouteRaw)) {
411
+ return {
412
+ ok: false,
413
+ message: `host_tool "ghidra.analyze" requires an "importRoute" matching one of ${GHIDRA_IMPORT_ROUTES.map((r) => JSON.stringify(r)).join(", ")}; got ${describe(importRouteRaw)}`,
414
+ };
415
+ }
416
+ const importRoute = importRouteRaw;
417
+ // loaderBaseAddr is a raw argv token, never a path -- validated against
418
+ // the anchored LOADER_BASE_ADDR_PATTERN rather than routed through
419
+ // resolveWorkspacePath(). On the "flat64k" route the base is the
420
+ // route's OWN; a differing supplied value is refused BY NAME rather
421
+ // than silently honoured. On "prg" an absent value defaults to the
422
+ // route's own base, since a .prg's load address is a property of the
423
+ // image, not of the route.
424
+ let loaderBaseAddr;
425
+ if ("loaderBaseAddr" in argsObj) {
426
+ const loaderBaseAddrRaw = argsObj.loaderBaseAddr;
427
+ if (typeof loaderBaseAddrRaw !== "string" || !LOADER_BASE_ADDR_PATTERN.test(loaderBaseAddrRaw)) {
428
+ return {
429
+ ok: false,
430
+ message: `host_tool "ghidra.analyze" args.loaderBaseAddr must match ${LOADER_BASE_ADDR_PATTERN.source} (a "0x" prefix followed by one to four lowercase hex digits); got ${describe(loaderBaseAddrRaw)}`,
431
+ };
432
+ }
433
+ if (importRoute === "flat64k" && loaderBaseAddrRaw !== importRouteBaseAddr("flat64k")) {
434
+ return {
435
+ ok: false,
436
+ message: `host_tool "ghidra.analyze" args.loaderBaseAddr (${loaderBaseAddrRaw}) conflicts with the "flat64k" route's own base address (${importRouteBaseAddr("flat64k")}) -- the route defines the base on this route; omit loaderBaseAddr or supply the matching value`,
437
+ };
438
+ }
439
+ loaderBaseAddr = loaderBaseAddrRaw;
440
+ }
441
+ else {
442
+ loaderBaseAddr = importRouteBaseAddr(importRoute);
443
+ }
444
+ // A typeof boolean check, never a truthiness coercion. Load-bearing
445
+ // rather than cosmetic: VolatileCarve.java's own run() calls
446
+ // analyzeAll(currentProgram) itself, so omitting -noanalysis would race
447
+ // Ghidra's own automatic post-preScript analysis against the manual
448
+ // call.
449
+ let noanalysis;
450
+ if ("noanalysis" in argsObj) {
451
+ const noanalysisRaw = argsObj.noanalysis;
452
+ if (typeof noanalysisRaw !== "boolean") {
453
+ return { ok: false, message: `host_tool "ghidra.analyze" args.noanalysis must be a boolean; got ${describe(noanalysisRaw)}` };
454
+ }
455
+ noanalysis = noanalysisRaw;
456
+ }
457
+ // A non-negative integer, refusing fractional, negative, NaN and string
458
+ // values by name -- this field exists so a verification gate can plant
459
+ // a deliberately wrong expectation and prove the check catches it.
460
+ let expectedClassificationLines;
461
+ if ("expectedClassificationLines" in argsObj) {
462
+ const linesRaw = argsObj.expectedClassificationLines;
463
+ if (typeof linesRaw !== "number" || !Number.isInteger(linesRaw) || linesRaw < 0) {
464
+ return {
465
+ ok: false,
466
+ message: `host_tool "ghidra.analyze" args.expectedClassificationLines must be a non-negative integer; got ${describe(linesRaw)}`,
467
+ };
468
+ }
469
+ expectedClassificationLines = linesRaw;
470
+ }
471
+ const args = { runId: runIdRaw, importPath: importPathRaw, processor: processorRaw, importRoute, loaderBaseAddr };
472
+ if (noanalysis !== undefined)
473
+ args.noanalysis = noanalysis;
474
+ if (expectedClassificationLines !== undefined)
475
+ args.expectedClassificationLines = expectedClassificationLines;
476
+ if ("preScript" in argsObj) {
477
+ const preScript = argsObj.preScript;
478
+ if (typeof preScript !== "string" || preScript === "") {
479
+ return { ok: false, message: `host_tool "ghidra.analyze" args.preScript must be a non-empty string; got ${describe(preScript)}` };
480
+ }
481
+ args.preScript = preScript;
482
+ }
483
+ if ("postScript" in argsObj) {
484
+ const postScript = argsObj.postScript;
485
+ if (typeof postScript !== "string" || postScript === "") {
486
+ return { ok: false, message: `host_tool "ghidra.analyze" args.postScript must be a non-empty string; got ${describe(postScript)}` };
487
+ }
488
+ args.postScript = postScript;
489
+ }
490
+ // Path-bearing -- resolved through resolveWorkspacePath() by
491
+ // runHostTool(), only validated here as a non-empty string, mirroring
492
+ // preScript/postScript above.
493
+ if ("scriptPath" in argsObj) {
494
+ const scriptPath = argsObj.scriptPath;
495
+ if (typeof scriptPath !== "string" || scriptPath === "") {
496
+ return { ok: false, message: `host_tool "ghidra.analyze" args.scriptPath must be a non-empty string; got ${describe(scriptPath)}` };
497
+ }
498
+ args.scriptPath = scriptPath;
499
+ }
500
+ if ("entrypointsPath" in argsObj) {
501
+ const entrypointsPath = argsObj.entrypointsPath;
502
+ if (typeof entrypointsPath !== "string" || entrypointsPath === "") {
503
+ return { ok: false, message: `host_tool "ghidra.analyze" args.entrypointsPath must be a non-empty string; got ${describe(entrypointsPath)}` };
504
+ }
505
+ args.entrypointsPath = entrypointsPath;
506
+ }
507
+ if ("exportPath" in argsObj) {
508
+ const exportPath = argsObj.exportPath;
509
+ if (typeof exportPath !== "string" || exportPath === "") {
510
+ return { ok: false, message: `host_tool "ghidra.analyze" args.exportPath must be a non-empty string; got ${describe(exportPath)}` };
511
+ }
512
+ args.exportPath = exportPath;
513
+ }
514
+ // Path-bearing -- resolved through resolveWorkspacePath() by
515
+ // runHostTool(), only validated here as a non-empty string, mirroring
516
+ // scriptPath/entrypointsPath/exportPath above. No cross-field
517
+ // requirement: unlike entrypointsPath (which is VolatileCarve.java's
518
+ // own positional argument and needs preScript to be present),
519
+ // dataRangesPath needs no OTHER script field to be useful.
520
+ if ("dataRangesPath" in argsObj) {
521
+ const dataRangesPath = argsObj.dataRangesPath;
522
+ if (typeof dataRangesPath !== "string" || dataRangesPath === "") {
523
+ return { ok: false, message: `host_tool "ghidra.analyze" args.dataRangesPath must be a non-empty string; got ${describe(dataRangesPath)}` };
524
+ }
525
+ args.dataRangesPath = dataRangesPath;
526
+ }
527
+ // "A script argument with no script" is refused BY NAME rather than
528
+ // silently dropped -- a dropped argument is how a run reports success
529
+ // having asserted nothing.
530
+ if (args.entrypointsPath !== undefined && args.preScript === undefined) {
531
+ return {
532
+ ok: false,
533
+ message: `host_tool "ghidra.analyze" args.entrypointsPath requires args.preScript to be present; got entrypointsPath with no preScript`,
534
+ };
535
+ }
536
+ if (args.postScript === undefined && (args.exportPath !== undefined || args.expectedClassificationLines !== undefined)) {
537
+ return {
538
+ ok: false,
539
+ message: `host_tool "ghidra.analyze" args.exportPath/args.expectedClassificationLines require args.postScript to be present; got one of them with no postScript`,
540
+ };
541
+ }
542
+ if (args.expectedClassificationLines !== undefined && args.exportPath === undefined) {
543
+ return {
544
+ ok: false,
545
+ message: `host_tool "ghidra.analyze" args.expectedClassificationLines requires args.exportPath to be present; got expectedClassificationLines with no exportPath`,
546
+ };
547
+ }
548
+ return { ok: true, request: { tool, args } };
549
+ }
550
+ if (tool === "oracle.probe") {
551
+ // No key is accepted at all -- the unknown-key check above already
552
+ // refused the retired "command" key (and any other key) by name, since
553
+ // HOST_TOOL_ARG_KEYS["oracle.probe"] is now empty. No new refusal code
554
+ // is needed here.
555
+ return { ok: true, request: { tool, args: {} } };
556
+ }
557
+ if (tool === "oracle.run") {
558
+ const source = argsObj.source;
559
+ if (typeof source !== "string" || source === "") {
560
+ return { ok: false, message: `host_tool "oracle.run" requires a non-empty string "source"; got ${describe(source)}` };
561
+ }
562
+ return { ok: true, request: { tool, args: { source } } };
563
+ }
564
+ if (tool === "dxa.disassemble") {
565
+ const image = argsObj.image;
566
+ if (typeof image !== "string" || image === "") {
567
+ return { ok: false, message: `host_tool "dxa.disassemble" requires a non-empty string "image"; got ${describe(image)}` };
568
+ }
569
+ const imageKindRaw = argsObj.imageKind;
570
+ // The enum is exact and case-sensitive -- "PRG" and "prg" never merge.
571
+ if (imageKindRaw !== "prg" && imageKindRaw !== "flat64k") {
572
+ return { ok: false, message: `host_tool "dxa.disassemble" args.imageKind must be "prg" or "flat64k"; got ${describe(imageKindRaw)}` };
573
+ }
574
+ const args = { image, imageKind: imageKindRaw };
575
+ if ("entrypointsPath" in argsObj) {
576
+ const entrypointsPath = argsObj.entrypointsPath;
577
+ if (typeof entrypointsPath !== "string" || entrypointsPath === "") {
578
+ return { ok: false, message: `host_tool "dxa.disassemble" args.entrypointsPath must be a non-empty string; got ${describe(entrypointsPath)}` };
579
+ }
580
+ args.entrypointsPath = entrypointsPath;
581
+ }
582
+ if ("datablocksPath" in argsObj) {
583
+ const datablocksPath = argsObj.datablocksPath;
584
+ if (typeof datablocksPath !== "string" || datablocksPath === "") {
585
+ return { ok: false, message: `host_tool "dxa.disassemble" args.datablocksPath must be a non-empty string; got ${describe(datablocksPath)}` };
586
+ }
587
+ args.datablocksPath = datablocksPath;
588
+ }
589
+ if ("labelsPath" in argsObj) {
590
+ const labelsPath = argsObj.labelsPath;
591
+ if (typeof labelsPath !== "string" || labelsPath === "") {
592
+ return { ok: false, message: `host_tool "dxa.disassemble" args.labelsPath must be a non-empty string; got ${describe(labelsPath)}` };
593
+ }
594
+ args.labelsPath = labelsPath;
595
+ }
596
+ if ("outDir" in argsObj) {
597
+ const outDir = argsObj.outDir;
598
+ if (typeof outDir !== "string" || outDir === "") {
599
+ return { ok: false, message: `host_tool "dxa.disassemble" args.outDir must be a non-empty string; got ${describe(outDir)}` };
600
+ }
601
+ args.outDir = outDir;
602
+ }
603
+ return { ok: true, request: { tool, args } };
604
+ }
605
+ if (tool === "ghidra.installExtension") {
606
+ const sourceDir = argsObj.sourceDir;
607
+ if (typeof sourceDir !== "string" || sourceDir === "") {
608
+ return { ok: false, message: `host_tool "ghidra.installExtension" requires a non-empty string "sourceDir"; got ${describe(sourceDir)}` };
609
+ }
610
+ const moduleName = argsObj.moduleName;
611
+ if (typeof moduleName !== "string" || moduleName === "" || !RUN_ID_PATTERN.test(moduleName)) {
612
+ return {
613
+ ok: false,
614
+ message: `host_tool "ghidra.installExtension" requires a non-empty "moduleName" string matching ${RUN_ID_PATTERN.source} (alphanumeric-first, alphanumeric/dash/underscore only, no separator, no dot, length-capped); got ${describe(moduleName)}`,
615
+ };
616
+ }
617
+ return { ok: true, request: { tool, args: { sourceDir, moduleName } } };
618
+ }
619
+ if (tool === "c1541.bam" || tool === "c1541.dir") {
620
+ const image = argsObj.image;
621
+ if (typeof image !== "string" || image === "") {
622
+ return { ok: false, message: `host_tool "${tool}" requires a non-empty string "image"; got ${describe(image)}` };
623
+ }
624
+ const args = { image };
625
+ if ("outDir" in argsObj) {
626
+ const outDir = argsObj.outDir;
627
+ if (typeof outDir !== "string" || outDir === "") {
628
+ return { ok: false, message: `host_tool "${tool}" args.outDir must be a non-empty string; got ${describe(outDir)}` };
629
+ }
630
+ args.outDir = outDir;
631
+ }
632
+ return { ok: true, request: { tool, args } };
633
+ }
634
+ if (tool === "c1541.entry" || tool === "c1541.chain" || tool === "c1541.read") {
635
+ const image = argsObj.image;
636
+ if (typeof image !== "string" || image === "") {
637
+ return { ok: false, message: `host_tool "${tool}" requires a non-empty string "image"; got ${describe(image)}` };
638
+ }
639
+ // `name` is a CBM filename or glob pattern -- REQUIRED, never a path --
640
+ // and refused BY NAME when its first character is a hyphen, before the
641
+ // child is ever spawned. The utility's own CLI would otherwise read
642
+ // such a value as a flag, an argument-injection route into a host
643
+ // process driven by container-side input.
644
+ const name = argsObj.name;
645
+ if (typeof name !== "string" || name === "") {
646
+ return { ok: false, message: `host_tool "${tool}" requires a non-empty string "name"; got ${describe(name)}` };
647
+ }
648
+ if (name.startsWith("-")) {
649
+ return {
650
+ ok: false,
651
+ message: `host_tool "${tool}" args.name must not begin with "-" -- c1541's own CLI would read it as a flag rather than a filename; got ${describe(name)}`,
652
+ };
653
+ }
654
+ const args = { image, name };
655
+ if ("outDir" in argsObj) {
656
+ const outDir = argsObj.outDir;
657
+ if (typeof outDir !== "string" || outDir === "") {
658
+ return { ok: false, message: `host_tool "${tool}" args.outDir must be a non-empty string; got ${describe(outDir)}` };
659
+ }
660
+ args.outDir = outDir;
661
+ }
662
+ return { ok: true, request: { tool, args } };
663
+ }
664
+ if (tool === "petcat.decode") {
665
+ const image = argsObj.image;
666
+ if (typeof image !== "string" || image === "") {
667
+ return { ok: false, message: `host_tool "petcat.decode" requires a non-empty string "image"; got ${describe(image)}` };
668
+ }
669
+ const args = { image };
670
+ if ("outDir" in argsObj) {
671
+ const outDir = argsObj.outDir;
672
+ if (typeof outDir !== "string" || outDir === "") {
673
+ return { ok: false, message: `host_tool "petcat.decode" args.outDir must be a non-empty string; got ${describe(outDir)}` };
674
+ }
675
+ args.outDir = outDir;
676
+ }
677
+ return { ok: true, request: { tool, args } };
678
+ }
679
+ // Unreachable while HOST_TOOL_IDS has exactly twelve members -- kept so a
680
+ // future tool added to HOST_TOOL_IDS without a matching narrowing arm
681
+ // fails loudly here rather than silently returning an under-typed request.
682
+ return { ok: false, message: `normaliseHostToolRequest: no narrowing arm for tool "${tool}"` };
683
+ }
684
+ // ---------------------------------------------------------------------------
685
+ // Workspace-relative path resolution. A `host_tool` request never carries a
686
+ // host-absolute path -- every path argument is workspace-relative and
687
+ // resolved HERE, against the broker's own `--repo-root`, then re-checked to
688
+ // be inside it. This is the ONLY place a wire-supplied path becomes a real
689
+ // path.
690
+ //
691
+ // BOTH the workspace root and the candidate go through the SAME
692
+ // ancestor-realpath walk (realpathOfNearestExisting(), below) before the
693
+ // separator-appended prefix comparison, and the returned `ok: true` value is
694
+ // the WALKED (real) path, never the lexical join. That is load-bearing
695
+ // rather than a symmetry preference, for the two reasons
696
+ // anno-types.ts:1159-1176 already names for its own two consumers of this
697
+ // walk: a workspace root that does not yet exist is a legitimate input (a
698
+ // bare realpath would throw a raw ENOENT), and resolving only the candidate
699
+ // side makes every in-workspace path look foreign whenever the root itself
700
+ // is reached through a symlink. A live symlink test is what a purely
701
+ // lexical path.resolve() + startsWith() check missed: a symlink planted
702
+ // inside the workspace, pointing outside it, lexically satisfied the prefix
703
+ // check while a real write through it landed outside the root.
704
+ //
705
+ // BEHAVIOURAL CONSEQUENCE, intended: because this returns the real path, a
706
+ // link pointing INSIDE the workspace is FOLLOWED and the request is
707
+ // accepted at the link's real location -- the alternative, refusing every
708
+ // symlink, is the over-broad fix host-tool.test.ts's discriminating cases
709
+ // (34-10 Task 2) exist to redden.
710
+ //
711
+ // Two residuals recorded here, beside the guarantee rather than past it: (1)
712
+ // the check-then-open window between this decision and the child process's
713
+ // own open is not closed at this layer -- the child is a third-party binary
714
+ // handed a path string, so there is no descriptor-based route to making the
715
+ // check and the open one operation (T-34-52, accepted); (2) the comparison
716
+ // is byte-wise over the resolved strings with the platform separator
717
+ // appended and applies no Unicode normalisation, so two spellings differing
718
+ // only in normalisation form are two distinct paths here (the same residual
719
+ // anno-confinement.test.ts records for the same comparison).
720
+ //
721
+ // A recorded residual: because the return value is now the REAL path, on a
722
+ // host whose workspace root is itself reached through a symlink the
723
+ // response `path` need not match any member of hostRootCandidates()
724
+ // (containerpath.ts), and containerPath() throws rather than passing an
725
+ // untranslatable path through -- HOST_WORKSPACE_PATH naming the real root
726
+ // is the pre-existing mitigation. This is a recorded limit, not a widened
727
+ // hostpath.ts consumer set.
728
+ // ---------------------------------------------------------------------------
729
+ /**
730
+ * The maximum number of DANGLING-symlink hops `realpathOfNearestExisting`
731
+ * will take before refusing. 40 is deliberately the same value
732
+ * anno-types.ts:971 uses -- Linux's own `MAXSYMLINKS`, so a chain this walk
733
+ * refuses is one the kernel would refuse too. Task 2's equivalence case
734
+ * (against anno-types.ts's storePathWithinWorkspace()) is what keeps the two
735
+ * copies from drifting apart. The bound exists because a cycle (`a -> b`,
736
+ * `b -> a`) is otherwise an infinite loop inside a function whose input
737
+ * arrives unvalidated from the transport.
738
+ */
739
+ const MAX_SYMLINK_HOPS = 40;
740
+ /**
741
+ * Does the path ENTRY `entry` exist -- does this NAME exist in its
742
+ * directory -- without following a symlink at the leaf, and without
743
+ * throwing. Mirrors anno-types.ts's own `pathEntryExists`, with one
744
+ * deliberate difference: this returns a refusal where that version throws,
745
+ * because `resolveWorkspacePath()`'s contract is a result object and this
746
+ * module's own never-throw discipline must not be widened by adding
747
+ * filesystem access.
748
+ *
749
+ * `throwIfNoEntry: false` suppresses `ENOENT` and NOTHING ELSE
750
+ * (anno-types.ts:985-1000's own REVERSED-2026-08-28 note) -- a permission
751
+ * error or any other stat failure on an ancestor becomes a named refusal
752
+ * here rather than escaping as a bare thrown error.
753
+ */
754
+ function pathEntryExists(entry, forPath) {
755
+ try {
756
+ return { ok: true, exists: lstatSync(entry, { throwIfNoEntry: false }) !== undefined };
757
+ }
758
+ catch (e) {
759
+ return {
760
+ ok: false,
761
+ message: `cannot stat ${JSON.stringify(entry)} while confining ${JSON.stringify(forPath)} (${e.message})`,
762
+ };
763
+ }
764
+ }
765
+ /**
766
+ * Returns the REAL absolute path of `p`, resolved through the deepest
767
+ * ancestor whose path ENTRY exists on disk, with the non-existent tail
768
+ * re-joined after it -- or a refusal naming the path when the walk cannot
769
+ * answer.
770
+ *
771
+ * Mirrors anno-types.ts:1082's `realpathOfNearestExisting()` exactly, with
772
+ * the same deliberate difference `pathEntryExists()` above states: this
773
+ * RETURNS a refusal where that version THROWS `AnnoStorePathError`. Walks up
774
+ * while the path ENTRY does not exist, unshifting each `basename` onto a
775
+ * `tail` array; when the walk reaches the filesystem root
776
+ * (`dirname(current) === current`) answers from `current` plus `tail`
777
+ * rather than from the pre-walk resolved string, because after a hop the
778
+ * pre-walk string describes a location the walk is no longer on; when the
779
+ * stopping entry is a symlink whose target does not exist, counts a hop,
780
+ * refuses past `MAX_SYMLINK_HOPS` naming the bound, and resolves the link's
781
+ * target against the LINK'S OWN DIRECTORY -- never the process cwd, since a
782
+ * relative target (`../outside/x`) is the common form. Otherwise
783
+ * `realpathSync(current)`, joined with `tail`. Every `lstatSync`,
784
+ * `readlinkSync` and `realpathSync` failure becomes a refusal naming the
785
+ * path, never a throw.
786
+ */
787
+ function realpathOfNearestExisting(p) {
788
+ const resolved = resolvePath(p);
789
+ const tail = [];
790
+ let current = resolved;
791
+ let hops = 0;
792
+ for (;;) {
793
+ let reachedFilesystemRoot = false;
794
+ for (;;) {
795
+ const entryCheck = pathEntryExists(current, resolved);
796
+ if (!entryCheck.ok)
797
+ return { ok: false, message: entryCheck.message };
798
+ if (entryCheck.exists)
799
+ break;
800
+ const parent = dirname(current);
801
+ if (parent === current) {
802
+ reachedFilesystemRoot = true;
803
+ break;
804
+ }
805
+ tail.unshift(basename(current));
806
+ current = parent;
807
+ }
808
+ if (reachedFilesystemRoot) {
809
+ return { ok: true, path: tail.length === 0 ? current : join(current, ...tail) };
810
+ }
811
+ // The stopping ENTRY exists. Is it a symlink whose target does not?
812
+ // That is the one class a resolve-following existence predicate cannot
813
+ // see, and the only one needing a hop.
814
+ let stoppedAtDanglingLink;
815
+ try {
816
+ stoppedAtDanglingLink = lstatSync(current).isSymbolicLink() && !existsSync(current);
817
+ }
818
+ catch (e) {
819
+ return {
820
+ ok: false,
821
+ message: `cannot stat ${JSON.stringify(current)} while confining ${JSON.stringify(resolved)} (${e.message})`,
822
+ };
823
+ }
824
+ if (stoppedAtDanglingLink) {
825
+ hops += 1;
826
+ if (hops > MAX_SYMLINK_HOPS) {
827
+ return {
828
+ ok: false,
829
+ message: `cannot resolve ${JSON.stringify(resolved)}: more than ${MAX_SYMLINK_HOPS} symbolic-link hops while resolving ` +
830
+ `${JSON.stringify(current)} -- a symlink cycle or an over-long chain, refused rather than followed`,
831
+ };
832
+ }
833
+ let link;
834
+ try {
835
+ link = readlinkSync(current);
836
+ }
837
+ catch (e) {
838
+ return {
839
+ ok: false,
840
+ message: `cannot read the symbolic link ${JSON.stringify(current)} while confining ${JSON.stringify(resolved)} (${e.message})`,
841
+ };
842
+ }
843
+ // Against the LINK'S directory, never the process cwd.
844
+ current = resolvePath(dirname(current), link);
845
+ continue;
846
+ }
847
+ let real;
848
+ try {
849
+ real = realpathSync(current);
850
+ }
851
+ catch (e) {
852
+ return {
853
+ ok: false,
854
+ message: `cannot resolve the real path of ${JSON.stringify(current)} while confining ${JSON.stringify(resolved)} (${e.message})`,
855
+ };
856
+ }
857
+ return { ok: true, path: tail.length === 0 ? real : join(real, ...tail) };
858
+ }
859
+ }
860
+ export function resolveWorkspacePath(repoRoot, relative) {
861
+ if (typeof relative !== "string" || relative === "") {
862
+ return { ok: false, message: `workspace path must be a non-empty relative string; got ${describe(relative)}` };
863
+ }
864
+ if (isAbsolute(relative)) {
865
+ return { ok: false, message: `workspace path must be relative to the workspace root, not absolute: ${describe(relative)}` };
866
+ }
867
+ const rootAbs = resolvePath(repoRoot);
868
+ const walkedRoot = realpathOfNearestExisting(rootAbs);
869
+ if (!walkedRoot.ok) {
870
+ return { ok: false, message: `cannot resolve the workspace root ${JSON.stringify(rootAbs)}: ${walkedRoot.message}` };
871
+ }
872
+ const walkedCandidate = realpathOfNearestExisting(resolvePath(walkedRoot.path, relative));
873
+ if (!walkedCandidate.ok) {
874
+ return { ok: false, message: walkedCandidate.message };
875
+ }
876
+ // A bug fix: when the workspace root walks to the filesystem root itself
877
+ // (`walkedRoot.path === sep`, e.g. "/"), appending `sep` a second time
878
+ // produces "//" -- a prefix no real absolute path ever starts with
879
+ // (`resolvePath()`/`realpathSync()` always normalise to a single leading
880
+ // separator), so EVERY candidate under root "/" was wrongly refused as
881
+ // "escaping" a root that in fact contains it. A root this broad is a
882
+ // legitimate input -- c1541.mjs's own commonAncestorDir() (mirroring
883
+ // acme.mjs's) collapses to "/" whenever a committed fixture inside the
884
+ // repo and a scratch --out-dir outside it share no smaller ancestor.
885
+ const requiredPrefix = walkedRoot.path === sep ? walkedRoot.path : walkedRoot.path + sep;
886
+ if (walkedCandidate.path !== walkedRoot.path && !walkedCandidate.path.startsWith(requiredPrefix)) {
887
+ return {
888
+ ok: false,
889
+ message: `workspace path escapes the workspace root: ${describe(relative)} resolves to ${walkedCandidate.path}, outside ${walkedRoot.path}`,
890
+ };
891
+ }
892
+ return { ok: true, path: walkedCandidate.path };
893
+ }
894
+ /** Deterministic: the same typed request and the same resolved paths yield
895
+ * a byte-identical argv array on two successive calls -- no randomness, no
896
+ * timestamp, no environment-dependent ordering. `log` is OPTIONAL and used
897
+ * ONLY by the c1541.dir branch to report a PATH-fallback binary resolution
898
+ * -- every pre-existing branch ignores it, exactly as they already ignore
899
+ * any parameter they do not need. */
900
+ export function buildHostToolArgv(request, resolved, log) {
901
+ if (request.tool === "acme.build") {
902
+ const { args } = request;
903
+ const { sourcePath, outDirPath, includePaths } = resolved;
904
+ const stem = join(outDirPath, basename(sourcePath).replace(/\.(a|asm|s)$/i, ""));
905
+ const prg = `${stem}.prg`;
906
+ // Overridable local variable named for what it holds -- never `binPath`/
907
+ // `viceBin`/`VICE_BIN`/`x64sc`, which spawn-seam.test.ts's
908
+ // EMULATOR_BIN_SHAPE would misclassify as an emulator spawn site.
909
+ const acmePath = process.env.ACME_BIN && process.env.ACME_BIN !== "" ? process.env.ACME_BIN : "acme";
910
+ // Fixed flags first, in the SAME order src/skills/acme-build/scripts/
911
+ // acme.mjs's build() uses today, then one -D per define and one -I pair
912
+ // per include in caller-given order, then --setpc if given, then the
913
+ // resolved source path LAST.
914
+ const argv = [
915
+ "--cpu",
916
+ "6510",
917
+ "-f",
918
+ args.format ?? "cbm",
919
+ "-Wtype-mismatch",
920
+ "--strict-segments",
921
+ "--msvc",
922
+ "-v1",
923
+ "-o",
924
+ prg,
925
+ "-l",
926
+ `${stem}.sym`,
927
+ "--vicelabels",
928
+ `${stem}.vs`,
929
+ ];
930
+ if (!args.noReport)
931
+ argv.push("-r", `${stem}.rep`);
932
+ for (const define of args.defines ?? [])
933
+ argv.push(`-D${define}`);
934
+ // Reads ONLY from resolved.includePaths -- never from
935
+ // request.args.includes -- so argv never carries a raw wire string for
936
+ // this field. Defensively defaults to [] so a caller that omits
937
+ // includePaths entirely still yields a valid, empty-include argv rather
938
+ // than throwing on an undefined iterable.
939
+ for (const include of includePaths ?? [])
940
+ argv.push("-I", include);
941
+ if (args.setpc)
942
+ argv.push("--setpc", args.setpc);
943
+ argv.push(sourcePath);
944
+ // The directory holding the resolved ROOT SOURCE, never `outDirPath`:
945
+ // `outDir` governs where the `.prg` lands and a caller may point it
946
+ // elsewhere, while `!source` resolution is about where the SOURCES live,
947
+ // beside `sourcePath` itself. See `BuildHostToolArgvResult.cwd`'s own
948
+ // doc-comment for the measured reason this field exists at all.
949
+ return { ok: true, toolPath: acmePath, argv, outputs: [prg], cwd: dirname(sourcePath) };
950
+ }
951
+ if (request.tool === "ghidra.analyze") {
952
+ const { importPath, projectLocation, projectName, preScriptPath, postScriptPath, scriptPathResolved, entrypointsPathResolved, exportPathResolved, dataRangesPathResolved, } = resolved;
953
+ // Named environment variable, never a guessed install location and
954
+ // never this repository's own local probe directory (T-34-16).
955
+ const ghidraHome = process.env.GHIDRA_HOME;
956
+ if (ghidraHome === undefined || ghidraHome === "") {
957
+ return {
958
+ ok: false,
959
+ message: `host_tool "ghidra.analyze" requires the GHIDRA_HOME environment variable to name a Ghidra installation directory; it is unset`,
960
+ };
961
+ }
962
+ // Overridable local variable named for what it holds -- never `binPath`/
963
+ // `viceBin`/`VICE_BIN`/`x64sc`, which spawn-seam.test.ts's
964
+ // EMULATOR_BIN_SHAPE would misclassify as an emulator spawn site.
965
+ const ghidraPath = join(ghidraHome, "support", "analyzeHeadless");
966
+ if (!existsSync(ghidraPath)) {
967
+ return {
968
+ ok: false,
969
+ message: `host_tool "ghidra.analyze" refuses: GHIDRA_HOME's resolved launcher does not exist on disk (${ghidraPath})`,
970
+ };
971
+ }
972
+ // The checked, NON-MATERIALISING language preflight -- refuses by
973
+ // name, before any child process is spawned, when the requested
974
+ // processor is not declared by any .ldefs Ghidra would load, or is
975
+ // declared but its slafile does not exist on disk. This is what makes a
976
+ // language that cannot load a named refusal instead of a green run on
977
+ // whatever .sla happens to be in place. The preflight CHECKS and NEVER
978
+ // FIXES: it must never create a directory, copy a file, invoke
979
+ // support/sleigh, or fall back to another language -- doing so would
980
+ // mask exactly the failure this check exists to catch.
981
+ const installedLanguages = installedLanguageIds(ghidraHome);
982
+ const requestedProcessor = request.args.processor;
983
+ const matchedLanguage = installedLanguages.find((lang) => lang.id === requestedProcessor);
984
+ if (!matchedLanguage) {
985
+ const declaredIds = installedLanguages.map((lang) => lang.id);
986
+ return {
987
+ ok: false,
988
+ message: `host_tool "ghidra.analyze" refuses: the requested processor ${JSON.stringify(requestedProcessor)} is not declared by any ` +
989
+ `installed Ghidra language; declared id(s): ${declaredIds.length > 0 ? declaredIds.join(", ") : "(none)"}`,
990
+ };
991
+ }
992
+ if (!matchedLanguage.slafileExists) {
993
+ const missingSlaPath = join(dirname(matchedLanguage.ldefsPath), matchedLanguage.slafile);
994
+ return {
995
+ ok: false,
996
+ message: `host_tool "ghidra.analyze" refuses: processor ${JSON.stringify(requestedProcessor)} is declared by ${matchedLanguage.ldefsPath} ` +
997
+ `but its slafile ${missingSlaPath} does not exist on disk -- run ghidra.installExtension to build it`,
998
+ };
999
+ }
1000
+ // Argv construction and the dot-segment re-check both live in
1001
+ // ghidra-project.mts's buildAnalyzeHeadlessArgv() -- never re-derived
1002
+ // here.
1003
+ // Reads ONLY from resolved.preScriptPath/postScriptPath/
1004
+ // scriptPathResolved/entrypointsPathResolved/exportPathResolved -- never
1005
+ // from request.args's own path-shaped fields -- so argv never carries a
1006
+ // raw, unresolved wire string for any of them.
1007
+ // `processor`/`loaderBaseAddr`/`noanalysis`/`expectedClassificationLines`
1008
+ // come straight from request.args -- each is a validated non-path value
1009
+ // (language id, hex string, boolean, integer), never a path, so none
1010
+ // flows through resolveWorkspacePath() and none appears in `resolved`
1011
+ // (ResolvedGhidraAnalyzePaths carries paths only).
1012
+ const argvInput = {
1013
+ projectLocation,
1014
+ projectName,
1015
+ importPath,
1016
+ processor: request.args.processor,
1017
+ loaderBaseAddr: request.args.loaderBaseAddr,
1018
+ };
1019
+ if (request.args.noanalysis !== undefined)
1020
+ argvInput.noanalysis = request.args.noanalysis;
1021
+ if (scriptPathResolved !== undefined)
1022
+ argvInput.scriptPath = scriptPathResolved;
1023
+ if (preScriptPath !== undefined)
1024
+ argvInput.preScript = preScriptPath;
1025
+ if (entrypointsPathResolved !== undefined)
1026
+ argvInput.entrypointsPath = entrypointsPathResolved;
1027
+ if (postScriptPath !== undefined)
1028
+ argvInput.postScript = postScriptPath;
1029
+ if (exportPathResolved !== undefined)
1030
+ argvInput.exportPath = exportPathResolved;
1031
+ if (request.args.expectedClassificationLines !== undefined)
1032
+ argvInput.expectedClassificationLines = request.args.expectedClassificationLines;
1033
+ // Reads ONLY from resolved.dataRangesPathResolved -- never from
1034
+ // request.args.dataRangesPath -- so argv never carries a raw,
1035
+ // unresolved wire string for this field.
1036
+ if (dataRangesPathResolved !== undefined)
1037
+ argvInput.dataRangesPath = dataRangesPathResolved;
1038
+ const built = buildAnalyzeHeadlessArgv(argvInput);
1039
+ if (!built.ok)
1040
+ return { ok: false, message: built.message };
1041
+ // outputs[0] is ALWAYS the run log for ghidra.analyze -- a SIBLING of
1042
+ // the reserved project directory (never a child of it), because
1043
+ // -deleteProject operates INSIDE projectLocation. runHostTool()'s
1044
+ // ghidra.analyze branch below writes the child's stdout followed by its
1045
+ // stderr here, before the digest loop runs (MEASURED:
1046
+ // analyzeHeadless's "Using Language/Compiler:" line arrives on stdout).
1047
+ const runLogPath = join(dirname(projectLocation), `${projectName}.ghidra-run.log`);
1048
+ // When exportPath is present, it is a SECOND outputs[] entry --
1049
+ // digested by the existing digestOutputFile() loop with no new digest
1050
+ // code. outputs[0] stays the run log unconditionally.
1051
+ const outputs = exportPathResolved !== undefined ? [runLogPath, exportPathResolved] : [runLogPath];
1052
+ return { ok: true, toolPath: ghidraPath, argv: built.argv, outputs };
1053
+ }
1054
+ if (request.tool === "dxa.disassemble") {
1055
+ const { args } = request;
1056
+ const { imagePath, outDirPath, entrypointsPath, datablocksPath, labelsPath } = resolved;
1057
+ // A-01: fixed, computed path -- never an env-var override (see the HERE
1058
+ // and findDxaBinary() comments above). Refuses BY NAME when the vendored
1059
+ // binary does not exist at EITHER candidate location, naming build.bash
1060
+ // as the remedy, per PLAN.md item 6.
1061
+ const dxaFound = findDxaBinary(HERE);
1062
+ if (dxaFound.path === null) {
1063
+ return {
1064
+ ok: false,
1065
+ message: `host_tool "dxa.disassemble" refuses: the vendored dxa binary does not exist (tried: ${dxaFound.tried.join(", ")}) -- run "bash vendor/dxa/build.bash build" to produce it`,
1066
+ };
1067
+ }
1068
+ const dxaPath = dxaFound.path;
1069
+ // Fixed flags first, in a fixed order (A-02): -g 0000 ONLY for a flat
1070
+ // 64K capture, never for a .prg, whose own 2-byte load address dxa reads
1071
+ // unassisted. Then -R/-B/-l for whichever optional resolved paths are
1072
+ // present, in that order, then -a dump, then the resolved image path
1073
+ // LAST. Deterministic: the same typed request and resolved paths yield a
1074
+ // byte-identical argv array on two successive calls.
1075
+ const argv = ["-p", "all-nmos6502", "-d", "skip-scanning", "-t", "detect-internal"];
1076
+ if (args.imageKind === "flat64k")
1077
+ argv.push("-g", "0000");
1078
+ if (entrypointsPath !== undefined)
1079
+ argv.push("-R", entrypointsPath);
1080
+ if (datablocksPath !== undefined)
1081
+ argv.push("-B", datablocksPath);
1082
+ if (labelsPath !== undefined)
1083
+ argv.push("-l", labelsPath);
1084
+ argv.push("-a", "dump");
1085
+ argv.push(imagePath);
1086
+ // A-03: dxa has NO output-file option -- every listing line is
1087
+ // fprintf(stdout, ...) (vendor/dxa/dump.c). The seam captures stdout and
1088
+ // writes it to this single outputs[] path, then digests the FILE --
1089
+ // never the dxa process's own exit status, which is not the pass/fail
1090
+ // signal for a listing (must_haves.prohibitions).
1091
+ const imageStem = basename(imagePath).replace(/\.[^./]+$/, "");
1092
+ const listingPath = join(outDirPath, `${imageStem}.dxa-dump.lst`);
1093
+ return { ok: true, toolPath: dxaPath, argv, outputs: [listingPath] };
1094
+ }
1095
+ if (request.tool === "ghidra.installExtension") {
1096
+ const { moduleName } = resolved;
1097
+ // Independently re-derived rather than threaded through `resolved` --
1098
+ // mirrors ghidra.analyze's own branch above, which reads GHIDRA_HOME
1099
+ // itself instead of accepting it as a resolved field. Both existence
1100
+ // checks were already performed (and, for the copy, already acted on)
1101
+ // by runHostTool()'s own resolution branch before this function was
1102
+ // ever called; re-checking here is defense in depth, the same posture
1103
+ // buildAnalyzeHeadlessArgv()'s own independent dot-segment re-check
1104
+ // takes for a caller that bypassed the resolution branch entirely.
1105
+ const ghidraHome = process.env.GHIDRA_HOME;
1106
+ if (ghidraHome === undefined || ghidraHome === "") {
1107
+ return {
1108
+ ok: false,
1109
+ message: `host_tool "ghidra.installExtension" requires the GHIDRA_HOME environment variable to name a Ghidra installation directory; it is unset`,
1110
+ };
1111
+ }
1112
+ const sleighPath = join(ghidraHome, "support", "sleigh");
1113
+ if (!existsSync(sleighPath)) {
1114
+ return {
1115
+ ok: false,
1116
+ message: `host_tool "ghidra.installExtension" refuses: GHIDRA_HOME's resolved "support/sleigh" does not exist on disk (${sleighPath})`,
1117
+ };
1118
+ }
1119
+ const installLanguagesDir = join(ghidraHome, "Ghidra", "Extensions", moduleName, "data", "languages");
1120
+ const slaspecPath = join(installLanguagesDir, "6502_nmos.slaspec");
1121
+ const slaPath = join(installLanguagesDir, "6502_nmos.sla");
1122
+ // Argv stays an ARRAY of individually-validated entries -- never a
1123
+ // string-concatenated single argument (must_haves.prohibitions).
1124
+ return { ok: true, toolPath: sleighPath, argv: [slaspecPath, slaPath], outputs: [slaPath] };
1125
+ }
1126
+ if (request.tool === "c1541.bam" ||
1127
+ request.tool === "c1541.dir" ||
1128
+ request.tool === "c1541.entry" ||
1129
+ request.tool === "c1541.chain" ||
1130
+ request.tool === "c1541.read") {
1131
+ const { imagePath, outDirPath } = resolved;
1132
+ // Resolved as a SIBLING of whichever x64sc backend-detect.mts already
1133
+ // resolved -- never a bare-name spawn, which a host carrying both a
1134
+ // stock and a fork build would silently answer with whichever build's
1135
+ // directory happens to sort first on $PATH (MEASURED live on this
1136
+ // project's own dev host, see the import comment above).
1137
+ // `resolvedBackend()` with no `supervisorDir` never touches the on-disk
1138
+ // cache; it still memoises in-process, which is what keeps a
1139
+ // long-running broker's SECOND call here free -- see the import
1140
+ // comment's own memoisation posture.
1141
+ const c1541Found = findSiblingBinary("c1541", resolvedBackend().binPath, log);
1142
+ if (c1541Found.path === null) {
1143
+ return {
1144
+ ok: false,
1145
+ message: `host_tool "${request.tool}" refuses: "c1541" does not exist (tried: ${c1541Found.tried.join(", ")})`,
1146
+ };
1147
+ }
1148
+ const c1541Path = c1541Found.path;
1149
+ const imageStem = basename(imagePath).replace(/\.[^./]+$/, "");
1150
+ // A-02-style fixed flags: `-attach <imagePath> <verb-flag> [name]`,
1151
+ // argv as an ARRAY of individually-validated entries, never a shell
1152
+ // string (must_haves.prohibitions). Deterministic: the same resolved
1153
+ // paths (and, for entry/chain/read, the same already-validated `name`)
1154
+ // yield a byte-identical argv array on two successive calls. c1541 has
1155
+ // NO output-file option for `-dir`/`-bam`/`-entry`/`-chain` -- every
1156
+ // listing line is printed to its OWN stdout (MEASURED against the real
1157
+ // committed fixture, fixtures/c1541/README.md); the seam captures
1158
+ // stdout and writes it to a single outputs[] path (TOOLS_WHOSE_OUTPUT_IS_STDOUT
1159
+ // above), then digests the FILE -- never c1541's own exit status, which
1160
+ // is 0 even on a genuine failure (MEASURED: "Error - Cannot open
1161
+ // file ..." exits 0) and is therefore never the pass/fail signal for a
1162
+ // listing. `-read` is the one exception: the child writes the output
1163
+ // file itself, so its argv passes the produced host path as its own
1164
+ // final positional argument.
1165
+ if (request.tool === "c1541.dir") {
1166
+ return { ok: true, toolPath: c1541Path, argv: ["-attach", imagePath, "-dir"], outputs: [join(outDirPath, `${imageStem}.dir.txt`)] };
1167
+ }
1168
+ if (request.tool === "c1541.bam") {
1169
+ return { ok: true, toolPath: c1541Path, argv: ["-attach", imagePath, "-bam"], outputs: [join(outDirPath, `${imageStem}.bam.txt`)] };
1170
+ }
1171
+ // c1541.entry / c1541.chain / c1541.read: `name` is a CBM filename or
1172
+ // glob pattern, already validated (non-empty, no leading hyphen) by
1173
+ // normaliseHostToolRequest() -- never re-derived here. `slug` is
1174
+ // `name` with every character outside [A-Za-z0-9] replaced by `_`,
1175
+ // truncated to 32 characters (interface_contract), so an arbitrary CBM
1176
+ // name never becomes an unsafe or over-long filesystem path segment.
1177
+ const { name } = request.args;
1178
+ const slug = name.replace(/[^A-Za-z0-9]/g, "_").slice(0, 32);
1179
+ if (request.tool === "c1541.entry") {
1180
+ return {
1181
+ ok: true,
1182
+ toolPath: c1541Path,
1183
+ argv: ["-attach", imagePath, "-entry", name],
1184
+ outputs: [join(outDirPath, `${imageStem}.${slug}.entry.txt`)],
1185
+ };
1186
+ }
1187
+ if (request.tool === "c1541.chain") {
1188
+ return {
1189
+ ok: true,
1190
+ toolPath: c1541Path,
1191
+ argv: ["-attach", imagePath, "-chain", name],
1192
+ outputs: [join(outDirPath, `${imageStem}.${slug}.chain.txt`)],
1193
+ };
1194
+ }
1195
+ // request.tool === "c1541.read": the single-file byte-extraction route,
1196
+ // mirroring extractEntry(image, entryName), the now-deleted MCP-side
1197
+ // pure-parse module's own signature, one-for-one. The produced host
1198
+ // path is the child's OWN output argument -- c1541
1199
+ // writes it directly, so this tool is deliberately absent from
1200
+ // TOOLS_WHOSE_OUTPUT_IS_STDOUT and the existing digest loop picks the
1201
+ // file up unchanged.
1202
+ const outputPath = join(outDirPath, `${imageStem}.${slug}.bin`);
1203
+ return { ok: true, toolPath: c1541Path, argv: ["-attach", imagePath, "-read", name, outputPath], outputs: [outputPath] };
1204
+ }
1205
+ if (request.tool === "petcat.decode") {
1206
+ const { imagePath, outDirPath } = resolved;
1207
+ // Resolved as a SIBLING of whichever x64sc backend-detect.mts already
1208
+ // resolved, never a bare-name spawn, with a logged $PATH-fallback
1209
+ // warning -- the same mechanism c1541.* already use above.
1210
+ const petcatFound = findSiblingBinary("petcat", resolvedBackend().binPath, log);
1211
+ if (petcatFound.path === null) {
1212
+ return {
1213
+ ok: false,
1214
+ message: `host_tool "petcat.decode" refuses: "petcat" does not exist (tried: ${petcatFound.tried.join(", ")})`,
1215
+ };
1216
+ }
1217
+ const petcatPath = petcatFound.path;
1218
+ // The BASIC dialect is a FIXED literal here, server-side -- "-2"
1219
+ // (BASIC V2.0, every stock C64's own dialect), first in argv, ahead of
1220
+ // the resolved image path LAST. There is no wire field that selects it
1221
+ // (HOST_TOOL_ARG_KEYS["petcat.decode"] carries no such key), nothing
1222
+ // validates it, and no caller can request a different one.
1223
+ const argv = ["-2", imagePath];
1224
+ // petcat has no output-file option for a plain decode -- every
1225
+ // listing line is printed to its own stdout (MEASURED, a scratch run
1226
+ // against both committed fixtures, see fixtures/petcat/README.md). The
1227
+ // seam captures stdout and writes it to this single outputs[] path,
1228
+ // then digests the FILE -- never petcat's own exit status, which is 0
1229
+ // even on garbage input (MEASURED) and therefore never the pass/fail
1230
+ // signal for a listing.
1231
+ const imageStem = basename(imagePath).replace(/\.[^./]+$/, "");
1232
+ const listingPath = join(outDirPath, `${imageStem}.bas.txt`);
1233
+ return { ok: true, toolPath: petcatPath, argv, outputs: [listingPath] };
1234
+ }
1235
+ return { ok: false, message: `buildHostToolArgv: no argv builder for tool "${request.tool}"` };
1236
+ }
1237
+ // ---------------------------------------------------------------------------
1238
+ // Async child-process invocation and result digest. NEVER `spawnSync` --
1239
+ // broker-kill.mts's uncaughtException/unhandledRejection handlers kill the
1240
+ // ENTIRE VICE pool on any unhandled throw in this process, and a
1241
+ // synchronous spawn for a multi-second tool run would block the
1242
+ // single-threaded event loop for its whole duration, starving acquires,
1243
+ // the launching -> ready promotion sweep (the warm floor this comment used
1244
+ // to name alongside it was later retired), and monitor claims.
1245
+ // ---------------------------------------------------------------------------
1246
+ /** Fallback per-invocation timeout for a tool id absent from
1247
+ * HOST_TOOL_TIMEOUT_MS below -- unreachable today, since every
1248
+ * HOST_TOOL_IDS member has an explicit table entry, but this constant
1249
+ * stays exported and consulted as the honest bottom of the resolver's
1250
+ * fallback chain. It is also the value acme.build/oracle.probe/oracle.run's
1251
+ * own table entries hold today (20s, the same value packer-finding.mjs's
1252
+ * own ORACLE_TIMEOUT_MS convention already used) -- no longer the ceiling
1253
+ * for EVERY invocation: a single default governing every tool is exactly
1254
+ * how a real incident happened -- a number chosen for a stateless
1255
+ * assembler silently governed a JVM. */
1256
+ export const DEFAULT_HOST_TOOL_TIMEOUT_MS = 20_000;
1257
+ /** The per-tool SERVER-side budget table, built with the SAME
1258
+ * `Object.freeze(Object.assign(Object.create(null), ...))` idiom
1259
+ * HOST_TOOL_ARG_KEYS uses, with an entry for EVERY HOST_TOOL_IDS member --
1260
+ * completeness enforced by host-tool.test.ts's own completeness case,
1261
+ * never assumed silently. `acme.build`, `oracle.probe` and `oracle.run`
1262
+ * keep the value DEFAULT_HOST_TOOL_TIMEOUT_MS already held (20_000ms) --
1263
+ * none of their measured costs approach the fixed ceiling. `ghidra.analyze`
1264
+ * gets 600_000ms (10 minutes), justified from this project's own recorded
1265
+ * numbers rather than a round guess: the documented JVM startup range is
1266
+ * 12.6-17.4s, live testing measured 12407ms and 11160ms for a *refusal*
1267
+ * alone, and the same measurement record states a real analysis run takes
1268
+ * multiple minutes -- 10 minutes clears startup plus a realistic analysis
1269
+ * budget with headroom, while staying a finite, stated ceiling: raising a
1270
+ * budget must never mean removing the kill-on-expiry bound --
1271
+ * spawnHostTool()'s timer below still kills and reports a refusal on
1272
+ * expiry, unchanged. */
1273
+ export const HOST_TOOL_TIMEOUT_MS = Object.freeze(Object.assign(Object.create(null), {
1274
+ "acme.build": DEFAULT_HOST_TOOL_TIMEOUT_MS,
1275
+ "ghidra.analyze": 600_000,
1276
+ "oracle.probe": DEFAULT_HOST_TOOL_TIMEOUT_MS,
1277
+ "oracle.run": DEFAULT_HOST_TOOL_TIMEOUT_MS,
1278
+ // DEFAULT_HOST_TOOL_TIMEOUT_MS, justified from a measurement rather
1279
+ // than a round guess -- the pinned dxa disassembles a full 65,536-byte
1280
+ // image in 21ms wall-clock (MEASURED), a 950x headroom against this 20s
1281
+ // ceiling. host-tool-client.ts's request-deadline table gains NO entry
1282
+ // for this tool, because DEFAULT_HOST_TOOL_REQUEST_TIMEOUT_MS (30_000)
1283
+ // already exceeds this value -- the cross-seam ordering test stays
1284
+ // satisfied by construction.
1285
+ "dxa.disassemble": DEFAULT_HOST_TOOL_TIMEOUT_MS,
1286
+ // DEFAULT_HOST_TOOL_TIMEOUT_MS, justified from a measurement rather
1287
+ // than a round guess -- `support/sleigh` compiled this extension's
1288
+ // whole vendored tree in 1763ms wall-clock (MEASURED, a scratch run),
1289
+ // an ~11x headroom against this 20s ceiling. host-tool-client.ts's
1290
+ // request-deadline table gains NO entry for this tool, for the same
1291
+ // reason dxa.disassemble's own comment above states:
1292
+ // DEFAULT_HOST_TOOL_REQUEST_TIMEOUT_MS (30_000) already exceeds this
1293
+ // value.
1294
+ "ghidra.installExtension": DEFAULT_HOST_TOOL_TIMEOUT_MS,
1295
+ // DEFAULT_HOST_TOOL_TIMEOUT_MS, justified from a measurement rather
1296
+ // than a round guess -- `c1541 -attach fixtures/c1541/synthetic.d64
1297
+ // -dir` completed in 15ms wall-clock (MEASURED, a scratch run against
1298
+ // the committed fixture), a >1300x headroom against this 20s ceiling.
1299
+ // host-tool-client.ts's request-deadline table gains NO entry for this
1300
+ // tool, for the same reason dxa.disassemble's own comment above states:
1301
+ // DEFAULT_HOST_TOOL_REQUEST_TIMEOUT_MS (30_000) already exceeds this
1302
+ // value.
1303
+ "c1541.dir": DEFAULT_HOST_TOOL_TIMEOUT_MS,
1304
+ // DEFAULT_HOST_TOOL_TIMEOUT_MS for all four, justified from a
1305
+ // measurement rather than a round guess -- each of `c1541 -attach
1306
+ // fixtures/c1541/synthetic.d64 -bam`, `-entry basicstub`, `-chain
1307
+ // basicstub` and `-read basicstub <out>` completed in 14-16ms
1308
+ // wall-clock (MEASURED, a scratch run against the committed fixture),
1309
+ // a >1200x headroom against this 20s ceiling. host-tool-client.ts's
1310
+ // request-deadline table gains NO entry for any of these, for the same
1311
+ // reason c1541.dir's own comment above states.
1312
+ "c1541.bam": DEFAULT_HOST_TOOL_TIMEOUT_MS,
1313
+ "c1541.entry": DEFAULT_HOST_TOOL_TIMEOUT_MS,
1314
+ "c1541.chain": DEFAULT_HOST_TOOL_TIMEOUT_MS,
1315
+ "c1541.read": DEFAULT_HOST_TOOL_TIMEOUT_MS,
1316
+ // DEFAULT_HOST_TOOL_TIMEOUT_MS, justified from a measurement rather
1317
+ // than a round guess -- `petcat -2` completed in 1-2ms wall-clock
1318
+ // against both committed fixtures (MEASURED, a scratch run), a
1319
+ // >10000x headroom against this 20s ceiling. host-tool-client.ts's
1320
+ // request-deadline table gains NO entry for this tool, for the same
1321
+ // reason c1541.dir's own comment above states:
1322
+ // DEFAULT_HOST_TOOL_REQUEST_TIMEOUT_MS (30_000) already exceeds this
1323
+ // value.
1324
+ "petcat.decode": DEFAULT_HOST_TOOL_TIMEOUT_MS,
1325
+ }));
1326
+ /** The resolver every spawn site reads its budget from: an explicit
1327
+ * override (`deps.timeoutMs` -- the in-process test seam) always wins;
1328
+ * else the table entry above for `tool`; else DEFAULT_HOST_TOOL_TIMEOUT_MS
1329
+ * as the fallback for a tool id with no table entry (unreachable today, but
1330
+ * keeps this function total rather than partial). This is the ONE place a
1331
+ * budget is decided -- runHostTool()'s acme.build/ghidra.analyze branch,
1332
+ * runOracleProbe() and runOracleRun() all call it rather than reading
1333
+ * DEFAULT_HOST_TOOL_TIMEOUT_MS or the table directly. */
1334
+ export function hostToolTimeoutMs(tool, override) {
1335
+ if (override !== undefined)
1336
+ return override;
1337
+ return HOST_TOOL_TIMEOUT_MS[tool] ?? DEFAULT_HOST_TOOL_TIMEOUT_MS;
1338
+ }
1339
+ /** stderrTail's byte cap -- diagnostics only, never a result. */
1340
+ const STDERR_TAIL_CAP_BYTES = 64 * 1024;
1341
+ /** oracle.run's stdout cap -- the SAME measured bound as
1342
+ * packer-finding.mjs's own (unmoved, still exported there) MAX_ORACLE_STDOUT_BYTES.
1343
+ * The executor enforces the bound on what it accumulates; the script keeps
1344
+ * exporting the number for its own parser and its own tests -- not a
1345
+ * duplicated maintenance burden, the same measured constant on both sides. */
1346
+ const ORACLE_STDOUT_CAP_BYTES = 64 * 1024;
1347
+ /** The hard ceiling spawnHostTool()'s own stdout/stderr accumulation
1348
+ * enforces, independent of the DEFAULT_HOST_TOOL_TIMEOUT_MS wall-clock kill
1349
+ * below -- previously the ONLY bound on a runaway child was the timeout,
1350
+ * so a pathological process could grow an unbounded in-memory string for
1351
+ * its full allotted budget. c1541.chain/c1541.bam are run directly against
1352
+ * untrusted, possibly-corrupt disk images (this project's own audit
1353
+ * tooling exists specifically to detect fabricated/cyclic directory
1354
+ * structures), and c1541 has no documented guard of its own against a
1355
+ * cyclic DATA sector chain.
1356
+ *
1357
+ * Deliberately NOT set to STDOUT_CLASSIFY_CAP_BYTES (64KiB) or "a slightly
1358
+ * larger" ceiling close to it, even though a smaller cap was the initial
1359
+ * suggestion: TOOLS_WHOSE_OUTPUT_IS_STDOUT tools (dxa.disassemble, all
1360
+ * four stdout-shaped c1541.* ids, petcat.decode) write the FULL captured
1361
+ * stdout verbatim to their declared output file below (the
1362
+ * writeFileSync() call right after the spawn) -- a full dxa.disassemble
1363
+ * listing for a real 64KB-image fixture already measures well past 64KiB
1364
+ * of text, so a cap anywhere near that size would silently truncate a
1365
+ * legitimate disassembly into a corrupt, incomplete listing every time it
1366
+ * ran, not just on a malicious input. This ceiling is sized purely as a
1367
+ * runaway-memory guard against a pathological/looping child, far above any
1368
+ * legitimate output this seam produces today. */
1369
+ const SPAWN_ACCUMULATION_HARD_CAP_BYTES = 64 * 1024 * 1024;
1370
+ /** Which tool ids write NO output file of their own -- their "output" IS
1371
+ * the captured stdout, so runHostTool() turns it into a file itself before
1372
+ * the digest loop runs (see the usage site below). Replaces the condition
1373
+ * that used to name only "dxa.disassemble" directly -- a future addition
1374
+ * is one entry in this frozen set, never a near-duplicate `if` branch.
1375
+ * `c1541.read` is deliberately ABSENT -- its argv passes the produced host
1376
+ * path as the child's own output argument (`-read <name> <outputPath>`),
1377
+ * so the child writes that file itself and the existing digest loop picks
1378
+ * it up unchanged. */
1379
+ const TOOLS_WHOSE_OUTPUT_IS_STDOUT = new Set([
1380
+ "dxa.disassemble",
1381
+ "c1541.bam",
1382
+ "c1541.dir",
1383
+ "c1541.entry",
1384
+ "c1541.chain",
1385
+ // petcat.decode has no output-file option for a plain decode -- every
1386
+ // listing line is printed to its own stdout, exactly like
1387
+ // dxa.disassemble/c1541.* above.
1388
+ "petcat.decode",
1389
+ ]);
1390
+ /** The byte cap this module's c1541.bam/c1541.entry/c1541.chain
1391
+ * classifiers apply to the captured text BEFORE testing it against a
1392
+ * declared shape -- spawnHostTool()'s own stdout accumulation has no
1393
+ * explicit bound today, and a sector chain (c1541.chain) is the first
1394
+ * disk-image-driven input that could make it large. Same value and the
1395
+ * same tail/cap convention (tailBytes(), keep the END, not the start)
1396
+ * STDERR_TAIL_CAP_BYTES below already applies. */
1397
+ const STDOUT_CLASSIFY_CAP_BYTES = 64 * 1024;
1398
+ /** Total `Readonly<Record<HostToolId, HostToolOutputClassifier | null>>` --
1399
+ * built with the SAME `Object.freeze(Object.assign(Object.create(null),
1400
+ * ...))` idiom HOST_TOOL_ARG_KEYS uses. Pre-existing ids map to `null`,
1401
+ * meaning "no declared shape, behaviour unchanged" -- NEVER absent, so a
1402
+ * tool id never falls through to an implicit success. `host-tool.test.ts`'s
1403
+ * completeness case asserts every HOST_TOOL_IDS member has an own
1404
+ * property here. */
1405
+ export const HOST_TOOL_OUTPUT_CLASSIFIERS = Object.freeze(Object.assign(Object.create(null), {
1406
+ "acme.build": null,
1407
+ "ghidra.analyze": null,
1408
+ "oracle.probe": null,
1409
+ "oracle.run": null,
1410
+ "dxa.disassemble": null,
1411
+ "ghidra.installExtension": null,
1412
+ // Declared shape (MEASURED against the committed fixture): a
1413
+ // `<N> blocks free` trailer. MEASURED also: c1541 prints an "OPENCBM:
1414
+ // opening dynamic library libopencbm.so failed!" complaint to stdout on
1415
+ // EVERY call on this host -- a negative stderr oracle would be wrong
1416
+ // here, since the complaint lands on stdout, not stderr, and is
1417
+ // unrelated to whether the listing itself succeeded.
1418
+ "c1541.dir": ((ctx) => classifyC1541DirOutput(ctx.stdout)),
1419
+ // Declared shape (MEASURED against the committed fixture): at least
1420
+ // one per-sector allocation row -- a digit-prefixed line followed by
1421
+ // a run of `*`/`.` characters.
1422
+ "c1541.bam": ((ctx) => classifyC1541BamOutput(ctx.stdout)),
1423
+ // Declared shape (MEASURED against the committed fixture): a
1424
+ // `T/S: <t>/<s>, <n> blocks` line.
1425
+ "c1541.entry": ((ctx) => classifyC1541EntryOutput(ctx.stdout)),
1426
+ // Declared shape (MEASURED against the committed fixture): at least
1427
+ // one `(track,sector) ->` arrow pair. MEASURED: a single-sector file's
1428
+ // chain output does NOT repeat a second (track,sector) tuple on the
1429
+ // right of the arrow -- the LAST hop prints only the byte count used
1430
+ // in the final sector, a plain integer -- so this classifier matches
1431
+ // on "at least one (t,s) followed by an arrow", not on a
1432
+ // tuple-on-both-sides shape (see fixtures/c1541/README.md).
1433
+ "c1541.chain": ((ctx) => classifyC1541ChainOutput(ctx.stdout)),
1434
+ // Declared shape: `results` contains exactly one entry whose
1435
+ // `byteLength` is greater than zero -- reads `results`, not captured
1436
+ // text, since c1541.read is absent from TOOLS_WHOSE_OUTPUT_IS_STDOUT
1437
+ // (the child writes its own output file).
1438
+ "c1541.read": ((ctx) => classifyC1541ReadOutput(ctx.results)),
1439
+ // Declared shape (MEASURED against both committed fixtures): the
1440
+ // leading banner line petcat -2 prints for a recognised BASIC program,
1441
+ // `;<path> ==<hex>==`. MEASURED also: petcat exits 0 on garbage input,
1442
+ // and its output for a truly non-BASIC file carries no such banner at
1443
+ // all -- confirmed live against 64 random bytes.
1444
+ "petcat.decode": ((ctx) => classifyPetcatDecodeOutput(ctx.stdout)),
1445
+ }));
1446
+ /** The declared success shape for c1541.dir's captured stdout: at least one
1447
+ * numeric block-count trailer of the form `<N> blocks free` (MEASURED
1448
+ * against the committed fixture, fixtures/c1541/README.md). A `-dir`
1449
+ * listing is small and bounded by the disk's own directory-sector budget,
1450
+ * so this classifier tests the FULL captured text -- c1541.bam/entry/chain
1451
+ * below, whose input can grow with a disk image's sector-chain contents,
1452
+ * classify over a capped prefix instead. */
1453
+ export function classifyC1541DirOutput(stdout) {
1454
+ if (/\d+\s+blocks\s+free/i.test(stdout))
1455
+ return { ok: true };
1456
+ return { ok: false, reason: `c1541.dir: captured stdout carries no "<N> blocks free" trailer -- got: ${describe(tailBytes(stdout, 512))}` };
1457
+ }
1458
+ /** The declared success shape for c1541.bam's captured stdout: at least one
1459
+ * per-sector allocation row (MEASURED against the committed fixture,
1460
+ * fixtures/c1541/README.md) -- a digit-prefixed line (" 1 ........
1461
+ * ........ .....", "17 **...... ........ .....") followed by a run of at
1462
+ * least two `*`/`.` characters. */
1463
+ export function classifyC1541BamOutput(stdout) {
1464
+ const capped = tailBytes(stdout, STDOUT_CLASSIFY_CAP_BYTES);
1465
+ if (/^\s*\d{1,2}\s+[*.]{2,}/m.test(capped))
1466
+ return { ok: true };
1467
+ return { ok: false, reason: `c1541.bam: captured stdout carries no per-sector allocation row -- got: ${describe(capped.slice(0, 512))}` };
1468
+ }
1469
+ /** The declared success shape for c1541.entry's captured stdout: a `T/S:
1470
+ * <t>/<s>, <n> blocks` line (MEASURED against the committed fixture,
1471
+ * fixtures/c1541/README.md -- e.g. "T/S: 17/0, 1 blocks", note the double
1472
+ * space before the count). */
1473
+ export function classifyC1541EntryOutput(stdout) {
1474
+ const capped = tailBytes(stdout, STDOUT_CLASSIFY_CAP_BYTES);
1475
+ if (/T\/S:\s*\d+\/\d+,\s*\d+\s*blocks/.test(capped))
1476
+ return { ok: true };
1477
+ return { ok: false, reason: `c1541.entry: captured stdout carries no "T/S: <t>/<s>, <n> blocks" line -- got: ${describe(capped.slice(0, 512))}` };
1478
+ }
1479
+ /** The declared success shape for c1541.chain's captured stdout: at least
1480
+ * one `(track,sector) ->` arrow pair (MEASURED against the committed
1481
+ * fixture, fixtures/c1541/README.md -- a single-sector file's chain reads
1482
+ * `(17, 0) -> 19`; a multi-sector file's reads `(17, 2) -> (17,12) -> 28`).
1483
+ * Matches on a track/sector tuple immediately followed by an arrow, NOT on
1484
+ * a tuple appearing on BOTH sides of the arrow -- the committed fixture's
1485
+ * own single-sector files never produce the latter shape. */
1486
+ export function classifyC1541ChainOutput(stdout) {
1487
+ const capped = tailBytes(stdout, STDOUT_CLASSIFY_CAP_BYTES);
1488
+ if (/\(\s*\d+\s*,\s*\d+\s*\)\s*->/.test(capped))
1489
+ return { ok: true };
1490
+ return { ok: false, reason: `c1541.chain: captured stdout carries no "(track,sector) ->" arrow pair -- got: ${describe(capped.slice(0, 512))}` };
1491
+ }
1492
+ /** The declared success shape for c1541.read: `results` (the digest loop's
1493
+ * own output, not captured text) contains exactly one entry whose
1494
+ * `byteLength` is greater than zero. */
1495
+ export function classifyC1541ReadOutput(results) {
1496
+ if (results.length === 1 && results[0].byteLength > 0)
1497
+ return { ok: true };
1498
+ return {
1499
+ ok: false,
1500
+ reason: `c1541.read: expected exactly one result with a byteLength greater than zero; got ${results.length} result(s)${results.length === 1 ? ` (byteLength ${results[0].byteLength})` : ""}`,
1501
+ };
1502
+ }
1503
+ /** The declared success shape for petcat.decode's captured stdout: the
1504
+ * leading banner line `petcat -2` prints for a recognised BASIC program,
1505
+ * `;<path> ==<hex>==` (MEASURED against both committed fixtures --
1506
+ * `;.../basic-stub.prg ==0801==`, `;.../computed-sys.prg ==0801==`).
1507
+ * Absence of it means the file was not recognised as a BASIC program at
1508
+ * all -- MEASURED against 64 random bytes, whose captured output carries a
1509
+ * leading `;<path> ` but never the `==<hex>==` pair that follows it for a
1510
+ * real BASIC program. petcat exits 0 either way -- this classifier, not
1511
+ * the exit code, is what decides success here. */
1512
+ export function classifyPetcatDecodeOutput(stdout) {
1513
+ if (/;\S+\s+==[0-9a-fA-F]+==/.test(stdout))
1514
+ return { ok: true };
1515
+ return {
1516
+ ok: false,
1517
+ reason: `petcat.decode: captured stdout carries no ";<path> ==<hex>==" banner -- the file was not recognised as a BASIC program -- got: ${describe(tailBytes(stdout, 512))}`,
1518
+ };
1519
+ }
1520
+ /** Parses `petcat -2`'s own detokenized BASIC listing for the program's own
1521
+ * machine-code handover instruction (`SYS`). Pure and total -- never
1522
+ * throws -- reading ONLY the classifier-accepted captured stdout, called
1523
+ * host-side immediately after the classifier above accepts. Three cases,
1524
+ * ALL successes -- `ok: false` is reserved for the classifier's own shape
1525
+ * refusal above, never for an unresolved SYS argument:
1526
+ * - an all-decimal-digit argument resolves to a numeric entry point,
1527
+ * named by the BASIC line it came from (the literal fast path);
1528
+ * - anything else is a named decline quoting the unresolved expression
1529
+ * verbatim, so a reader sees exactly what could not be resolved (the
1530
+ * computed case, whose own fixture exercises it);
1531
+ * - no SYS token at all is a named decline saying so.
1532
+ * Matches the FIRST BASIC line whose statement (immediately after the line
1533
+ * number) is the `sys` keyword -- petcat's own detokenized output always
1534
+ * prints a line as `<blanks><line number> <statement>`, MEASURED against
1535
+ * both committed fixtures (fixtures/dxa/basic-stub.prg, "10 sys2064";
1536
+ * fixtures/petcat/computed-sys.prg, "10 sys peek(43)+256*peek(44)"). */
1537
+ export function derivePetcatEntrypoint(detokenizedText) {
1538
+ for (const line of detokenizedText.split(/\r?\n/)) {
1539
+ const m = line.match(/^\s*(\d+)\s+sys\s*(\S.*?)\s*$/i);
1540
+ if (!m)
1541
+ continue;
1542
+ const basicLine = m[1];
1543
+ const argument = m[2];
1544
+ if (/^\d+$/.test(argument)) {
1545
+ // A fix for a real bug: an all-decimal-digit SYS argument used to be
1546
+ // accepted as a literal entry point with no upper-bound check --
1547
+ // `Number()` converts an arbitrarily long digit string (with silent
1548
+ // precision loss past 2^53) and a BASIC program can legally contain
1549
+ // `SYS 999999` or larger. The C64's real address space is 0..65535;
1550
+ // anything outside that range (or that loses precision on the way to
1551
+ // a safe integer) is reported through the SAME named-decline path the
1552
+ // non-literal ("computed") case below already uses, rather than
1553
+ // passed through as a real entry point.
1554
+ const value = Number(argument);
1555
+ if (Number.isSafeInteger(value) && value >= 0 && value <= 0xffff) {
1556
+ return {
1557
+ entrypoint: value,
1558
+ entrypointReason: `literal SYS argument on BASIC line ${basicLine}: sys${argument}`,
1559
+ };
1560
+ }
1561
+ return {
1562
+ entrypoint: null,
1563
+ entrypointReason: `SYS argument on BASIC line ${basicLine} (${argument}) is outside the C64's 16-bit address space and cannot be a real entry point`,
1564
+ };
1565
+ }
1566
+ return {
1567
+ entrypoint: null,
1568
+ entrypointReason: `SYS argument on BASIC line ${basicLine} is not a literal decimal value and cannot be resolved to an address: sys ${argument}`,
1569
+ };
1570
+ }
1571
+ return { entrypoint: null, entrypointReason: "the listing contains no handover instruction" };
1572
+ }
1573
+ /** Digests one produced output file: byte size from a filesystem stat, sha256
1574
+ * over its real bytes. A zero-byte file yields `byteLength: 0` and the
1575
+ * sha256 of the empty byte string -- never an omitted or null entry. Returns
1576
+ * `null` only when the file does not exist / is unreadable, so a tool run
1577
+ * that never produced this output reports no entry for it at all (distinct
1578
+ * from a produced-but-empty file). */
1579
+ function digestOutputFile(path) {
1580
+ try {
1581
+ // byteLength must describe the SAME bytes sha256 was computed over --
1582
+ // derived from the buffer actually read, never from a separate
1583
+ // statSync() call, which could observe a different byte string if the
1584
+ // file is written to between the two reads.
1585
+ const contents = readFileSync(path);
1586
+ const sha256 = createHash("sha256").update(contents).digest("hex");
1587
+ return { path, sha256, byteLength: contents.length };
1588
+ }
1589
+ catch {
1590
+ return null;
1591
+ }
1592
+ }
1593
+ /** Caps `text` to its LAST `capBytes` bytes (UTF-8), never its first --
1594
+ * diagnostics from the tail of a long run are what a caller actually needs. */
1595
+ function tailBytes(text, capBytes) {
1596
+ const buf = Buffer.from(text, "utf8");
1597
+ if (buf.length <= capBytes)
1598
+ return text;
1599
+ return buf.subarray(buf.length - capBytes).toString("utf8");
1600
+ }
1601
+ /** Spawns `toolPath` with `argv` (an ARRAY, never a shell string; the
1602
+ * command interpreter is never enabled) and resolves -- NEVER rejects --
1603
+ * once the child exits, errors, or is killed on timeout expiry. This is the
1604
+ * ONE spawn call in this module -- oracle.probe/oracle.run reuse it rather
1605
+ * than adding a second. `env` defaults to the broker process's own
1606
+ * environment (`spawn()`'s own default) when omitted; acme.build overrides
1607
+ * it to inject a probed `ACME` library directory (see `findAcmeLib()`
1608
+ * below). `stdout` is captured (not just `stderr`) because oracle.run's
1609
+ * contract is "the oracle's stdout", not a file digest -- acme.build/
1610
+ * ghidra.analyze simply ignore the field, exactly as they ignored stdout
1611
+ * before it was piped (ACME writes nothing to stdout; verified
1612
+ * empirically). `cwd` defaults to the broker process's own working
1613
+ * directory (`spawn()`'s own default) when omitted -- exactly `env`'s
1614
+ * existing default shape; only `acme.build` passes one, and every other
1615
+ * tool's spawn is therefore byte-identical to before this parameter
1616
+ * existed. */
1617
+ function spawnHostTool(toolPath, argv, timeoutMs, env, cwd) {
1618
+ return new Promise((resolvePromise) => {
1619
+ let settled = false;
1620
+ let timedOut = false;
1621
+ let stdout = "";
1622
+ let stderr = "";
1623
+ let child;
1624
+ try {
1625
+ child = spawn(toolPath, argv, { stdio: ["ignore", "pipe", "pipe"], ...(env ? { env } : {}), ...(cwd ? { cwd } : {}) });
1626
+ }
1627
+ catch (e) {
1628
+ resolvePromise({
1629
+ exitCode: null,
1630
+ timedOut: false,
1631
+ spawnErrorMessage: e instanceof Error ? e.message : String(e),
1632
+ stdout: "",
1633
+ stderr: "",
1634
+ });
1635
+ return;
1636
+ }
1637
+ const timer = setTimeout(() => {
1638
+ timedOut = true;
1639
+ child.kill("SIGKILL");
1640
+ }, timeoutMs);
1641
+ if (typeof timer.unref === "function")
1642
+ timer.unref();
1643
+ // Stop appending once the hard ceiling is reached, rather than capping
1644
+ // via tailBytes()'s "keep the end" convention used elsewhere in this
1645
+ // module -- unlike stderrTail/the classifier window (diagnostics only),
1646
+ // this accumulated string doubles as the literal file content for
1647
+ // TOOLS_WHOSE_OUTPUT_IS_STDOUT tools, so preserving the HEAD (the
1648
+ // already-received, in-order prefix) rather than an arbitrary tail
1649
+ // fragment keeps a capped run's written output internally coherent (a
1650
+ // truncated-but-ordered listing) instead of discarding its beginning.
1651
+ // The ceiling itself is set far above any legitimate output this seam
1652
+ // produces (see SPAWN_ACCUMULATION_HARD_CAP_BYTES above), so this
1653
+ // branch is never taken on a normal, successful run.
1654
+ child.stdout?.on("data", (chunk) => {
1655
+ if (stdout.length < SPAWN_ACCUMULATION_HARD_CAP_BYTES) {
1656
+ stdout += chunk.toString("utf8");
1657
+ }
1658
+ });
1659
+ child.stderr?.on("data", (chunk) => {
1660
+ if (stderr.length < SPAWN_ACCUMULATION_HARD_CAP_BYTES) {
1661
+ stderr += chunk.toString("utf8");
1662
+ }
1663
+ });
1664
+ child.on("error", (err) => {
1665
+ if (settled)
1666
+ return;
1667
+ settled = true;
1668
+ clearTimeout(timer);
1669
+ resolvePromise({ exitCode: null, timedOut, spawnErrorMessage: err.message, stdout, stderr });
1670
+ });
1671
+ child.on("close", (code) => {
1672
+ if (settled)
1673
+ return;
1674
+ settled = true;
1675
+ clearTimeout(timer);
1676
+ resolvePromise({ exitCode: code, timedOut, spawnErrorMessage: null, stdout, stderr });
1677
+ });
1678
+ });
1679
+ }
1680
+ // ---------------------------------------------------------------------------
1681
+ // The ACME library probe. Moved server-side from acme.mjs's own
1682
+ // findAcmeLib(): the project owner's rule is that a container has no PATH
1683
+ // to a host binary, and these five candidates are HOST paths -- so probing
1684
+ // them belongs on the host side of the seam, not in the container-side
1685
+ // skill script. Behaviourally identical to the removed client-side
1686
+ // function: same candidate order, same marker file, same "first candidate
1687
+ // whose marker exists wins" rule.
1688
+ // ---------------------------------------------------------------------------
1689
+ /** The marker file used to validate a candidate ACME library directory --
1690
+ * the layout fact `acme.mjs`'s own troubleshooting hint names. */
1691
+ const ACME_LIB_MARKER = join("cbm", "c64", "vic.a");
1692
+ // ---------------------------------------------------------------------------
1693
+ // The vendored dxa binary probe. This module ships two ways: as unbuilt
1694
+ // source (src/mcp/vice/host-tool.mts, HERE == src/mcp/vice/) and as the
1695
+ // compiled artifact this project actually runs
1696
+ // (src/mcp/vice/resources/host-tool.mjs, HERE == src/mcp/vice/resources/).
1697
+ // ghidra-project.mjs's own sibling-ness to host-tool.mjs survives that move
1698
+ // because BOTH are compiled into resources/ together (build.ts's
1699
+ // HOST_BOUND_ARTIFACTS). vendor/dxa/dxa does NOT survive it -- it is a real
1700
+ // binary, never copied anywhere by build.ts, always at
1701
+ // src/mcp/vice/vendor/dxa/dxa. So "vendor/dxa/dxa relative to
1702
+ // import.meta.url" means two DIFFERENT candidate locations depending on
1703
+ // which form of this module is executing: same-directory for the unbuilt
1704
+ // source, one level up for the compiled artifact. Mirrors findAcmeLib()'s
1705
+ // own "candidate list, first existing wins" idiom, immediately below.
1706
+ // ---------------------------------------------------------------------------
1707
+ export function findDxaBinary(here) {
1708
+ const tried = [join(here, "vendor", "dxa", "dxa"), join(here, "..", "vendor", "dxa", "dxa")];
1709
+ for (const candidate of tried) {
1710
+ if (existsSync(candidate))
1711
+ return { path: candidate, tried };
1712
+ }
1713
+ return { path: null, tried };
1714
+ }
1715
+ function findAcmeLib() {
1716
+ const tried = [];
1717
+ const candidates = [
1718
+ process.env.ACME,
1719
+ "/usr/local/share/acme",
1720
+ "/usr/share/acme",
1721
+ "/usr/lib/acme",
1722
+ process.env.HOME ? join(process.env.HOME, ".acme") : undefined,
1723
+ ].filter((c) => typeof c === "string" && c !== "");
1724
+ for (const c of candidates) {
1725
+ tried.push(c);
1726
+ if (existsSync(join(c, ACME_LIB_MARKER)))
1727
+ return { path: c, tried };
1728
+ }
1729
+ return { path: null, tried };
1730
+ }
1731
+ // ---------------------------------------------------------------------------
1732
+ // The c1541/petcat sibling-binary probe. Follows findDxaBinary()'s own
1733
+ // candidate-list idiom: try the most-trustworthy candidate first, fall back
1734
+ // only when it does not exist, and return every candidate tried so a
1735
+ // refusal can name them all. Unlike findDxaBinary() (a FIXED,
1736
+ // project-vendored path) and findAcmeLib() (a FIXED list of well-known
1737
+ // host install locations), this probe's first candidate is COMPUTED per
1738
+ // call, from whichever x64sc backend-detect.mts already resolved -- see
1739
+ // the resolvedBackend() import comment above for why that call is cheap
1740
+ // here. No version probe: deliberately withdrawn by the project owner --
1741
+ // this stays a name-and-location probe only and must not gain one back.
1742
+ // ---------------------------------------------------------------------------
1743
+ /** Memoised per binary name for the process lifetime -- mirrors
1744
+ * backend-detect.mts's own stated posture of resolving once per process,
1745
+ * never re-probing per call. A `null` (not found) answer is memoised too: a
1746
+ * transient host misconfiguration that resolves differently mid-process is
1747
+ * not a case this module has ever handled for any of its other binary
1748
+ * probes (findDxaBinary()/findAcmeLib() are also called fresh per
1749
+ * buildHostToolArgv() invocation but read a fixed, unchanging candidate set
1750
+ * -- this probe's OWN per-binary-name memo exists because its first
1751
+ * candidate is computed from a resolvedBackend() call that is itself
1752
+ * memoised, so re-deriving it per call would just re-walk $PATH for no new
1753
+ * information). */
1754
+ const siblingBinaryMemo = new Map();
1755
+ function findSiblingBinary(binaryName, resolvedX64scPath, log) {
1756
+ const memoised = siblingBinaryMemo.get(binaryName);
1757
+ if (memoised)
1758
+ return memoised;
1759
+ const tried = [];
1760
+ // First candidate: the SAME directory the resolved x64sc itself lives in.
1761
+ const siblingCandidate = join(dirname(resolvedX64scPath), binaryName);
1762
+ tried.push(siblingCandidate);
1763
+ if (existsSync(siblingCandidate)) {
1764
+ const result = { path: siblingCandidate, tried };
1765
+ siblingBinaryMemo.set(binaryName, result);
1766
+ return result;
1767
+ }
1768
+ // Fallback: a $PATH walk (mirrors defaultResolveBinPath()'s own algorithm,
1769
+ // backend-detect.mts), logging a warning naming the resolved x64sc path,
1770
+ // the PATH match, and that this MAY be a DIFFERENT VICE build than the
1771
+ // emulator -- never a silent PATH fallback.
1772
+ const pathEnv = process.env.PATH ?? "";
1773
+ for (const dir of pathEnv.split(":")) {
1774
+ if (!dir)
1775
+ continue;
1776
+ const candidate = join(dir, binaryName);
1777
+ tried.push(candidate);
1778
+ if (existsSync(candidate)) {
1779
+ log?.(`host_tool: "${binaryName}" was not found alongside the resolved x64sc (${resolvedX64scPath}); ` +
1780
+ `falling back to a $PATH match at ${candidate} -- this may be a DIFFERENT VICE build than the emulator`);
1781
+ const result = { path: candidate, tried };
1782
+ siblingBinaryMemo.set(binaryName, result);
1783
+ return result;
1784
+ }
1785
+ }
1786
+ const result = { path: null, tried };
1787
+ siblingBinaryMemo.set(binaryName, result);
1788
+ return result;
1789
+ }
1790
+ /** Narrows, resolves, builds argv, then spawns the child ASYNCHRONOUSLY.
1791
+ * NOTHING throws out of this function -- every failure path (refusal,
1792
+ * launch error, timeout, non-zero exit, unreadable output) resolves to a
1793
+ * response object, because broker-kill.mts's uncaughtException/
1794
+ * unhandledRejection handlers kill the whole VICE pool on an unhandled
1795
+ * throw in this process. Emits exactly one `log()` line per ATTEMPTED
1796
+ * invocation (i.e. once argv construction succeeded and a child was
1797
+ * actually spawned) naming the tool id, the exit status, the elapsed
1798
+ * milliseconds and the budget that was actually applied
1799
+ * (`timeout_ms=<n>`, from hostToolTimeoutMs()) -- so which budget governed
1800
+ * a run is observable off the log line rather than inferred. A request
1801
+ * refused before a child is ever spawned emits no log line -- there is no
1802
+ * invocation to record. */
1803
+ export async function runHostTool(raw, deps) {
1804
+ const narrowed = normaliseHostToolRequest(raw);
1805
+ if (!narrowed.ok)
1806
+ return { ok: false, message: narrowed.message };
1807
+ const { request } = narrowed;
1808
+ // oracle.probe/oracle.run do not fit the "spawn a tool that writes files,
1809
+ // then digest them" shape below -- their contract is the SPAWNED
1810
+ // PROCESS'S OWN stdout (a version banner, or the oracle's unpacked-output
1811
+ // text), not a produced-file digest. Handled as their own branch, reusing
1812
+ // spawnHostTool() (the one spawn call) rather than adding a second.
1813
+ if (request.tool === "oracle.probe")
1814
+ return runOracleProbe(deps);
1815
+ if (request.tool === "oracle.run")
1816
+ return runOracleRun(request.args, deps);
1817
+ const repoRootAbs = resolvePath(deps.repoRoot);
1818
+ let built;
1819
+ let acmeLib = null;
1820
+ // resolveGhidraProject() (below, in the ghidra.analyze branch) RESERVES
1821
+ // the run directory (creates it on disk) before buildHostToolArgv()'s own
1822
+ // GHIDRA_HOME/launcher/language preflight checks ever run -- those checks
1823
+ // can still fail for a completely ordinary, fixable reason (unset
1824
+ // GHIDRA_HOME, processor not yet installed). Recorded here so the shared
1825
+ // `!built.ok` check below can clean up the orphaned reservation rather
1826
+ // than burning the runId permanently.
1827
+ let ghidraReservedProjectLocation;
1828
+ if (request.tool === "acme.build") {
1829
+ const sourceResolved = resolveWorkspacePath(repoRootAbs, request.args.source);
1830
+ if (!sourceResolved.ok)
1831
+ return { ok: false, message: sourceResolved.message };
1832
+ let outDirPath;
1833
+ if (request.args.outDir !== undefined) {
1834
+ const outDirResolved = resolveWorkspacePath(repoRootAbs, request.args.outDir);
1835
+ if (!outDirResolved.ok)
1836
+ return { ok: false, message: outDirResolved.message };
1837
+ outDirPath = outDirResolved.path;
1838
+ }
1839
+ else {
1840
+ outDirPath = dirname(sourceResolved.path);
1841
+ }
1842
+ // Every `includes` entry resolved through the SAME
1843
+ // resolveWorkspacePath() site source/outDir just used. The FIRST
1844
+ // refusal returns unchanged -- the whole request fails, the offending
1845
+ // entry is never dropped and the remaining entries are never resolved
1846
+ // (no partial-success degradation). An absent or empty `includes`
1847
+ // yields an empty array, which buildHostToolArgv() emits as no -I
1848
+ // flags at all.
1849
+ const includePaths = [];
1850
+ for (const entry of request.args.includes ?? []) {
1851
+ const includeResolved = resolveWorkspacePath(repoRootAbs, entry);
1852
+ if (!includeResolved.ok)
1853
+ return { ok: false, message: includeResolved.message };
1854
+ includePaths.push(includeResolved.path);
1855
+ }
1856
+ built = buildHostToolArgv(request, { sourcePath: sourceResolved.path, outDirPath, includePaths });
1857
+ acmeLib = findAcmeLib();
1858
+ }
1859
+ else if (request.tool === "ghidra.analyze") {
1860
+ // Converted from a previous `if (acme.build) … else (ghidra.analyze)`
1861
+ // shape into an explicit per-tool branch -- the `else`'s own comment
1862
+ // claiming ghidra.analyze was the only remaining member stopped being
1863
+ // true the moment dxa.disassemble (below) was added; leaving the
1864
+ // implicit shape would have routed a dxa.disassemble request into
1865
+ // Ghidra's own resolver. `importPath` is workspace-relative, resolved
1866
+ // through the SAME resolveWorkspacePath() site acme.build's `source`
1867
+ // uses; the project location itself comes from ghidra-project.mts's
1868
+ // resolveGhidraProject() -- never computed here.
1869
+ const importResolved = resolveWorkspacePath(repoRootAbs, request.args.importPath);
1870
+ if (!importResolved.ok)
1871
+ return { ok: false, message: importResolved.message };
1872
+ // preScript/postScript resolved through the SAME resolveWorkspacePath()
1873
+ // site, BEFORE resolveGhidraProject()'s own directory RESERVATION below
1874
+ // -- a refusal here must never leave a reserved-but-unused run
1875
+ // directory behind.
1876
+ let preScriptPath;
1877
+ if (request.args.preScript !== undefined) {
1878
+ const preScriptResolved = resolveWorkspacePath(repoRootAbs, request.args.preScript);
1879
+ if (!preScriptResolved.ok)
1880
+ return { ok: false, message: preScriptResolved.message };
1881
+ preScriptPath = preScriptResolved.path;
1882
+ }
1883
+ let postScriptPath;
1884
+ if (request.args.postScript !== undefined) {
1885
+ const postScriptResolved = resolveWorkspacePath(repoRootAbs, request.args.postScript);
1886
+ if (!postScriptResolved.ok)
1887
+ return { ok: false, message: postScriptResolved.message };
1888
+ postScriptPath = postScriptResolved.path;
1889
+ }
1890
+ // scriptPath/entrypointsPath/exportPath resolved through the SAME
1891
+ // resolveWorkspacePath() site, BEFORE resolveGhidraProject()'s own
1892
+ // directory RESERVATION below -- a refusal here must never leave a
1893
+ // reserved-but-unused run directory behind, exactly as preScript/
1894
+ // postScript already are.
1895
+ let scriptPathResolved;
1896
+ if (request.args.scriptPath !== undefined) {
1897
+ const scriptPathResult = resolveWorkspacePath(repoRootAbs, request.args.scriptPath);
1898
+ if (!scriptPathResult.ok)
1899
+ return { ok: false, message: scriptPathResult.message };
1900
+ scriptPathResolved = scriptPathResult.path;
1901
+ }
1902
+ let entrypointsPathResolved;
1903
+ if (request.args.entrypointsPath !== undefined) {
1904
+ const entrypointsPathResult = resolveWorkspacePath(repoRootAbs, request.args.entrypointsPath);
1905
+ if (!entrypointsPathResult.ok)
1906
+ return { ok: false, message: entrypointsPathResult.message };
1907
+ entrypointsPathResolved = entrypointsPathResult.path;
1908
+ }
1909
+ let exportPathResolved;
1910
+ if (request.args.exportPath !== undefined) {
1911
+ const exportPathResult = resolveWorkspacePath(repoRootAbs, request.args.exportPath);
1912
+ if (!exportPathResult.ok)
1913
+ return { ok: false, message: exportPathResult.message };
1914
+ exportPathResolved = exportPathResult.path;
1915
+ }
1916
+ // Resolved through the SAME site, BEFORE resolveGhidraProject()'s own
1917
+ // directory RESERVATION below -- a refusal here must never leave a
1918
+ // reserved-but-unused run directory behind, exactly as every other
1919
+ // script-adjacent path field above.
1920
+ let dataRangesPathResolved;
1921
+ if (request.args.dataRangesPath !== undefined) {
1922
+ const dataRangesPathResult = resolveWorkspacePath(repoRootAbs, request.args.dataRangesPath);
1923
+ if (!dataRangesPathResult.ok)
1924
+ return { ok: false, message: dataRangesPathResult.message };
1925
+ dataRangesPathResolved = dataRangesPathResult.path;
1926
+ }
1927
+ const projectResolved = resolveGhidraProject({ repoRoot: repoRootAbs, runId: request.args.runId });
1928
+ if (!projectResolved.ok)
1929
+ return { ok: false, message: projectResolved.message };
1930
+ ghidraReservedProjectLocation = projectResolved.projectLocation;
1931
+ built = buildHostToolArgv(request, {
1932
+ importPath: importResolved.path,
1933
+ projectLocation: projectResolved.projectLocation,
1934
+ projectName: projectResolved.projectName,
1935
+ preScriptPath,
1936
+ postScriptPath,
1937
+ scriptPathResolved,
1938
+ entrypointsPathResolved,
1939
+ exportPathResolved,
1940
+ dataRangesPathResolved,
1941
+ });
1942
+ }
1943
+ else if (request.tool === "dxa.disassemble") {
1944
+ // (35-01, item 7). `image` and each present optional path resolved
1945
+ // through the SAME resolveWorkspacePath() site acme.build's `source`
1946
+ // uses; `outDir` defaults to dirname(imagePath) exactly as acme.build's
1947
+ // own default does. The FIRST refusal returns unchanged -- no
1948
+ // partial-success degradation, no dropped key.
1949
+ const imageResolved = resolveWorkspacePath(repoRootAbs, request.args.image);
1950
+ if (!imageResolved.ok)
1951
+ return { ok: false, message: imageResolved.message };
1952
+ let outDirPath;
1953
+ if (request.args.outDir !== undefined) {
1954
+ const outDirResolved = resolveWorkspacePath(repoRootAbs, request.args.outDir);
1955
+ if (!outDirResolved.ok)
1956
+ return { ok: false, message: outDirResolved.message };
1957
+ outDirPath = outDirResolved.path;
1958
+ }
1959
+ else {
1960
+ outDirPath = dirname(imageResolved.path);
1961
+ }
1962
+ let entrypointsPath;
1963
+ if (request.args.entrypointsPath !== undefined) {
1964
+ const entrypointsResolved = resolveWorkspacePath(repoRootAbs, request.args.entrypointsPath);
1965
+ if (!entrypointsResolved.ok)
1966
+ return { ok: false, message: entrypointsResolved.message };
1967
+ entrypointsPath = entrypointsResolved.path;
1968
+ }
1969
+ let datablocksPath;
1970
+ if (request.args.datablocksPath !== undefined) {
1971
+ const datablocksResolved = resolveWorkspacePath(repoRootAbs, request.args.datablocksPath);
1972
+ if (!datablocksResolved.ok)
1973
+ return { ok: false, message: datablocksResolved.message };
1974
+ datablocksPath = datablocksResolved.path;
1975
+ }
1976
+ let labelsPath;
1977
+ if (request.args.labelsPath !== undefined) {
1978
+ const labelsResolved = resolveWorkspacePath(repoRootAbs, request.args.labelsPath);
1979
+ if (!labelsResolved.ok)
1980
+ return { ok: false, message: labelsResolved.message };
1981
+ labelsPath = labelsResolved.path;
1982
+ }
1983
+ built = buildHostToolArgv(request, {
1984
+ imagePath: imageResolved.path,
1985
+ outDirPath,
1986
+ entrypointsPath,
1987
+ datablocksPath,
1988
+ labelsPath,
1989
+ });
1990
+ }
1991
+ else if (request.tool === "ghidra.installExtension") {
1992
+ // `sourceDir`
1993
+ // resolved through the SAME resolveWorkspacePath() site every other
1994
+ // tool's path argument uses. The materialisation side effects (create
1995
+ // the install directory, copy the vendored tree, copy the three stock
1996
+ // 6502 language files) happen HERE, in the resolution branch -- mirroring
1997
+ // resolveGhidraProject()'s own "reservation" side effect above -- so
1998
+ // buildHostToolArgv() stays the one place argv/outputs are DERIVED from
1999
+ // already-materialised, typed fields (never the place a filesystem
2000
+ // mutation happens).
2001
+ const sourceDirResolved = resolveWorkspacePath(repoRootAbs, request.args.sourceDir);
2002
+ if (!sourceDirResolved.ok)
2003
+ return { ok: false, message: sourceDirResolved.message };
2004
+ // `sourceDir` is otherwise accepted as ANY workspace-relative directory
2005
+ // and copied wholesale (via cpSync below) into
2006
+ // `<GHIDRA_HOME>/Ghidra/Extensions/<moduleName>/` -- a shared, host-wide
2007
+ // location outside this project's own workspace. Refuse by name unless
2008
+ // it resolves to exactly this project's own vendored extension tree,
2009
+ // mirroring the "checked, non-materialising preflight" discipline
2010
+ // ghidra.analyze's own language check already applies (never a
2011
+ // materialising fix, only a refusal).
2012
+ const vendoredGhidraExtResolved = resolveWorkspacePath(repoRootAbs, join("src", "mcp", "vice", "vendor", "ghidra-ext"));
2013
+ if (!vendoredGhidraExtResolved.ok || sourceDirResolved.path !== vendoredGhidraExtResolved.path) {
2014
+ return {
2015
+ ok: false,
2016
+ message: `host_tool "ghidra.installExtension" refuses: "sourceDir" must resolve to this project's own vendored ` +
2017
+ `extension tree (src/mcp/vice/vendor/ghidra-ext), which is copied wholesale into a shared, host-wide Ghidra ` +
2018
+ `installation; got ${JSON.stringify(request.args.sourceDir)}, which resolves to ${sourceDirResolved.path}`,
2019
+ };
2020
+ }
2021
+ const ghidraHome = process.env.GHIDRA_HOME;
2022
+ if (ghidraHome === undefined || ghidraHome === "") {
2023
+ return {
2024
+ ok: false,
2025
+ message: `host_tool "ghidra.installExtension" requires the GHIDRA_HOME environment variable to name a Ghidra installation directory; it is unset`,
2026
+ };
2027
+ }
2028
+ const sleighPath = join(ghidraHome, "support", "sleigh");
2029
+ if (!existsSync(sleighPath)) {
2030
+ return {
2031
+ ok: false,
2032
+ message: `host_tool "ghidra.installExtension" refuses: GHIDRA_HOME's resolved "support/sleigh" does not exist on disk (${sleighPath})`,
2033
+ };
2034
+ }
2035
+ const stockLanguagesDir = join(ghidraHome, "Ghidra", "Processors", "6502", "data", "languages");
2036
+ const missingStockFiles = GHIDRA_STOCK_6502_LANGUAGE_FILES.filter((name) => !existsSync(join(stockLanguagesDir, name)));
2037
+ if (missingStockFiles.length > 0) {
2038
+ return {
2039
+ ok: false,
2040
+ message: `host_tool "ghidra.installExtension" refuses: the stock 6502 language file(s) ${missingStockFiles.join(", ")} do not exist at ${stockLanguagesDir} -- this Ghidra installation is missing its own 6502 processor module`,
2041
+ };
2042
+ }
2043
+ const installDir = join(ghidraHome, "Ghidra", "Extensions", request.args.moduleName);
2044
+ const installLanguagesDir = join(installDir, "data", "languages");
2045
+ try {
2046
+ mkdirSync(installLanguagesDir, { recursive: true });
2047
+ cpSync(sourceDirResolved.path, installDir, { recursive: true });
2048
+ for (const name of GHIDRA_STOCK_6502_LANGUAGE_FILES) {
2049
+ cpSync(join(stockLanguagesDir, name), join(installLanguagesDir, name));
2050
+ }
2051
+ }
2052
+ catch (e) {
2053
+ return {
2054
+ ok: false,
2055
+ message: `host_tool "ghidra.installExtension" failed to materialise the extension at ${installDir}: ${e instanceof Error ? e.message : String(e)}`,
2056
+ };
2057
+ }
2058
+ built = buildHostToolArgv(request, { sourceDirPath: sourceDirResolved.path, moduleName: request.args.moduleName });
2059
+ }
2060
+ else {
2061
+ // request.tool is one of the five c1541.* ids or petcat.decode -- every
2062
+ // remaining id resolves the SAME two fields, so this one branch covers
2063
+ // all six. `image` resolved through the SAME resolveWorkspacePath()
2064
+ // site every other tool's path argument uses; `outDir` defaults to
2065
+ // dirname(imagePath) exactly as dxa.disassemble's own default does.
2066
+ // `name` (c1541.entry/chain/read only) is NOT resolved here -- it is a
2067
+ // validated, non-path CBM filename/glob, read straight from
2068
+ // request.args by buildHostToolArgv() (mirrors ghidra.analyze's own
2069
+ // processor/runId split); petcat.decode has no such field at all.
2070
+ const imageResolved = resolveWorkspacePath(repoRootAbs, request.args.image);
2071
+ if (!imageResolved.ok)
2072
+ return { ok: false, message: imageResolved.message };
2073
+ let outDirPath;
2074
+ if (request.args.outDir !== undefined) {
2075
+ const outDirResolved = resolveWorkspacePath(repoRootAbs, request.args.outDir);
2076
+ if (!outDirResolved.ok)
2077
+ return { ok: false, message: outDirResolved.message };
2078
+ outDirPath = outDirResolved.path;
2079
+ }
2080
+ else {
2081
+ outDirPath = dirname(imageResolved.path);
2082
+ }
2083
+ built = buildHostToolArgv(request, { imagePath: imageResolved.path, outDirPath }, deps.log);
2084
+ }
2085
+ if (!built.ok) {
2086
+ // buildHostToolArgv()'s own GHIDRA_HOME/launcher/language preflight
2087
+ // checks can still fail here even though resolveGhidraProject() already
2088
+ // reserved (created) the run directory above -- clean it up,
2089
+ // best-effort, so a caller who retries the same runId after fixing the
2090
+ // underlying problem (setting GHIDRA_HOME, running
2091
+ // ghidra.installExtension) gets a fresh reservation instead of
2092
+ // resolveGhidraProject()'s unrelated "refuses to reuse an existing run
2093
+ // directory" refusal.
2094
+ if (ghidraReservedProjectLocation !== undefined) {
2095
+ try {
2096
+ rmSync(ghidraReservedProjectLocation, { recursive: true, force: true });
2097
+ }
2098
+ catch {
2099
+ // Best-effort only -- the original buildHostToolArgv() refusal below
2100
+ // is always returned regardless of whether cleanup itself succeeded.
2101
+ }
2102
+ }
2103
+ return { ok: false, message: built.message };
2104
+ }
2105
+ const timeoutMs = hostToolTimeoutMs(request.tool, deps.timeoutMs);
2106
+ const startedAt = Date.now();
2107
+ // acme.build only: inject the probed ACME library directory as the child's
2108
+ // `ACME` env var, exactly as acme.mjs's own removed findAcmeLib() call
2109
+ // used to (T-34's own "same behaviour, moved" requirement) -- undefined
2110
+ // when no candidate matched, which spawnHostTool() treats identically to
2111
+ // "no override" (inherits the broker's own environment unchanged).
2112
+ const spawnEnv = acmeLib?.path ? { ...process.env, ACME: acmeLib.path } : undefined;
2113
+ // c1541.read's output file is written by the CHILD process itself
2114
+ // (`-read <name> <outputPath>`), never pre-cleared before
2115
+ // this module's own spawn -- so a colliding slug (two different CBM names
2116
+ // that agree on their first 32 alphanumeric characters) could leave a
2117
+ // PRIOR successful read's bytes at `outputPath`, and a later, genuinely
2118
+ // failing call for the colliding name would then digest that stale file
2119
+ // and report `ok: true`. Best-effort unlink immediately before spawning
2120
+ // removes any stale file so a failed run can never be mistaken for a
2121
+ // fresh success; `force: true` makes a missing file a no-op (never an
2122
+ // ENOENT throw).
2123
+ if (request.tool === "c1541.read") {
2124
+ try {
2125
+ rmSync(built.outputs[0], { force: true });
2126
+ }
2127
+ catch {
2128
+ // Best-effort only -- if the unlink itself fails for some other
2129
+ // reason (e.g. permissions), the spawn below proceeds unchanged and
2130
+ // classifyC1541ReadOutput() still digests whatever c1541 produces.
2131
+ }
2132
+ }
2133
+ const spawnResult = await spawnHostTool(built.toolPath, built.argv, timeoutMs, spawnEnv, built.cwd);
2134
+ const elapsedMs = Date.now() - startedAt;
2135
+ if (spawnResult.spawnErrorMessage !== null) {
2136
+ deps.log?.(`host_tool tool=${request.tool} exit=spawn_error elapsed_ms=${elapsedMs} timeout_ms=${timeoutMs} bin=${built.toolPath}`);
2137
+ return { ok: false, message: `runHostTool: failed to launch "${built.toolPath}": ${spawnResult.spawnErrorMessage}` };
2138
+ }
2139
+ if (spawnResult.timedOut) {
2140
+ deps.log?.(`host_tool tool=${request.tool} exit=timeout elapsed_ms=${elapsedMs} timeout_ms=${timeoutMs} bin=${built.toolPath}`);
2141
+ return { ok: false, message: `runHostTool: "${request.tool}" timed out after ${timeoutMs}ms and was killed` };
2142
+ }
2143
+ // `bin=` names the RESOLVED absolute binary path that answered this call
2144
+ // -- for every tool, not only c1541.* -- `built.toolPath` is already the
2145
+ // resolved path every branch above produces, so this is a pure addition
2146
+ // to an existing field, never a new resolution. A transcript read in
2147
+ // isolation can now say which build answered, which matters most for a
2148
+ // host carrying two VICE builds (MEASURED live on this project's own dev
2149
+ // host).
2150
+ deps.log?.(`host_tool tool=${request.tool} exit=${spawnResult.exitCode ?? "null"} elapsed_ms=${elapsedMs} timeout_ms=${timeoutMs} bin=${built.toolPath}`);
2151
+ // dxa.disassemble: dxa has NO output-file option -- every listing line is
2152
+ // fprintf(stdout, ...) (vendor/dxa/dump.c). c1541.dir: c1541 -dir has no
2153
+ // output-file option either -- every listing line is printed to its own
2154
+ // stdout (MEASURED against the committed fixture, fixtures/c1541/
2155
+ // README.md). Every other tool's outputs[] entries are already real
2156
+ // files the child process wrote itself; TOOLS_WHOSE_OUTPUT_IS_STDOUT
2157
+ // names the ones whose "output" IS the captured stdout, so this is the
2158
+ // one place that stdout is turned into a file before the digest loop
2159
+ // below ever runs. No second spawn call is added.
2160
+ if (TOOLS_WHOSE_OUTPUT_IS_STDOUT.has(request.tool) && built.outputs.length > 0) {
2161
+ try {
2162
+ writeFileSync(built.outputs[0], spawnResult.stdout, "utf8");
2163
+ }
2164
+ catch {
2165
+ // Falls through to the digest loop below, whose digestOutputFile()
2166
+ // returns null for a file that does not exist -- an empty results[]
2167
+ // rather than a thrown error, consistent with this module's
2168
+ // never-throw discipline.
2169
+ }
2170
+ }
2171
+ // ghidra.analyze's outputs[0] is ALWAYS the run log.
2172
+ // `HostToolClientResult` carries no stdout field at all, so this is the
2173
+ // ONE place the run log becomes reachable from the container side.
2174
+ // MEASURED against real Ghidra 12.1.3: analyzeHeadless's own "Using
2175
+ // Language/Compiler:" line arrives on STDOUT; stderr is appended after
2176
+ // it so no line can be lost.
2177
+ if (request.tool === "ghidra.analyze" && built.outputs.length > 0) {
2178
+ try {
2179
+ writeFileSync(built.outputs[0], `${spawnResult.stdout}${spawnResult.stderr}`, "utf8");
2180
+ }
2181
+ catch {
2182
+ // Falls through to the digest loop below, whose digestOutputFile()
2183
+ // returns null for a file that does not exist -- an empty results[]
2184
+ // rather than a thrown error, consistent with this module's
2185
+ // never-throw discipline.
2186
+ }
2187
+ }
2188
+ const results = [];
2189
+ for (const outputPath of built.outputs) {
2190
+ const digested = digestOutputFile(outputPath);
2191
+ if (digested)
2192
+ results.push(digested);
2193
+ }
2194
+ // The classifier table entry for this tool id, run immediately after the
2195
+ // digest loop and before the success envelope is constructed.
2196
+ // Pre-existing ids map to `null` -- "no declared shape, behaviour
2197
+ // unchanged" -- so this is a no-op for every tool that predates this
2198
+ // mechanism. Absence of the declared success shape IS the failure; exit
2199
+ // status stays recorded in the log line only and is never consulted
2200
+ // here.
2201
+ const classifier = HOST_TOOL_OUTPUT_CLASSIFIERS[request.tool];
2202
+ if (classifier) {
2203
+ const verdict = classifier({ stdout: spawnResult.stdout, stderr: spawnResult.stderr, results });
2204
+ if (!verdict.ok) {
2205
+ return { ok: false, message: `host_tool "${request.tool}" refuses: ${verdict.reason}` };
2206
+ }
2207
+ }
2208
+ // petcat.decode's handover verdict, computed HOST-SIDE immediately after
2209
+ // the classifier above accepts and before the response envelope below is
2210
+ // constructed. All three cases (literal/computed/no-SYS-token) are
2211
+ // successes -- the classifier's own shape refusal above is the only
2212
+ // `ok: false` this tool ever reports; conflating the two would make a
2213
+ // genuine tool failure and a merely-unresolved SYS argument
2214
+ // indistinguishable.
2215
+ const petcatVerdict = request.tool === "petcat.decode" ? derivePetcatEntrypoint(spawnResult.stdout) : null;
2216
+ // acme.build only: ACME's own "for <...> includes..." complaint names no
2217
+ // directory it tried -- append a note line (in the plain, non-MSVC shape
2218
+ // acme.mjs's own parseDiagnostics() already treats as a "note" entry)
2219
+ // naming every candidate this probe tried, exactly as the removed
2220
+ // client-side hint used to. A line appended here, rather than reported as
2221
+ // a separate field, keeps acme.mjs's diagnostics parsing untouched -- it
2222
+ // already scans the combined text for exactly this shape.
2223
+ let stderrText = spawnResult.stderr;
2224
+ if (acmeLib && /ACME.*environment variable/i.test(stderrText)) {
2225
+ stderrText += `\nfor <...> includes, set $ACME to the directory holding ${ACME_LIB_MARKER} (looked in: ${acmeLib.tried.join(", ")})`;
2226
+ }
2227
+ // The two verdict fields attach ONLY to petcat.decode's own response,
2228
+ // never to the shared envelope below -- every other tool id's response
2229
+ // key set is byte-for-byte what it was before this field existed
2230
+ // (dxa-seam.test.ts's own exact-key-set assertion is the committed guard
2231
+ // on that).
2232
+ if (request.tool === "petcat.decode") {
2233
+ // Non-null by construction: petcatVerdict was computed from THIS SAME
2234
+ // `request.tool === "petcat.decode"` check above; TypeScript cannot
2235
+ // correlate the two independent expressions, so the assertion is
2236
+ // narrowing-only, never a runtime risk.
2237
+ const verdict = petcatVerdict;
2238
+ return {
2239
+ ok: true,
2240
+ tool: "petcat.decode",
2241
+ exitStatus: spawnResult.exitCode,
2242
+ results,
2243
+ stderrTail: tailBytes(stderrText, STDERR_TAIL_CAP_BYTES),
2244
+ entrypoint: verdict.entrypoint,
2245
+ entrypointReason: verdict.entrypointReason,
2246
+ };
2247
+ }
2248
+ return {
2249
+ ok: true,
2250
+ tool: request.tool,
2251
+ exitStatus: spawnResult.exitCode,
2252
+ results,
2253
+ stderrTail: tailBytes(stderrText, STDERR_TAIL_CAP_BYTES),
2254
+ };
2255
+ }
2256
+ // ---------------------------------------------------------------------------
2257
+ // oracle.probe / oracle.run. Migrated from packer-finding.mjs's own
2258
+ // probeUnp64()/runUnp64(): everything about the BINARY (locating it, the
2259
+ // version-banner probe, the scratch output location, the argument array,
2260
+ // the runtime bound) lives here now; the script keeps everything about the
2261
+ // FINDING (the name parser, the accepted character set, the caps, the
2262
+ // packedness threshold, the never-throw return shapes). Response shapes
2263
+ // are NOT the generic `{ ok, tool, exitStatus, results, stderrTail }`
2264
+ // envelope above -- they mirror packer-finding.mjs's OWN pre-existing
2265
+ // `{ available, command, version, reason }` / `{ ok, stdout, reason }`
2266
+ // contracts directly, so the migrated client-side functions can return the
2267
+ // seam's response with no field renaming.
2268
+ // ---------------------------------------------------------------------------
2269
+ /** Default command name when no host-side configuration is present -- the
2270
+ * same default packer-finding.mjs's own (removed) DEFAULT_ORACLE_COMMAND
2271
+ * used. */
2272
+ const DEFAULT_ORACLE_COMMAND = "unp64";
2273
+ /** The two environment variables the oracle's location is read from, in this
2274
+ * order -- the SAME variable order and names packer-finding.mjs's own
2275
+ * (client-side, container-facing) `ORACLE_ENV_VARS` declares, so a
2276
+ * container-side hint naming one of these two variables always describes
2277
+ * where this host-side resolver actually looked. */
2278
+ const ORACLE_ENV_VARS = Object.freeze(["UNP64", "UNP64_PATH"]);
2279
+ /** THE ONE PLACE the oracle binary's location is decided, consulted by
2280
+ * BOTH `runOracleProbe()` and `runOracleRun()` -- mirrors `findAcmeLib()`
2281
+ * above, moved host-side for exactly this reason: the container has no
2282
+ * PATH to a host binary, so probing host locations belongs on the host
2283
+ * side of the seam. Reads the BROKER PROCESS'S OWN environment -- never a
2284
+ * wire value, because `HOST_TOOL_ARG_KEYS["oracle.probe"]` accepts no keys
2285
+ * at all. When a variable is set, two checks apply in order: the
2286
+ * configured path's base name must equal `DEFAULT_ORACLE_COMMAND` (a
2287
+ * second layer over the wire-key removal), and the path must exist on
2288
+ * disk. Each refusal reason names WHICH variable was set and NEVER
2289
+ * interpolates the configured value. With no variable set, answers the
2290
+ * bare `DEFAULT_ORACLE_COMMAND` -- the existing search-path behaviour,
2291
+ * unchanged. */
2292
+ function resolveOracleCommand() {
2293
+ for (const varName of ORACLE_ENV_VARS) {
2294
+ const raw = process.env[varName];
2295
+ if (typeof raw !== "string" || raw.trim() === "")
2296
+ continue;
2297
+ const configured = raw.trim();
2298
+ if (basename(configured) !== DEFAULT_ORACLE_COMMAND) {
2299
+ return {
2300
+ ok: false,
2301
+ reason: `the oracle configured via ${varName} is not named "${DEFAULT_ORACLE_COMMAND}" -- treated as oracle-absent`,
2302
+ };
2303
+ }
2304
+ if (!existsSync(configured)) {
2305
+ return {
2306
+ ok: false,
2307
+ reason: `the oracle configured via ${varName} does not exist on disk -- treated as oracle-absent`,
2308
+ };
2309
+ }
2310
+ return { ok: true, command: configured };
2311
+ }
2312
+ return { ok: true, command: DEFAULT_ORACLE_COMMAND };
2313
+ }
2314
+ async function runOracleProbe(deps) {
2315
+ const resolved = resolveOracleCommand();
2316
+ if (!resolved.ok) {
2317
+ // No child is spawned on this branch -- WITHOUT echoing the configured
2318
+ // value anywhere (T-19-18); resolved.reason already names the variable,
2319
+ // never the value.
2320
+ deps.log?.(`host_tool tool=oracle.probe exit=absent_configured_path`);
2321
+ return { ok: true, tool: "oracle.probe", available: false, command: null, version: null, reason: resolved.reason };
2322
+ }
2323
+ const command = resolved.command;
2324
+ const timeoutMs = hostToolTimeoutMs("oracle.probe", deps.timeoutMs);
2325
+ const spawnResult = await spawnHostTool(command, ["--version"], timeoutMs);
2326
+ if (spawnResult.spawnErrorMessage !== null) {
2327
+ deps.log?.(`host_tool tool=oracle.probe exit=spawn_error timeout_ms=${timeoutMs}`);
2328
+ return {
2329
+ ok: true,
2330
+ tool: "oracle.probe",
2331
+ available: false,
2332
+ command: null,
2333
+ version: null,
2334
+ reason: `no "${DEFAULT_ORACLE_COMMAND}" packer identifier could be launched`,
2335
+ };
2336
+ }
2337
+ if (spawnResult.timedOut) {
2338
+ deps.log?.(`host_tool tool=oracle.probe exit=timeout timeout_ms=${timeoutMs}`);
2339
+ return {
2340
+ ok: true,
2341
+ tool: "oracle.probe",
2342
+ available: false,
2343
+ command: null,
2344
+ version: null,
2345
+ reason: "the packer identifier timed out during the version probe",
2346
+ };
2347
+ }
2348
+ deps.log?.(`host_tool tool=oracle.probe exit=${spawnResult.exitCode ?? "null"} timeout_ms=${timeoutMs}`);
2349
+ const banner = `${spawnResult.stdout}${spawnResult.stderr}`.trim();
2350
+ if (banner === "") {
2351
+ return {
2352
+ ok: true,
2353
+ tool: "oracle.probe",
2354
+ available: false,
2355
+ command: null,
2356
+ version: null,
2357
+ reason: "the packer identifier produced no version banner, so it was not accepted as an oracle",
2358
+ };
2359
+ }
2360
+ return { ok: true, tool: "oracle.probe", available: true, command, version: banner.slice(0, 200), reason: null };
2361
+ }
2362
+ async function runOracleRun(args, deps) {
2363
+ const repoRootAbs = resolvePath(deps.repoRoot);
2364
+ const sourceResolved = resolveWorkspacePath(repoRootAbs, args.source);
2365
+ if (!sourceResolved.ok) {
2366
+ return { ok: false, tool: "oracle.run", stdout: "", reason: sourceResolved.message };
2367
+ }
2368
+ if (!existsSync(sourceResolved.path)) {
2369
+ return { ok: false, tool: "oracle.run", stdout: "", reason: "the input file does not exist" };
2370
+ }
2371
+ // The SAME resolver oracle.probe consults -- never a bare
2372
+ // DEFAULT_ORACLE_COMMAND argument at the spawn site below. Before this
2373
+ // fix a host-side configured oracle was honoured by the probe and
2374
+ // silently ignored by the run, so a working probe could be followed by a
2375
+ // failing run; resolving here closes that gap as a real defect fix.
2376
+ const resolvedCommand = resolveOracleCommand();
2377
+ if (!resolvedCommand.ok) {
2378
+ deps.log?.(`host_tool tool=oracle.run exit=absent_configured_path`);
2379
+ return { ok: false, tool: "oracle.run", stdout: "", reason: resolvedCommand.reason };
2380
+ }
2381
+ // The oracle's unpacked output goes to a scratch location INSIDE the
2382
+ // workspace tree -- never the system temp directory, which cannot be
2383
+ // translated back across the container boundary -- removed after this
2384
+ // function returns, mirroring packer-finding.mjs's own (removed)
2385
+ // "removed before this function returns" property (T-19-24).
2386
+ //
2387
+ // MOVED 2026-09-08: this used to be `<repoRoot>/tools/oracle-runs/...`.
2388
+ // This scratch directory is one of several writers consolidated onto a
2389
+ // single tool-written root -- it now lives under `runs/oracle` beneath
2390
+ // the root `repo-root.ts`'s `toolsDir()` owns. This module is host-bound
2391
+ // (compiled by build.ts) and must not import the container-side
2392
+ // repo-root.ts, so the two segments are joined directly here --
2393
+ // ".c64-re-tools" and "runs"/"oracle" must stay equal to
2394
+ // `join(toolsDir(), "runs", "oracle")`, the same convention
2395
+ // install-resources.ts's installTargetDir() uses.
2396
+ //
2397
+ // CORRECTED 2026-09-08: this used to also describe ghidra-project.mts's
2398
+ // runs root as following "the same convention", full stop. That is now
2399
+ // true of the PHYSICAL location -- both this directory and the Ghidra
2400
+ // runs root land under the same `.c64-re-tools/runs/<subdir>` shape --
2401
+ // but it is NOT true of how the location is REACHED. This scratch
2402
+ // directory is joined DIRECTLY, exactly as written above. The Ghidra
2403
+ // runs root is joined the same way internally (`ghidraRunsRealRoot()`),
2404
+ // but Ghidra itself is never handed that direct path -- it is handed a
2405
+ // path through `ghidraRunsRoot()`'s non-dotted ALIAS HANDLE
2406
+ // (`<repoRoot>/c64-re-tools`, a symlink to `.c64-re-tools`), because
2407
+ // Ghidra's own project-location check refuses a dot-prefixed segment in
2408
+ // the path it is handed, while this scratch directory's caller (this
2409
+ // project's own oracle spawn) has no such refusal and is handed the
2410
+ // direct path unchanged.
2411
+ const scratchDir = join(repoRootAbs, ".c64-re-tools", "runs", "oracle", `run-${Date.now()}-${Math.random().toString(36).slice(2)}`);
2412
+ try {
2413
+ // A fix: scratch-directory creation moved INSIDE this try block -- a
2414
+ // full disk or an unwritable parent now resolves to the function's
2415
+ // existing refusal shape instead of throwing synchronously out of
2416
+ // runHostTool(), which sits outside any try/catch of its own.
2417
+ mkdirSync(scratchDir, { recursive: true });
2418
+ const scratchOut = join(scratchDir, "unpacked.out");
2419
+ const timeoutMs = hostToolTimeoutMs("oracle.run", deps.timeoutMs);
2420
+ const spawnResult = await spawnHostTool(resolvedCommand.command, [sourceResolved.path, scratchOut], timeoutMs);
2421
+ if (spawnResult.spawnErrorMessage !== null) {
2422
+ deps.log?.(`host_tool tool=oracle.run exit=spawn_error timeout_ms=${timeoutMs}`);
2423
+ return { ok: false, tool: "oracle.run", stdout: "", reason: "the oracle could not be run against the input file" };
2424
+ }
2425
+ if (spawnResult.timedOut) {
2426
+ deps.log?.(`host_tool tool=oracle.run exit=timeout timeout_ms=${timeoutMs}`);
2427
+ return { ok: false, tool: "oracle.run", stdout: "", reason: "the oracle timed out" };
2428
+ }
2429
+ deps.log?.(`host_tool tool=oracle.run exit=${spawnResult.exitCode ?? "null"} timeout_ms=${timeoutMs}`);
2430
+ // Capped the same way packer-finding.mjs's own MAX_ORACLE_STDOUT_BYTES
2431
+ // caps it client-side -- the executor enforces the bound on what it
2432
+ // accumulates; the script still exports the number for its own parser
2433
+ // and its own tests, so the value is not duplicated as a maintained pair,
2434
+ // only as the same measured constant on both sides of the seam.
2435
+ const stdout = spawnResult.stdout.length > ORACLE_STDOUT_CAP_BYTES ? spawnResult.stdout.slice(0, ORACLE_STDOUT_CAP_BYTES) : spawnResult.stdout;
2436
+ return { ok: true, tool: "oracle.run", stdout, reason: null };
2437
+ }
2438
+ catch (err) {
2439
+ // The only synchronous throw this block can produce is mkdirSync()
2440
+ // above (a full disk or an unwritable scratch parent) -- resolved here
2441
+ // to the function's own refusal shape, naming the directory, rather
2442
+ // than propagating out of runHostTool()'s never-throw boundary.
2443
+ const message = err instanceof Error ? err.message : String(err);
2444
+ return { ok: false, tool: "oracle.run", stdout: "", reason: `could not create the oracle scratch directory ${scratchDir}: ${message}` };
2445
+ }
2446
+ finally {
2447
+ try {
2448
+ rmSync(scratchDir, { recursive: true, force: true });
2449
+ }
2450
+ catch {
2451
+ // Best effort -- a leftover empty scratch directory is not worth
2452
+ // failing a read-only recon finding over (mirrors packer-finding.mjs's
2453
+ // own removed comment to the same effect).
2454
+ }
2455
+ }
2456
+ }
2457
+ // ---------------------------------------------------------------------------
2458
+ // CLI entry point (guarded on being the process entry point, the
2459
+ // check-npm-packages.mjs:159 IS_ENTRY_POINT idiom). Needed for the
2460
+ // host-local route (no broker in the loop). `node resources/host-tool.mjs
2461
+ // run --repo-root <path> --request <json>` prints the response as one JSON
2462
+ // line on stdout and exits non-zero on a refusal.
2463
+ // ---------------------------------------------------------------------------
2464
+ function parseCliArgs(argv) {
2465
+ let repoRoot;
2466
+ let request;
2467
+ for (let i = 0; i < argv.length; i++) {
2468
+ if (argv[i] === "--repo-root") {
2469
+ repoRoot = argv[i + 1];
2470
+ i++;
2471
+ }
2472
+ else if (argv[i] === "--request") {
2473
+ request = argv[i + 1];
2474
+ i++;
2475
+ }
2476
+ }
2477
+ return { repoRoot, request };
2478
+ }
2479
+ const IS_ENTRY_POINT = process.argv[1] !== undefined && resolvePath(process.argv[1]) === fileURLToPath(import.meta.url);
2480
+ if (IS_ENTRY_POINT) {
2481
+ const [, , cliCommand, ...cliRest] = process.argv;
2482
+ if (cliCommand !== "run") {
2483
+ process.stderr.write("usage: host-tool.mjs run --repo-root <path> --request <json>\n");
2484
+ process.exitCode = 1;
2485
+ }
2486
+ else {
2487
+ const { repoRoot, request } = parseCliArgs(cliRest);
2488
+ if (!repoRoot || !request) {
2489
+ process.stderr.write("usage: host-tool.mjs run --repo-root <path> --request <json>\n");
2490
+ process.exitCode = 1;
2491
+ }
2492
+ else {
2493
+ let raw;
2494
+ try {
2495
+ raw = JSON.parse(request);
2496
+ }
2497
+ catch {
2498
+ raw = null;
2499
+ }
2500
+ // TEST-ONLY escape hatch for the CLI's own promise-rejection
2501
+ // handling (host-tool.test.ts): every fs call reachable from
2502
+ // runHostTool()'s real business logic is deliberately guarded, so
2503
+ // there is no organic wire input that makes the real function reject
2504
+ // its promise today -- proving that is a GOOD thing, not a gap, but
2505
+ // it also means the CLI's own `.catch()` below has no
2506
+ // naturally-reachable trigger to regression-test against. This reads
2507
+ // the BROKER PROCESS'S OWN environment, never a wire value, mirroring
2508
+ // `resolveOracleCommand()`'s own "broker env, never wire" convention
2509
+ // above -- a caller can never reach this by shaping `--request`.
2510
+ // Unset in every real invocation; only host-tool.test.ts's own
2511
+ // spawned subprocess ever sets it.
2512
+ const runHostToolOrForcedRejectForTest = process.env.HOST_TOOL_TEST_FORCE_CLI_REJECT === "1"
2513
+ ? Promise.reject(new Error("HOST_TOOL_TEST_FORCE_CLI_REJECT: simulated runHostTool() rejection for CLI never-throw regression testing"))
2514
+ : runHostTool(raw, { repoRoot });
2515
+ runHostToolOrForcedRejectForTest
2516
+ .then((response) => {
2517
+ process.stdout.write(`${JSON.stringify(response)}\n`);
2518
+ process.exitCode = response.ok ? 0 : 1;
2519
+ })
2520
+ // A rejection from runHostTool() used to become an unhandled
2521
+ // rejection with NO stdout at all, surfacing to the caller as the
2522
+ // opaque "host-tool.mjs produced no output on stdout",
2523
+ // indistinguishable from a hang. Mirrors host-tool-client.ts's own
2524
+ // never-reject CLI entry point field-for-field: same envelope shape
2525
+ // ({ ok: false, message }), same stdout-not-stderr destination, same
2526
+ // non-zero exit-code convention.
2527
+ .catch((err) => {
2528
+ process.stdout.write(`${JSON.stringify({ ok: false, message: err instanceof Error ? err.message : String(err) })}\n`);
2529
+ process.exitCode = 1;
2530
+ });
2531
+ }
2532
+ }
2533
+ }