@henols/vice-mcp 0.2.3 → 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/anno-cli.ts +156 -158
- package/anno-confidence.ts +2 -2
- package/anno-derive.ts +6 -6
- package/anno-details.ts +4 -4
- package/anno-export-asm.ts +100 -101
- package/anno-graphics.ts +16 -16
- package/anno-hazard-report.ts +2 -2
- package/anno-import.ts +15 -15
- package/anno-index.ts +8 -8
- package/anno-join.ts +35 -35
- package/anno-memmap-render.ts +22 -21
- package/anno-provenance-ledger.ts +4 -4
- package/anno-regbits-gen.ts +13 -13
- package/anno-store-export.ts +11 -11
- package/anno-store.ts +139 -144
- package/anno-symbols.ts +7 -7
- package/anno-types.ts +55 -55
- package/package.json +1 -1
- package/resources/broker-control.mjs +85 -92
- package/resources/broker-epoch.mjs +6 -7
- package/resources/broker-kill.mjs +29 -30
- package/resources/broker-launch.mjs +352 -370
- package/resources/broker-state.mjs +9 -10
- package/resources/host-tool.mjs +636 -664
- package/resources/vice-broker.mjs +189 -191
- package/vice-broker-client.ts +98 -100
package/resources/host-tool.mjs
CHANGED
|
@@ -6,15 +6,15 @@
|
|
|
6
6
|
// rebuild.
|
|
7
7
|
// host-tool.mts
|
|
8
8
|
//
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
//
|
|
12
|
-
//
|
|
13
|
-
//
|
|
14
|
-
//
|
|
15
|
-
//
|
|
16
|
-
//
|
|
17
|
-
//
|
|
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
18
|
//
|
|
19
19
|
// THIS IS THE ONE AUTHORITATIVE PLACE for three things, none of which may be
|
|
20
20
|
// re-derived anywhere else:
|
|
@@ -30,8 +30,8 @@
|
|
|
30
30
|
// never a shell string, and bounded by a per-invocation timeout (T-34-02,
|
|
31
31
|
// T-34-05).
|
|
32
32
|
//
|
|
33
|
-
// WHAT NOT TO DO, each naming the prohibition it guards (
|
|
34
|
-
//
|
|
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
35
|
// - No generic wire op that accepts a raw argv array or a raw command
|
|
36
36
|
// string for a host tool -- argv is constructed server-side from typed
|
|
37
37
|
// fields only (T-34-01).
|
|
@@ -44,13 +44,13 @@
|
|
|
44
44
|
// anno-types.ts's storePathWithinWorkspace() and its own incident
|
|
45
45
|
// history by name) before the prefix comparison, and the comparison is
|
|
46
46
|
// over the WALKED (real) paths, never the lexical join -- a purely
|
|
47
|
-
// lexical path.resolve() + startsWith() check is exactly what
|
|
48
|
-
//
|
|
49
|
-
//
|
|
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
|
|
50
51
|
// on every tool, not only the ones present when this file was first
|
|
51
52
|
// written: acme.build's source/outDir AND each entry of its includes
|
|
52
|
-
// array
|
|
53
|
-
// preScript/postScript (34-07, CR-02).
|
|
53
|
+
// array, and ghidra.analyze's importPath AND its preScript/postScript.
|
|
54
54
|
// Two residuals recorded beside the guarantee, not hidden past it: the
|
|
55
55
|
// check-then-open window between this decision and the child process's
|
|
56
56
|
// own open is NOT closed here -- the child is a third-party binary
|
|
@@ -59,14 +59,14 @@
|
|
|
59
59
|
// comparison is byte-wise over the resolved strings with no Unicode
|
|
60
60
|
// normalisation, so two spellings differing only in normalisation form
|
|
61
61
|
// are two distinct paths here (same residual anno-confinement.test.ts
|
|
62
|
-
// records for the same comparison). A third
|
|
63
|
-
//
|
|
64
|
-
//
|
|
65
|
-
//
|
|
66
|
-
//
|
|
67
|
-
//
|
|
68
|
-
//
|
|
69
|
-
//
|
|
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
70
|
// - No inline byte payload on a host-tool response, at any result size --
|
|
71
71
|
// every result crosses as `{ path, sha256, byteLength }`, never bytes.
|
|
72
72
|
// - No second copy of a tool's argv construction -- buildHostToolArgv() is
|
|
@@ -81,49 +81,48 @@
|
|
|
81
81
|
// `resources/host-tool.mjs` artifact, added to build.ts's HOST_BOUND_ARTIFACTS
|
|
82
82
|
// and tsconfig.build.json's include[] in the same commit as this file).
|
|
83
83
|
//
|
|
84
|
-
//
|
|
85
|
-
//
|
|
86
|
-
//
|
|
87
|
-
//
|
|
88
|
-
//
|
|
89
|
-
//
|
|
90
|
-
//
|
|
91
|
-
//
|
|
92
|
-
//
|
|
93
|
-
//
|
|
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
94
|
import { spawn } from "node:child_process";
|
|
95
95
|
import { createHash } from "node:crypto";
|
|
96
96
|
import { cpSync, existsSync, lstatSync, mkdirSync, readFileSync, readlinkSync, realpathSync, rmSync, writeFileSync } from "node:fs";
|
|
97
97
|
import { basename, dirname, isAbsolute, join, resolve as resolvePath, sep } from "node:path";
|
|
98
98
|
import { fileURLToPath } from "node:url";
|
|
99
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
|
-
//
|
|
101
|
-
//
|
|
102
|
-
//
|
|
103
|
-
//
|
|
104
|
-
//
|
|
105
|
-
//
|
|
106
|
-
//
|
|
107
|
-
//
|
|
108
|
-
//
|
|
109
|
-
//
|
|
110
|
-
//
|
|
111
|
-
//
|
|
112
|
-
//
|
|
113
|
-
//
|
|
114
|
-
//
|
|
115
|
-
// broker in the loop, see this plan's own host_route_note) has no such
|
|
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
|
|
116
115
|
// warm memo and pays one `--help` probe per invocation, mirroring the
|
|
117
116
|
// existing, already-accepted cost vice-broker.mts's own startup call pays
|
|
118
117
|
// once per broker lifetime -- never re-probed per c1541.* call within the
|
|
119
118
|
// SAME process, per findSiblingBinary()'s own memo below.
|
|
120
119
|
import { resolvedBackend } from "./backend-detect.mjs";
|
|
121
|
-
//
|
|
122
|
-
//
|
|
123
|
-
//
|
|
124
|
-
//
|
|
125
|
-
//
|
|
126
|
-
//
|
|
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.
|
|
127
126
|
const HERE = dirname(fileURLToPath(import.meta.url));
|
|
128
127
|
export const HOST_TOOL_IDS = Object.freeze([
|
|
129
128
|
"acme.build",
|
|
@@ -140,46 +139,46 @@ export const HOST_TOOL_IDS = Object.freeze([
|
|
|
140
139
|
"petcat.decode",
|
|
141
140
|
]);
|
|
142
141
|
/** Per-tool accepted argument-key lists, built with `Object.create(null)`
|
|
143
|
-
* (the vsf-slice.mjs
|
|
144
|
-
* value here even if a future caller indexed it with an
|
|
145
|
-
* directly -- belt-and-suspenders alongside the
|
|
146
|
-
* above, which is what actually guards the lookup
|
|
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
147
|
*
|
|
148
148
|
* `ghidra.analyze`'s accepted keys carry no raw argv array and no raw
|
|
149
|
-
* command string. Per field (corrected
|
|
150
|
-
*
|
|
151
|
-
* for the two script fields until
|
|
152
|
-
* a path, and flows through
|
|
153
|
-
* resolution; `importPath`,
|
|
154
|
-
* `resolveWorkspacePath()` --
|
|
155
|
-
* `acme.build`'s `source`/`outDir
|
|
156
|
-
* ever reaches argv.
|
|
157
|
-
*
|
|
158
|
-
*
|
|
159
|
-
*
|
|
160
|
-
*
|
|
161
|
-
* sites entirely.
|
|
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
162
|
*
|
|
163
|
-
*
|
|
164
|
-
*
|
|
165
|
-
*
|
|
166
|
-
*
|
|
167
|
-
* `
|
|
168
|
-
*
|
|
169
|
-
* (
|
|
170
|
-
*
|
|
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`). */
|
|
171
172
|
export const HOST_TOOL_ARG_KEYS = Object.freeze(Object.assign(Object.create(null), {
|
|
172
173
|
"acme.build": Object.freeze(["source", "outDir", "format", "setpc", "defines", "includes", "noReport"]),
|
|
173
|
-
//
|
|
174
|
-
//
|
|
175
|
-
//
|
|
176
|
-
//
|
|
177
|
-
//
|
|
178
|
-
//
|
|
179
|
-
//
|
|
180
|
-
//
|
|
181
|
-
// requirement: a first pass without graphics feedback must keep
|
|
182
|
-
// working unchanged).
|
|
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.
|
|
183
182
|
"ghidra.analyze": Object.freeze([
|
|
184
183
|
"runId",
|
|
185
184
|
"importPath",
|
|
@@ -195,40 +194,38 @@ export const HOST_TOOL_ARG_KEYS = Object.freeze(Object.assign(Object.create(null
|
|
|
195
194
|
"expectedClassificationLines",
|
|
196
195
|
"dataRangesPath",
|
|
197
196
|
]),
|
|
198
|
-
//
|
|
199
|
-
//
|
|
200
|
-
//
|
|
201
|
-
//
|
|
202
|
-
// tool.
|
|
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.
|
|
203
201
|
"oracle.probe": Object.freeze([]),
|
|
204
202
|
"oracle.run": Object.freeze(["source"]),
|
|
205
|
-
//
|
|
206
|
-
//
|
|
207
|
-
//
|
|
203
|
+
// Frozen exactly as originally specified -- five path-bearing keys plus
|
|
204
|
+
// the one enum key (`imageKind`), never re-derived from
|
|
205
|
+
// ResolvedDxaDisassemblePaths below.
|
|
208
206
|
"dxa.disassemble": Object.freeze(["image", "imageKind", "entrypointsPath", "datablocksPath", "labelsPath", "outDir"]),
|
|
209
|
-
//
|
|
210
|
-
//
|
|
211
|
-
// <GHIDRA_HOME>/Ghidra/Extensions/.
|
|
207
|
+
// `sourceDir` is the vendored extension tree; `moduleName` names the
|
|
208
|
+
// install target directory under <GHIDRA_HOME>/Ghidra/Extensions/.
|
|
212
209
|
"ghidra.installExtension": Object.freeze(["sourceDir", "moduleName"]),
|
|
213
|
-
//
|
|
214
|
-
//
|
|
215
|
-
//
|
|
216
|
-
//
|
|
217
|
-
//
|
|
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).
|
|
218
215
|
"c1541.bam": Object.freeze(["image", "outDir"]),
|
|
219
216
|
"c1541.dir": Object.freeze(["image", "outDir"]),
|
|
220
217
|
"c1541.entry": Object.freeze(["image", "name", "outDir"]),
|
|
221
218
|
"c1541.chain": Object.freeze(["image", "name", "outDir"]),
|
|
222
219
|
"c1541.read": Object.freeze(["image", "name", "outDir"]),
|
|
223
|
-
//
|
|
224
|
-
//
|
|
225
|
-
//
|
|
226
|
-
//
|
|
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.
|
|
227
224
|
"petcat.decode": Object.freeze(["image", "outDir"]),
|
|
228
225
|
}));
|
|
229
|
-
/**
|
|
230
|
-
*
|
|
231
|
-
*
|
|
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
|
|
232
229
|
* `Object.freeze(Object.assign(Object.create(null), ...))` idiom
|
|
233
230
|
* `HOST_TOOL_ARG_KEYS` above uses. Consumed by `host-tool.test.ts`'s
|
|
234
231
|
* data-driven census, never by production code -- the census is what makes
|
|
@@ -243,25 +240,24 @@ export const HOST_TOOL_ARG_KEYS = Object.freeze(Object.assign(Object.create(null
|
|
|
243
240
|
* ghidra-project.mts), turned into a path only by `resolveGhidraProject()`
|
|
244
241
|
* -- a DIFFERENT mechanism with its own guard, not `resolveWorkspacePath()`.
|
|
245
242
|
* `oracle.probe`'s entry is empty because that tool accepts no arguments at
|
|
246
|
-
* all
|
|
243
|
+
* all.
|
|
247
244
|
*
|
|
248
|
-
*
|
|
249
|
-
*
|
|
250
|
-
*
|
|
251
|
-
*
|
|
252
|
-
*
|
|
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
|
|
253
250
|
* (`RUN_ID_PATTERN`) turned into a path segment only inside
|
|
254
251
|
* `runHostTool()`'s own resolution branch below, never through
|
|
255
252
|
* `resolveWorkspacePath()`. */
|
|
256
253
|
export const HOST_TOOL_PATH_ARG_KEYS = Object.freeze(Object.assign(Object.create(null), {
|
|
257
254
|
"acme.build": Object.freeze(["source", "outDir", "includes"]),
|
|
258
|
-
//
|
|
259
|
-
//
|
|
260
|
-
//
|
|
261
|
-
//
|
|
262
|
-
//
|
|
263
|
-
//
|
|
264
|
-
// branch, exactly like every other script-adjacent path field.
|
|
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.
|
|
265
261
|
"ghidra.analyze": Object.freeze(["importPath", "preScript", "postScript", "scriptPath", "entrypointsPath", "exportPath", "dataRangesPath"]),
|
|
266
262
|
"oracle.probe": Object.freeze([]),
|
|
267
263
|
"oracle.run": Object.freeze(["source"]),
|
|
@@ -270,20 +266,20 @@ export const HOST_TOOL_PATH_ARG_KEYS = Object.freeze(Object.assign(Object.create
|
|
|
270
266
|
// classifies for this tool.
|
|
271
267
|
"dxa.disassemble": Object.freeze(["image", "entrypointsPath", "datablocksPath", "labelsPath", "outDir"]),
|
|
272
268
|
"ghidra.installExtension": Object.freeze(["sourceDir"]),
|
|
273
|
-
//
|
|
274
|
-
//
|
|
275
|
-
//
|
|
276
|
-
//
|
|
277
|
-
//
|
|
278
|
-
//
|
|
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.
|
|
279
275
|
"c1541.bam": Object.freeze(["image", "outDir"]),
|
|
280
276
|
"c1541.dir": Object.freeze(["image", "outDir"]),
|
|
281
277
|
"c1541.entry": Object.freeze(["image", "outDir"]),
|
|
282
278
|
"c1541.chain": Object.freeze(["image", "outDir"]),
|
|
283
279
|
"c1541.read": Object.freeze(["image", "outDir"]),
|
|
284
|
-
//
|
|
285
|
-
//
|
|
286
|
-
//
|
|
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.
|
|
287
283
|
"petcat.decode": Object.freeze(["image", "outDir"]),
|
|
288
284
|
}));
|
|
289
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`;
|
|
@@ -366,11 +362,11 @@ export function normaliseHostToolRequest(raw) {
|
|
|
366
362
|
}
|
|
367
363
|
if ("includes" in argsObj) {
|
|
368
364
|
const includes = argsObj.includes;
|
|
369
|
-
//
|
|
370
|
-
//
|
|
371
|
-
//
|
|
372
|
-
//
|
|
373
|
-
//
|
|
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.
|
|
374
370
|
if (!Array.isArray(includes) || !includes.every((i) => typeof i === "string" && i !== "")) {
|
|
375
371
|
return { ok: false, message: `host_tool "acme.build" args.includes must be an array of strings; got ${describe(includes)}` };
|
|
376
372
|
}
|
|
@@ -394,13 +390,12 @@ export function normaliseHostToolRequest(raw) {
|
|
|
394
390
|
if (typeof importPathRaw !== "string" || importPathRaw === "") {
|
|
395
391
|
return { ok: false, message: `host_tool "ghidra.analyze" requires a non-empty string "importPath"; got ${describe(importPathRaw)}` };
|
|
396
392
|
}
|
|
397
|
-
//
|
|
398
|
-
//
|
|
399
|
-
//
|
|
400
|
-
//
|
|
401
|
-
//
|
|
402
|
-
//
|
|
403
|
-
// never case-folded (must_haves.truths, 36-01-PLAN.md).
|
|
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.
|
|
404
399
|
const processorRaw = argsObj.processor;
|
|
405
400
|
if (typeof processorRaw !== "string" || processorRaw === "" || !LANGUAGE_ID_PATTERN.test(processorRaw)) {
|
|
406
401
|
return {
|
|
@@ -408,10 +403,9 @@ export function normaliseHostToolRequest(raw) {
|
|
|
408
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)}`,
|
|
409
404
|
};
|
|
410
405
|
}
|
|
411
|
-
//
|
|
412
|
-
//
|
|
413
|
-
//
|
|
414
|
-
// literal and never a wire field at all.
|
|
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.
|
|
415
409
|
const importRouteRaw = argsObj.importRoute;
|
|
416
410
|
if (typeof importRouteRaw !== "string" || !GHIDRA_IMPORT_ROUTES.includes(importRouteRaw)) {
|
|
417
411
|
return {
|
|
@@ -420,9 +414,8 @@ export function normaliseHostToolRequest(raw) {
|
|
|
420
414
|
};
|
|
421
415
|
}
|
|
422
416
|
const importRoute = importRouteRaw;
|
|
423
|
-
//
|
|
424
|
-
//
|
|
425
|
-
// LOADER_BASE_ADDR_PATTERN rather than routed through
|
|
417
|
+
// loaderBaseAddr is a raw argv token, never a path -- validated against
|
|
418
|
+
// the anchored LOADER_BASE_ADDR_PATTERN rather than routed through
|
|
426
419
|
// resolveWorkspacePath(). On the "flat64k" route the base is the
|
|
427
420
|
// route's OWN; a differing supplied value is refused BY NAME rather
|
|
428
421
|
// than silently honoured. On "prg" an absent value defaults to the
|
|
@@ -448,11 +441,11 @@ export function normaliseHostToolRequest(raw) {
|
|
|
448
441
|
else {
|
|
449
442
|
loaderBaseAddr = importRouteBaseAddr(importRoute);
|
|
450
443
|
}
|
|
451
|
-
//
|
|
452
|
-
//
|
|
453
|
-
//
|
|
454
|
-
//
|
|
455
|
-
//
|
|
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.
|
|
456
449
|
let noanalysis;
|
|
457
450
|
if ("noanalysis" in argsObj) {
|
|
458
451
|
const noanalysisRaw = argsObj.noanalysis;
|
|
@@ -461,9 +454,9 @@ export function normaliseHostToolRequest(raw) {
|
|
|
461
454
|
}
|
|
462
455
|
noanalysis = noanalysisRaw;
|
|
463
456
|
}
|
|
464
|
-
//
|
|
465
|
-
//
|
|
466
|
-
//
|
|
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.
|
|
467
460
|
let expectedClassificationLines;
|
|
468
461
|
if ("expectedClassificationLines" in argsObj) {
|
|
469
462
|
const linesRaw = argsObj.expectedClassificationLines;
|
|
@@ -494,9 +487,9 @@ export function normaliseHostToolRequest(raw) {
|
|
|
494
487
|
}
|
|
495
488
|
args.postScript = postScript;
|
|
496
489
|
}
|
|
497
|
-
//
|
|
498
|
-
//
|
|
499
|
-
//
|
|
490
|
+
// Path-bearing -- resolved through resolveWorkspacePath() by
|
|
491
|
+
// runHostTool(), only validated here as a non-empty string, mirroring
|
|
492
|
+
// preScript/postScript above.
|
|
500
493
|
if ("scriptPath" in argsObj) {
|
|
501
494
|
const scriptPath = argsObj.scriptPath;
|
|
502
495
|
if (typeof scriptPath !== "string" || scriptPath === "") {
|
|
@@ -518,12 +511,12 @@ export function normaliseHostToolRequest(raw) {
|
|
|
518
511
|
}
|
|
519
512
|
args.exportPath = exportPath;
|
|
520
513
|
}
|
|
521
|
-
//
|
|
522
|
-
//
|
|
523
|
-
//
|
|
524
|
-
//
|
|
525
|
-
//
|
|
526
|
-
//
|
|
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.
|
|
527
520
|
if ("dataRangesPath" in argsObj) {
|
|
528
521
|
const dataRangesPath = argsObj.dataRangesPath;
|
|
529
522
|
if (typeof dataRangesPath !== "string" || dataRangesPath === "") {
|
|
@@ -531,9 +524,9 @@ export function normaliseHostToolRequest(raw) {
|
|
|
531
524
|
}
|
|
532
525
|
args.dataRangesPath = dataRangesPath;
|
|
533
526
|
}
|
|
534
|
-
//
|
|
535
|
-
//
|
|
536
|
-
//
|
|
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.
|
|
537
530
|
if (args.entrypointsPath !== undefined && args.preScript === undefined) {
|
|
538
531
|
return {
|
|
539
532
|
ok: false,
|
|
@@ -555,10 +548,10 @@ export function normaliseHostToolRequest(raw) {
|
|
|
555
548
|
return { ok: true, request: { tool, args } };
|
|
556
549
|
}
|
|
557
550
|
if (tool === "oracle.probe") {
|
|
558
|
-
//
|
|
559
|
-
//
|
|
560
|
-
//
|
|
561
|
-
//
|
|
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.
|
|
562
555
|
return { ok: true, request: { tool, args: {} } };
|
|
563
556
|
}
|
|
564
557
|
if (tool === "oracle.run") {
|
|
@@ -574,8 +567,7 @@ export function normaliseHostToolRequest(raw) {
|
|
|
574
567
|
return { ok: false, message: `host_tool "dxa.disassemble" requires a non-empty string "image"; got ${describe(image)}` };
|
|
575
568
|
}
|
|
576
569
|
const imageKindRaw = argsObj.imageKind;
|
|
577
|
-
// The enum is exact and case-sensitive -- "PRG" and "prg" never merge
|
|
578
|
-
// (must_haves.truths, 35-01-PLAN.md).
|
|
570
|
+
// The enum is exact and case-sensitive -- "PRG" and "prg" never merge.
|
|
579
571
|
if (imageKindRaw !== "prg" && imageKindRaw !== "flat64k") {
|
|
580
572
|
return { ok: false, message: `host_tool "dxa.disassemble" args.imageKind must be "prg" or "flat64k"; got ${describe(imageKindRaw)}` };
|
|
581
573
|
}
|
|
@@ -644,12 +636,11 @@ export function normaliseHostToolRequest(raw) {
|
|
|
644
636
|
if (typeof image !== "string" || image === "") {
|
|
645
637
|
return { ok: false, message: `host_tool "${tool}" requires a non-empty string "image"; got ${describe(image)}` };
|
|
646
638
|
}
|
|
647
|
-
//
|
|
648
|
-
//
|
|
649
|
-
//
|
|
650
|
-
//
|
|
651
|
-
//
|
|
652
|
-
// container-side input.
|
|
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.
|
|
653
644
|
const name = argsObj.name;
|
|
654
645
|
if (typeof name !== "string" || name === "") {
|
|
655
646
|
return { ok: false, message: `host_tool "${tool}" requires a non-empty string "name"; got ${describe(name)}` };
|
|
@@ -691,11 +682,11 @@ export function normaliseHostToolRequest(raw) {
|
|
|
691
682
|
return { ok: false, message: `normaliseHostToolRequest: no narrowing arm for tool "${tool}"` };
|
|
692
683
|
}
|
|
693
684
|
// ---------------------------------------------------------------------------
|
|
694
|
-
// Workspace-relative path resolution
|
|
695
|
-
//
|
|
696
|
-
//
|
|
697
|
-
//
|
|
698
|
-
//
|
|
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.
|
|
699
690
|
//
|
|
700
691
|
// BOTH the workspace root and the candidate go through the SAME
|
|
701
692
|
// ancestor-realpath walk (realpathOfNearestExisting(), below) before the
|
|
@@ -706,10 +697,10 @@ export function normaliseHostToolRequest(raw) {
|
|
|
706
697
|
// walk: a workspace root that does not yet exist is a legitimate input (a
|
|
707
698
|
// bare realpath would throw a raw ENOENT), and resolving only the candidate
|
|
708
699
|
// side makes every in-workspace path look foreign whenever the root itself
|
|
709
|
-
// is reached through a symlink.
|
|
710
|
-
//
|
|
711
|
-
//
|
|
712
|
-
//
|
|
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.
|
|
713
704
|
//
|
|
714
705
|
// BEHAVIOURAL CONSEQUENCE, intended: because this returns the real path, a
|
|
715
706
|
// link pointing INSIDE the workspace is FOLLOWED and the request is
|
|
@@ -727,13 +718,13 @@ export function normaliseHostToolRequest(raw) {
|
|
|
727
718
|
// only in normalisation form are two distinct paths here (the same residual
|
|
728
719
|
// anno-confinement.test.ts records for the same comparison).
|
|
729
720
|
//
|
|
730
|
-
// A
|
|
731
|
-
//
|
|
732
|
-
//
|
|
733
|
-
//
|
|
734
|
-
//
|
|
735
|
-
//
|
|
736
|
-
//
|
|
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.
|
|
737
728
|
// ---------------------------------------------------------------------------
|
|
738
729
|
/**
|
|
739
730
|
* The maximum number of DANGLING-symlink hops `realpathOfNearestExisting`
|
|
@@ -882,17 +873,15 @@ export function resolveWorkspacePath(repoRoot, relative) {
|
|
|
882
873
|
if (!walkedCandidate.ok) {
|
|
883
874
|
return { ok: false, message: walkedCandidate.message };
|
|
884
875
|
}
|
|
885
|
-
//
|
|
886
|
-
//
|
|
887
|
-
//
|
|
888
|
-
// `
|
|
889
|
-
//
|
|
890
|
-
// a
|
|
891
|
-
//
|
|
892
|
-
//
|
|
893
|
-
//
|
|
894
|
-
// inside the repo and a scratch --out-dir outside it share no smaller
|
|
895
|
-
// ancestor, exactly this plan's own Task 1 verify command.
|
|
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.
|
|
896
885
|
const requiredPrefix = walkedRoot.path === sep ? walkedRoot.path : walkedRoot.path + sep;
|
|
897
886
|
if (walkedCandidate.path !== walkedRoot.path && !walkedCandidate.path.startsWith(requiredPrefix)) {
|
|
898
887
|
return {
|
|
@@ -902,12 +891,12 @@ export function resolveWorkspacePath(repoRoot, relative) {
|
|
|
902
891
|
}
|
|
903
892
|
return { ok: true, path: walkedCandidate.path };
|
|
904
893
|
}
|
|
905
|
-
/** Deterministic: the same typed request and the same resolved paths yield
|
|
906
|
-
* byte-identical argv array on two successive calls -- no randomness, no
|
|
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
|
|
907
896
|
* timestamp, no environment-dependent ordering. `log` is OPTIONAL and used
|
|
908
|
-
* ONLY by the c1541.dir branch
|
|
909
|
-
*
|
|
910
|
-
*
|
|
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. */
|
|
911
900
|
export function buildHostToolArgv(request, resolved, log) {
|
|
912
901
|
if (request.tool === "acme.build") {
|
|
913
902
|
const { args } = request;
|
|
@@ -942,7 +931,7 @@ export function buildHostToolArgv(request, resolved, log) {
|
|
|
942
931
|
argv.push("-r", `${stem}.rep`);
|
|
943
932
|
for (const define of args.defines ?? [])
|
|
944
933
|
argv.push(`-D${define}`);
|
|
945
|
-
//
|
|
934
|
+
// Reads ONLY from resolved.includePaths -- never from
|
|
946
935
|
// request.args.includes -- so argv never carries a raw wire string for
|
|
947
936
|
// this field. Defensively defaults to [] so a caller that omits
|
|
948
937
|
// includePaths entirely still yields a valid, empty-include argv rather
|
|
@@ -980,16 +969,15 @@ export function buildHostToolArgv(request, resolved, log) {
|
|
|
980
969
|
message: `host_tool "ghidra.analyze" refuses: GHIDRA_HOME's resolved launcher does not exist on disk (${ghidraPath})`,
|
|
981
970
|
};
|
|
982
971
|
}
|
|
983
|
-
//
|
|
984
|
-
//
|
|
985
|
-
//
|
|
986
|
-
//
|
|
987
|
-
//
|
|
988
|
-
//
|
|
989
|
-
//
|
|
990
|
-
//
|
|
991
|
-
//
|
|
992
|
-
// failure criterion 1 exists to catch.
|
|
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.
|
|
993
981
|
const installedLanguages = installedLanguageIds(ghidraHome);
|
|
994
982
|
const requestedProcessor = request.args.processor;
|
|
995
983
|
const matchedLanguage = installedLanguages.find((lang) => lang.id === requestedProcessor);
|
|
@@ -1011,18 +999,16 @@ export function buildHostToolArgv(request, resolved, log) {
|
|
|
1011
999
|
}
|
|
1012
1000
|
// Argv construction and the dot-segment re-check both live in
|
|
1013
1001
|
// ghidra-project.mts's buildAnalyzeHeadlessArgv() -- never re-derived
|
|
1014
|
-
// here
|
|
1015
|
-
//
|
|
1016
|
-
//
|
|
1017
|
-
//
|
|
1018
|
-
//
|
|
1019
|
-
//
|
|
1020
|
-
//
|
|
1021
|
-
//
|
|
1022
|
-
//
|
|
1023
|
-
//
|
|
1024
|
-
// and none appears in `resolved` (ResolvedGhidraAnalyzePaths carries
|
|
1025
|
-
// paths only).
|
|
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).
|
|
1026
1012
|
const argvInput = {
|
|
1027
1013
|
projectLocation,
|
|
1028
1014
|
projectName,
|
|
@@ -1044,25 +1030,24 @@ export function buildHostToolArgv(request, resolved, log) {
|
|
|
1044
1030
|
argvInput.exportPath = exportPathResolved;
|
|
1045
1031
|
if (request.args.expectedClassificationLines !== undefined)
|
|
1046
1032
|
argvInput.expectedClassificationLines = request.args.expectedClassificationLines;
|
|
1047
|
-
//
|
|
1048
|
-
//
|
|
1049
|
-
//
|
|
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.
|
|
1050
1036
|
if (dataRangesPathResolved !== undefined)
|
|
1051
1037
|
argvInput.dataRangesPath = dataRangesPathResolved;
|
|
1052
1038
|
const built = buildAnalyzeHeadlessArgv(argvInput);
|
|
1053
1039
|
if (!built.ok)
|
|
1054
1040
|
return { ok: false, message: built.message };
|
|
1055
|
-
//
|
|
1056
|
-
//
|
|
1057
|
-
//
|
|
1058
|
-
//
|
|
1059
|
-
//
|
|
1060
|
-
//
|
|
1061
|
-
// line arrives on stdout).
|
|
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).
|
|
1062
1047
|
const runLogPath = join(dirname(projectLocation), `${projectName}.ghidra-run.log`);
|
|
1063
|
-
//
|
|
1064
|
-
//
|
|
1065
|
-
//
|
|
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.
|
|
1066
1051
|
const outputs = exportPathResolved !== undefined ? [runLogPath, exportPathResolved] : [runLogPath];
|
|
1067
1052
|
return { ok: true, toolPath: ghidraPath, argv: built.argv, outputs };
|
|
1068
1053
|
}
|
|
@@ -1144,15 +1129,15 @@ export function buildHostToolArgv(request, resolved, log) {
|
|
|
1144
1129
|
request.tool === "c1541.chain" ||
|
|
1145
1130
|
request.tool === "c1541.read") {
|
|
1146
1131
|
const { imagePath, outDirPath } = resolved;
|
|
1147
|
-
//
|
|
1148
|
-
//
|
|
1149
|
-
//
|
|
1150
|
-
//
|
|
1151
|
-
//
|
|
1152
|
-
//
|
|
1153
|
-
//
|
|
1154
|
-
//
|
|
1155
|
-
//
|
|
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.
|
|
1156
1141
|
const c1541Found = findSiblingBinary("c1541", resolvedBackend().binPath, log);
|
|
1157
1142
|
if (c1541Found.path === null) {
|
|
1158
1143
|
return {
|
|
@@ -1172,7 +1157,7 @@ export function buildHostToolArgv(request, resolved, log) {
|
|
|
1172
1157
|
// committed fixture, fixtures/c1541/README.md); the seam captures
|
|
1173
1158
|
// stdout and writes it to a single outputs[] path (TOOLS_WHOSE_OUTPUT_IS_STDOUT
|
|
1174
1159
|
// above), then digests the FILE -- never c1541's own exit status, which
|
|
1175
|
-
// is 0 even on a genuine failure (
|
|
1160
|
+
// is 0 even on a genuine failure (MEASURED: "Error - Cannot open
|
|
1176
1161
|
// file ..." exits 0) and is therefore never the pass/fail signal for a
|
|
1177
1162
|
// listing. `-read` is the one exception: the child writes the output
|
|
1178
1163
|
// file itself, so its argv passes the produced host path as its own
|
|
@@ -1209,8 +1194,8 @@ export function buildHostToolArgv(request, resolved, log) {
|
|
|
1209
1194
|
}
|
|
1210
1195
|
// request.tool === "c1541.read": the single-file byte-extraction route,
|
|
1211
1196
|
// mirroring extractEntry(image, entryName), the now-deleted MCP-side
|
|
1212
|
-
// pure-parse module's own signature
|
|
1213
|
-
//
|
|
1197
|
+
// pure-parse module's own signature, one-for-one. The produced host
|
|
1198
|
+
// path is the child's OWN output argument -- c1541
|
|
1214
1199
|
// writes it directly, so this tool is deliberately absent from
|
|
1215
1200
|
// TOOLS_WHOSE_OUTPUT_IS_STDOUT and the existing digest loop picks the
|
|
1216
1201
|
// file up unchanged.
|
|
@@ -1219,9 +1204,9 @@ export function buildHostToolArgv(request, resolved, log) {
|
|
|
1219
1204
|
}
|
|
1220
1205
|
if (request.tool === "petcat.decode") {
|
|
1221
1206
|
const { imagePath, outDirPath } = resolved;
|
|
1222
|
-
//
|
|
1223
|
-
//
|
|
1224
|
-
//
|
|
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.
|
|
1225
1210
|
const petcatFound = findSiblingBinary("petcat", resolvedBackend().binPath, log);
|
|
1226
1211
|
if (petcatFound.path === null) {
|
|
1227
1212
|
return {
|
|
@@ -1230,19 +1215,19 @@ export function buildHostToolArgv(request, resolved, log) {
|
|
|
1230
1215
|
};
|
|
1231
1216
|
}
|
|
1232
1217
|
const petcatPath = petcatFound.path;
|
|
1233
|
-
//
|
|
1218
|
+
// The BASIC dialect is a FIXED literal here, server-side -- "-2"
|
|
1234
1219
|
// (BASIC V2.0, every stock C64's own dialect), first in argv, ahead of
|
|
1235
1220
|
// the resolved image path LAST. There is no wire field that selects it
|
|
1236
1221
|
// (HOST_TOOL_ARG_KEYS["petcat.decode"] carries no such key), nothing
|
|
1237
1222
|
// validates it, and no caller can request a different one.
|
|
1238
1223
|
const argv = ["-2", imagePath];
|
|
1239
|
-
//
|
|
1240
|
-
//
|
|
1241
|
-
//
|
|
1242
|
-
//
|
|
1243
|
-
//
|
|
1244
|
-
//
|
|
1245
|
-
//
|
|
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.
|
|
1246
1231
|
const imageStem = basename(imagePath).replace(/\.[^./]+$/, "");
|
|
1247
1232
|
const listingPath = join(outDirPath, `${imageStem}.bas.txt`);
|
|
1248
1233
|
return { ok: true, toolPath: petcatPath, argv, outputs: [listingPath] };
|
|
@@ -1250,93 +1235,90 @@ export function buildHostToolArgv(request, resolved, log) {
|
|
|
1250
1235
|
return { ok: false, message: `buildHostToolArgv: no argv builder for tool "${request.tool}"` };
|
|
1251
1236
|
}
|
|
1252
1237
|
// ---------------------------------------------------------------------------
|
|
1253
|
-
// Async child-process invocation and result digest
|
|
1254
|
-
//
|
|
1255
|
-
//
|
|
1256
|
-
//
|
|
1257
|
-
//
|
|
1258
|
-
//
|
|
1259
|
-
//
|
|
1260
|
-
// it), and monitor claims.
|
|
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.
|
|
1261
1245
|
// ---------------------------------------------------------------------------
|
|
1262
1246
|
/** Fallback per-invocation timeout for a tool id absent from
|
|
1263
|
-
* HOST_TOOL_TIMEOUT_MS below -- unreachable today, since every
|
|
1264
|
-
* member has an explicit table entry, but this constant
|
|
1265
|
-
* consulted as the honest bottom of the resolver's
|
|
1266
|
-
* also the value acme.build/oracle.probe/oracle.run's
|
|
1267
|
-
* hold today (20s, the same value packer-finding.mjs's
|
|
1268
|
-
* ORACLE_TIMEOUT_MS convention already used) -- no longer the ceiling
|
|
1269
|
-
* EVERY invocation
|
|
1270
|
-
*
|
|
1271
|
-
* silently governed a JVM. */
|
|
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. */
|
|
1272
1256
|
export const DEFAULT_HOST_TOOL_TIMEOUT_MS = 20_000;
|
|
1273
|
-
/**
|
|
1257
|
+
/** The per-tool SERVER-side budget table, built with the SAME
|
|
1274
1258
|
* `Object.freeze(Object.assign(Object.create(null), ...))` idiom
|
|
1275
1259
|
* HOST_TOOL_ARG_KEYS uses, with an entry for EVERY HOST_TOOL_IDS member --
|
|
1276
|
-
* completeness enforced by host-tool.test.ts's own completeness case,
|
|
1277
|
-
* assumed silently. `acme.build`, `oracle.probe` and `oracle.run`
|
|
1278
|
-
* value DEFAULT_HOST_TOOL_TIMEOUT_MS already held (20_000ms) --
|
|
1279
|
-
* their measured costs approach the fixed ceiling. `ghidra.analyze`
|
|
1280
|
-
* 600_000ms (10 minutes), justified from this project's own recorded
|
|
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
|
|
1281
1265
|
* numbers rather than a round guess: the documented JVM startup range is
|
|
1282
|
-
* 12.6-17.4s
|
|
1283
|
-
*
|
|
1284
|
-
*
|
|
1285
|
-
*
|
|
1286
|
-
*
|
|
1287
|
-
*
|
|
1288
|
-
*
|
|
1289
|
-
* reports a refusal on expiry, unchanged. */
|
|
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. */
|
|
1290
1273
|
export const HOST_TOOL_TIMEOUT_MS = Object.freeze(Object.assign(Object.create(null), {
|
|
1291
1274
|
"acme.build": DEFAULT_HOST_TOOL_TIMEOUT_MS,
|
|
1292
1275
|
"ghidra.analyze": 600_000,
|
|
1293
1276
|
"oracle.probe": DEFAULT_HOST_TOOL_TIMEOUT_MS,
|
|
1294
1277
|
"oracle.run": DEFAULT_HOST_TOOL_TIMEOUT_MS,
|
|
1295
|
-
//
|
|
1296
|
-
//
|
|
1297
|
-
//
|
|
1298
|
-
// a 950x headroom against this 20s ceiling. host-tool-client.ts's
|
|
1299
|
-
// request-deadline table gains NO entry for this tool, because
|
|
1300
|
-
// DEFAULT_HOST_TOOL_REQUEST_TIMEOUT_MS (30_000) already exceeds this
|
|
1301
|
-
// value -- the cross-seam ordering test stays satisfied by construction.
|
|
1302
|
-
"dxa.disassemble": DEFAULT_HOST_TOOL_TIMEOUT_MS,
|
|
1303
|
-
// Phase 36, plan 36-01: DEFAULT_HOST_TOOL_TIMEOUT_MS, justified from a
|
|
1304
|
-
// measurement rather than a round guess -- `support/sleigh` compiled
|
|
1305
|
-
// this extension's whole vendored tree in 1763ms wall-clock (MEASURED,
|
|
1306
|
-
// this plan's own scratch run), an ~11x headroom against this 20s
|
|
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
|
|
1307
1281
|
// ceiling. host-tool-client.ts's request-deadline table gains NO entry
|
|
1308
|
-
// for this tool,
|
|
1309
|
-
//
|
|
1310
|
-
//
|
|
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.
|
|
1311
1294
|
"ghidra.installExtension": DEFAULT_HOST_TOOL_TIMEOUT_MS,
|
|
1312
|
-
//
|
|
1313
|
-
//
|
|
1314
|
-
// -
|
|
1315
|
-
//
|
|
1316
|
-
// committed fixture), a >1300x headroom against this 20s ceiling.
|
|
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.
|
|
1317
1299
|
// host-tool-client.ts's request-deadline table gains NO entry for this
|
|
1318
1300
|
// tool, for the same reason dxa.disassemble's own comment above states:
|
|
1319
1301
|
// DEFAULT_HOST_TOOL_REQUEST_TIMEOUT_MS (30_000) already exceeds this
|
|
1320
1302
|
// value.
|
|
1321
1303
|
"c1541.dir": DEFAULT_HOST_TOOL_TIMEOUT_MS,
|
|
1322
|
-
//
|
|
1323
|
-
//
|
|
1324
|
-
//
|
|
1325
|
-
// basicstub
|
|
1326
|
-
//
|
|
1327
|
-
//
|
|
1328
|
-
//
|
|
1329
|
-
//
|
|
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.
|
|
1330
1312
|
"c1541.bam": DEFAULT_HOST_TOOL_TIMEOUT_MS,
|
|
1331
1313
|
"c1541.entry": DEFAULT_HOST_TOOL_TIMEOUT_MS,
|
|
1332
1314
|
"c1541.chain": DEFAULT_HOST_TOOL_TIMEOUT_MS,
|
|
1333
1315
|
"c1541.read": DEFAULT_HOST_TOOL_TIMEOUT_MS,
|
|
1334
|
-
//
|
|
1335
|
-
//
|
|
1336
|
-
//
|
|
1337
|
-
//
|
|
1338
|
-
//
|
|
1339
|
-
//
|
|
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:
|
|
1340
1322
|
// DEFAULT_HOST_TOOL_REQUEST_TIMEOUT_MS (30_000) already exceeds this
|
|
1341
1323
|
// value.
|
|
1342
1324
|
"petcat.decode": DEFAULT_HOST_TOOL_TIMEOUT_MS,
|
|
@@ -1362,63 +1344,63 @@ const STDERR_TAIL_CAP_BYTES = 64 * 1024;
|
|
|
1362
1344
|
* exporting the number for its own parser and its own tests -- not a
|
|
1363
1345
|
* duplicated maintenance burden, the same measured constant on both sides. */
|
|
1364
1346
|
const ORACLE_STDOUT_CAP_BYTES = 64 * 1024;
|
|
1365
|
-
/**
|
|
1366
|
-
*
|
|
1367
|
-
*
|
|
1368
|
-
*
|
|
1369
|
-
*
|
|
1370
|
-
*
|
|
1371
|
-
*
|
|
1372
|
-
*
|
|
1373
|
-
*
|
|
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.
|
|
1374
1356
|
*
|
|
1375
1357
|
* Deliberately NOT set to STDOUT_CLASSIFY_CAP_BYTES (64KiB) or "a slightly
|
|
1376
|
-
* larger" ceiling close to it,
|
|
1377
|
-
* suggestion: TOOLS_WHOSE_OUTPUT_IS_STDOUT tools (dxa.disassemble, all
|
|
1378
|
-
* stdout-shaped c1541.* ids, petcat.decode) write the FULL captured
|
|
1379
|
-
* verbatim to their declared output file below (the
|
|
1380
|
-
* right after the spawn) -- a full dxa.disassemble
|
|
1381
|
-
* 64KB-image fixture already measures well past 64KiB
|
|
1382
|
-
* anywhere near that size would silently truncate a
|
|
1383
|
-
* into a corrupt, incomplete listing every time it
|
|
1384
|
-
* malicious input. This ceiling is sized purely as a
|
|
1385
|
-
* against a pathological/looping child, far above any
|
|
1386
|
-
* this seam produces today. */
|
|
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. */
|
|
1387
1369
|
const SPAWN_ACCUMULATION_HARD_CAP_BYTES = 64 * 1024 * 1024;
|
|
1388
|
-
/**
|
|
1389
|
-
*
|
|
1390
|
-
*
|
|
1391
|
-
*
|
|
1392
|
-
*
|
|
1393
|
-
*
|
|
1394
|
-
*
|
|
1395
|
-
*
|
|
1396
|
-
*
|
|
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. */
|
|
1397
1379
|
const TOOLS_WHOSE_OUTPUT_IS_STDOUT = new Set([
|
|
1398
1380
|
"dxa.disassemble",
|
|
1399
1381
|
"c1541.bam",
|
|
1400
1382
|
"c1541.dir",
|
|
1401
1383
|
"c1541.entry",
|
|
1402
1384
|
"c1541.chain",
|
|
1403
|
-
//
|
|
1404
|
-
//
|
|
1405
|
-
//
|
|
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.
|
|
1406
1388
|
"petcat.decode",
|
|
1407
1389
|
]);
|
|
1408
|
-
/**
|
|
1409
|
-
*
|
|
1410
|
-
*
|
|
1411
|
-
*
|
|
1412
|
-
*
|
|
1413
|
-
*
|
|
1414
|
-
*
|
|
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. */
|
|
1415
1397
|
const STDOUT_CLASSIFY_CAP_BYTES = 64 * 1024;
|
|
1416
1398
|
/** Total `Readonly<Record<HostToolId, HostToolOutputClassifier | null>>` --
|
|
1417
1399
|
* built with the SAME `Object.freeze(Object.assign(Object.create(null),
|
|
1418
1400
|
* ...))` idiom HOST_TOOL_ARG_KEYS uses. Pre-existing ids map to `null`,
|
|
1419
1401
|
* meaning "no declared shape, behaviour unchanged" -- NEVER absent, so a
|
|
1420
1402
|
* tool id never falls through to an implicit success. `host-tool.test.ts`'s
|
|
1421
|
-
* completeness case
|
|
1403
|
+
* completeness case asserts every HOST_TOOL_IDS member has an own
|
|
1422
1404
|
* property here. */
|
|
1423
1405
|
export const HOST_TOOL_OUTPUT_CLASSIFIERS = Object.freeze(Object.assign(Object.create(null), {
|
|
1424
1406
|
"acme.build": null,
|
|
@@ -1427,38 +1409,38 @@ export const HOST_TOOL_OUTPUT_CLASSIFIERS = Object.freeze(Object.assign(Object.c
|
|
|
1427
1409
|
"oracle.run": null,
|
|
1428
1410
|
"dxa.disassemble": null,
|
|
1429
1411
|
"ghidra.installExtension": null,
|
|
1430
|
-
// Declared shape (
|
|
1412
|
+
// Declared shape (MEASURED against the committed fixture): a
|
|
1431
1413
|
// `<N> blocks free` trailer. MEASURED also: c1541 prints an "OPENCBM:
|
|
1432
1414
|
// opening dynamic library libopencbm.so failed!" complaint to stdout on
|
|
1433
1415
|
// EVERY call on this host -- a negative stderr oracle would be wrong
|
|
1434
1416
|
// here, since the complaint lands on stdout, not stderr, and is
|
|
1435
1417
|
// unrelated to whether the listing itself succeeded.
|
|
1436
1418
|
"c1541.dir": ((ctx) => classifyC1541DirOutput(ctx.stdout)),
|
|
1437
|
-
// Declared shape (
|
|
1438
|
-
//
|
|
1439
|
-
//
|
|
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.
|
|
1440
1422
|
"c1541.bam": ((ctx) => classifyC1541BamOutput(ctx.stdout)),
|
|
1441
|
-
// Declared shape (
|
|
1423
|
+
// Declared shape (MEASURED against the committed fixture): a
|
|
1442
1424
|
// `T/S: <t>/<s>, <n> blocks` line.
|
|
1443
1425
|
"c1541.entry": ((ctx) => classifyC1541EntryOutput(ctx.stdout)),
|
|
1444
|
-
// Declared shape (
|
|
1445
|
-
//
|
|
1446
|
-
//
|
|
1447
|
-
//
|
|
1448
|
-
//
|
|
1449
|
-
//
|
|
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
|
|
1450
1432
|
// tuple-on-both-sides shape (see fixtures/c1541/README.md).
|
|
1451
1433
|
"c1541.chain": ((ctx) => classifyC1541ChainOutput(ctx.stdout)),
|
|
1452
|
-
// Declared shape
|
|
1434
|
+
// Declared shape: `results` contains exactly one entry whose
|
|
1453
1435
|
// `byteLength` is greater than zero -- reads `results`, not captured
|
|
1454
1436
|
// text, since c1541.read is absent from TOOLS_WHOSE_OUTPUT_IS_STDOUT
|
|
1455
1437
|
// (the child writes its own output file).
|
|
1456
1438
|
"c1541.read": ((ctx) => classifyC1541ReadOutput(ctx.results)),
|
|
1457
|
-
// Declared shape (
|
|
1439
|
+
// Declared shape (MEASURED against both committed fixtures): the
|
|
1458
1440
|
// leading banner line petcat -2 prints for a recognised BASIC program,
|
|
1459
|
-
// `;<path> ==<hex>==`. MEASURED also: petcat exits 0 on garbage input
|
|
1460
|
-
//
|
|
1461
|
-
//
|
|
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.
|
|
1462
1444
|
"petcat.decode": ((ctx) => classifyPetcatDecodeOutput(ctx.stdout)),
|
|
1463
1445
|
}));
|
|
1464
1446
|
/** The declared success shape for c1541.dir's captured stdout: at least one
|
|
@@ -1525,8 +1507,8 @@ export function classifyC1541ReadOutput(results) {
|
|
|
1525
1507
|
* Absence of it means the file was not recognised as a BASIC program at
|
|
1526
1508
|
* all -- MEASURED against 64 random bytes, whose captured output carries a
|
|
1527
1509
|
* leading `;<path> ` but never the `==<hex>==` pair that follows it for a
|
|
1528
|
-
* real BASIC program. petcat exits 0 either way
|
|
1529
|
-
*
|
|
1510
|
+
* real BASIC program. petcat exits 0 either way -- this classifier, not
|
|
1511
|
+
* the exit code, is what decides success here. */
|
|
1530
1512
|
export function classifyPetcatDecodeOutput(stdout) {
|
|
1531
1513
|
if (/;\S+\s+==[0-9a-fA-F]+==/.test(stdout))
|
|
1532
1514
|
return { ok: true };
|
|
@@ -1535,18 +1517,17 @@ export function classifyPetcatDecodeOutput(stdout) {
|
|
|
1535
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))}`,
|
|
1536
1518
|
};
|
|
1537
1519
|
}
|
|
1538
|
-
/**
|
|
1539
|
-
*
|
|
1540
|
-
*
|
|
1541
|
-
*
|
|
1542
|
-
*
|
|
1543
|
-
*
|
|
1544
|
-
* never for an unresolved SYS argument:
|
|
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:
|
|
1545
1526
|
* - an all-decimal-digit argument resolves to a numeric entry point,
|
|
1546
1527
|
* named by the BASIC line it came from (the literal fast path);
|
|
1547
1528
|
* - anything else is a named decline quoting the unresolved expression
|
|
1548
1529
|
* verbatim, so a reader sees exactly what could not be resolved (the
|
|
1549
|
-
* computed case,
|
|
1530
|
+
* computed case, whose own fixture exercises it);
|
|
1550
1531
|
* - no SYS token at all is a named decline saying so.
|
|
1551
1532
|
* Matches the FIRST BASIC line whose statement (immediately after the line
|
|
1552
1533
|
* number) is the `sys` keyword -- petcat's own detokenized output always
|
|
@@ -1561,7 +1542,7 @@ export function derivePetcatEntrypoint(detokenizedText) {
|
|
|
1561
1542
|
const basicLine = m[1];
|
|
1562
1543
|
const argument = m[2];
|
|
1563
1544
|
if (/^\d+$/.test(argument)) {
|
|
1564
|
-
//
|
|
1545
|
+
// A fix for a real bug: an all-decimal-digit SYS argument used to be
|
|
1565
1546
|
// accepted as a literal entry point with no upper-bound check --
|
|
1566
1547
|
// `Number()` converts an arbitrarily long digit string (with silent
|
|
1567
1548
|
// precision loss past 2^53) and a BASIC program can legally contain
|
|
@@ -1597,8 +1578,8 @@ export function derivePetcatEntrypoint(detokenizedText) {
|
|
|
1597
1578
|
* from a produced-but-empty file). */
|
|
1598
1579
|
function digestOutputFile(path) {
|
|
1599
1580
|
try {
|
|
1600
|
-
//
|
|
1601
|
-
//
|
|
1581
|
+
// byteLength must describe the SAME bytes sha256 was computed over --
|
|
1582
|
+
// derived from the buffer actually read, never from a separate
|
|
1602
1583
|
// statSync() call, which could observe a different byte string if the
|
|
1603
1584
|
// file is written to between the two reads.
|
|
1604
1585
|
const contents = readFileSync(path);
|
|
@@ -1617,21 +1598,22 @@ function tailBytes(text, capBytes) {
|
|
|
1617
1598
|
return text;
|
|
1618
1599
|
return buf.subarray(buf.length - capBytes).toString("utf8");
|
|
1619
1600
|
}
|
|
1620
|
-
/** Spawns `toolPath` with `argv` (an ARRAY, never a shell string; the
|
|
1621
|
-
* interpreter is never enabled) and resolves -- NEVER rejects --
|
|
1622
|
-
* child exits, errors, or is killed on timeout expiry. This is the
|
|
1623
|
-
* call in this module -- oracle.probe/oracle.run
|
|
1624
|
-
*
|
|
1625
|
-
* environment (`spawn()`'s own default) when omitted; acme.build overrides
|
|
1626
|
-
* to inject a probed `ACME` library directory (see `findAcmeLib()`
|
|
1627
|
-
* `stdout` is captured (not just `stderr`) because oracle.run's
|
|
1628
|
-
* "the oracle's stdout", not a file digest -- acme.build/
|
|
1629
|
-
* simply ignore the field, exactly as they ignored stdout
|
|
1630
|
-
* piped (ACME writes nothing to stdout; verified
|
|
1631
|
-
* `cwd` defaults to the broker process's own working
|
|
1632
|
-
* own default) when omitted -- exactly `env`'s
|
|
1633
|
-
* `acme.build` passes one
|
|
1634
|
-
* spawn is therefore byte-identical to before this parameter
|
|
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. */
|
|
1635
1617
|
function spawnHostTool(toolPath, argv, timeoutMs, env, cwd) {
|
|
1636
1618
|
return new Promise((resolvePromise) => {
|
|
1637
1619
|
let settled = false;
|
|
@@ -1658,17 +1640,17 @@ function spawnHostTool(toolPath, argv, timeoutMs, env, cwd) {
|
|
|
1658
1640
|
}, timeoutMs);
|
|
1659
1641
|
if (typeof timer.unref === "function")
|
|
1660
1642
|
timer.unref();
|
|
1661
|
-
//
|
|
1662
|
-
//
|
|
1663
|
-
//
|
|
1664
|
-
//
|
|
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
|
|
1665
1647
|
// TOOLS_WHOSE_OUTPUT_IS_STDOUT tools, so preserving the HEAD (the
|
|
1666
1648
|
// already-received, in-order prefix) rather than an arbitrary tail
|
|
1667
1649
|
// fragment keeps a capped run's written output internally coherent (a
|
|
1668
1650
|
// truncated-but-ordered listing) instead of discarding its beginning.
|
|
1669
1651
|
// The ceiling itself is set far above any legitimate output this seam
|
|
1670
|
-
// produces (see SPAWN_ACCUMULATION_HARD_CAP_BYTES above), so this
|
|
1671
|
-
// is never taken on a normal, successful run.
|
|
1652
|
+
// produces (see SPAWN_ACCUMULATION_HARD_CAP_BYTES above), so this
|
|
1653
|
+
// branch is never taken on a normal, successful run.
|
|
1672
1654
|
child.stdout?.on("data", (chunk) => {
|
|
1673
1655
|
if (stdout.length < SPAWN_ACCUMULATION_HARD_CAP_BYTES) {
|
|
1674
1656
|
stdout += chunk.toString("utf8");
|
|
@@ -1696,31 +1678,31 @@ function spawnHostTool(toolPath, argv, timeoutMs, env, cwd) {
|
|
|
1696
1678
|
});
|
|
1697
1679
|
}
|
|
1698
1680
|
// ---------------------------------------------------------------------------
|
|
1699
|
-
// The ACME library probe
|
|
1700
|
-
//
|
|
1701
|
-
//
|
|
1702
|
-
//
|
|
1703
|
-
//
|
|
1704
|
-
//
|
|
1705
|
-
//
|
|
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.
|
|
1706
1688
|
// ---------------------------------------------------------------------------
|
|
1707
1689
|
/** The marker file used to validate a candidate ACME library directory --
|
|
1708
1690
|
* the layout fact `acme.mjs`'s own troubleshooting hint names. */
|
|
1709
1691
|
const ACME_LIB_MARKER = join("cbm", "c64", "vic.a");
|
|
1710
1692
|
// ---------------------------------------------------------------------------
|
|
1711
|
-
// The vendored dxa binary probe
|
|
1712
|
-
//
|
|
1713
|
-
//
|
|
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
|
|
1714
1696
|
// (src/mcp/vice/resources/host-tool.mjs, HERE == src/mcp/vice/resources/).
|
|
1715
1697
|
// ghidra-project.mjs's own sibling-ness to host-tool.mjs survives that move
|
|
1716
1698
|
// because BOTH are compiled into resources/ together (build.ts's
|
|
1717
1699
|
// HOST_BOUND_ARTIFACTS). vendor/dxa/dxa does NOT survive it -- it is a real
|
|
1718
1700
|
// binary, never copied anywhere by build.ts, always at
|
|
1719
|
-
// src/mcp/vice/vendor/dxa/dxa. So "vendor/dxa/dxa relative to
|
|
1720
|
-
// means two DIFFERENT candidate locations depending on
|
|
1721
|
-
// module is executing: same-directory for the unbuilt
|
|
1722
|
-
// for the compiled artifact. Mirrors findAcmeLib()'s
|
|
1723
|
-
// first existing wins" idiom, immediately below.
|
|
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.
|
|
1724
1706
|
// ---------------------------------------------------------------------------
|
|
1725
1707
|
export function findDxaBinary(here) {
|
|
1726
1708
|
const tried = [join(here, "vendor", "dxa", "dxa"), join(here, "..", "vendor", "dxa", "dxa")];
|
|
@@ -1747,29 +1729,28 @@ function findAcmeLib() {
|
|
|
1747
1729
|
return { path: null, tried };
|
|
1748
1730
|
}
|
|
1749
1731
|
// ---------------------------------------------------------------------------
|
|
1750
|
-
// The c1541/petcat sibling-binary probe (
|
|
1751
|
-
//
|
|
1752
|
-
//
|
|
1753
|
-
//
|
|
1754
|
-
//
|
|
1755
|
-
//
|
|
1756
|
-
//
|
|
1757
|
-
//
|
|
1758
|
-
//
|
|
1759
|
-
//
|
|
1760
|
-
// and must not be re-added.
|
|
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.
|
|
1761
1742
|
// ---------------------------------------------------------------------------
|
|
1762
|
-
/** Memoised per binary name for the process lifetime
|
|
1763
|
-
*
|
|
1764
|
-
*
|
|
1765
|
-
*
|
|
1766
|
-
*
|
|
1767
|
-
*
|
|
1768
|
-
*
|
|
1769
|
-
*
|
|
1770
|
-
*
|
|
1771
|
-
*
|
|
1772
|
-
*
|
|
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). */
|
|
1773
1754
|
const siblingBinaryMemo = new Map();
|
|
1774
1755
|
function findSiblingBinary(binaryName, resolvedX64scPath, log) {
|
|
1775
1756
|
const memoised = siblingBinaryMemo.get(binaryName);
|
|
@@ -1787,7 +1768,7 @@ function findSiblingBinary(binaryName, resolvedX64scPath, log) {
|
|
|
1787
1768
|
// Fallback: a $PATH walk (mirrors defaultResolveBinPath()'s own algorithm,
|
|
1788
1769
|
// backend-detect.mts), logging a warning naming the resolved x64sc path,
|
|
1789
1770
|
// the PATH match, and that this MAY be a DIFFERENT VICE build than the
|
|
1790
|
-
// emulator -- never a silent PATH fallback
|
|
1771
|
+
// emulator -- never a silent PATH fallback.
|
|
1791
1772
|
const pathEnv = process.env.PATH ?? "";
|
|
1792
1773
|
for (const dir of pathEnv.split(":")) {
|
|
1793
1774
|
if (!dir)
|
|
@@ -1807,29 +1788,28 @@ function findSiblingBinary(binaryName, resolvedX64scPath, log) {
|
|
|
1807
1788
|
return result;
|
|
1808
1789
|
}
|
|
1809
1790
|
/** Narrows, resolves, builds argv, then spawns the child ASYNCHRONOUSLY.
|
|
1810
|
-
* NOTHING throws out of this function -- every failure path (refusal,
|
|
1811
|
-
* error, timeout, non-zero exit, unreadable output) resolves to a
|
|
1812
|
-
* object, because broker-kill.mts's uncaughtException/
|
|
1813
|
-
* handlers kill the whole VICE pool on an unhandled
|
|
1814
|
-
* Emits exactly one `log()` line per ATTEMPTED
|
|
1815
|
-
* construction succeeded and a child was
|
|
1816
|
-
* id, the exit status, the elapsed
|
|
1817
|
-
* budget that was actually applied
|
|
1818
|
-
* hostToolTimeoutMs()) -- so which budget governed
|
|
1819
|
-
* the log line rather than inferred
|
|
1820
|
-
* child is ever spawned emits no log line -- there is no
|
|
1821
|
-
* record. */
|
|
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. */
|
|
1822
1803
|
export async function runHostTool(raw, deps) {
|
|
1823
1804
|
const narrowed = normaliseHostToolRequest(raw);
|
|
1824
1805
|
if (!narrowed.ok)
|
|
1825
1806
|
return { ok: false, message: narrowed.message };
|
|
1826
1807
|
const { request } = narrowed;
|
|
1827
|
-
//
|
|
1828
|
-
//
|
|
1829
|
-
//
|
|
1830
|
-
//
|
|
1831
|
-
//
|
|
1832
|
-
// than adding a second.
|
|
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.
|
|
1833
1813
|
if (request.tool === "oracle.probe")
|
|
1834
1814
|
return runOracleProbe(deps);
|
|
1835
1815
|
if (request.tool === "oracle.run")
|
|
@@ -1837,13 +1817,13 @@ export async function runHostTool(raw, deps) {
|
|
|
1837
1817
|
const repoRootAbs = resolvePath(deps.repoRoot);
|
|
1838
1818
|
let built;
|
|
1839
1819
|
let acmeLib = null;
|
|
1840
|
-
//
|
|
1841
|
-
//
|
|
1842
|
-
//
|
|
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
|
|
1843
1823
|
// can still fail for a completely ordinary, fixable reason (unset
|
|
1844
1824
|
// GHIDRA_HOME, processor not yet installed). Recorded here so the shared
|
|
1845
|
-
// `!built.ok` check below can clean up the orphaned reservation rather
|
|
1846
|
-
// burning the runId permanently.
|
|
1825
|
+
// `!built.ok` check below can clean up the orphaned reservation rather
|
|
1826
|
+
// than burning the runId permanently.
|
|
1847
1827
|
let ghidraReservedProjectLocation;
|
|
1848
1828
|
if (request.tool === "acme.build") {
|
|
1849
1829
|
const sourceResolved = resolveWorkspacePath(repoRootAbs, request.args.source);
|
|
@@ -1859,13 +1839,13 @@ export async function runHostTool(raw, deps) {
|
|
|
1859
1839
|
else {
|
|
1860
1840
|
outDirPath = dirname(sourceResolved.path);
|
|
1861
1841
|
}
|
|
1862
|
-
//
|
|
1842
|
+
// Every `includes` entry resolved through the SAME
|
|
1863
1843
|
// resolveWorkspacePath() site source/outDir just used. The FIRST
|
|
1864
1844
|
// refusal returns unchanged -- the whole request fails, the offending
|
|
1865
1845
|
// entry is never dropped and the remaining entries are never resolved
|
|
1866
|
-
// (no partial-success degradation
|
|
1867
|
-
//
|
|
1868
|
-
//
|
|
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.
|
|
1869
1849
|
const includePaths = [];
|
|
1870
1850
|
for (const entry of request.args.includes ?? []) {
|
|
1871
1851
|
const includeResolved = resolveWorkspacePath(repoRootAbs, entry);
|
|
@@ -1877,23 +1857,22 @@ export async function runHostTool(raw, deps) {
|
|
|
1877
1857
|
acmeLib = findAcmeLib();
|
|
1878
1858
|
}
|
|
1879
1859
|
else if (request.tool === "ghidra.analyze") {
|
|
1880
|
-
//
|
|
1881
|
-
//
|
|
1882
|
-
//
|
|
1883
|
-
//
|
|
1884
|
-
//
|
|
1885
|
-
//
|
|
1886
|
-
//
|
|
1887
|
-
//
|
|
1888
|
-
//
|
|
1889
|
-
// here (A-06).
|
|
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.
|
|
1890
1869
|
const importResolved = resolveWorkspacePath(repoRootAbs, request.args.importPath);
|
|
1891
1870
|
if (!importResolved.ok)
|
|
1892
1871
|
return { ok: false, message: importResolved.message };
|
|
1893
|
-
//
|
|
1894
|
-
//
|
|
1895
|
-
//
|
|
1896
|
-
//
|
|
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.
|
|
1897
1876
|
let preScriptPath;
|
|
1898
1877
|
if (request.args.preScript !== undefined) {
|
|
1899
1878
|
const preScriptResolved = resolveWorkspacePath(repoRootAbs, request.args.preScript);
|
|
@@ -1908,11 +1887,11 @@ export async function runHostTool(raw, deps) {
|
|
|
1908
1887
|
return { ok: false, message: postScriptResolved.message };
|
|
1909
1888
|
postScriptPath = postScriptResolved.path;
|
|
1910
1889
|
}
|
|
1911
|
-
//
|
|
1912
|
-
//
|
|
1913
|
-
//
|
|
1914
|
-
//
|
|
1915
|
-
//
|
|
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.
|
|
1916
1895
|
let scriptPathResolved;
|
|
1917
1896
|
if (request.args.scriptPath !== undefined) {
|
|
1918
1897
|
const scriptPathResult = resolveWorkspacePath(repoRootAbs, request.args.scriptPath);
|
|
@@ -1934,10 +1913,10 @@ export async function runHostTool(raw, deps) {
|
|
|
1934
1913
|
return { ok: false, message: exportPathResult.message };
|
|
1935
1914
|
exportPathResolved = exportPathResult.path;
|
|
1936
1915
|
}
|
|
1937
|
-
//
|
|
1938
|
-
//
|
|
1939
|
-
//
|
|
1940
|
-
//
|
|
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.
|
|
1941
1920
|
let dataRangesPathResolved;
|
|
1942
1921
|
if (request.args.dataRangesPath !== undefined) {
|
|
1943
1922
|
const dataRangesPathResult = resolveWorkspacePath(repoRootAbs, request.args.dataRangesPath);
|
|
@@ -2010,7 +1989,7 @@ export async function runHostTool(raw, deps) {
|
|
|
2010
1989
|
});
|
|
2011
1990
|
}
|
|
2012
1991
|
else if (request.tool === "ghidra.installExtension") {
|
|
2013
|
-
//
|
|
1992
|
+
// `sourceDir`
|
|
2014
1993
|
// resolved through the SAME resolveWorkspacePath() site every other
|
|
2015
1994
|
// tool's path argument uses. The materialisation side effects (create
|
|
2016
1995
|
// the install directory, copy the vendored tree, copy the three stock
|
|
@@ -2022,8 +2001,8 @@ export async function runHostTool(raw, deps) {
|
|
|
2022
2001
|
const sourceDirResolved = resolveWorkspacePath(repoRootAbs, request.args.sourceDir);
|
|
2023
2002
|
if (!sourceDirResolved.ok)
|
|
2024
2003
|
return { ok: false, message: sourceDirResolved.message };
|
|
2025
|
-
//
|
|
2026
|
-
//
|
|
2004
|
+
// `sourceDir` is otherwise accepted as ANY workspace-relative directory
|
|
2005
|
+
// and copied wholesale (via cpSync below) into
|
|
2027
2006
|
// `<GHIDRA_HOME>/Ghidra/Extensions/<moduleName>/` -- a shared, host-wide
|
|
2028
2007
|
// location outside this project's own workspace. Refuse by name unless
|
|
2029
2008
|
// it resolves to exactly this project's own vendored extension tree,
|
|
@@ -2079,16 +2058,15 @@ export async function runHostTool(raw, deps) {
|
|
|
2079
2058
|
built = buildHostToolArgv(request, { sourceDirPath: sourceDirResolved.path, moduleName: request.args.moduleName });
|
|
2080
2059
|
}
|
|
2081
2060
|
else {
|
|
2082
|
-
// request.tool is one of the five c1541.* ids
|
|
2083
|
-
//
|
|
2084
|
-
//
|
|
2085
|
-
//
|
|
2086
|
-
//
|
|
2087
|
-
//
|
|
2088
|
-
//
|
|
2089
|
-
//
|
|
2090
|
-
//
|
|
2091
|
-
// split); petcat.decode has no such field at all.
|
|
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.
|
|
2092
2070
|
const imageResolved = resolveWorkspacePath(repoRootAbs, request.args.image);
|
|
2093
2071
|
if (!imageResolved.ok)
|
|
2094
2072
|
return { ok: false, message: imageResolved.message };
|
|
@@ -2105,13 +2083,14 @@ export async function runHostTool(raw, deps) {
|
|
|
2105
2083
|
built = buildHostToolArgv(request, { imagePath: imageResolved.path, outDirPath }, deps.log);
|
|
2106
2084
|
}
|
|
2107
2085
|
if (!built.ok) {
|
|
2108
|
-
//
|
|
2086
|
+
// buildHostToolArgv()'s own GHIDRA_HOME/launcher/language preflight
|
|
2109
2087
|
// checks can still fail here even though resolveGhidraProject() already
|
|
2110
|
-
// reserved (created) the run directory above -- clean it up,
|
|
2111
|
-
// so a caller who retries the same runId after fixing the
|
|
2112
|
-
// problem (setting GHIDRA_HOME, running
|
|
2113
|
-
// fresh reservation instead of
|
|
2114
|
-
// "refuses to reuse an existing run
|
|
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.
|
|
2115
2094
|
if (ghidraReservedProjectLocation !== undefined) {
|
|
2116
2095
|
try {
|
|
2117
2096
|
rmSync(ghidraReservedProjectLocation, { recursive: true, force: true });
|
|
@@ -2131,8 +2110,8 @@ export async function runHostTool(raw, deps) {
|
|
|
2131
2110
|
// when no candidate matched, which spawnHostTool() treats identically to
|
|
2132
2111
|
// "no override" (inherits the broker's own environment unchanged).
|
|
2133
2112
|
const spawnEnv = acmeLib?.path ? { ...process.env, ACME: acmeLib.path } : undefined;
|
|
2134
|
-
//
|
|
2135
|
-
//
|
|
2113
|
+
// c1541.read's output file is written by the CHILD process itself
|
|
2114
|
+
// (`-read <name> <outputPath>`), never pre-cleared before
|
|
2136
2115
|
// this module's own spawn -- so a colliding slug (two different CBM names
|
|
2137
2116
|
// that agree on their first 32 alphanumeric characters) could leave a
|
|
2138
2117
|
// PRIOR successful read's bytes at `outputPath`, and a later, genuinely
|
|
@@ -2161,24 +2140,23 @@ export async function runHostTool(raw, deps) {
|
|
|
2161
2140
|
deps.log?.(`host_tool tool=${request.tool} exit=timeout elapsed_ms=${elapsedMs} timeout_ms=${timeoutMs} bin=${built.toolPath}`);
|
|
2162
2141
|
return { ok: false, message: `runHostTool: "${request.tool}" timed out after ${timeoutMs}ms and was killed` };
|
|
2163
2142
|
}
|
|
2164
|
-
//
|
|
2165
|
-
//
|
|
2166
|
-
//
|
|
2167
|
-
//
|
|
2168
|
-
//
|
|
2169
|
-
//
|
|
2170
|
-
//
|
|
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).
|
|
2171
2150
|
deps.log?.(`host_tool tool=${request.tool} exit=${spawnResult.exitCode ?? "null"} elapsed_ms=${elapsedMs} timeout_ms=${timeoutMs} bin=${built.toolPath}`);
|
|
2172
|
-
// dxa.disassemble
|
|
2173
|
-
//
|
|
2174
|
-
//
|
|
2175
|
-
//
|
|
2176
|
-
//
|
|
2177
|
-
//
|
|
2178
|
-
//
|
|
2179
|
-
//
|
|
2180
|
-
//
|
|
2181
|
-
// call is added.
|
|
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.
|
|
2182
2160
|
if (TOOLS_WHOSE_OUTPUT_IS_STDOUT.has(request.tool) && built.outputs.length > 0) {
|
|
2183
2161
|
try {
|
|
2184
2162
|
writeFileSync(built.outputs[0], spawnResult.stdout, "utf8");
|
|
@@ -2190,12 +2168,12 @@ export async function runHostTool(raw, deps) {
|
|
|
2190
2168
|
// never-throw discipline.
|
|
2191
2169
|
}
|
|
2192
2170
|
}
|
|
2193
|
-
//
|
|
2194
|
-
//
|
|
2195
|
-
//
|
|
2196
|
-
//
|
|
2197
|
-
//
|
|
2198
|
-
//
|
|
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.
|
|
2199
2177
|
if (request.tool === "ghidra.analyze" && built.outputs.length > 0) {
|
|
2200
2178
|
try {
|
|
2201
2179
|
writeFileSync(built.outputs[0], `${spawnResult.stdout}${spawnResult.stderr}`, "utf8");
|
|
@@ -2213,13 +2191,13 @@ export async function runHostTool(raw, deps) {
|
|
|
2213
2191
|
if (digested)
|
|
2214
2192
|
results.push(digested);
|
|
2215
2193
|
}
|
|
2216
|
-
//
|
|
2217
|
-
//
|
|
2218
|
-
//
|
|
2219
|
-
//
|
|
2220
|
-
//
|
|
2221
|
-
//
|
|
2222
|
-
//
|
|
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.
|
|
2223
2201
|
const classifier = HOST_TOOL_OUTPUT_CLASSIFIERS[request.tool];
|
|
2224
2202
|
if (classifier) {
|
|
2225
2203
|
const verdict = classifier({ stdout: spawnResult.stdout, stderr: spawnResult.stderr, results });
|
|
@@ -2227,13 +2205,13 @@ export async function runHostTool(raw, deps) {
|
|
|
2227
2205
|
return { ok: false, message: `host_tool "${request.tool}" refuses: ${verdict.reason}` };
|
|
2228
2206
|
}
|
|
2229
2207
|
}
|
|
2230
|
-
//
|
|
2231
|
-
//
|
|
2232
|
-
//
|
|
2233
|
-
//
|
|
2234
|
-
//
|
|
2235
|
-
//
|
|
2236
|
-
//
|
|
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.
|
|
2237
2215
|
const petcatVerdict = request.tool === "petcat.decode" ? derivePetcatEntrypoint(spawnResult.stdout) : null;
|
|
2238
2216
|
// acme.build only: ACME's own "for <...> includes..." complaint names no
|
|
2239
2217
|
// directory it tried -- append a note line (in the plain, non-MSVC shape
|
|
@@ -2246,11 +2224,11 @@ export async function runHostTool(raw, deps) {
|
|
|
2246
2224
|
if (acmeLib && /ACME.*environment variable/i.test(stderrText)) {
|
|
2247
2225
|
stderrText += `\nfor <...> includes, set $ACME to the directory holding ${ACME_LIB_MARKER} (looked in: ${acmeLib.tried.join(", ")})`;
|
|
2248
2226
|
}
|
|
2249
|
-
//
|
|
2250
|
-
//
|
|
2251
|
-
//
|
|
2252
|
-
//
|
|
2253
|
-
//
|
|
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).
|
|
2254
2232
|
if (request.tool === "petcat.decode") {
|
|
2255
2233
|
// Non-null by construction: petcatVerdict was computed from THIS SAME
|
|
2256
2234
|
// `request.tool === "petcat.decode"` check above; TypeScript cannot
|
|
@@ -2276,17 +2254,17 @@ export async function runHostTool(raw, deps) {
|
|
|
2276
2254
|
};
|
|
2277
2255
|
}
|
|
2278
2256
|
// ---------------------------------------------------------------------------
|
|
2279
|
-
// oracle.probe / oracle.run
|
|
2280
|
-
//
|
|
2281
|
-
//
|
|
2282
|
-
//
|
|
2283
|
-
//
|
|
2284
|
-
//
|
|
2285
|
-
//
|
|
2286
|
-
//
|
|
2287
|
-
//
|
|
2288
|
-
//
|
|
2289
|
-
//
|
|
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.
|
|
2290
2268
|
// ---------------------------------------------------------------------------
|
|
2291
2269
|
/** Default command name when no host-side configuration is present -- the
|
|
2292
2270
|
* same default packer-finding.mjs's own (removed) DEFAULT_ORACLE_COMMAND
|
|
@@ -2298,20 +2276,19 @@ const DEFAULT_ORACLE_COMMAND = "unp64";
|
|
|
2298
2276
|
* container-side hint naming one of these two variables always describes
|
|
2299
2277
|
* where this host-side resolver actually looked. */
|
|
2300
2278
|
const ORACLE_ENV_VARS = Object.freeze(["UNP64", "UNP64_PATH"]);
|
|
2301
|
-
/**
|
|
2302
|
-
*
|
|
2303
|
-
*
|
|
2304
|
-
*
|
|
2305
|
-
*
|
|
2306
|
-
*
|
|
2307
|
-
*
|
|
2308
|
-
*
|
|
2309
|
-
*
|
|
2310
|
-
*
|
|
2311
|
-
*
|
|
2312
|
-
*
|
|
2313
|
-
*
|
|
2314
|
-
* -- the existing search-path behaviour, unchanged. */
|
|
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. */
|
|
2315
2292
|
function resolveOracleCommand() {
|
|
2316
2293
|
for (const varName of ORACLE_ENV_VARS) {
|
|
2317
2294
|
const raw = process.env[varName];
|
|
@@ -2391,12 +2368,11 @@ async function runOracleRun(args, deps) {
|
|
|
2391
2368
|
if (!existsSync(sourceResolved.path)) {
|
|
2392
2369
|
return { ok: false, tool: "oracle.run", stdout: "", reason: "the input file does not exist" };
|
|
2393
2370
|
}
|
|
2394
|
-
//
|
|
2371
|
+
// The SAME resolver oracle.probe consults -- never a bare
|
|
2395
2372
|
// DEFAULT_ORACLE_COMMAND argument at the spawn site below. Before this
|
|
2396
|
-
//
|
|
2373
|
+
// fix a host-side configured oracle was honoured by the probe and
|
|
2397
2374
|
// silently ignored by the run, so a working probe could be followed by a
|
|
2398
|
-
// failing run; resolving here closes that gap as a real defect fix
|
|
2399
|
-
// merely a mechanical follow-on from Task 1's wire-key removal.
|
|
2375
|
+
// failing run; resolving here closes that gap as a real defect fix.
|
|
2400
2376
|
const resolvedCommand = resolveOracleCommand();
|
|
2401
2377
|
if (!resolvedCommand.ok) {
|
|
2402
2378
|
deps.log?.(`host_tool tool=oracle.run exit=absent_configured_path`);
|
|
@@ -2408,38 +2384,36 @@ async function runOracleRun(args, deps) {
|
|
|
2408
2384
|
// function returns, mirroring packer-finding.mjs's own (removed)
|
|
2409
2385
|
// "removed before this function returns" property (T-19-24).
|
|
2410
2386
|
//
|
|
2411
|
-
// MOVED 2026-09-08
|
|
2412
|
-
//
|
|
2413
|
-
//
|
|
2414
|
-
//
|
|
2415
|
-
//
|
|
2416
|
-
//
|
|
2417
|
-
//
|
|
2418
|
-
//
|
|
2419
|
-
// equal to `join(toolsDir(), "runs", "oracle")`, the same convention
|
|
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
|
|
2420
2395
|
// install-resources.ts's installTargetDir() uses.
|
|
2421
2396
|
//
|
|
2422
|
-
// CORRECTED 2026-09-08
|
|
2423
|
-
// .
|
|
2424
|
-
//
|
|
2425
|
-
//
|
|
2426
|
-
//
|
|
2427
|
-
//
|
|
2428
|
-
//
|
|
2429
|
-
//
|
|
2430
|
-
//
|
|
2431
|
-
//
|
|
2432
|
-
//
|
|
2433
|
-
//
|
|
2434
|
-
//
|
|
2435
|
-
//
|
|
2436
|
-
// such refusal and is handed the direct path unchanged.
|
|
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.
|
|
2437
2411
|
const scratchDir = join(repoRootAbs, ".c64-re-tools", "runs", "oracle", `run-${Date.now()}-${Math.random().toString(36).slice(2)}`);
|
|
2438
2412
|
try {
|
|
2439
|
-
//
|
|
2440
|
-
//
|
|
2441
|
-
//
|
|
2442
|
-
//
|
|
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.
|
|
2443
2417
|
mkdirSync(scratchDir, { recursive: true });
|
|
2444
2418
|
const scratchOut = join(scratchDir, "unpacked.out");
|
|
2445
2419
|
const timeoutMs = hostToolTimeoutMs("oracle.run", deps.timeoutMs);
|
|
@@ -2462,11 +2436,10 @@ async function runOracleRun(args, deps) {
|
|
|
2462
2436
|
return { ok: true, tool: "oracle.run", stdout, reason: null };
|
|
2463
2437
|
}
|
|
2464
2438
|
catch (err) {
|
|
2465
|
-
//
|
|
2466
|
-
//
|
|
2467
|
-
//
|
|
2468
|
-
//
|
|
2469
|
-
// boundary.
|
|
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.
|
|
2470
2443
|
const message = err instanceof Error ? err.message : String(err);
|
|
2471
2444
|
return { ok: false, tool: "oracle.run", stdout: "", reason: `could not create the oracle scratch directory ${scratchDir}: ${message}` };
|
|
2472
2445
|
}
|
|
@@ -2483,11 +2456,10 @@ async function runOracleRun(args, deps) {
|
|
|
2483
2456
|
}
|
|
2484
2457
|
// ---------------------------------------------------------------------------
|
|
2485
2458
|
// CLI entry point (guarded on being the process entry point, the
|
|
2486
|
-
// check-npm-packages.mjs:159 IS_ENTRY_POINT idiom).
|
|
2487
|
-
//
|
|
2488
|
-
//
|
|
2489
|
-
//
|
|
2490
|
-
// on a refusal.
|
|
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.
|
|
2491
2463
|
// ---------------------------------------------------------------------------
|
|
2492
2464
|
function parseCliArgs(argv) {
|
|
2493
2465
|
let repoRoot;
|
|
@@ -2525,29 +2497,29 @@ if (IS_ENTRY_POINT) {
|
|
|
2525
2497
|
catch {
|
|
2526
2498
|
raw = null;
|
|
2527
2499
|
}
|
|
2528
|
-
// TEST-ONLY escape hatch for the
|
|
2529
|
-
// (host-tool.test.ts): every fs call reachable from
|
|
2530
|
-
// real business logic is deliberately guarded
|
|
2531
|
-
//
|
|
2532
|
-
//
|
|
2533
|
-
//
|
|
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
|
|
2534
2506
|
// naturally-reachable trigger to regression-test against. This reads
|
|
2535
2507
|
// the BROKER PROCESS'S OWN environment, never a wire value, mirroring
|
|
2536
2508
|
// `resolveOracleCommand()`'s own "broker env, never wire" convention
|
|
2537
|
-
// above -- a caller can never reach this by shaping `--request`.
|
|
2538
|
-
// in every real invocation; only host-tool.test.ts's own
|
|
2539
|
-
// subprocess ever sets it.
|
|
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.
|
|
2540
2512
|
const runHostToolOrForcedRejectForTest = process.env.HOST_TOOL_TEST_FORCE_CLI_REJECT === "1"
|
|
2541
|
-
? Promise.reject(new Error("HOST_TOOL_TEST_FORCE_CLI_REJECT: simulated runHostTool() rejection for
|
|
2513
|
+
? Promise.reject(new Error("HOST_TOOL_TEST_FORCE_CLI_REJECT: simulated runHostTool() rejection for CLI never-throw regression testing"))
|
|
2542
2514
|
: runHostTool(raw, { repoRoot });
|
|
2543
2515
|
runHostToolOrForcedRejectForTest
|
|
2544
2516
|
.then((response) => {
|
|
2545
2517
|
process.stdout.write(`${JSON.stringify(response)}\n`);
|
|
2546
2518
|
process.exitCode = response.ok ? 0 : 1;
|
|
2547
2519
|
})
|
|
2548
|
-
//
|
|
2549
|
-
//
|
|
2550
|
-
//
|
|
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",
|
|
2551
2523
|
// indistinguishable from a hang. Mirrors host-tool-client.ts's own
|
|
2552
2524
|
// never-reject CLI entry point field-for-field: same envelope shape
|
|
2553
2525
|
// ({ ok: false, message }), same stdout-not-stderr destination, same
|