@henols/vice-mcp 0.2.2 → 0.2.3

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