@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.
@@ -0,0 +1,173 @@
1
+ // GENERATED FILE -- DO NOT EDIT.
2
+ // Compiled by `tsc` from broker-state.mts. Edit the TypeScript source and rebuild;
3
+ // changes made directly to this file are silently overwritten by the next build, and are never
4
+ // deployed to the host on their own -- install-resources.mjs copies THIS file's on-disk contents
5
+ // verbatim to tools/, so an edit made only here reaches the host but is lost on the very next
6
+ // rebuild.
7
+ import { createServer } from "node:net";
8
+ export function createBrokerState() {
9
+ return { instances: new Map(), grants: new Map(), blockedPorts: new Set() };
10
+ }
11
+ /** Deep, plain-object copy of `state` for tests -- a real, typed, named
12
+ * export imported directly by test files, modelled on build.ts's own
13
+ * exported build(). Never a global, never a subprocess-and-inspect round
14
+ * trip. */
15
+ export function _snapshotState(state) {
16
+ return {
17
+ instances: Array.from(state.instances.values()).map((r) => ({ ...r, viceArgs: [...r.viceArgs] })),
18
+ grants: Array.from(state.grants.values()).map((g) => ({ ...g })),
19
+ blockedPorts: Array.from(state.blockedPorts).sort((a, b) => a - b),
20
+ };
21
+ }
22
+ /** VICE_BROKER_BASE_PORT's default (D-18): the broker's port band moves
23
+ * from 6510 to 6600 in this phase -- 6510-6599 stays reserved by convention
24
+ * for an x64sc a human launches for their own work. */
25
+ export const DEFAULT_BASE_PORT = 6600;
26
+ /** Scan ceiling matching vice-broker.sh's own next_free_port(): exactly one
27
+ * hundred candidates starting at (and including) the base port. Bounded so
28
+ * an exhausted host produces one explicit `no_free_port` result rather than
29
+ * an unbounded scan. */
30
+ const PORT_SCAN_CEILING = 100;
31
+ /** Exported (plan 05): vice-broker.mts's host_state control-plane response
32
+ * and broker.json's own `base_port` field both need the SAME resolved base
33
+ * port this allocator itself uses -- reading it here rather than
34
+ * re-duplicating the env-var lookup a third time keeps the two values
35
+ * structurally unable to disagree. */
36
+ export function resolveBasePort() {
37
+ const raw = process.env.VICE_BROKER_BASE_PORT;
38
+ if (raw === undefined || raw === "")
39
+ return DEFAULT_BASE_PORT;
40
+ const n = Number(raw);
41
+ return Number.isFinite(n) ? n : DEFAULT_BASE_PORT;
42
+ }
43
+ /** Real default: attempts to bind the candidate port on 127.0.0.1 and
44
+ * immediately releases it. Answers ONLY "is a TCP listener already bound
45
+ * here" -- the exact question vice-broker.sh's own /dev/tcp-based
46
+ * port_in_use() asked, and deliberately never reused as a readiness check
47
+ * (see broker-launch.mts's probeReady() header comment for why those two
48
+ * questions are never conflated: a C64 can accept a connection before it
49
+ * has finished booting). EADDRINUSE means genuinely in use; any other
50
+ * listen error is treated as "not in use" -- cheaper and clearer than
51
+ * letting a launch fail later for an unrelated reason. */
52
+ export function defaultPortInUse(port) {
53
+ return new Promise((resolve) => {
54
+ const server = createServer();
55
+ server.once("error", (err) => {
56
+ resolve(err.code === "EADDRINUSE");
57
+ });
58
+ server.once("listening", () => {
59
+ server.close(() => resolve(false));
60
+ });
61
+ server.listen(port, "127.0.0.1");
62
+ });
63
+ }
64
+ export function isPortBlocked(state, port) {
65
+ return state.blockedPorts.has(port);
66
+ }
67
+ /** Remembers a refused port for the lifetime of THIS broker process only --
68
+ * never persisted. A port refused now may be free after the next reboot;
69
+ * persisting the refusal would silently shrink the allocation band
70
+ * forever. Idempotent: blocking an already-blocked port is a no-op. */
71
+ export function blockPort(state, port) {
72
+ state.blockedPorts.add(port);
73
+ }
74
+ /** Allocates the lowest free port at or above the base port (default 6600
75
+ * per D-18, overridable via VICE_BROKER_BASE_PORT -- the same env var name
76
+ * the bash daemon used), scanning up to PORT_SCAN_CEILING candidates.
77
+ * "Free" means: not already recorded in the instance map (granted,
78
+ * launching or ready all occupy their port), not already in the
79
+ * process-scoped blocked set, and not reported in use by the injectable
80
+ * port-in-use probe (defaulting to defaultPortInUse's real bind-and-release
81
+ * check). A candidate the probe reports as in use is added to the blocked
82
+ * set before scanning continues, so it is never re-offered or re-probed by
83
+ * this process again. Never throws -- returns a typed failure naming
84
+ * exhaustion when every candidate in the window is taken. */
85
+ // Gap closure (plan 14, discovered live during Task 2's own end-to-end
86
+ // proof -- see RE-FINDINGS.md's dated entry for the full account):
87
+ // EADDRINUSE is delivered to defaultPortInUse()'s `error` listener without
88
+ // ever yielding to libuv's poll phase, so a scan running against MANY
89
+ // already-bound candidates in a row does not merely take longer -- for its
90
+ // ENTIRE duration, the control listener cannot accept a new connection or
91
+ // read data already sitting on an existing one (verified live: a second,
92
+ // already-established connection's own request was not read by this
93
+ // process until the ENTIRE scan, spawn and record sequence had already
94
+ // resolved, confirmed with the real production functions in isolation
95
+ // before this fix). That is a real liveness gap independent of this
96
+ // plan's own test -- a release, a recycle or a status request over an
97
+ // UNRELATED connection would be held up for as long as a contended scan
98
+ // takes, not merely a competing acquire. Yielding via setImmediate every
99
+ // few candidates restores that liveness at negligible cost (the scan
100
+ // itself already costs one real bind-and-release round trip per
101
+ // candidate; this adds one cheap timer-phase turn every YIELD_EVERY of
102
+ // them) without changing what this function returns for any input.
103
+ const YIELD_EVERY_N_CANDIDATES = 5;
104
+ export async function nextFreePort(state, opts = {}) {
105
+ const basePort = opts.basePort ?? resolveBasePort();
106
+ const portInUse = opts.portInUse ?? defaultPortInUse;
107
+ const limit = basePort + PORT_SCAN_CEILING;
108
+ let checked = 0;
109
+ for (let port = basePort; port < limit; port++) {
110
+ if (state.instances.has(port))
111
+ continue;
112
+ if (isPortBlocked(state, port))
113
+ continue;
114
+ if (await portInUse(port)) {
115
+ blockPort(state, port);
116
+ checked++;
117
+ if (checked % YIELD_EVERY_N_CANDIDATES === 0) {
118
+ await new Promise((resolvePromise) => setImmediate(resolvePromise));
119
+ }
120
+ continue;
121
+ }
122
+ return { ok: true, port };
123
+ }
124
+ return { ok: false, reason: "no_free_port" };
125
+ }
126
+ /** Counts ready, unclaimed instances -- filters the SAME in-memory map every
127
+ * other count reads, no filesystem access anywhere in this expression. */
128
+ export function countReady(state) {
129
+ let n = 0;
130
+ for (const record of state.instances.values()) {
131
+ if (record.state === "ready")
132
+ n++;
133
+ }
134
+ return n;
135
+ }
136
+ /** Counts every launched instance regardless of state (launching, ready or
137
+ * granted) -- the denominator of the total <= VICE_BROKER_MAX ceiling. */
138
+ export function countTotal(state) {
139
+ return state.instances.size;
140
+ }
141
+ /** Counts instances currently "launching" -- THE single counter both launch
142
+ * paths (a cold acquire, via handleAcquire in vice-broker.mts, and warm
143
+ * floor maintenance, via maintainWarmFloor in broker-launch.mts) consult
144
+ * before starting a new launch. Two counters that could ever disagree about
145
+ * whether a boot is already under way is exactly how the two bash launch
146
+ * paths raced each other into the 2026-08-01 outage (three simultaneous
147
+ * x64sc launches: one SEGV, one exit 1, one exit 0 at the identical spawn
148
+ * second) -- there is now exactly one, read here and nowhere else. */
149
+ export function countLaunching(state) {
150
+ let n = 0;
151
+ for (const record of state.instances.values()) {
152
+ if (record.state === "launching")
153
+ n++;
154
+ }
155
+ return n;
156
+ }
157
+ function resolveCeiling(override) {
158
+ if (typeof override === "number")
159
+ return override;
160
+ const raw = process.env.VICE_BROKER_MAX;
161
+ if (raw === undefined || raw === "")
162
+ return 16;
163
+ const n = Number(raw);
164
+ return Number.isFinite(n) ? n : 16;
165
+ }
166
+ /** True once countTotal() has reached the configured instance ceiling
167
+ * (VICE_BROKER_MAX, default 16, untouched by this phase). A cold acquire
168
+ * consults this BEFORE attempting to allocate a port or spawn (plan 05's
169
+ * control-plane `at_capacity` error code), so an at-capacity host answers
170
+ * without ever touching the port allocator. */
171
+ export function atCapacity(state, ceiling) {
172
+ return countTotal(state) >= resolveCeiling(ceiling);
173
+ }
@@ -0,0 +1,211 @@
1
+ // GENERATED FILE -- DO NOT EDIT.
2
+ // Compiled by `tsc` from container-guard.mts. Edit the TypeScript source and rebuild;
3
+ // changes made directly to this file are silently overwritten by the next build, and are never
4
+ // deployed to the host on their own -- install-resources.mjs copies THIS file's on-disk contents
5
+ // verbatim to tools/, so an edit made only here reaches the host but is lost on the very next
6
+ // rebuild.
7
+ // container-guard.mts
8
+ //
9
+ // PD-03: TypeScript port of resources/lib/container-guard.sh's five
10
+ // container-detection signals, checked at broker PROCESS STARTUP -- not
11
+ // only at vice-launcher.sh's shell wrapper. This closes the
12
+ // invocation-scoped hole recorded in RE-FINDINGS.md (2026-08-03): running
13
+ // the compiled broker directly (bypassing the launcher) was previously
14
+ // unguarded, since the bash guard only ever ran inside the scripts that
15
+ // sourced it.
16
+ //
17
+ // Every dependency this needs (filesystem existence/reads, the environment,
18
+ // a subprocess runner for systemd-detect-virt) is injected with real
19
+ // defaults, so every signal is exercised in a test without a real
20
+ // /proc/1/cgroup or a real systemd-detect-virt binary on the machine
21
+ // running the test.
22
+ import { existsSync, readFileSync } from "node:fs";
23
+ import { execFileSync } from "node:child_process";
24
+ const defaultDeps = {
25
+ fileExists: (path) => existsSync(path),
26
+ readFile: (path) => readFileSync(path, "utf8"),
27
+ env: process.env,
28
+ runSystemdDetectVirt: () => {
29
+ try {
30
+ return execFileSync("systemd-detect-virt", ["--container"], { encoding: "utf8" }).trim();
31
+ }
32
+ catch {
33
+ return null;
34
+ }
35
+ },
36
+ };
37
+ /** Matches container-guard.sh's own awk cgroup matcher: the field after the
38
+ * LAST colon on any /proc/1/cgroup line, tested against a container-naming
39
+ * path component. Deliberately does NOT match a systemd host's
40
+ * `0::/init.scope`, a bare root cgroup, or the Docker daemon's own
41
+ * `/system.slice/docker.service` cgroup -- only `/docker/<id>`,
42
+ * `/system.slice/docker-<id>.scope`, `/kubepods/...`, `/libpod-...` and
43
+ * `/lxc/...` match. */
44
+ function cgroupNamesContainer(cgroupText) {
45
+ const CONTAINER_PATH = /(^|\/)(docker|lxc|kubepods|libpod)(\/|-|$)/;
46
+ for (const line of cgroupText.split("\n")) {
47
+ const idx = line.lastIndexOf(":");
48
+ if (idx === -1)
49
+ continue;
50
+ const path = line.slice(idx + 1);
51
+ if (CONTAINER_PATH.test(path))
52
+ return line;
53
+ }
54
+ return null;
55
+ }
56
+ /** Evaluates all five signals and returns one ContainerSignal per signal
57
+ * (fired or not, with its evidence) -- the same two-array shape
58
+ * container_guard_evaluate() builds (CONTAINER_SIGNALS/CONTAINER_REPORT),
59
+ * as one typed return value.
60
+ *
61
+ * REMOVED, DO NOT RE-ADD: a `grep docker /proc/self/mountinfo` signal used
62
+ * to exist here (ported from the bash guard's own header). It answers "is
63
+ * Docker installed on this machine", not "is THIS process inside a
64
+ * container" -- it fires on the real host (which runs the devcontainer
65
+ * daemon) and refuses to launch on exactly the machine this guard exists to
66
+ * allow. Not fixable by tightening the pattern; the signal itself is
67
+ * invalid. It is gone in the bash version and must not come back here
68
+ * either. */
69
+ export function evaluateContainerSignals(deps = defaultDeps) {
70
+ const signals = [];
71
+ signals.push({
72
+ description: "/.dockerenv exists",
73
+ fired: deps.fileExists("/.dockerenv"),
74
+ evidence: "",
75
+ });
76
+ signals.push({
77
+ description: "/run/.containerenv exists (podman)",
78
+ fired: deps.fileExists("/run/.containerenv"),
79
+ evidence: "",
80
+ });
81
+ const workspacePath = deps.env.CONTAINER_WORKSPACE_PATH;
82
+ signals.push({
83
+ description: "CONTAINER_WORKSPACE_PATH is set (this devcontainer sets it)",
84
+ fired: Boolean(workspacePath),
85
+ evidence: workspacePath ?? "",
86
+ });
87
+ const detectedVirt = deps.runSystemdDetectVirt();
88
+ const virtFired = detectedVirt !== null && detectedVirt !== "" && detectedVirt !== "none";
89
+ signals.push({
90
+ description: "systemd-detect-virt --container",
91
+ fired: virtFired,
92
+ evidence: detectedVirt === null ? "binary not present, signal skipped" : `reports: ${detectedVirt || "none"}`,
93
+ });
94
+ let cgroupMatch = null;
95
+ if (deps.fileExists("/proc/1/cgroup")) {
96
+ try {
97
+ cgroupMatch = cgroupNamesContainer(deps.readFile("/proc/1/cgroup"));
98
+ }
99
+ catch {
100
+ cgroupMatch = null;
101
+ }
102
+ }
103
+ signals.push({
104
+ description: "/proc/1/cgroup path names a container",
105
+ fired: cgroupMatch !== null,
106
+ evidence: cgroupMatch ?? "no container path component in PID 1's cgroup",
107
+ });
108
+ return signals;
109
+ }
110
+ /** Prints one report line per signal to stderr and returns 3 in a container
111
+ * (>=1 signal fired), 0 on a host (none fired) -- mirrors
112
+ * container_guard_report()'s exit-code contract exactly. Never calls
113
+ * process.exit() itself (D-4 discipline this module tree observes
114
+ * throughout): the caller (vice-broker.mts's CLI wrapper) turns the
115
+ * returned code into process.exitCode. */
116
+ export function containerGuardReport(deps = defaultDeps) {
117
+ const signals = evaluateContainerSignals(deps);
118
+ process.stderr.write("vice-broker: container guard evaluation\n");
119
+ for (const s of signals) {
120
+ if (s.fired) {
121
+ process.stderr.write(` [FIRED] ${s.description}${s.evidence ? ` -- evidence: ${s.evidence}` : ""}\n`);
122
+ }
123
+ else {
124
+ process.stderr.write(` [clear] ${s.description}${s.evidence ? ` (${s.evidence})` : ""}\n`);
125
+ }
126
+ }
127
+ const fired = signals.filter((s) => s.fired);
128
+ if (fired.length > 0) {
129
+ process.stderr.write(`verdict: CONTAINER (${fired.length} signal(s) fired) -- the guard would refuse here.\n`);
130
+ return 3;
131
+ }
132
+ process.stderr.write("verdict: HOST (no signals fired) -- the guard would allow x64sc to launch here.\n");
133
+ return 0;
134
+ }
135
+ /** Evaluates the guard and, if any signal fired, writes a FATAL block naming
136
+ * every fired signal and returns 2 -- UNLESS VICE_SUPERVISOR_ALLOW_CONTAINER
137
+ * is EXACTLY "1" (testing only; never set it to actually run VICE). On a
138
+ * clear host verdict, returns 0 and writes nothing. Mirrors
139
+ * container_guard_enforce()'s escape hatch, wording and exit-code contract
140
+ * verbatim -- never calls process.exit() itself, matching
141
+ * containerGuardReport()'s own posture above. */
142
+ export function containerGuardEnforce(deps = defaultDeps) {
143
+ const signals = evaluateContainerSignals(deps);
144
+ const fired = signals.filter((s) => s.fired);
145
+ if (fired.length > 0 && deps.env.VICE_SUPERVISOR_ALLOW_CONTAINER !== "1") {
146
+ process.stderr.write("FATAL: vice-broker refuses to run inside a container.\n");
147
+ process.stderr.write("This process is HOST-ONLY. Signals that fired:\n");
148
+ for (const s of fired) {
149
+ process.stderr.write(` - ${s.description}${s.evidence ? ` -- ${s.evidence}` : ""}\n`);
150
+ }
151
+ process.stderr.write("\n");
152
+ process.stderr.write("If you believe this IS the host, run --check-container for the full\n");
153
+ process.stderr.write("per-signal breakdown and report which signal is wrong.\n");
154
+ process.stderr.write("\n");
155
+ process.stderr.write("This cannot work in here: there is no x64sc binary, no display, and\n");
156
+ process.stderr.write("the entire point of this process is to launch or supervise a process\n");
157
+ process.stderr.write("the container has no access to in the first place.\n");
158
+ process.stderr.write("\n");
159
+ process.stderr.write("Escape hatch (TESTING ONLY -- never to actually run VICE):\n");
160
+ process.stderr.write(" VICE_SUPERVISOR_ALLOW_CONTAINER=1\n");
161
+ process.stderr.write("\n");
162
+ process.stderr.write("Run this broker on the HOST instead, from the host workspace.\n");
163
+ return 2;
164
+ }
165
+ return 0;
166
+ }
167
+ // -------------------------------------------------- environment predicate
168
+ //
169
+ // containerGuardReport()/containerGuardEnforce() above answer "should this
170
+ // process REFUSE to run here". This answers the different question "which
171
+ // environment am I in", for callers that must CHOOSE behaviour rather than
172
+ // refuse -- specifically vice.ts's mcpHost(), which has to return the
173
+ // container-visible bridge alias inside a container and a loopback address
174
+ // on a host, because `host.docker.internal` is a Docker-provided alias that
175
+ // does not resolve on the host at all.
176
+ //
177
+ // It shares this module's detection deliberately rather than growing a
178
+ // second, weaker copy. That is the exact mistake this file's own header
179
+ // records for the REMOVED /proc/self/mountinfo signal: an independent
180
+ // "looks dockery" heuristic fired on the real host -- the machine running
181
+ // the devcontainer daemon -- and so answered the wrong question. Any new
182
+ // detector would risk re-earning that bug; this one is already calibrated
183
+ // against precisely the host-versus-container distinction being asked here.
184
+ //
185
+ // The verdict rule is not invented here: it is the one containerGuardReport()
186
+ // and containerGuardEnforce() both state -- >=1 signal fired means CONTAINER,
187
+ // none fired means HOST.
188
+ /** Memoised verdict for the default-deps path only. */
189
+ let cachedDefaultVerdict = null;
190
+ /** True inside a container, false on a host.
191
+ *
192
+ * MEMOISED on the default-deps path, deliberately: one of the five signals
193
+ * shells out to `systemd-detect-virt`, and mcpHost() is read fresh on EVERY
194
+ * forwarded tool call -- spawning a subprocess per call would be a real cost
195
+ * for an answer that cannot change. Container membership is fixed for a
196
+ * process lifetime, so caching the verdict is safe. Note what is NOT cached:
197
+ * the caller's own env-var read (`VICE_MCP_HOST`) stays fresh, preserving the
198
+ * override-sensitivity mcpHost()'s comment says a module-level constant would
199
+ * have silently destroyed.
200
+ *
201
+ * Passing explicit deps ALWAYS re-evaluates and never touches the cache, so
202
+ * tests can drive both branches in-process, in any order, without one test's
203
+ * verdict leaking into another's. */
204
+ export function isInsideContainer(deps) {
205
+ if (deps)
206
+ return evaluateContainerSignals(deps).some((s) => s.fired);
207
+ if (cachedDefaultVerdict === null) {
208
+ cachedDefaultVerdict = evaluateContainerSignals(defaultDeps).some((s) => s.fired);
209
+ }
210
+ return cachedDefaultVerdict;
211
+ }