@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 +66 -0
- package/build.ts +268 -0
- package/container-guard.mts +226 -0
- package/containerpath.ts +296 -0
- package/hostpath.ts +318 -0
- package/incident-record.ts +442 -0
- package/install-resources.ts +532 -0
- package/package.json +71 -0
- package/refresh-manifest.ts +103 -0
- package/repo-root.ts +198 -0
- package/resources/broker-control.mjs +343 -0
- package/resources/broker-epoch.mjs +126 -0
- package/resources/broker-kill.mjs +491 -0
- package/resources/broker-launch.mjs +659 -0
- package/resources/broker-state.mjs +173 -0
- package/resources/container-guard.mjs +211 -0
- package/resources/vice-broker.mjs +855 -0
- package/resources/vice-launcher.sh +169 -0
- package/tools-manifest.json +1231 -0
- package/vice-broker-client.ts +899 -0
- package/vice-probe.ts +278 -0
- package/vice-proxy.ts +3093 -0
- package/vice-sync.ts +336 -0
- package/vice.ts +772 -0
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
|
+
}
|