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