@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/containerpath.ts
ADDED
|
@@ -0,0 +1,296 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Build a CONTAINER path from something a HOST-side process handed back to
|
|
3
|
+
// us in host-filesystem terms.
|
|
4
|
+
//
|
|
5
|
+
// This is the INVERSE of hostpath.ts's outbound direction. hostpath.ts
|
|
6
|
+
// solves "I have a container path and need to hand it to something running
|
|
7
|
+
// on the host" -- container -> host. This file solves the opposite: "a
|
|
8
|
+
// host-side process handed ME a path (or a URL naming a loopback host) in
|
|
9
|
+
// ITS OWN terms, and I need the container-side equivalent to actually open
|
|
10
|
+
// it" -- host -> container.
|
|
11
|
+
//
|
|
12
|
+
// This direction did not exist until Phase 01.2's on-demand VICE broker
|
|
13
|
+
// started handing this container back its OWN grant records: the broker
|
|
14
|
+
// runs on the host, legitimately resolves its own repo root, and writes a
|
|
15
|
+
// grant carrying a loopback `url` plus host-rooted `epoch_file` and
|
|
16
|
+
// `supervisor_dir` fields (its own view of the filesystem -- entirely
|
|
17
|
+
// correct from where it's standing). Until this module existed, nothing
|
|
18
|
+
// inverted those coordinates before they were adopted as the session's
|
|
19
|
+
// identity: loopback meant the CONTAINER's own loopback (ECONNREFUSED, since
|
|
20
|
+
// nothing listens there), and the host-rooted epoch path simply didn't
|
|
21
|
+
// resolve inside the container. Every broker-granted instance was silently
|
|
22
|
+
// unreachable -- see
|
|
23
|
+
// .planning/quick/260801-ccn-translate-broker-granted-host-coordinate/260801-ccn-PLAN.md's
|
|
24
|
+
// "The bug" section for the full, already-root-caused failure shape this
|
|
25
|
+
// module fixes.
|
|
26
|
+
//
|
|
27
|
+
// Mirrors hostpath.ts deliberately: the same derive-never-hardcode rule
|
|
28
|
+
// (the mapping is never written down as a literal -- see hostRootCandidates()
|
|
29
|
+
// below), the same `{ candidates, exact?, reason? }` return contract, the
|
|
30
|
+
// same throw-with-the-env-hint contract, the same CLI-at-the-bottom guard.
|
|
31
|
+
// What this file does NOT know: which FIELDS a grant record carries, or what
|
|
32
|
+
// the container-visible host alias is -- that is broker-protocol knowledge
|
|
33
|
+
// that belongs at the one seam that sees every grant as written
|
|
34
|
+
// (vice-proxy.mjs's containerizeGrant()), not duplicated here. This module
|
|
35
|
+
// only ever answers "given a host path or a URL naming a host, what is its
|
|
36
|
+
// container-side equivalent" -- nothing more, so it stays a generic
|
|
37
|
+
// host<->container primitive rather than a second copy of broker knowledge.
|
|
38
|
+
import { hostPathCandidates, SET_ENV_HINT } from "./hostpath.ts";
|
|
39
|
+
import { repoRoot } from "./repo-root.ts";
|
|
40
|
+
import { fileURLToPath } from "node:url";
|
|
41
|
+
import { dirname, resolve } from "node:path";
|
|
42
|
+
|
|
43
|
+
export { SET_ENV_HINT };
|
|
44
|
+
|
|
45
|
+
const HERE = dirname(fileURLToPath(import.meta.url));
|
|
46
|
+
// Same derivation and the same env override hostpath.ts's own
|
|
47
|
+
// WORKSPACE_ROOT/CONTAINER_WS use -- this file lives beside it, so the two
|
|
48
|
+
// must always agree; this is not a second, possibly-diverging copy, just the
|
|
49
|
+
// same resolution applied to this file's own location. Both dropped their
|
|
50
|
+
// hard-coded four-level hop when they moved here from
|
|
51
|
+
// .claude/skills/devcontainer-host-path/scripts/, where that count was
|
|
52
|
+
// correct; see hostpath.ts's note on why a fixed count is the wrong shape.
|
|
53
|
+
const WORKSPACE_ROOT = repoRoot({ from: HERE });
|
|
54
|
+
const CONTAINER_WS = process.env.CONTAINER_WORKSPACE_PATH || WORKSPACE_ROOT;
|
|
55
|
+
|
|
56
|
+
// Matched structurally (127.0.0.0/8 in full, "localhost", and the IPv6
|
|
57
|
+
// loopback exactly as the WHATWG URL parser itself renders `.hostname` --
|
|
58
|
+
// bracketed, "[::1]"), never against the single "127.0.0.1" literal this bug
|
|
59
|
+
// was actually observed with (D-4).
|
|
60
|
+
const IPV4_LOOPBACK_RE = /^127\.\d{1,3}\.\d{1,3}\.\d{1,3}$/;
|
|
61
|
+
|
|
62
|
+
function isLoopbackHostname(hostname: string): boolean {
|
|
63
|
+
return hostname === "localhost" || hostname === "[::1]" || IPV4_LOOPBACK_RE.test(hostname);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** hostRootCandidates()'s own return shape: `roots` the cleaned, de-duplicated,
|
|
67
|
+
* longest-first list of candidate host root paths, `exact` true only when the
|
|
68
|
+
* sibling's own hostPathCandidates() came back with an exact (env-derived)
|
|
69
|
+
* match needing no adjudication. */
|
|
70
|
+
export interface HostRootCandidatesResult {
|
|
71
|
+
roots: string[];
|
|
72
|
+
exact: boolean;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* The container workspace root's own candidate HOST paths -- from
|
|
77
|
+
* `hostPathCandidates()`, the sibling's own outbound derivation, never
|
|
78
|
+
* duplicated here (this is the whole of D-3: the mapping is derived at
|
|
79
|
+
* runtime off the sibling's own knowledge, and no absolute host path is ever
|
|
80
|
+
* written down in this file, checked by containerpath.test.ts's own
|
|
81
|
+
* runtime-derived source assertion). Cleaned up for use as a match prefix: a
|
|
82
|
+
* trailing separator stripped (hostPathCandidates() for the workspace root
|
|
83
|
+
* itself returns a candidate with one, since its template joins an empty
|
|
84
|
+
* relative tail), de-duplicated, and sorted LONGEST-first so a short or
|
|
85
|
+
* empty guess prefix can never shadow a longer, more specific one when
|
|
86
|
+
* matching a host path below.
|
|
87
|
+
*/
|
|
88
|
+
export function hostRootCandidates(): HostRootCandidatesResult {
|
|
89
|
+
const { candidates, exact } = hostPathCandidates(CONTAINER_WS, { workspaceRoot: WORKSPACE_ROOT });
|
|
90
|
+
const cleaned = [...new Set(candidates.map((c) => c.replace(/\/+$/, "")).filter((c) => c.length > 0))];
|
|
91
|
+
cleaned.sort((a, b) => b.length - a.length);
|
|
92
|
+
return { roots: cleaned, exact: Boolean(exact) };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** containerPathCandidates()'s own return shape, mirroring hostpath.ts's
|
|
96
|
+
* HostPathCandidatesResult with `raw` in place of `abs`: there is nothing to
|
|
97
|
+
* resolve against this container's cwd, the input is already host-absolute
|
|
98
|
+
* or it is not translatable at all. `raw` is `unknown` rather than `string`
|
|
99
|
+
* because this module's whole job is judging host-side input that may not
|
|
100
|
+
* even be a path string -- see the non-absolute-input branch below. */
|
|
101
|
+
export interface ContainerPathCandidatesResult {
|
|
102
|
+
raw: unknown;
|
|
103
|
+
candidates: string[];
|
|
104
|
+
exact?: boolean;
|
|
105
|
+
reason?: string;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Candidate CONTAINER paths for a HOST-side path string, best first.
|
|
110
|
+
* Returns `{ raw, candidates, exact?, reason? }` -- the sibling's own shape
|
|
111
|
+
* (`raw` in place of `abs`: there is nothing to resolve against this
|
|
112
|
+
* container's cwd, the input is already host-absolute or it is not
|
|
113
|
+
* translatable at all).
|
|
114
|
+
*
|
|
115
|
+
* STATED RESIDUAL, matching hostpath.ts's own for its direction: a
|
|
116
|
+
* non-absolute string is left alone, never guessed at -- indistinguishable
|
|
117
|
+
* from a non-path value without guessing, and guessing would be a worse
|
|
118
|
+
* failure than leaving it untranslated.
|
|
119
|
+
*/
|
|
120
|
+
export function containerPathCandidates(hostish: unknown): ContainerPathCandidatesResult {
|
|
121
|
+
if (typeof hostish !== "string" || !hostish.startsWith("/")) {
|
|
122
|
+
return {
|
|
123
|
+
raw: hostish,
|
|
124
|
+
candidates: [],
|
|
125
|
+
reason:
|
|
126
|
+
"not an absolute host-style path -- relative strings are deliberately untouched " +
|
|
127
|
+
"(mirrors hostpath.ts's own stated residual for its own direction).",
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
const { roots, exact } = hostRootCandidates();
|
|
131
|
+
const candidates: string[] = [];
|
|
132
|
+
for (const root of roots) {
|
|
133
|
+
if (hostish === root || hostish.startsWith(`${root}/`)) {
|
|
134
|
+
const tail = hostish.slice(root.length);
|
|
135
|
+
candidates.push(resolve(`${CONTAINER_WS}${tail}`));
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
const deduped = [...new Set(candidates)];
|
|
139
|
+
if (!deduped.length) {
|
|
140
|
+
return {
|
|
141
|
+
raw: hostish,
|
|
142
|
+
candidates: [],
|
|
143
|
+
reason: `${hostish} does not match any known host root -- translation is impossible, not merely unknown.`,
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
return exact ? { raw: hostish, candidates: deduped, exact: true } : { raw: hostish, candidates: deduped };
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** The single best container path, or throw with the reason plus the env
|
|
150
|
+
* hint -- exactly as the sibling's hostPath() does. */
|
|
151
|
+
export function containerPath(hostish: unknown): string {
|
|
152
|
+
const { candidates, reason, raw } = containerPathCandidates(hostish);
|
|
153
|
+
if (!candidates.length) {
|
|
154
|
+
throw new Error(`${reason || `cannot determine a container path for ${String(raw)}`}\n Or ${SET_ENV_HINT}`);
|
|
155
|
+
}
|
|
156
|
+
return candidates[0];
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Rewrite a loopback hostname in `urlString` to `alias`, preserving scheme,
|
|
161
|
+
* port and path. Byte-identical passthrough for anything else: a
|
|
162
|
+
* non-loopback host is NEVER re-pointed (D-4 -- a bad grant must never be
|
|
163
|
+
* able to aim a session at an arbitrary endpoint just because it happened to
|
|
164
|
+
* hand back some other hostname), and a string that does not parse as a URL
|
|
165
|
+
* at all is returned exactly as given.
|
|
166
|
+
*/
|
|
167
|
+
export function containerHost(urlString: string, alias: string): string {
|
|
168
|
+
let url: URL;
|
|
169
|
+
try {
|
|
170
|
+
url = new URL(urlString);
|
|
171
|
+
} catch {
|
|
172
|
+
return urlString;
|
|
173
|
+
}
|
|
174
|
+
if (!isLoopbackHostname(url.hostname)) {
|
|
175
|
+
return urlString;
|
|
176
|
+
}
|
|
177
|
+
url.hostname = alias;
|
|
178
|
+
return url.toString();
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/** containerizeRecord()'s options -- the FIELD LIST is supplied by the
|
|
182
|
+
* caller deliberately (D-7), so `pathFields`/`urlFields` default to empty
|
|
183
|
+
* arrays. `alias` is required: every real caller (vice-proxy.mjs's
|
|
184
|
+
* containerizeGrant()) and every test in this suite always supplies one, so
|
|
185
|
+
* typing it as mandatory here costs nothing in practice (the same
|
|
186
|
+
* cost-free-tightening call Plan 03 made for install-resources.ts's
|
|
187
|
+
* resourcesStatus() -- see that plan's SUMMARY). */
|
|
188
|
+
export interface ContainerizeRecordOptions {
|
|
189
|
+
pathFields?: string[];
|
|
190
|
+
urlFields?: string[];
|
|
191
|
+
alias: string;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/** One field actually rewritten by containerizeRecord(). */
|
|
195
|
+
export interface ContainerizeRecordChange {
|
|
196
|
+
field: string;
|
|
197
|
+
from: unknown;
|
|
198
|
+
to: unknown;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/** One field left alone by containerizeRecord(), with the reason why. */
|
|
202
|
+
export interface ContainerizeRecordUntranslated {
|
|
203
|
+
field: string;
|
|
204
|
+
value: unknown;
|
|
205
|
+
reason: string;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/** containerizeRecord()'s return shape: a NEW record (the input is never
|
|
209
|
+
* mutated), plus the changed and untranslated field lists. */
|
|
210
|
+
export interface ContainerizeRecordResult {
|
|
211
|
+
record: Record<string, unknown>;
|
|
212
|
+
changes: ContainerizeRecordChange[];
|
|
213
|
+
untranslated: ContainerizeRecordUntranslated[];
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* Pure, never-throwing translation of a host-written record: `pathFields`
|
|
218
|
+
* go through containerPath(), `urlFields` through containerHost() with
|
|
219
|
+
* `alias`. Returns a NEW record -- the input is never mutated -- plus
|
|
220
|
+
* `changes` (`{ field, from, to }`, one per field actually rewritten) and
|
|
221
|
+
* `untranslated` (`{ field, value, reason }`, one per field left alone
|
|
222
|
+
* because it was already container-shaped, matched no known host root, or
|
|
223
|
+
* wasn't a loopback host / parseable URL). Absent and non-string fields are
|
|
224
|
+
* silently skipped -- never a throw, never reported either way.
|
|
225
|
+
*
|
|
226
|
+
* The FIELD LIST is supplied by the caller deliberately (D-7): which fields
|
|
227
|
+
* a grant record carries is broker-specific knowledge, and keeping it out of
|
|
228
|
+
* this module is what keeps this a generic host<->container primitive
|
|
229
|
+
* rather than a broker-protocol module wearing a generic name.
|
|
230
|
+
*/
|
|
231
|
+
export function containerizeRecord(
|
|
232
|
+
record: Record<string, unknown> | undefined,
|
|
233
|
+
{ pathFields = [], urlFields = [], alias }: ContainerizeRecordOptions
|
|
234
|
+
): ContainerizeRecordResult {
|
|
235
|
+
const out: Record<string, unknown> = { ...(record || {}) };
|
|
236
|
+
const changes: ContainerizeRecordChange[] = [];
|
|
237
|
+
const untranslated: ContainerizeRecordUntranslated[] = [];
|
|
238
|
+
|
|
239
|
+
for (const field of pathFields) {
|
|
240
|
+
const value = record ? record[field] : undefined;
|
|
241
|
+
if (typeof value !== "string") continue; // absent/non-string -- skip silently
|
|
242
|
+
let translated: string;
|
|
243
|
+
try {
|
|
244
|
+
translated = containerPath(value);
|
|
245
|
+
} catch (e) {
|
|
246
|
+
untranslated.push({ field, value, reason: (e as Error).message });
|
|
247
|
+
continue;
|
|
248
|
+
}
|
|
249
|
+
out[field] = translated;
|
|
250
|
+
if (translated !== value) {
|
|
251
|
+
changes.push({ field, from: value, to: translated });
|
|
252
|
+
} else {
|
|
253
|
+
untranslated.push({ field, value, reason: "already container-shaped -- no translation needed" });
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
for (const field of urlFields) {
|
|
258
|
+
const value = record ? record[field] : undefined;
|
|
259
|
+
if (typeof value !== "string") continue;
|
|
260
|
+
const translated = containerHost(value, alias);
|
|
261
|
+
out[field] = translated;
|
|
262
|
+
if (translated !== value) {
|
|
263
|
+
changes.push({ field, from: value, to: translated });
|
|
264
|
+
} else {
|
|
265
|
+
untranslated.push({ field, value, reason: "not a loopback host, or not a parseable URL -- left unchanged" });
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
return { record: out, changes, untranslated };
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
// -------------------------------------------------------------------- CLI
|
|
273
|
+
|
|
274
|
+
if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
|
275
|
+
const argv = process.argv.slice(2);
|
|
276
|
+
if (argv.includes("--help") || argv.includes("-h") || argv.length === 0) {
|
|
277
|
+
console.log(`usage: node containerpath.ts <host-path...>
|
|
278
|
+
|
|
279
|
+
Print the container-side path(s) for host filesystem paths handed back by a
|
|
280
|
+
host-side process (e.g. the on-demand VICE broker's grant records), best
|
|
281
|
+
first, one per line -- the inverse of hostpath.ts.
|
|
282
|
+
|
|
283
|
+
env: CONTAINER_WORKSPACE_PATH container-side workspace root (default ${CONTAINER_WS})`);
|
|
284
|
+
process.exit(argv.length === 0 ? 1 : 0);
|
|
285
|
+
}
|
|
286
|
+
try {
|
|
287
|
+
for (const p of argv) {
|
|
288
|
+
const { candidates, reason, raw } = containerPathCandidates(p);
|
|
289
|
+
if (!candidates.length) throw new Error(reason || `cannot determine a container path for ${String(raw)}`);
|
|
290
|
+
for (const c of candidates) console.log(c);
|
|
291
|
+
}
|
|
292
|
+
} catch (e) {
|
|
293
|
+
console.error(`error: ${(e as Error).message}`);
|
|
294
|
+
process.exit(1);
|
|
295
|
+
}
|
|
296
|
+
}
|
package/hostpath.ts
ADDED
|
@@ -0,0 +1,318 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Build a path that something OUTSIDE this devcontainer can use to reach a file
|
|
3
|
+
// INSIDE the workspace.
|
|
4
|
+
//
|
|
5
|
+
// The problem this solves is generic. Anything running on the host -- an MCP
|
|
6
|
+
// server, a GUI app, a viewer, a debugger, a browser -- resolves paths on the
|
|
7
|
+
// host filesystem, so a container path like /workspaces/foo/bar.d64 means
|
|
8
|
+
// nothing to it and the call fails on a file it cannot find. The workspace is a
|
|
9
|
+
// bind mount, though: the same bytes exist on the host under a different prefix,
|
|
10
|
+
// so the container path only needs its prefix translated.
|
|
11
|
+
//
|
|
12
|
+
// The prefix is never hardcoded -- always discovered at runtime, so this is
|
|
13
|
+
// portable across machines and users. Two mechanisms, best first:
|
|
14
|
+
//
|
|
15
|
+
// 1. HOST_WORKSPACE_PATH, exported from devcontainer.json as
|
|
16
|
+
// "containerEnv": { "HOST_WORKSPACE_PATH": "${localWorkspaceFolder}" }
|
|
17
|
+
// Exact, no guessing, resolved per-machine by the devcontainer CLI. Needs a
|
|
18
|
+
// container rebuild to take effect.
|
|
19
|
+
//
|
|
20
|
+
// 2. Fallback: /proc/self/mountinfo, which leaks the bind-mount source and so
|
|
21
|
+
// works with no rebuild. Its 4th field is the path *within the source
|
|
22
|
+
// device*:
|
|
23
|
+
// <path-within-device> <container-mountpoint> rw,... - ext4 /dev/<device>
|
|
24
|
+
// That is not yet a host path -- the device is itself mounted somewhere on
|
|
25
|
+
// the host, which mountinfo cannot reveal. So we emit one candidate per
|
|
26
|
+
// plausible device mountpoint and let the consumer adjudicate: a wrong path
|
|
27
|
+
// fails fast and unambiguously. Heuristic by nature, hence the nudge
|
|
28
|
+
// towards mechanism 1.
|
|
29
|
+
//
|
|
30
|
+
// Node >= 18. No dependencies, no network, nothing machine-specific: copy this
|
|
31
|
+
// file into any devcontainer-based project's .claude/mcp/vice/ and it works.
|
|
32
|
+
//
|
|
33
|
+
// HOSTING CHOICE (01.6.1-02, Criterion B / RESEARCH §3.4 Option B): this
|
|
34
|
+
// module takes the workspace root as an ARGUMENT (an optional `workspaceRoot`
|
|
35
|
+
// on every exported function's options object) and imports NOTHING from
|
|
36
|
+
// repo-root.ts. It used to import { repoRoot } and call it at its own top
|
|
37
|
+
// level -- exactly the shape repo-root.ts's own header (and
|
|
38
|
+
// install-resources.ts's) warns against: repo-root.ts imports
|
|
39
|
+
// install-resources.ts, which imports this module, which imported
|
|
40
|
+
// repo-root.ts back -- a three-module cycle. This module evaluating that
|
|
41
|
+
// top-level call while repo-root.ts's own `HERE` is still in its temporal
|
|
42
|
+
// dead zone is exactly the "Cannot access 'HERE' before initialization"
|
|
43
|
+
// crash 01.6-RESEARCH.md §E and 01.6.1-RESEARCH.md §3.2 both reproduced live.
|
|
44
|
+
//
|
|
45
|
+
// THE CYCLE IS AVOIDED STRUCTURALLY: do not "clean this up" by re-adding
|
|
46
|
+
// `import { repoRoot } from "./repo-root.mjs"` here -- that importable
|
|
47
|
+
// convenience is exactly the cycle described above. Every caller that has a
|
|
48
|
+
// root in scope threads it through the `workspaceRoot` option instead; when
|
|
49
|
+
// none is supplied this module falls back to `CONTAINER_WORKSPACE_PATH`
|
|
50
|
+
// alone and throws a named error if even that is absent -- loud failure over
|
|
51
|
+
// a silently wrong host path (see resolveWorkspaceRoot() below).
|
|
52
|
+
|
|
53
|
+
import { readFileSync } from "node:fs";
|
|
54
|
+
import { fileURLToPath } from "node:url";
|
|
55
|
+
import { dirname, resolve, relative, isAbsolute } from "node:path";
|
|
56
|
+
|
|
57
|
+
const HERE = dirname(fileURLToPath(import.meta.url));
|
|
58
|
+
|
|
59
|
+
/** Options threaded through every exported function below: the caller's
|
|
60
|
+
* workspace root, resolved lazily and only when actually needed (see
|
|
61
|
+
* resolveWorkspaceRoot()). This is the shape Task 1 of 01.6.1-02 added --
|
|
62
|
+
* see this file's own header for why it exists instead of an import from
|
|
63
|
+
* repo-root.ts. */
|
|
64
|
+
export interface HostpathOptions {
|
|
65
|
+
workspaceRoot?: string;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** The mount backing a container path, per /proc/self/mountinfo -- returned
|
|
69
|
+
* by mountFor() below. */
|
|
70
|
+
export interface MountInfo {
|
|
71
|
+
root: string;
|
|
72
|
+
mountPoint: string;
|
|
73
|
+
fstype: string;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** hostPathCandidates()'s own return shape: `abs` is the resolved container
|
|
77
|
+
* path, `candidates` the ordered list of host-path guesses (best first,
|
|
78
|
+
* possibly empty), `exact` true only when the single candidate came from
|
|
79
|
+
* HOST_WORKSPACE_PATH and needs no adjudication, `mount` the backing mount
|
|
80
|
+
* when one was found, `reason` set only when `candidates` is empty. */
|
|
81
|
+
export interface HostPathCandidatesResult {
|
|
82
|
+
abs: string;
|
|
83
|
+
candidates: string[];
|
|
84
|
+
exact?: boolean;
|
|
85
|
+
mount?: MountInfo;
|
|
86
|
+
reason?: string;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Resolve the workspace root for a single call: the caller-supplied
|
|
90
|
+
* `workspaceRoot`, else `CONTAINER_WORKSPACE_PATH`, else a loud, named
|
|
91
|
+
* throw -- never a silent guess. Called LAZILY, only from inside the one
|
|
92
|
+
* branch that actually needs the value (the HOST_WORKSPACE_PATH-relative
|
|
93
|
+
* branch of hostPathCandidates(), and describe()'s own `if (explicit)`
|
|
94
|
+
* branch) so an environment with neither input, where the mountinfo
|
|
95
|
+
* fallback works fine today, keeps working fine -- calling this eagerly at
|
|
96
|
+
* function entry would turn that today-safe path into a throw, a behaviour
|
|
97
|
+
* regression disguised as a tightening. */
|
|
98
|
+
function resolveWorkspaceRoot(workspaceRoot?: string): string {
|
|
99
|
+
if (workspaceRoot) return workspaceRoot;
|
|
100
|
+
const cwp = process.env.CONTAINER_WORKSPACE_PATH;
|
|
101
|
+
if (cwp) return cwp;
|
|
102
|
+
throw new Error(
|
|
103
|
+
"hostpath: cannot resolve the workspace root -- no `workspaceRoot` option was supplied and " +
|
|
104
|
+
"CONTAINER_WORKSPACE_PATH is not set in the environment. Supply one of the two."
|
|
105
|
+
);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// Where a bind-mount source device is *itself* mounted on the host. Ordered by
|
|
109
|
+
// how often each turns out to be right; "" covers a source path that is already
|
|
110
|
+
// absolute on the host.
|
|
111
|
+
const DEVICE_MOUNT_GUESSES = ["/home", "", "/Users", "/mnt", "/media", "/host"];
|
|
112
|
+
|
|
113
|
+
// Filesystems that exist only inside the container: a path on one of these has
|
|
114
|
+
// no host-side counterpart at all, so translation is impossible rather than
|
|
115
|
+
// merely unknown -- and saying so beats emitting six wrong candidates.
|
|
116
|
+
const CONTAINER_ONLY_FS = new Set([
|
|
117
|
+
"overlay", "tmpfs", "proc", "sysfs", "devtmpfs", "devpts", "cgroup", "cgroup2",
|
|
118
|
+
"mqueue", "squashfs", "ramfs",
|
|
119
|
+
]);
|
|
120
|
+
|
|
121
|
+
export const SET_ENV_HINT =
|
|
122
|
+
'set the mapping explicitly in .devcontainer/devcontainer.json and rebuild:\n' +
|
|
123
|
+
' "containerEnv": { "HOST_WORKSPACE_PATH": "${localWorkspaceFolder}" }';
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* The mount backing `containerPath`, per /proc/self/mountinfo: the longest
|
|
127
|
+
* matching mountpoint, its path within the source device, and its fstype.
|
|
128
|
+
*/
|
|
129
|
+
export function mountFor(containerPath: string): MountInfo | null {
|
|
130
|
+
let info: string;
|
|
131
|
+
try {
|
|
132
|
+
info = readFileSync("/proc/self/mountinfo", "utf8");
|
|
133
|
+
} catch {
|
|
134
|
+
return null;
|
|
135
|
+
}
|
|
136
|
+
let best: MountInfo | null = null;
|
|
137
|
+
for (const line of info.split("\n")) {
|
|
138
|
+
// <id> <parent> <maj:min> <root> <mountpoint> <opts> ... - <fstype> <source> ...
|
|
139
|
+
const [pre, post] = line.split(" - ");
|
|
140
|
+
if (!post) continue;
|
|
141
|
+
const f = pre.split(" ");
|
|
142
|
+
if (f.length < 5) continue;
|
|
143
|
+
const [, , , root, mountPoint] = f;
|
|
144
|
+
const fstype = post.split(" ")[0];
|
|
145
|
+
if (containerPath === mountPoint || containerPath.startsWith(mountPoint.replace(/\/?$/, "/"))) {
|
|
146
|
+
if (!best || mountPoint.length > best.mountPoint.length) best = { root, mountPoint, fstype };
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
return best;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Candidate host paths for a container path, best first.
|
|
154
|
+
*
|
|
155
|
+
* Returns { abs, candidates, exact?, mount?, reason? }. `exact` means the single
|
|
156
|
+
* candidate came from HOST_WORKSPACE_PATH and needs no adjudication; `reason`
|
|
157
|
+
* explains an empty candidate list.
|
|
158
|
+
*
|
|
159
|
+
* Deliberately not tied to any repo, workspace layout or file type: translation
|
|
160
|
+
* is driven by whichever bind mount happens to back the path, so any shared
|
|
161
|
+
* location works.
|
|
162
|
+
*
|
|
163
|
+
* `opts.workspaceRoot`, if supplied, is used ahead of CONTAINER_WORKSPACE_PATH
|
|
164
|
+
* -- see resolveWorkspaceRoot() above. Resolved lazily, only inside this
|
|
165
|
+
* branch: the mountinfo fallback below needs no workspace root at all and
|
|
166
|
+
* must keep working when neither input is available.
|
|
167
|
+
*/
|
|
168
|
+
export function hostPathCandidates(
|
|
169
|
+
containerPath: string,
|
|
170
|
+
{ workspaceRoot }: HostpathOptions = {}
|
|
171
|
+
): HostPathCandidatesResult {
|
|
172
|
+
const abs = isAbsolute(containerPath) ? containerPath : resolve(process.cwd(), containerPath);
|
|
173
|
+
|
|
174
|
+
// 1. Explicit workspace mapping, when the path falls inside the workspace.
|
|
175
|
+
const hostWs = process.env.HOST_WORKSPACE_PATH;
|
|
176
|
+
if (hostWs) {
|
|
177
|
+
const containerWs = resolveWorkspaceRoot(workspaceRoot);
|
|
178
|
+
const rel = relative(containerWs, abs);
|
|
179
|
+
if (!rel.startsWith("..")) {
|
|
180
|
+
return { abs, candidates: [`${hostWs.replace(/\/$/, "")}/${rel}`], exact: true };
|
|
181
|
+
}
|
|
182
|
+
// Outside the workspace: fall through to the generic mount-based path rather
|
|
183
|
+
// than refusing -- the file may still be shared by some other mount.
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// 2. Generic: derive from whichever mount backs this specific path.
|
|
187
|
+
const m = mountFor(abs);
|
|
188
|
+
if (!m) return { abs, candidates: [], reason: "could not read /proc/self/mountinfo" };
|
|
189
|
+
if (CONTAINER_ONLY_FS.has(m.fstype)) {
|
|
190
|
+
return {
|
|
191
|
+
abs,
|
|
192
|
+
candidates: [],
|
|
193
|
+
reason:
|
|
194
|
+
`${abs} lives on a container-only filesystem (${m.fstype} at ${m.mountPoint}), ` +
|
|
195
|
+
"so nothing outside the container can see it under any path. Move or copy it " +
|
|
196
|
+
"into a directory that is bind-mounted from the host.",
|
|
197
|
+
};
|
|
198
|
+
}
|
|
199
|
+
const tail = relative(m.mountPoint, abs);
|
|
200
|
+
const rootForPath = tail ? `${m.root.replace(/\/$/, "")}/${tail}` : m.root;
|
|
201
|
+
return {
|
|
202
|
+
abs,
|
|
203
|
+
candidates: [...new Set(DEVICE_MOUNT_GUESSES.map((p) => `${p}${rootForPath}`))],
|
|
204
|
+
mount: m,
|
|
205
|
+
};
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/** The single best host path, or throw with the reason it cannot be built. */
|
|
209
|
+
export function hostPath(containerPath: string, opts: HostpathOptions = {}): string {
|
|
210
|
+
const { abs, candidates, reason } = hostPathCandidates(containerPath, opts);
|
|
211
|
+
if (!candidates.length) {
|
|
212
|
+
throw new Error(`${reason || `cannot determine a host path for ${abs}`}\n Or ${SET_ENV_HINT}`);
|
|
213
|
+
}
|
|
214
|
+
return candidates[0];
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/** Printed once when a mapping is guessed, since a guess can silently misfire. */
|
|
218
|
+
export function guessNote(): string {
|
|
219
|
+
return (
|
|
220
|
+
"note: HOST_WORKSPACE_PATH is unset, so the host path is being guessed from\n" +
|
|
221
|
+
" /proc/self/mountinfo. For an exact, portable mapping, " +
|
|
222
|
+
SET_ENV_HINT.replace(/\n/g, "\n ") +
|
|
223
|
+
"\n"
|
|
224
|
+
);
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* Run `fn(hostPath)` against each candidate until one succeeds, letting the
|
|
229
|
+
* consumer -- which is the only thing that can actually resolve a host path --
|
|
230
|
+
* adjudicate. Returns { result, hostPath }.
|
|
231
|
+
*
|
|
232
|
+
* `fatal(err)` marks an error as "not a wrong-path signal" (a connection
|
|
233
|
+
* failure, say), so probing stops instead of retrying five more times.
|
|
234
|
+
* `workspaceRoot`, alongside `fatal`, threads straight through to
|
|
235
|
+
* hostPathCandidates() -- see resolveWorkspaceRoot() above.
|
|
236
|
+
*/
|
|
237
|
+
export async function tryHostPaths<T>(
|
|
238
|
+
containerPath: string,
|
|
239
|
+
fn: (hostPath: string) => T | Promise<T>,
|
|
240
|
+
{ fatal, workspaceRoot }: { fatal?: (e: unknown) => boolean; workspaceRoot?: string } = {}
|
|
241
|
+
): Promise<{ result: T; hostPath: string }> {
|
|
242
|
+
const { abs, candidates, reason, exact } = hostPathCandidates(containerPath, { workspaceRoot });
|
|
243
|
+
if (!candidates.length) {
|
|
244
|
+
throw new Error(`${reason || `cannot determine a host path for ${abs}`}\n Or ${SET_ENV_HINT}`);
|
|
245
|
+
}
|
|
246
|
+
if (!exact) process.stderr.write(guessNote());
|
|
247
|
+
const errors: string[] = [];
|
|
248
|
+
for (const p of candidates) {
|
|
249
|
+
try {
|
|
250
|
+
return { result: await fn(p), hostPath: p };
|
|
251
|
+
} catch (e) {
|
|
252
|
+
errors.push(` ${p}\n -> ${(e as Error).message}`);
|
|
253
|
+
if (fatal?.(e)) throw e;
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
throw new Error(
|
|
257
|
+
`no candidate host path worked for ${abs}:\n${errors.join("\n")}\n ${SET_ENV_HINT}`
|
|
258
|
+
);
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/** Human-readable report: the mapping, and how it was arrived at.
|
|
262
|
+
* `opts.workspaceRoot`, resolved lazily inside the `if (explicit)` branch
|
|
263
|
+
* below (same laziness rule as hostPathCandidates() itself), threads through
|
|
264
|
+
* to that same call. */
|
|
265
|
+
export function describe(
|
|
266
|
+
paths: string[],
|
|
267
|
+
log: (message: string) => void = console.log,
|
|
268
|
+
{ workspaceRoot }: HostpathOptions = {}
|
|
269
|
+
): void {
|
|
270
|
+
const explicit = process.env.HOST_WORKSPACE_PATH;
|
|
271
|
+
log(`HOST_WORKSPACE_PATH: ${explicit || "(unset — falling back to /proc/self/mountinfo)"}`);
|
|
272
|
+
if (explicit) log(`maps container path: ${resolveWorkspaceRoot(workspaceRoot)}`);
|
|
273
|
+
for (const p of paths.length ? paths : [process.cwd()]) {
|
|
274
|
+
const { abs, candidates, reason, mount, exact } = hostPathCandidates(p, { workspaceRoot });
|
|
275
|
+
log(`\n${abs}`);
|
|
276
|
+
if (mount) log(` backed by: ${mount.fstype} mount at ${mount.mountPoint} (source ${mount.root})`);
|
|
277
|
+
if (reason) log(` UNRESOLVABLE: ${reason}`);
|
|
278
|
+
for (const [i, c] of candidates.entries()) {
|
|
279
|
+
log(` ${i === 0 ? "->" : " "} ${c}${exact ? " (exact, from env)" : ""}`);
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
// -------------------------------------------------------------------------- CLI
|
|
285
|
+
|
|
286
|
+
if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
|
287
|
+
const argv = process.argv.slice(2);
|
|
288
|
+
const plain = argv.includes("--plain");
|
|
289
|
+
const paths = argv.filter((a) => !a.startsWith("--"));
|
|
290
|
+
if (argv.includes("--help") || argv.includes("-h")) {
|
|
291
|
+
console.log(`usage: node hostpath.ts [--plain] <path...>
|
|
292
|
+
|
|
293
|
+
Print the host path(s) for files inside this workspace, for handing to anything
|
|
294
|
+
running outside the container.
|
|
295
|
+
|
|
296
|
+
(default) report the mapping and how it was derived
|
|
297
|
+
--plain print candidate host paths only, best first, one per line
|
|
298
|
+
|
|
299
|
+
env: HOST_WORKSPACE_PATH host location of the workspace (exact mapping)
|
|
300
|
+
CONTAINER_WORKSPACE_PATH container location it maps to (read at call time; no default is computed here)`);
|
|
301
|
+
process.exit(0);
|
|
302
|
+
}
|
|
303
|
+
try {
|
|
304
|
+
if (plain) {
|
|
305
|
+
for (const p of paths.length ? paths : [process.cwd()]) {
|
|
306
|
+
const { candidates, reason, abs, exact } = hostPathCandidates(p);
|
|
307
|
+
if (!candidates.length) throw new Error(reason || `cannot determine a host path for ${abs}`);
|
|
308
|
+
if (!exact) process.stderr.write(guessNote());
|
|
309
|
+
for (const c of candidates) console.log(c);
|
|
310
|
+
}
|
|
311
|
+
} else {
|
|
312
|
+
describe(paths);
|
|
313
|
+
}
|
|
314
|
+
} catch (e) {
|
|
315
|
+
console.error(`error: ${(e as Error).message}`);
|
|
316
|
+
process.exit(1);
|
|
317
|
+
}
|
|
318
|
+
}
|