@henols/vice-mcp 0.2.2 → 0.2.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -2
- package/THIRD-PARTY-NOTICES.md +422 -1
- package/anno-bank.ts +171 -0
- package/anno-cli.ts +1674 -99
- package/anno-enum-gen.ts +416 -30
- package/anno-export-asm.ts +1175 -89
- package/anno-graphics.ts +338 -0
- package/anno-hazard-report.ts +1367 -0
- package/anno-import.ts +495 -0
- package/anno-join.ts +480 -0
- package/anno-provenance-ledger.ts +472 -0
- package/anno-register.ts +159 -0
- package/anno-store-export.ts +661 -0
- package/anno-store.ts +518 -2
- package/anno-tools.ts +1169 -16
- package/anno-types.ts +275 -2
- package/backend-detect.mts +124 -312
- package/build.ts +3 -1
- package/capture-predicate.ts +597 -0
- package/channel-lock.ts +349 -0
- package/evid-ingest.ts +217 -0
- package/evid-reconcile.ts +316 -0
- package/host-tool-client.ts +430 -0
- package/incident-record.ts +23 -12
- package/install-resources.ts +29 -13
- package/memmap-lookup.ts +285 -0
- package/package.json +27 -8
- package/prg-image.ts +1 -2
- package/repo-root.ts +87 -3
- package/resources/backend-detect.mjs +98 -236
- package/resources/broker-control.mjs +189 -16
- package/resources/broker-epoch.mjs +1 -1
- package/resources/broker-kill.mjs +8 -2
- package/resources/broker-launch.mjs +365 -210
- package/resources/broker-state.mjs +64 -18
- package/resources/container-guard.mjs +1 -1
- package/resources/ghidra-project.mjs +790 -0
- package/resources/host-tool.mjs +2561 -0
- package/resources/vice-broker.mjs +330 -184
- package/resources/vice-launcher.sh +127 -9
- package/stock-address.ts +1 -1
- package/stock-condition.ts +1 -1
- package/stock-connect.ts +9 -5
- package/stock-derived.ts +29 -37
- package/stock-diagnose.ts +200 -36
- package/stock-dispatch.ts +179 -77
- package/stock-handler.ts +1 -1
- package/stock-paths.ts +18 -14
- package/stock-petscii.ts +1 -1
- package/stock-protocol.ts +1 -1
- package/stock-recycle.ts +83 -2
- package/stock-reproducible-run.ts +811 -0
- package/stock-run-until.ts +100 -1
- package/stock-symbols.ts +4 -4
- package/stock-timing.ts +1 -1
- package/stop-oracle.ts +167 -0
- package/text-capability-probe.ts +660 -0
- package/text-connect.ts +157 -0
- package/text-protocol.ts +810 -0
- package/text-tools.ts +778 -0
- package/textmon-backtrace.ts +385 -0
- package/textmon-cpuhistory.ts +335 -0
- package/textmon-memmap.ts +494 -0
- package/textmon-profile.ts +458 -0
- package/textmon-registers.ts +748 -0
- package/tools-manifest.stock.json +864 -3
- package/vice-broker-client.ts +189 -42
- package/vice-errors.ts +268 -0
- package/vice-proxy.ts +339 -2144
- package/vsf-slice.ts +640 -0
- package/anno-d64.ts +0 -310
- package/capability-registry.ts +0 -390
- package/refresh-manifest.ts +0 -124
- package/tools-manifest.json +0 -1223
- package/vice-probe.ts +0 -278
- package/vice-sync.ts +0 -336
- package/vice.ts +0 -772
|
@@ -0,0 +1,430 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// host-tool-client.ts
|
|
3
|
+
//
|
|
4
|
+
// Phase 34, plan 34-01 (SEAM-01..SEAM-03, tracer): the CONTAINER-side half of
|
|
5
|
+
// the host-tool execution seam. Mirrors vice-broker-client.ts's
|
|
6
|
+
// acquireOverControlPlane() shape exactly: read broker.json ONCE, resolve the
|
|
7
|
+
// dial target, open ONE connection, write ONE JSON line, await ONE response
|
|
8
|
+
// line, then close -- session-less and short-lived, no lease held. That is
|
|
9
|
+
// SEAM-01's "consumes no emulator lease" satisfied by construction: this
|
|
10
|
+
// module never writes an `acquire` line, never holds a socket open past one
|
|
11
|
+
// request/response pair, and the broker's own host_tool dispatch branch
|
|
12
|
+
// (broker-control.mts) never calls any of the seven VICE callbacks for it.
|
|
13
|
+
//
|
|
14
|
+
// TWO ROUTES, chosen by isInsideContainer() -- the project's ONE container
|
|
15
|
+
// detector -- and NEVER by whether a broker happens to be reachable:
|
|
16
|
+
// - inside a container, the ONLY route is the control op
|
|
17
|
+
// (hostToolOverControlPlane(), below), dialled at the broker's control
|
|
18
|
+
// port exactly like acquireOverControlPlane() already is;
|
|
19
|
+
// - on the host, the ONLY route is a direct `spawn` of
|
|
20
|
+
// `resources/host-tool.mjs` under `process.execPath`, carrying the same
|
|
21
|
+
// typed request on argv.
|
|
22
|
+
// WHY THE HOST ROUTE EXISTS, AND WHY IT IS NOT A SECOND EXECUTOR:
|
|
23
|
+
// `.github/workflows/ci.yml` invokes `acme.mjs build` on a flat GitHub
|
|
24
|
+
// Actions runner with no container and no broker -- removing the host route
|
|
25
|
+
// would DELETE a working route rather than migrate it. Both routes reach the
|
|
26
|
+
// SAME executor module (host-tool.mts, compiled to resources/host-tool.mjs),
|
|
27
|
+
// the same allowlist, the same argv construction and the same digest -- there
|
|
28
|
+
// is exactly one place a binary is ever spawned; this file only ever decides
|
|
29
|
+
// HOW to reach it.
|
|
30
|
+
//
|
|
31
|
+
// Every response `path` is translated through containerPath()
|
|
32
|
+
// (containerpath.ts) before it is handed back to a caller -- never through
|
|
33
|
+
// hostpath.ts, which this file must NEVER import (A-03): a `host_tool`
|
|
34
|
+
// request never carries a host-absolute path in the first place (every path
|
|
35
|
+
// argument is workspace-relative, resolved server-side by
|
|
36
|
+
// host-tool.mts's resolveWorkspacePath()), so only the RESULT direction
|
|
37
|
+
// (host -> container) ever needs translation here, and containerpath.ts is
|
|
38
|
+
// already a declared consumer of hostpath.ts's own closed set
|
|
39
|
+
// (hostpath-consumers.test.ts) -- reaching it through containerpath.ts keeps
|
|
40
|
+
// this new family off that five-member list entirely, by construction.
|
|
41
|
+
//
|
|
42
|
+
// Do not write the tokens `binPath`, `viceBin`, `VICE_BIN` or `x64sc`
|
|
43
|
+
// anywhere in this file -- it ships (package.json `files[]`) and is scanned
|
|
44
|
+
// by spawn-seam.test.ts, which would misclassify a bare identifier match as
|
|
45
|
+
// an emulator spawn site.
|
|
46
|
+
import { readFileSync } from "node:fs";
|
|
47
|
+
import { spawn } from "node:child_process";
|
|
48
|
+
import { connect } from "node:net";
|
|
49
|
+
import { dirname, join, resolve as resolvePath } from "node:path";
|
|
50
|
+
import { fileURLToPath } from "node:url";
|
|
51
|
+
|
|
52
|
+
import { brokerRootDir, brokerJsonPath, newRequestId, resolveControlTarget, CONTROL_CONNECT_TIMEOUT_MS } from "./vice-broker-client.ts";
|
|
53
|
+
import { containerPath } from "./containerpath.ts";
|
|
54
|
+
import { isInsideContainer } from "./container-guard.mts";
|
|
55
|
+
import { repoRoot } from "./repo-root.ts";
|
|
56
|
+
|
|
57
|
+
const HERE = dirname(fileURLToPath(import.meta.url));
|
|
58
|
+
|
|
59
|
+
/** True iff `value` is a well-formed, generic JSON object -- not null, not an
|
|
60
|
+
* array. A local copy of vice-broker-client.ts's own isPlainObject(), which
|
|
61
|
+
* is not exported -- this file's own untrusted-input parsing needs the exact
|
|
62
|
+
* same shape check, not a re-export of an internal helper. */
|
|
63
|
+
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
|
64
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Reads and parses `path` as JSON, returning `null` (never throwing) on any
|
|
68
|
+
* failure -- absent file, unreadable, malformed JSON, or a non-object
|
|
69
|
+
* result. Mirrors vice-broker-client.ts's own never-throw posture toward
|
|
70
|
+
* broker.json. */
|
|
71
|
+
function readJsonMaybe(path: string): Record<string, unknown> | null {
|
|
72
|
+
try {
|
|
73
|
+
const parsed = JSON.parse(readFileSync(path, "utf8"));
|
|
74
|
+
return isPlainObject(parsed) ? parsed : null;
|
|
75
|
+
} catch {
|
|
76
|
+
return null;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export interface HostToolFileResult {
|
|
81
|
+
path: string;
|
|
82
|
+
sha256: string;
|
|
83
|
+
byteLength: number;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** 34-09 (CR-04): the per-tool CLIENT-side request-deadline table, declared
|
|
87
|
+
* in THIS file (never in vice-broker-client.ts, whose export list is pinned
|
|
88
|
+
* by exact set equality -- vice-broker-client.test.ts:1208 -- this file has
|
|
89
|
+
* no such census). Bounds the REQUEST phase only, the phase AFTER the
|
|
90
|
+
* connect timer below has already been cleared -- mirroring
|
|
91
|
+
* openBrokerControl()'s own connect-then-request split
|
|
92
|
+
* (vice-broker-client.ts). Every entry here MUST be strictly greater than
|
|
93
|
+
* host-tool.mts's own HOST_TOOL_TIMEOUT_MS entry for the SAME tool id: the
|
|
94
|
+
* side that owns the budget (the host-bound executor) must be the side that
|
|
95
|
+
* reports the verdict, or a caller sees an opaque transport timeout instead
|
|
96
|
+
* of the host's own diagnosable refusal. This ordering is asserted by
|
|
97
|
+
* host-tool.test.ts's own cross-seam ordering case, which imports BOTH
|
|
98
|
+
* sides and iterates every tool id -- the anti-drift mechanism for two
|
|
99
|
+
* numbers that deliberately live in two files (two processes, one
|
|
100
|
+
* container-side and one host-bound). `ghidra.analyze`'s entry (660_000ms)
|
|
101
|
+
* exceeds the server-side Ghidra budget (600_000ms, host-tool.mts) by 60
|
|
102
|
+
* seconds -- comfortably larger without being needlessly slack. */
|
|
103
|
+
export const HOST_TOOL_REQUEST_TIMEOUT_MS: Readonly<Record<string, number>> = Object.freeze({
|
|
104
|
+
"ghidra.analyze": 660_000,
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
/** Fallback request-deadline for a tool id absent from the table above --
|
|
108
|
+
* strictly greater than host-tool.mts's own DEFAULT_HOST_TOOL_TIMEOUT_MS
|
|
109
|
+
* (20_000ms), the server-side fallback for the same tools. */
|
|
110
|
+
export const DEFAULT_HOST_TOOL_REQUEST_TIMEOUT_MS = 30_000;
|
|
111
|
+
|
|
112
|
+
/** The resolver: an exact table entry wins, else the default above. Mirrors
|
|
113
|
+
* host-tool.mts's own hostToolTimeoutMs() shape on the OTHER side of the
|
|
114
|
+
* seam -- deliberately duplicated, never imported: the two sides run in
|
|
115
|
+
* different processes (this file is container-side, host-tool.mts is
|
|
116
|
+
* host-bound), so there is nothing to import across that boundary. The
|
|
117
|
+
* cross-seam ordering test is what keeps the two numbers from drifting
|
|
118
|
+
* apart, not a shared value. */
|
|
119
|
+
export function hostToolRequestTimeoutMs(tool: string): number {
|
|
120
|
+
return HOST_TOOL_REQUEST_TIMEOUT_MS[tool] ?? DEFAULT_HOST_TOOL_REQUEST_TIMEOUT_MS;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** The wire shape host-tool.mts's runHostTool() produces, mirrored here
|
|
124
|
+
* rather than imported as a value -- this file only ever receives this shape
|
|
125
|
+
* as untrusted JSON off a socket or a child process's stdout, never as a
|
|
126
|
+
* same-process function call. */
|
|
127
|
+
export type HostToolClientResult =
|
|
128
|
+
| { ok: true; tool: string; exitStatus: number | null; results: HostToolFileResult[]; stderrTail: string }
|
|
129
|
+
| { ok: false; message: string };
|
|
130
|
+
|
|
131
|
+
/** Dials the broker's control plane, sends ONE `host_tool` request line, and
|
|
132
|
+
* resolves with the raw (untranslated) response -- never rejects on an
|
|
133
|
+
* application-level refusal (`{ ok: false, message }` from host-tool.mts's
|
|
134
|
+
* own allowlist), only on a transport/protocol failure: unreadable
|
|
135
|
+
* broker.json, a connection error, a malformed response line, a
|
|
136
|
+
* control-plane-level `error` response (unauthorized/bad_request/internal --
|
|
137
|
+
* distinct from the tool's OWN refusal shape), or a timeout. `dir` defaults
|
|
138
|
+
* to brokerRootDir(), exactly as acquireOverControlPlane() does, so a test
|
|
139
|
+
* can point it at a scratch directory. */
|
|
140
|
+
export function hostToolOverControlPlane(
|
|
141
|
+
dir: string = brokerRootDir(),
|
|
142
|
+
tool: string,
|
|
143
|
+
args: Record<string, unknown>,
|
|
144
|
+
): Promise<HostToolClientResult> {
|
|
145
|
+
return new Promise((resolvePromise, reject) => {
|
|
146
|
+
const broker = readJsonMaybe(brokerJsonPath(dir));
|
|
147
|
+
if (broker === null) {
|
|
148
|
+
reject(new Error("hostToolOverControlPlane: broker.json not present or unreadable"));
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
const port = typeof broker.control_port === "number" ? broker.control_port : null;
|
|
152
|
+
const token = typeof broker.control_token === "string" ? broker.control_token : null;
|
|
153
|
+
if (port === null || token === null) {
|
|
154
|
+
reject(new Error("hostToolOverControlPlane: broker.json missing control_port/control_token"));
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const targetResult = resolveControlTarget(broker, port);
|
|
159
|
+
if (!targetResult.ok) {
|
|
160
|
+
reject(new Error(targetResult.message));
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
const { host } = targetResult.target;
|
|
164
|
+
|
|
165
|
+
const socket = connect({ host, port });
|
|
166
|
+
let buffer = "";
|
|
167
|
+
let settled = false;
|
|
168
|
+
|
|
169
|
+
// 34-09 (CR-04): TWO timers, mirroring openBrokerControl()'s own
|
|
170
|
+
// connect-then-request split (vice-broker-client.ts). Before this plan a
|
|
171
|
+
// SINGLE timer bounded the CONNECT phase AND the tool's entire
|
|
172
|
+
// execution -- a ghidra.analyze run that legitimately outlives the
|
|
173
|
+
// TCP-connect budget could never complete over this route at all. The
|
|
174
|
+
// connect timer bounds ONLY the TCP handshake and is cleared the moment
|
|
175
|
+
// `connect` fires; the request-deadline timer then bounds the actual
|
|
176
|
+
// tool execution, sized per tool by hostToolRequestTimeoutMs() above.
|
|
177
|
+
let connectTimer: ReturnType<typeof setTimeout> | null = setTimeout(() => {
|
|
178
|
+
if (settled) return;
|
|
179
|
+
settled = true;
|
|
180
|
+
socket.destroy();
|
|
181
|
+
reject(new Error(`hostToolOverControlPlane: no connection within ${CONTROL_CONNECT_TIMEOUT_MS}ms (connect phase)`));
|
|
182
|
+
}, CONTROL_CONNECT_TIMEOUT_MS);
|
|
183
|
+
if (typeof connectTimer.unref === "function") connectTimer.unref();
|
|
184
|
+
|
|
185
|
+
let requestTimer: ReturnType<typeof setTimeout> | null = null;
|
|
186
|
+
|
|
187
|
+
socket.on("connect", () => {
|
|
188
|
+
if (settled) return;
|
|
189
|
+
// Clear the connect timer THE MOMENT the request line is written --
|
|
190
|
+
// exactly where openBrokerControl()'s own onConnect() clears its
|
|
191
|
+
// connect timer before sendAndAwaitLine()'s separate per-request
|
|
192
|
+
// deadline takes over.
|
|
193
|
+
if (connectTimer !== null) {
|
|
194
|
+
clearTimeout(connectTimer);
|
|
195
|
+
connectTimer = null;
|
|
196
|
+
}
|
|
197
|
+
const requestId = newRequestId();
|
|
198
|
+
socket.write(`${JSON.stringify({ op: "host_tool", id: requestId, token, tool, args })}\n`);
|
|
199
|
+
|
|
200
|
+
const requestTimeoutMs = hostToolRequestTimeoutMs(tool);
|
|
201
|
+
requestTimer = setTimeout(() => {
|
|
202
|
+
if (settled) return;
|
|
203
|
+
settled = true;
|
|
204
|
+
socket.destroy();
|
|
205
|
+
reject(new Error(`hostToolOverControlPlane: no response within ${requestTimeoutMs}ms (request deadline)`));
|
|
206
|
+
}, requestTimeoutMs);
|
|
207
|
+
if (typeof requestTimer.unref === "function") requestTimer.unref();
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
socket.on("data", (chunk: Buffer) => {
|
|
211
|
+
if (settled) return;
|
|
212
|
+
buffer += chunk.toString("utf8");
|
|
213
|
+
const newlineIdx = buffer.indexOf("\n");
|
|
214
|
+
if (newlineIdx === -1) return;
|
|
215
|
+
const line = buffer.slice(0, newlineIdx);
|
|
216
|
+
|
|
217
|
+
let parsed: unknown;
|
|
218
|
+
try {
|
|
219
|
+
parsed = JSON.parse(line);
|
|
220
|
+
} catch {
|
|
221
|
+
settled = true;
|
|
222
|
+
if (requestTimer !== null) clearTimeout(requestTimer);
|
|
223
|
+
socket.destroy();
|
|
224
|
+
reject(new Error("hostToolOverControlPlane: malformed response line"));
|
|
225
|
+
return;
|
|
226
|
+
}
|
|
227
|
+
if (!isPlainObject(parsed)) {
|
|
228
|
+
settled = true;
|
|
229
|
+
if (requestTimer !== null) clearTimeout(requestTimer);
|
|
230
|
+
socket.destroy();
|
|
231
|
+
reject(new Error("hostToolOverControlPlane: response line is not a JSON object"));
|
|
232
|
+
return;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
settled = true;
|
|
236
|
+
if (requestTimer !== null) clearTimeout(requestTimer);
|
|
237
|
+
socket.destroy();
|
|
238
|
+
|
|
239
|
+
// A control-plane-level error (unauthorized/bad_request/internal) is a
|
|
240
|
+
// DIFFERENT failure from the tool's own `{ ok: false, message }`
|
|
241
|
+
// refusal -- only THIS shape rejects; the tool's own refusal resolves
|
|
242
|
+
// normally so a caller can inspect `message` without a try/catch.
|
|
243
|
+
if (parsed.kind === "error") {
|
|
244
|
+
reject(new Error(`hostToolOverControlPlane: ${String(parsed.code)}: ${String(parsed.message)}`));
|
|
245
|
+
return;
|
|
246
|
+
}
|
|
247
|
+
resolvePromise(parsed as unknown as HostToolClientResult);
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
socket.on("error", (err) => {
|
|
251
|
+
if (settled) return;
|
|
252
|
+
settled = true;
|
|
253
|
+
if (connectTimer !== null) clearTimeout(connectTimer);
|
|
254
|
+
if (requestTimer !== null) clearTimeout(requestTimer);
|
|
255
|
+
reject(err);
|
|
256
|
+
});
|
|
257
|
+
});
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/** The host-local route: spawns resources/host-tool.mjs directly, under
|
|
261
|
+
* `process.execPath`, with the same typed request passed on argv -- the same
|
|
262
|
+
* executor, the same allowlist, the same argv construction, the same digest.
|
|
263
|
+
* Never rejects on the tool's OWN refusal (a non-zero exit still prints one
|
|
264
|
+
* JSON response line, which this function parses and resolves); rejects only
|
|
265
|
+
* on a transport failure -- the child could not be spawned, or its stdout
|
|
266
|
+
* did not end in a parseable JSON line. */
|
|
267
|
+
function hostToolOverHostRoute(repoRootPath: string, tool: string, args: Record<string, unknown>): Promise<HostToolClientResult> {
|
|
268
|
+
return new Promise((resolvePromise, reject) => {
|
|
269
|
+
const scriptPath = join(HERE, "resources", "host-tool.mjs");
|
|
270
|
+
const requestJson = JSON.stringify({ tool, args });
|
|
271
|
+
let child;
|
|
272
|
+
try {
|
|
273
|
+
child = spawn(process.execPath, [scriptPath, "run", "--repo-root", repoRootPath, "--request", requestJson], {
|
|
274
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
275
|
+
});
|
|
276
|
+
} catch (e) {
|
|
277
|
+
reject(e instanceof Error ? e : new Error(String(e)));
|
|
278
|
+
return;
|
|
279
|
+
}
|
|
280
|
+
let stdout = "";
|
|
281
|
+
let stderr = "";
|
|
282
|
+
child.stdout?.on("data", (chunk: Buffer) => {
|
|
283
|
+
stdout += chunk.toString("utf8");
|
|
284
|
+
});
|
|
285
|
+
child.stderr?.on("data", (chunk: Buffer) => {
|
|
286
|
+
stderr += chunk.toString("utf8");
|
|
287
|
+
});
|
|
288
|
+
child.on("error", (err) => reject(err));
|
|
289
|
+
child.on("close", () => {
|
|
290
|
+
const lines = stdout.split("\n").filter((line) => line.trim() !== "");
|
|
291
|
+
const lastLine = lines[lines.length - 1];
|
|
292
|
+
if (lastLine === undefined) {
|
|
293
|
+
reject(new Error(`hostToolOverHostRoute: host-tool.mjs produced no output on stdout${stderr ? ` (stderr: ${stderr})` : ""}`));
|
|
294
|
+
return;
|
|
295
|
+
}
|
|
296
|
+
let parsed: unknown;
|
|
297
|
+
try {
|
|
298
|
+
parsed = JSON.parse(lastLine);
|
|
299
|
+
} catch {
|
|
300
|
+
reject(new Error(`hostToolOverHostRoute: host-tool.mjs's stdout is not a parseable JSON line: ${lastLine}`));
|
|
301
|
+
return;
|
|
302
|
+
}
|
|
303
|
+
if (!isPlainObject(parsed)) {
|
|
304
|
+
reject(new Error("hostToolOverHostRoute: host-tool.mjs's response line is not a JSON object"));
|
|
305
|
+
return;
|
|
306
|
+
}
|
|
307
|
+
resolvePromise(parsed as unknown as HostToolClientResult);
|
|
308
|
+
});
|
|
309
|
+
});
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
// Translates every response `path` through containerPath() -- never the
|
|
313
|
+
// sibling host-to-container translation module this file must never import
|
|
314
|
+
// (A-03, see this file's own header). A refusal (`{ ok: false }`) carries no
|
|
315
|
+
// `path` field at all, so it passes through unchanged.
|
|
316
|
+
function translateHostToolResponse(response: HostToolClientResult): HostToolClientResult {
|
|
317
|
+
if (!response.ok) return response;
|
|
318
|
+
return {
|
|
319
|
+
...response,
|
|
320
|
+
results: response.results.map((result) => ({ ...result, path: containerPath(result.path) })),
|
|
321
|
+
};
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
export interface RunHostToolFromContainerOptions {
|
|
325
|
+
/** Control-plane state directory override (test seam) -- only consulted on
|
|
326
|
+
* the container route; ignored on the host route. */
|
|
327
|
+
dir?: string;
|
|
328
|
+
/** Repo root override for the host route (test seam); defaults to
|
|
329
|
+
* repoRoot()'s own ladder. Ignored on the container route -- the broker's
|
|
330
|
+
* own `--repo-root` is authoritative there. */
|
|
331
|
+
repoRoot?: string;
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
/** The single route-selecting entry point. Transport is chosen by
|
|
335
|
+
* isInsideContainer() -- the project's ONE container detector -- and NOT by
|
|
336
|
+
* whether a broker happens to be reachable. */
|
|
337
|
+
export async function runHostToolFromContainer(
|
|
338
|
+
tool: string,
|
|
339
|
+
args: Record<string, unknown>,
|
|
340
|
+
opts: RunHostToolFromContainerOptions = {},
|
|
341
|
+
): Promise<HostToolClientResult> {
|
|
342
|
+
if (isInsideContainer()) {
|
|
343
|
+
const raw = await hostToolOverControlPlane(opts.dir ?? brokerRootDir(), tool, args);
|
|
344
|
+
return translateHostToolResponse(raw);
|
|
345
|
+
}
|
|
346
|
+
// Host route (34-04, SEAM-05): host and container coordinates are the
|
|
347
|
+
// SAME filesystem here -- there is no container to translate across --
|
|
348
|
+
// so the raw host-absolute paths in the response are already correct for
|
|
349
|
+
// the calling process. Skip containerPath() entirely rather than call it
|
|
350
|
+
// unconditionally: containerPath() only resolves a path that falls under
|
|
351
|
+
// THIS project's own workspace root (hostRootCandidates()), which a
|
|
352
|
+
// legitimate host-route invocation need not be under at all -- e.g. a
|
|
353
|
+
// build rooted at a scratch directory outside the repo (this project's own
|
|
354
|
+
// skill-acme-build-cli.test.ts, and CI's RUNNER_TEMP-rooted scaffold
|
|
355
|
+
// check, both build entirely outside the repo tree).
|
|
356
|
+
return hostToolOverHostRoute(opts.repoRoot ?? repoRoot({ from: HERE }), tool, args);
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
// ---------------------------------------------------------------------------
|
|
360
|
+
// CLI entry point (Phase 34, plan 34-04, SEAM-05).
|
|
361
|
+
//
|
|
362
|
+
// The migrated skill scripts (acme.mjs, packer-finding.mjs) live in a
|
|
363
|
+
// DIFFERENT npm package than this file (@henols/c64-re-tools vs.
|
|
364
|
+
// @henols/vice-mcp), so a static `import` of runHostToolFromContainer()
|
|
365
|
+
// resolves on neither npm-installer distribution route -- exactly the
|
|
366
|
+
// cross-package constraint vsf-slice.mjs's own header already records for a
|
|
367
|
+
// different module. Those scripts instead LOCATE this file on disk (via
|
|
368
|
+
// mcp-module.mjs's resolveMcpModule() ladder) and invoke it as a subprocess:
|
|
369
|
+
// `process.execPath <resolved-path> run --tool <id> --args <json> [--repo-root <path>]`.
|
|
370
|
+
// This is the interpreter already running the calling script, spawned on an
|
|
371
|
+
// in-tree module -- not an external host binary, and not a second executor:
|
|
372
|
+
// it is the SAME route selection (isInsideContainer()) and the SAME
|
|
373
|
+
// runHostToolFromContainer() this file already exposes as a value import for
|
|
374
|
+
// same-package (src/mcp/vice/**) callers.
|
|
375
|
+
//
|
|
376
|
+
// Prints exactly ONE line of JSON on stdout (the translated
|
|
377
|
+
// HostToolClientResult) and exits 0 when it carries `ok: true`, 1 otherwise
|
|
378
|
+
// -- including a transport-level failure (rejected promise), which is
|
|
379
|
+
// reported in the SAME `{ ok: false, message }` shape a tool's own refusal
|
|
380
|
+
// uses, so a caller never needs to distinguish "the seam refused" from "the
|
|
381
|
+
// seam was unreachable" by inspecting anything but `ok`/`message`.
|
|
382
|
+
function parseRunCliArgs(argv: string[]): { tool?: string; args?: string; repoRoot?: string } {
|
|
383
|
+
let tool: string | undefined;
|
|
384
|
+
let args: string | undefined;
|
|
385
|
+
let repoRoot: string | undefined;
|
|
386
|
+
for (let i = 0; i < argv.length; i++) {
|
|
387
|
+
if (argv[i] === "--tool") {
|
|
388
|
+
tool = argv[++i];
|
|
389
|
+
} else if (argv[i] === "--args") {
|
|
390
|
+
args = argv[++i];
|
|
391
|
+
} else if (argv[i] === "--repo-root") {
|
|
392
|
+
repoRoot = argv[++i];
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
return { tool, args, repoRoot };
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
const IS_ENTRY_POINT = process.argv[1] !== undefined && resolvePath(process.argv[1]) === fileURLToPath(import.meta.url);
|
|
399
|
+
|
|
400
|
+
if (IS_ENTRY_POINT) {
|
|
401
|
+
const USAGE = "usage: host-tool-client.ts run --tool <tool> --args <json> [--repo-root <path>]\n";
|
|
402
|
+
const [, , cliCommand, ...cliRest] = process.argv;
|
|
403
|
+
if (cliCommand !== "run") {
|
|
404
|
+
process.stderr.write(USAGE);
|
|
405
|
+
process.exitCode = 1;
|
|
406
|
+
} else {
|
|
407
|
+
const { tool, args, repoRoot: repoRootArg } = parseRunCliArgs(cliRest);
|
|
408
|
+
if (!tool || args === undefined) {
|
|
409
|
+
process.stderr.write(USAGE);
|
|
410
|
+
process.exitCode = 1;
|
|
411
|
+
} else {
|
|
412
|
+
let parsedArgs: unknown;
|
|
413
|
+
try {
|
|
414
|
+
parsedArgs = JSON.parse(args);
|
|
415
|
+
} catch {
|
|
416
|
+
parsedArgs = null;
|
|
417
|
+
}
|
|
418
|
+
const argsObj = isPlainObject(parsedArgs) ? parsedArgs : {};
|
|
419
|
+
runHostToolFromContainer(tool, argsObj, repoRootArg ? { repoRoot: repoRootArg } : {})
|
|
420
|
+
.then((response) => {
|
|
421
|
+
process.stdout.write(`${JSON.stringify(response)}\n`);
|
|
422
|
+
process.exitCode = response.ok ? 0 : 1;
|
|
423
|
+
})
|
|
424
|
+
.catch((err: unknown) => {
|
|
425
|
+
process.stdout.write(`${JSON.stringify({ ok: false, message: err instanceof Error ? err.message : String(err) })}\n`);
|
|
426
|
+
process.exitCode = 1;
|
|
427
|
+
});
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
}
|
package/incident-record.ts
CHANGED
|
@@ -1,9 +1,19 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
// Records a vice_recycle incident to disk BEFORE anything is killed (D-17,
|
|
3
|
-
// plan 01.3-01).
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
3
|
+
// plan 01.3-01).
|
|
4
|
+
//
|
|
5
|
+
// MOVED 2026-09-08 (D-33, clean break): this directory used to live at
|
|
6
|
+
// `<repoRoot>/.planning/incidents`, repo-tracked and never gitignored, with
|
|
7
|
+
// its own README.md inside the GSD planning tree explaining why. That was
|
|
8
|
+
// the wrong place for product output to land -- writing incident records
|
|
9
|
+
// (which can carry screenshots and snapshot metadata) into a consumer's GSD
|
|
10
|
+
// planning tree was the named reason for the move. It now lives under the
|
|
11
|
+
// single tool-written root `repo-root.ts`'s `toolsDir()` owns, alongside the
|
|
12
|
+
// other five writers that root consolidates, and is gitignored along with
|
|
13
|
+
// the rest of that root. There is no fallback to the old location and no
|
|
14
|
+
// migration shim -- a pre-existing `.planning/incidents/` tree is left on
|
|
15
|
+
// disk, unread, for the user to delete by hand. Never write incident records
|
|
16
|
+
// back into the consumer's planning tree.
|
|
7
17
|
//
|
|
8
18
|
// This module makes NO network call of any kind, and never will -- the
|
|
9
19
|
// file-writing remit this phase adds expands, the transport remit does not
|
|
@@ -21,19 +31,20 @@ import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, writeFileSy
|
|
|
21
31
|
import { randomUUID } from "node:crypto";
|
|
22
32
|
import { join, resolve } from "node:path";
|
|
23
33
|
|
|
24
|
-
import {
|
|
34
|
+
import { toolsDir } from "./repo-root.ts";
|
|
25
35
|
|
|
26
36
|
export const INCIDENT_RECORD_VERSION = 1;
|
|
27
37
|
|
|
28
|
-
/** `<
|
|
29
|
-
* `
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
*
|
|
38
|
+
/** `<toolsDir>/incidents` -- gitignored, a subdirectory of the single
|
|
39
|
+
* tool-written root `repo-root.ts`'s `toolsDir()` owns (D-33, moved
|
|
40
|
+
* 2026-09-08). `VICE_INCIDENTS_DIR` overrides the resolved location when
|
|
41
|
+
* set, mirroring vice-broker-client.mjs's own `VICE_POOL_DIR` override --
|
|
42
|
+
* the seam this module's own test suite uses to write against a disposable
|
|
43
|
+
* temp directory instead of the real, permanent `<toolsDir>/incidents/`
|
|
44
|
+
* every production caller resolves to. */
|
|
34
45
|
export function incidentsDir(): string {
|
|
35
46
|
if (process.env.VICE_INCIDENTS_DIR) return resolve(process.env.VICE_INCIDENTS_DIR);
|
|
36
|
-
return join(
|
|
47
|
+
return join(toolsDir(), "incidents");
|
|
37
48
|
}
|
|
38
49
|
|
|
39
50
|
function sanitiseUtcTimestamp(at: Date | string | number): string {
|
package/install-resources.ts
CHANGED
|
@@ -1,8 +1,11 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
// Deploys this skill's resources/ (the host-side shell launchers) into
|
|
3
|
-
// <repo
|
|
4
|
-
// this skill directory alone is sufficient -- nobody has to
|
|
5
|
-
// copy three shell scripts from somewhere else (D-1,
|
|
3
|
+
// <repo>/.c64-re-tools/bin/ the first time any skill .mjs entry point runs,
|
|
4
|
+
// so a copy of this skill directory alone is sufficient -- nobody has to
|
|
5
|
+
// remember to also copy three shell scripts from somewhere else (D-1,
|
|
6
|
+
// quick-260730-q4b). Deploy target moved from `<repo>/tools/` to
|
|
7
|
+
// `<repo>/.c64-re-tools/bin/` on 2026-09-08 (D-33) -- see installTargetDir()
|
|
8
|
+
// below.
|
|
6
9
|
//
|
|
7
10
|
// HOSTING CHOICE (D-3): this check lives in a DEDICATED module, triggered
|
|
8
11
|
// from repo-root.mjs, for two reasons. First, repo-root.mjs is a pure path
|
|
@@ -87,9 +90,15 @@ export type ResourceStatus = "missing" | "present" | "diverged";
|
|
|
87
90
|
export const RESOURCES_DIR = join(HERE, "resources");
|
|
88
91
|
|
|
89
92
|
/** Where resources/ gets deployed to, for a given repo root. Always
|
|
90
|
-
* `<root
|
|
93
|
+
* `<root>/.c64-re-tools/bin` -- moved 2026-09-08 (D-33) under the single
|
|
94
|
+
* tool-written root every other writer in this codebase now resolves
|
|
95
|
+
* through `repo-root.ts`'s `toolsDir()`. This function cannot import
|
|
96
|
+
* `toolsDir()` itself (see this file's header: importing repo-root.ts here
|
|
97
|
+
* would be a module cycle), so it joins the two segments directly --
|
|
98
|
+
* `".c64-re-tools"` and `"bin"` must stay equal to `join(toolsDir(root),
|
|
99
|
+
* "bin")` by convention, not by shared code. */
|
|
91
100
|
export function installTargetDir(root: string): string {
|
|
92
|
-
return join(root, "tools");
|
|
101
|
+
return join(root, ".c64-re-tools", "bin");
|
|
93
102
|
}
|
|
94
103
|
|
|
95
104
|
/** Recursive walk of RESOURCES_DIR, returning the relative path (posix-style,
|
|
@@ -173,7 +182,9 @@ export function hostLaunchInstructions(root: string): string {
|
|
|
173
182
|
`vice-mcp-selector: deployed host launcher scripts to ${installTargetDir(root)}`,
|
|
174
183
|
"vice-mcp-selector: for MCP-mediated access (mcp__vice__* tools), start the on-demand broker from the HOST workspace, e.g.:",
|
|
175
184
|
` ${displayPath}`,
|
|
176
|
-
|
|
185
|
+
// Plan 41-05 (folded todo): the warm floor is retired -- the broker
|
|
186
|
+
// launches strictly on demand now, with no speculative pre-warming.
|
|
187
|
+
"vice-mcp-selector: the broker launches a boot-fresh instance strictly on demand, on the first request, supervises it, and respawns a crashed one with backoff.",
|
|
177
188
|
"vice-mcp-selector: it cannot run inside the container -- the container guard refuses with exit 2.",
|
|
178
189
|
"vice-mcp-selector: if it refuses when it should not, run it with --check-container for the full per-signal diagnostic.",
|
|
179
190
|
"vice-mcp-selector: press Ctrl-C to stop it -- SIGINT/SIGTERM are handled and it shuts down cleanly.",
|
|
@@ -263,13 +274,18 @@ function isSafeManifestCandidate(entry: unknown, targetDir: string): entry is st
|
|
|
263
274
|
* step"; a retired executable would otherwise linger on the host forever).
|
|
264
275
|
*
|
|
265
276
|
* The candidate set is EXACTLY `readDeployManifest(root)` minus the current
|
|
266
|
-
* `resourceEntries()` -- never a directory walk of `installTargetDir(root)
|
|
267
|
-
*
|
|
268
|
-
*
|
|
269
|
-
*
|
|
270
|
-
*
|
|
271
|
-
*
|
|
272
|
-
*
|
|
277
|
+
* `resourceEntries()` -- never a directory walk of `installTargetDir(root)`.
|
|
278
|
+
* Before the 2026-09-08 `.c64-re-tools/` consolidation (D-33),
|
|
279
|
+
* `installTargetDir(root)` was `<root>/tools`, a MIXED directory also
|
|
280
|
+
* holding tracked reverse-engineering tooling (diff-images.mjs,
|
|
281
|
+
* watch-loads.mjs, recovery-schema.mjs, releases.mjs and
|
|
282
|
+
* their tests) -- this manifest-only candidate set is what kept the prune
|
|
283
|
+
* from ever reaching those tracked files. The deploy target has since moved
|
|
284
|
+
* to `<root>/.c64-re-tools/bin`, no longer shared with that tracked tooling,
|
|
285
|
+
* but the same manifest-only discipline is kept unconditionally: a file
|
|
286
|
+
* present in the target but ABSENT from the manifest is left untouched no
|
|
287
|
+
* matter what it is, so the prune can only ever reach a path it recorded
|
|
288
|
+
* having placed there itself (T-01.6-11).
|
|
273
289
|
*
|
|
274
290
|
* Every candidate is validated by isSafeManifestCandidate() BEFORE any
|
|
275
291
|
* unlink is attempted; a rejected candidate is pushed to `skipped` (nothing
|