@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.
- package/README.md +2 -2
- package/THIRD-PARTY-NOTICES.md +422 -1
- package/anno-bank.ts +171 -0
- package/anno-cli.ts +1674 -99
- package/anno-enum-gen.ts +416 -30
- package/anno-export-asm.ts +1175 -89
- package/anno-graphics.ts +338 -0
- package/anno-hazard-report.ts +1367 -0
- package/anno-import.ts +495 -0
- package/anno-join.ts +480 -0
- package/anno-provenance-ledger.ts +472 -0
- package/anno-register.ts +159 -0
- package/anno-store-export.ts +661 -0
- package/anno-store.ts +518 -2
- package/anno-tools.ts +1169 -16
- package/anno-types.ts +275 -2
- package/backend-detect.mts +124 -312
- package/build.ts +3 -1
- package/capture-predicate.ts +597 -0
- package/channel-lock.ts +349 -0
- package/evid-ingest.ts +217 -0
- package/evid-reconcile.ts +316 -0
- package/host-tool-client.ts +430 -0
- package/incident-record.ts +23 -12
- package/install-resources.ts +29 -13
- package/memmap-lookup.ts +285 -0
- package/package.json +27 -8
- package/prg-image.ts +1 -2
- package/repo-root.ts +87 -3
- package/resources/backend-detect.mjs +98 -236
- package/resources/broker-control.mjs +189 -16
- package/resources/broker-epoch.mjs +1 -1
- package/resources/broker-kill.mjs +8 -2
- package/resources/broker-launch.mjs +365 -210
- package/resources/broker-state.mjs +64 -18
- package/resources/container-guard.mjs +1 -1
- package/resources/ghidra-project.mjs +790 -0
- package/resources/host-tool.mjs +2561 -0
- package/resources/vice-broker.mjs +330 -184
- package/resources/vice-launcher.sh +127 -9
- package/stock-address.ts +1 -1
- package/stock-condition.ts +1 -1
- package/stock-connect.ts +9 -5
- package/stock-derived.ts +29 -37
- package/stock-diagnose.ts +200 -36
- package/stock-dispatch.ts +179 -77
- package/stock-handler.ts +1 -1
- package/stock-paths.ts +18 -14
- package/stock-petscii.ts +1 -1
- package/stock-protocol.ts +1 -1
- package/stock-recycle.ts +83 -2
- package/stock-reproducible-run.ts +811 -0
- package/stock-run-until.ts +100 -1
- package/stock-symbols.ts +4 -4
- package/stock-timing.ts +1 -1
- package/stop-oracle.ts +167 -0
- package/text-capability-probe.ts +660 -0
- package/text-connect.ts +157 -0
- package/text-protocol.ts +810 -0
- package/text-tools.ts +778 -0
- package/textmon-backtrace.ts +385 -0
- package/textmon-cpuhistory.ts +335 -0
- package/textmon-memmap.ts +494 -0
- package/textmon-profile.ts +458 -0
- package/textmon-registers.ts +748 -0
- package/tools-manifest.stock.json +864 -3
- package/vice-broker-client.ts +189 -42
- package/vice-errors.ts +268 -0
- package/vice-proxy.ts +339 -2144
- package/vsf-slice.ts +640 -0
- package/anno-d64.ts +0 -310
- package/capability-registry.ts +0 -390
- package/refresh-manifest.ts +0 -124
- package/tools-manifest.json +0 -1223
- package/vice-probe.ts +0 -278
- package/vice-sync.ts +0 -336
- package/vice.ts +0 -772
|
@@ -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
|
+
}
|