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