@henols/vice-mcp 0.1.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,66 @@
1
+ # @henols/vice-mcp
2
+
3
+ A stdio [MCP](https://modelcontextprotocol.io) server that exposes a running
4
+ [VICE](https://vice-emu.sourceforge.io/) Commodore 64 emulator to an MCP client
5
+ (such as Claude Code) for reverse-engineering work. It forwards `vice` tool calls
6
+ to a **host** VICE MCP server, and on first use deploys the host launcher scripts
7
+ it needs into `<project>/tools/`.
8
+
9
+ This package is normally installed for you by the
10
+ [`@henols/c64-re-tools`](https://www.npmjs.com/package/@henols/c64-re-tools)
11
+ installer, which also drops the matching skills into your project. It is published
12
+ separately so it can be launched directly by an MCP client.
13
+
14
+ ## Requirements
15
+
16
+ - **Node.js ≥ 22.18** (or ≥ 23.6). The server ships as TypeScript and runs under
17
+ Node's native type-stripping — no build step, no flags. Older Node needs
18
+ `--experimental-strip-types` and is unsupported.
19
+ - A **host** with VICE (`x64sc`) available, reachable from wherever the MCP client
20
+ runs. The server talks to the host VICE MCP server over HTTP (default
21
+ `http://host.docker.internal:6510/mcp` in a container, `http://127.0.0.1:6510/mcp`
22
+ otherwise); override with `VICE_MCP_URL` / `VICE_MCP_HOST`.
23
+
24
+ ## Use as an MCP server
25
+
26
+ Add it to your MCP client configuration and let the client launch it:
27
+
28
+ ```json
29
+ {
30
+ "mcpServers": {
31
+ "vice": {
32
+ "command": "npx",
33
+ "args": ["-y", "@henols/vice-mcp"],
34
+ "timeout": 150000,
35
+ "env": { "MASTRA_TELEMETRY_DISABLED": "1" }
36
+ }
37
+ }
38
+ }
39
+ ```
40
+
41
+ The bin (`vice-mcp`) speaks the MCP stdio protocol. `initialize` and `tools/list`
42
+ are answered locally (from `tools-manifest.json`); `tools/call` forwards to the host
43
+ VICE MCP server.
44
+
45
+ ## Environment
46
+
47
+ | Variable | Purpose |
48
+ | --- | --- |
49
+ | `VICE_MCP_URL` | Full host MCP endpoint (overrides host/port derivation). |
50
+ | `VICE_MCP_HOST` | Host to reach the VICE MCP server on. |
51
+ | `VICE_SKIP_RESOURCE_INSTALL=1` | Disable deploying host launcher scripts into `<project>/tools/`. |
52
+ | `MASTRA_TELEMETRY_DISABLED=1` | Disable Mastra telemetry. |
53
+
54
+ ## Development
55
+
56
+ ```sh
57
+ npm ci
58
+ npm run typecheck
59
+ npm test
60
+ npm run smoke # boots the server and completes an MCP initialize + tools/list handshake
61
+ npm run build # recompiles the host-bound .mts launchers into resources/
62
+ ```
63
+
64
+ ## License
65
+
66
+ MIT © Henrik Olsson
package/build.ts ADDED
@@ -0,0 +1,268 @@
1
+ // build.ts
2
+ //
3
+ // Compiles the host-bound TypeScript sources (today: vice-broker.mts only --
4
+ // see tsconfig.build.json's `include`, which IS the definition of host-bound)
5
+ // into banner-marked, committed JavaScript under resources/. Run directly as
6
+ // `node build.ts` (native type stripping, no tsc needed to run THIS file --
7
+ // only to run the compiler it shells out to).
8
+ //
9
+ // This file itself must stay inside erasableSyntaxOnly's restrictions (no
10
+ // enum/namespace/constructor parameter properties) so it can run unflagged
11
+ // under bare `node`, exactly like vice-broker.mts.
12
+ import { execFileSync } from "node:child_process";
13
+ import {
14
+ existsSync,
15
+ mkdirSync,
16
+ mkdtempSync,
17
+ readdirSync,
18
+ readFileSync,
19
+ renameSync,
20
+ rmSync,
21
+ writeFileSync,
22
+ statSync,
23
+ } from "node:fs";
24
+ import { dirname, join, resolve as resolvePath } from "node:path";
25
+ import { fileURLToPath } from "node:url";
26
+
27
+ const HERE = dirname(fileURLToPath(import.meta.url));
28
+
29
+ /** Resolves an `outDir` option (or the CLI's `--out-dir` flag) to an absolute
30
+ * path: an absolute input is used as-is, a relative one is joined against
31
+ * this module's own directory. Shared by build() and the CLI success
32
+ * message below so the two never drift apart. */
33
+ function resolveOutDirAbs(outDir: string): string {
34
+ return resolvePath(outDir).startsWith("/") && outDir.startsWith("/") ? outDir : join(HERE, outDir);
35
+ }
36
+
37
+ /** The literal expected emitted relative paths -- today exactly the one
38
+ * broker artifact. This list IS the host-bound artifact set: build() asserts
39
+ * the emitted file set equals this exactly, so an unexpected addition or a
40
+ * silent omission both fail loudly rather than deploying something nobody
41
+ * reviewed. */
42
+ export const HOST_BOUND_ARTIFACTS: string[] = [
43
+ "vice-broker.mjs",
44
+ "container-guard.mjs",
45
+ "broker-state.mjs",
46
+ "broker-launch.mjs",
47
+ "broker-kill.mjs",
48
+ "broker-epoch.mjs",
49
+ "broker-control.mjs",
50
+ ];
51
+
52
+ /** The generated-file banner (01.6-RESEARCH.md §F), a function of the
53
+ * source's relative path. Prepended to every emitted file by build() below --
54
+ * this is the ONLY place that produces this text, so a sync test built
55
+ * against the same build() entry point can never observe a second,
56
+ * independently-drifted banner implementation. */
57
+ export function GENERATED_BANNER(relSourcePath: string): string {
58
+ return (
59
+ "// GENERATED FILE -- DO NOT EDIT.\n" +
60
+ `// Compiled by \`tsc\` from ${relSourcePath}. Edit the TypeScript source and rebuild;\n` +
61
+ "// changes made directly to this file are silently overwritten by the next build, and are never\n" +
62
+ "// deployed to the host on their own -- install-resources.mjs copies THIS file's on-disk contents\n" +
63
+ "// verbatim to tools/, so an edit made only here reaches the host but is lost on the very next\n" +
64
+ "// rebuild.\n"
65
+ );
66
+ }
67
+
68
+ /** Recursive walk of `dir`, returning every `.mjs` file's relative (posix,
69
+ * "/"-joined) path underneath it. Mirrors install-resources.mjs's own walk()
70
+ * shape -- a real directory listing, not a hardcoded list. */
71
+ function emittedMjsFilesUnder(dir: string, base = ""): string[] {
72
+ if (!existsSync(dir)) return [];
73
+ const out: string[] = [];
74
+ for (const dirent of readdirSync(dir, { withFileTypes: true })) {
75
+ const rel = base ? `${base}/${dirent.name}` : dirent.name;
76
+ const abs = join(dir, dirent.name);
77
+ if (dirent.isDirectory()) {
78
+ out.push(...emittedMjsFilesUnder(abs, rel));
79
+ } else if (dirent.isFile() && dirent.name.endsWith(".mjs")) {
80
+ out.push(rel);
81
+ }
82
+ }
83
+ return out.sort();
84
+ }
85
+
86
+ /** Maps an emitted `.mjs` relative path back to the `.mts` source path the
87
+ * banner should name -- the only mapping this build knows (rootDir "." means
88
+ * emitted layout mirrors source layout 1:1, with the .mts->.mjs extension
89
+ * swap tsc performs automatically for module: nodenext). */
90
+ function sourceRelForEmitted(emittedRel: string): string {
91
+ return emittedRel.replace(/\.mjs$/, ".mts");
92
+ }
93
+
94
+ export interface BuildOptions {
95
+ outDir?: string;
96
+ }
97
+
98
+ /**
99
+ * Runs the pinned compiler against tsconfig.build.json into a private,
100
+ * same-filesystem staging directory, asserts the emitted file set is
101
+ * EXACTLY HOST_BOUND_ARTIFACTS (catches both a missing artifact and an
102
+ * unexpected one), prepends the generated-file banner to each artifact
103
+ * WHILE STILL STAGED, then atomically `rename()`s each finished artifact
104
+ * into `outDir` (default "resources", relative to this module's own
105
+ * directory or an absolute path).
106
+ *
107
+ * Nothing lands at a path inside `outDir` until that path's final bytes
108
+ * (compiled output plus banner) already exist complete elsewhere, so a
109
+ * reader of `outDir` -- including a sibling `build()` call's own
110
+ * resources-sync-style comparison, or a process that spawns an artifact
111
+ * straight out of `outDir` -- can never observe a partial or banner-less
112
+ * file. This is per-file atomic replacement, not a lock: no caller in this
113
+ * repo mutates the .mts sources between builds, so every concurrent build
114
+ * emits byte-identical output and there is no "which generation wins"
115
+ * question to answer, only "never expose a half-written file", which
116
+ * `rename()` onto a fully-finished path already guarantees.
117
+ *
118
+ * The staging directory is a SIBLING of `outDir` (never inside it -- a
119
+ * directory walk over `outDir`, such as resources-sync.test.ts's, must
120
+ * never see it) on `outDir`'s own filesystem (never `os.tmpdir()`, which
121
+ * may be a different mount and would make the final rename fail EXDEV). Its
122
+ * name carries exactly one leading dot and no dot in the tail, so it stays
123
+ * invisible to this directory's shallow extension-filtered listing gates
124
+ * (`/\.[cm]?[jt]s$/`). It is removed on every path, success or failure.
125
+ *
126
+ * Takes an --out-dir-shaped option rather than always writing to
127
+ * resources/, so the sync test can build into a scratch directory through
128
+ * this EXACT code path -- the banner must never exist in two
129
+ * implementations.
130
+ */
131
+ /** Where to stage a build before renaming artifacts into `outDirAbs`.
132
+ *
133
+ * Two constraints pull in opposite directions, which is why this is its own
134
+ * function rather than an inline `join`:
135
+ *
136
+ * 1. **Same filesystem as `outDirAbs`.** The atomic-replacement guarantee is a
137
+ * `renameSync()` per artifact, and `rename(2)` fails `EXDEV` across mounts.
138
+ * That is why the original implementation staged at `dirname(outDirAbs)`.
139
+ * 2. **Outside any directory a test walks.** Staging at `dirname(outDirAbs)`
140
+ * put a transient `.build-tmp-*` inside `.claude/mcp/vice/`, and
141
+ * `vice-mcp-selector-docs.test.ts`'s `walkFiles()` recurses through every
142
+ * directory there except `node_modules` — so a concurrent walk descended
143
+ * into the staging dir and died `ENOENT` when the rename removed it. That
144
+ * is a race this very function introduced while fixing a different one
145
+ * (quick-260804-o09), and constraint 1 is why the obvious fix of "just move
146
+ * it somewhere else" is not obvious.
147
+ *
148
+ * `node_modules/.cache/` satisfies both **when the default `outDir` is in
149
+ * play**: it is under `HERE`, so same-filesystem; it is the one directory that
150
+ * walk structurally excludes; and `build()` already requires
151
+ * `node_modules/.bin/tsc` to exist, so it can never be absent when a build can
152
+ * run at all.
153
+ *
154
+ * When a caller passes an `outDir` on a *different* device — `resources-sync`
155
+ * builds into a scratch dir, which may be another mount — the preferred
156
+ * location would make every `renameSync()` throw `EXDEV`, so this falls back
157
+ * to the adjacent sibling. That fallback keeps correctness and gives up only
158
+ * walk-invisibility, which costs nothing there: no test walks a scratch dir's
159
+ * parent. Device identity is compared via `statSync().dev` rather than assumed
160
+ * from the path shape. */
161
+ export function resolveStagingParent(outDirAbs: string): string {
162
+ const adjacent = dirname(outDirAbs);
163
+ const preferred = join(HERE, "node_modules", ".cache");
164
+ try {
165
+ mkdirSync(preferred, { recursive: true });
166
+ if (statSync(preferred).dev === statSync(adjacent).dev) return preferred;
167
+ } catch {
168
+ // node_modules absent or unwritable -- fall through to the adjacent
169
+ // sibling, which is where this always used to stage.
170
+ }
171
+ return adjacent;
172
+ }
173
+
174
+ export function build({ outDir = "resources" }: BuildOptions = {}): void {
175
+ const outDirAbs = resolveOutDirAbs(outDir);
176
+ // Runs first, and MUST: when resolveStagingParent() falls back to the
177
+ // adjacent sibling, recursive mkdirSync of outDirAbs is what guarantees that
178
+ // parent directory exists before mkdtempSync needs it.
179
+ mkdirSync(outDirAbs, { recursive: true });
180
+
181
+ const stagingDir = mkdtempSync(join(resolveStagingParent(outDirAbs), ".build-tmp-" + process.pid + "-"));
182
+ try {
183
+ const tscBin = join(HERE, "node_modules", ".bin", "tsc");
184
+ execFileSync(tscBin, ["-p", join(HERE, "tsconfig.build.json"), "--outDir", stagingDir], {
185
+ cwd: HERE,
186
+ stdio: "inherit",
187
+ });
188
+
189
+ const emitted = emittedMjsFilesUnder(stagingDir);
190
+ const expected = [...HOST_BOUND_ARTIFACTS].sort();
191
+ const missing = expected.filter((f) => !emitted.includes(f));
192
+ const unexpected = emitted.filter((f) => !expected.includes(f));
193
+
194
+ if (missing.length > 0 || unexpected.length > 0) {
195
+ throw new Error(
196
+ "build: emitted file set does not match HOST_BOUND_ARTIFACTS.\n" +
197
+ ` expected: ${JSON.stringify(expected)}\n` +
198
+ ` emitted: ${JSON.stringify(emitted)}\n` +
199
+ ` missing: ${JSON.stringify(missing)}\n` +
200
+ ` unexpected: ${JSON.stringify(unexpected)}`
201
+ );
202
+ }
203
+
204
+ // Banner every staged artifact BEFORE any rename -- a half-bannered
205
+ // file must never become reachable at an `outDir` path.
206
+ for (const rel of HOST_BOUND_ARTIFACTS) {
207
+ const staged = join(stagingDir, rel);
208
+ const banner = GENERATED_BANNER(sourceRelForEmitted(rel));
209
+ const content = readFileSync(staged, "utf8");
210
+ if (!content.startsWith(banner)) {
211
+ writeFileSync(staged, banner + content);
212
+ }
213
+ }
214
+
215
+ // Move each finished artifact into place. Iterates the artifact list,
216
+ // not a walk of stagingDir, so a hand-authored file that also lives
217
+ // under outDir (resources/vice-launcher.sh) is never a rename source or
218
+ // target -- it was never staged and is left untouched.
219
+ for (const rel of HOST_BOUND_ARTIFACTS) {
220
+ const from = join(stagingDir, rel);
221
+ const to = join(outDirAbs, rel);
222
+ try {
223
+ renameSync(from, to);
224
+ } catch (e) {
225
+ const detail = (e as NodeJS.ErrnoException).code === "EXDEV" ? " (EXDEV: staging dir and outDir are on different filesystems -- outDir must be reachable via a same-filesystem sibling)" : "";
226
+ throw new Error(`build: failed to move staged artifact into place: ${from} -> ${to}${detail}`, { cause: e });
227
+ }
228
+ }
229
+
230
+ // tsc emits exactly HOST_BOUND_ARTIFACTS today (verified). A leftover
231
+ // here means the compiler started emitting something this list does not
232
+ // describe -- fail loudly rather than silently drop a file that used to
233
+ // reach outDir.
234
+ const leftovers = readdirSync(stagingDir);
235
+ if (leftovers.length > 0) {
236
+ throw new Error(
237
+ `build: staging directory still holds file(s) after moving every HOST_BOUND_ARTIFACTS entry -- ` +
238
+ `the compiler emitted something not in that list: ${JSON.stringify(leftovers)}`
239
+ );
240
+ }
241
+ } finally {
242
+ rmSync(stagingDir, { recursive: true, force: true });
243
+ }
244
+ }
245
+
246
+ // -------------------------------------------------------------------- CLI
247
+ function parseCliArgs(argv: string[]): BuildOptions {
248
+ let outDir: string | undefined;
249
+ for (let i = 0; i < argv.length; i++) {
250
+ if (argv[i] === "--out-dir") {
251
+ outDir = argv[i + 1];
252
+ i++;
253
+ }
254
+ }
255
+ return outDir ? { outDir } : {};
256
+ }
257
+
258
+ if (process.argv[1] && resolvePath(process.argv[1]) === fileURLToPath(import.meta.url)) {
259
+ const opts = parseCliArgs(process.argv.slice(2));
260
+ try {
261
+ build(opts);
262
+ const outDirAbs = resolveOutDirAbs(opts.outDir ?? "resources");
263
+ process.stderr.write(`build: wrote ${HOST_BOUND_ARTIFACTS.length} artifact(s) to ${outDirAbs}\n`);
264
+ } catch (e) {
265
+ process.stderr.write(`build: FAILED -- ${(e as Error).message}\n`);
266
+ process.exitCode = 1;
267
+ }
268
+ }
@@ -0,0 +1,226 @@
1
+ // container-guard.mts
2
+ //
3
+ // PD-03: TypeScript port of resources/lib/container-guard.sh's five
4
+ // container-detection signals, checked at broker PROCESS STARTUP -- not
5
+ // only at vice-launcher.sh's shell wrapper. This closes the
6
+ // invocation-scoped hole recorded in RE-FINDINGS.md (2026-08-03): running
7
+ // the compiled broker directly (bypassing the launcher) was previously
8
+ // unguarded, since the bash guard only ever ran inside the scripts that
9
+ // sourced it.
10
+ //
11
+ // Every dependency this needs (filesystem existence/reads, the environment,
12
+ // a subprocess runner for systemd-detect-virt) is injected with real
13
+ // defaults, so every signal is exercised in a test without a real
14
+ // /proc/1/cgroup or a real systemd-detect-virt binary on the machine
15
+ // running the test.
16
+ import { existsSync, readFileSync } from "node:fs";
17
+ import { execFileSync } from "node:child_process";
18
+
19
+ export interface ContainerSignal {
20
+ description: string;
21
+ fired: boolean;
22
+ evidence: string;
23
+ }
24
+
25
+ export interface ContainerGuardDeps {
26
+ fileExists: (path: string) => boolean;
27
+ readFile: (path: string) => string;
28
+ env: NodeJS.ProcessEnv;
29
+ runSystemdDetectVirt: () => string | null; // null = binary not present or errored
30
+ }
31
+
32
+ const defaultDeps: ContainerGuardDeps = {
33
+ fileExists: (path) => existsSync(path),
34
+ readFile: (path) => readFileSync(path, "utf8"),
35
+ env: process.env,
36
+ runSystemdDetectVirt: () => {
37
+ try {
38
+ return execFileSync("systemd-detect-virt", ["--container"], { encoding: "utf8" }).trim();
39
+ } catch {
40
+ return null;
41
+ }
42
+ },
43
+ };
44
+
45
+ /** Matches container-guard.sh's own awk cgroup matcher: the field after the
46
+ * LAST colon on any /proc/1/cgroup line, tested against a container-naming
47
+ * path component. Deliberately does NOT match a systemd host's
48
+ * `0::/init.scope`, a bare root cgroup, or the Docker daemon's own
49
+ * `/system.slice/docker.service` cgroup -- only `/docker/<id>`,
50
+ * `/system.slice/docker-<id>.scope`, `/kubepods/...`, `/libpod-...` and
51
+ * `/lxc/...` match. */
52
+ function cgroupNamesContainer(cgroupText: string): string | null {
53
+ const CONTAINER_PATH = /(^|\/)(docker|lxc|kubepods|libpod)(\/|-|$)/;
54
+ for (const line of cgroupText.split("\n")) {
55
+ const idx = line.lastIndexOf(":");
56
+ if (idx === -1) continue;
57
+ const path = line.slice(idx + 1);
58
+ if (CONTAINER_PATH.test(path)) return line;
59
+ }
60
+ return null;
61
+ }
62
+
63
+ /** Evaluates all five signals and returns one ContainerSignal per signal
64
+ * (fired or not, with its evidence) -- the same two-array shape
65
+ * container_guard_evaluate() builds (CONTAINER_SIGNALS/CONTAINER_REPORT),
66
+ * as one typed return value.
67
+ *
68
+ * REMOVED, DO NOT RE-ADD: a `grep docker /proc/self/mountinfo` signal used
69
+ * to exist here (ported from the bash guard's own header). It answers "is
70
+ * Docker installed on this machine", not "is THIS process inside a
71
+ * container" -- it fires on the real host (which runs the devcontainer
72
+ * daemon) and refuses to launch on exactly the machine this guard exists to
73
+ * allow. Not fixable by tightening the pattern; the signal itself is
74
+ * invalid. It is gone in the bash version and must not come back here
75
+ * either. */
76
+ export function evaluateContainerSignals(deps: ContainerGuardDeps = defaultDeps): ContainerSignal[] {
77
+ const signals: ContainerSignal[] = [];
78
+
79
+ signals.push({
80
+ description: "/.dockerenv exists",
81
+ fired: deps.fileExists("/.dockerenv"),
82
+ evidence: "",
83
+ });
84
+
85
+ signals.push({
86
+ description: "/run/.containerenv exists (podman)",
87
+ fired: deps.fileExists("/run/.containerenv"),
88
+ evidence: "",
89
+ });
90
+
91
+ const workspacePath = deps.env.CONTAINER_WORKSPACE_PATH;
92
+ signals.push({
93
+ description: "CONTAINER_WORKSPACE_PATH is set (this devcontainer sets it)",
94
+ fired: Boolean(workspacePath),
95
+ evidence: workspacePath ?? "",
96
+ });
97
+
98
+ const detectedVirt = deps.runSystemdDetectVirt();
99
+ const virtFired = detectedVirt !== null && detectedVirt !== "" && detectedVirt !== "none";
100
+ signals.push({
101
+ description: "systemd-detect-virt --container",
102
+ fired: virtFired,
103
+ evidence: detectedVirt === null ? "binary not present, signal skipped" : `reports: ${detectedVirt || "none"}`,
104
+ });
105
+
106
+ let cgroupMatch: string | null = null;
107
+ if (deps.fileExists("/proc/1/cgroup")) {
108
+ try {
109
+ cgroupMatch = cgroupNamesContainer(deps.readFile("/proc/1/cgroup"));
110
+ } catch {
111
+ cgroupMatch = null;
112
+ }
113
+ }
114
+ signals.push({
115
+ description: "/proc/1/cgroup path names a container",
116
+ fired: cgroupMatch !== null,
117
+ evidence: cgroupMatch ?? "no container path component in PID 1's cgroup",
118
+ });
119
+
120
+ return signals;
121
+ }
122
+
123
+ /** Prints one report line per signal to stderr and returns 3 in a container
124
+ * (>=1 signal fired), 0 on a host (none fired) -- mirrors
125
+ * container_guard_report()'s exit-code contract exactly. Never calls
126
+ * process.exit() itself (D-4 discipline this module tree observes
127
+ * throughout): the caller (vice-broker.mts's CLI wrapper) turns the
128
+ * returned code into process.exitCode. */
129
+ export function containerGuardReport(deps: ContainerGuardDeps = defaultDeps): number {
130
+ const signals = evaluateContainerSignals(deps);
131
+ process.stderr.write("vice-broker: container guard evaluation\n");
132
+ for (const s of signals) {
133
+ if (s.fired) {
134
+ process.stderr.write(` [FIRED] ${s.description}${s.evidence ? ` -- evidence: ${s.evidence}` : ""}\n`);
135
+ } else {
136
+ process.stderr.write(` [clear] ${s.description}${s.evidence ? ` (${s.evidence})` : ""}\n`);
137
+ }
138
+ }
139
+ const fired = signals.filter((s) => s.fired);
140
+ if (fired.length > 0) {
141
+ process.stderr.write(`verdict: CONTAINER (${fired.length} signal(s) fired) -- the guard would refuse here.\n`);
142
+ return 3;
143
+ }
144
+ process.stderr.write("verdict: HOST (no signals fired) -- the guard would allow x64sc to launch here.\n");
145
+ return 0;
146
+ }
147
+
148
+ /** Evaluates the guard and, if any signal fired, writes a FATAL block naming
149
+ * every fired signal and returns 2 -- UNLESS VICE_SUPERVISOR_ALLOW_CONTAINER
150
+ * is EXACTLY "1" (testing only; never set it to actually run VICE). On a
151
+ * clear host verdict, returns 0 and writes nothing. Mirrors
152
+ * container_guard_enforce()'s escape hatch, wording and exit-code contract
153
+ * verbatim -- never calls process.exit() itself, matching
154
+ * containerGuardReport()'s own posture above. */
155
+ export function containerGuardEnforce(deps: ContainerGuardDeps = defaultDeps): number {
156
+ const signals = evaluateContainerSignals(deps);
157
+ const fired = signals.filter((s) => s.fired);
158
+ if (fired.length > 0 && deps.env.VICE_SUPERVISOR_ALLOW_CONTAINER !== "1") {
159
+ process.stderr.write("FATAL: vice-broker refuses to run inside a container.\n");
160
+ process.stderr.write("This process is HOST-ONLY. Signals that fired:\n");
161
+ for (const s of fired) {
162
+ process.stderr.write(` - ${s.description}${s.evidence ? ` -- ${s.evidence}` : ""}\n`);
163
+ }
164
+ process.stderr.write("\n");
165
+ process.stderr.write("If you believe this IS the host, run --check-container for the full\n");
166
+ process.stderr.write("per-signal breakdown and report which signal is wrong.\n");
167
+ process.stderr.write("\n");
168
+ process.stderr.write("This cannot work in here: there is no x64sc binary, no display, and\n");
169
+ process.stderr.write("the entire point of this process is to launch or supervise a process\n");
170
+ process.stderr.write("the container has no access to in the first place.\n");
171
+ process.stderr.write("\n");
172
+ process.stderr.write("Escape hatch (TESTING ONLY -- never to actually run VICE):\n");
173
+ process.stderr.write(" VICE_SUPERVISOR_ALLOW_CONTAINER=1\n");
174
+ process.stderr.write("\n");
175
+ process.stderr.write("Run this broker on the HOST instead, from the host workspace.\n");
176
+ return 2;
177
+ }
178
+ return 0;
179
+ }
180
+
181
+ // -------------------------------------------------- environment predicate
182
+ //
183
+ // containerGuardReport()/containerGuardEnforce() above answer "should this
184
+ // process REFUSE to run here". This answers the different question "which
185
+ // environment am I in", for callers that must CHOOSE behaviour rather than
186
+ // refuse -- specifically vice.ts's mcpHost(), which has to return the
187
+ // container-visible bridge alias inside a container and a loopback address
188
+ // on a host, because `host.docker.internal` is a Docker-provided alias that
189
+ // does not resolve on the host at all.
190
+ //
191
+ // It shares this module's detection deliberately rather than growing a
192
+ // second, weaker copy. That is the exact mistake this file's own header
193
+ // records for the REMOVED /proc/self/mountinfo signal: an independent
194
+ // "looks dockery" heuristic fired on the real host -- the machine running
195
+ // the devcontainer daemon -- and so answered the wrong question. Any new
196
+ // detector would risk re-earning that bug; this one is already calibrated
197
+ // against precisely the host-versus-container distinction being asked here.
198
+ //
199
+ // The verdict rule is not invented here: it is the one containerGuardReport()
200
+ // and containerGuardEnforce() both state -- >=1 signal fired means CONTAINER,
201
+ // none fired means HOST.
202
+
203
+ /** Memoised verdict for the default-deps path only. */
204
+ let cachedDefaultVerdict: boolean | null = null;
205
+
206
+ /** True inside a container, false on a host.
207
+ *
208
+ * MEMOISED on the default-deps path, deliberately: one of the five signals
209
+ * shells out to `systemd-detect-virt`, and mcpHost() is read fresh on EVERY
210
+ * forwarded tool call -- spawning a subprocess per call would be a real cost
211
+ * for an answer that cannot change. Container membership is fixed for a
212
+ * process lifetime, so caching the verdict is safe. Note what is NOT cached:
213
+ * the caller's own env-var read (`VICE_MCP_HOST`) stays fresh, preserving the
214
+ * override-sensitivity mcpHost()'s comment says a module-level constant would
215
+ * have silently destroyed.
216
+ *
217
+ * Passing explicit deps ALWAYS re-evaluates and never touches the cache, so
218
+ * tests can drive both branches in-process, in any order, without one test's
219
+ * verdict leaking into another's. */
220
+ export function isInsideContainer(deps?: ContainerGuardDeps): boolean {
221
+ if (deps) return evaluateContainerSignals(deps).some((s) => s.fired);
222
+ if (cachedDefaultVerdict === null) {
223
+ cachedDefaultVerdict = evaluateContainerSignals(defaultDeps).some((s) => s.fired);
224
+ }
225
+ return cachedDefaultVerdict;
226
+ }