@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
|
@@ -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
|
+
}
|