fix(ipc): the client is found on ARM too, and a relative path is refused instead of run #1

Merged
enricobuehler merged 2 commits from worktree-exec-binary-resolution into main 2026-08-03 16:49:22 +00:00
4 changed files with 277 additions and 39 deletions
+27 -1
View File
@@ -76,7 +76,7 @@ punktfunk-host plugins enable # once, if the runner isn't on yet
| `rules[].forClient` | any | Only bind for this punktfunk client name. |
| `rules[].forApp` | any | Only bind for this app id. `bindOn: "stream"` only. |
| `rules[].enabled` | `true` | Switch a rule off without deleting it. |
| `clientBinary` | auto | Absolute path to the VirtualHere client. **Linux/macOS only** — the Windows transport needs no binary. |
| `clientBinary` | auto | Absolute path to the VirtualHere client. **Linux/macOS only** — the Windows transport talks a named pipe and needs no binary, so setting this on Windows does nothing. Must be **absolute**: a bare name is refused rather than executed, because it would resolve from the plugin runner's `PATH` and not from the shell you tested in. Left unset, the plugin looks for `vhclientx86_64`, `vhclientarm64`, `vhclienti386` and `vhclientarmhf` on `PATH` (`vhclientosx` on macOS). |
| `watchdogSecs` | `30` | How often the background pass runs: reconcile our belief with VirtualHere's, and sweep if idle. `0` disables both. |
| `idleSweep` | `true` | Hand back anything still held once the host reports no stream and no session — the backstop for a stop event that never arrived. Under `bindOn: "client"` it additionally waits until the last couch has disconnected, so the device still stays with the host between streams. |
@@ -109,6 +109,32 @@ Platform-split, and deliberately so:
Every call is serialized, argv-only (never a shell), and addresses are validated before they
reach a verb.
### If it works in your shell but not from the plugin (Linux)
The FIFOs are in `/tmp`, so the plugin runner has to see the *real* `/tmp`. Punktfunk's
`punktfunk-scripting` unit used to set `PrivateTmp=yes`, which gave it a private one — the plugin
launched `vhclient` fine and could then never reach the daemon behind it. Fixed host-side after
punktfunk 0.23.0; on a host that predates the fix, apply the drop-in yourself:
```sh
systemctl --user edit punktfunk-scripting
```
```ini
[Service]
PrivateTmp=no
ReadWritePaths=/tmp
```
```sh
systemctl --user restart punktfunk-scripting
```
### Where the logs are
Plugin output goes to the punktfunk web console's **Logs** page — pick the **Plugins** filter.
(On a host at 0.23.0 or older that page carries host lines only; use
`journalctl --user -u punktfunk-scripting -f` on Linux, and on Windows run the runner in the
foreground, since its scheduled task writes no log file.)
## Development
```sh
+126 -37
View File
@@ -6,56 +6,145 @@
// every other consumer on the box (the tray app, an operator's own script) and silently eat
// their replies. The vendor binary is the only thing that knows the framing, and their docs
// confirm non-root can drive the daemon — which suits the `systemctl --user` runner.
//
// ⚠ Those FIFOs live in `/tmp`, so the runner must be able to SEE the real `/tmp`. The
// `punktfunk-scripting` systemd unit used to set `PrivateTmp=yes`, which gave it a private
// namespace: the binary launched fine and could then never reach the daemon behind it, which
// presents as timeouts on a box where `vhclient` works perfectly in a shell. Fixed host-side
// 2026-08-03; check it first if these symptoms come back.
import { execFile } from "node:child_process";
import * as fs from "node:fs";
import * as path from "node:path";
import { Effect } from "effect";
import { IPC_TIMEOUT_MS, type VhIpcBackend, VhIpcError } from "./types.js";
/**
* Client binary names we will resolve from PATH, per platform. An operator-supplied
* `clientBinary` must be an absolute path; anything else is rejected rather than executed,
* so config cannot point us at an arbitrary name picked up from a hostile PATH (design §11).
* Client binary names we resolve from PATH, per platform, in preference order.
*
* ALL of them are tried — the list is architecture variants and exactly one normally exists on a
* given box. (Until 2026-08-03 only the first was ever used, so an ARM Linux host with a correctly
* installed `vhclientarm64` was told to install the client it already had.)
*/
export const KNOWN_BINARIES: readonly string[] =
process.platform === "darwin"
? ["vhclientosx"]
: ["vhclientx86_64", "vhclientarm64", "vhclienti386", "vhclientarmhf"];
export const resolveBinary = (configured?: string): string => {
if (configured !== undefined && configured !== "") return configured;
return KNOWN_BINARIES[0] as string;
/**
* Where the vendor binary is, or why we cannot say.
*
* Deliberately NOT a thrown error or a failed layer: a missing client is the ordinary state of a
* fresh install (VirtualHere is sold separately and installed by hand), and taking the plugin down
* for it would lose the console page that explains the problem. The reason rides along to the
* first `send`, where it surfaces as a normal `VhIpcError` — which is what the diagnostics ladder
* already renders, with a remedy, in the Diagnostics tab and in `doctor`.
*/
export type BinaryResolution =
| { readonly kind: "resolved"; readonly binary: string }
| { readonly kind: "unresolved"; readonly reason: string };
/** An existing, executable regular file? */
const isExecutableFile = (file: string): boolean => {
try {
if (!fs.statSync(file).isFile()) return false;
fs.accessSync(file, fs.constants.X_OK);
return true;
} catch {
return false;
}
};
export const makeExecBackend = (binary: string): VhIpcBackend => ({
/** The first [`KNOWN_BINARIES`] entry that resolves to an executable on `PATH`. */
const probePath = (): string | undefined => {
const dirs = (process.env.PATH ?? "")
.split(path.delimiter)
.filter((d) => d !== "");
for (const name of KNOWN_BINARIES) {
for (const dir of dirs) {
const candidate = path.join(dir, name);
if (isExecutableFile(candidate)) return candidate;
}
}
return undefined;
};
/**
* Resolve the client binary: an operator-supplied `clientBinary` when given, else a PATH probe.
*
* An operator-supplied path **must be absolute**, and anything else is rejected rather than
* executed (design §11) — a bare name would be resolved out of the *runner's* PATH, which is not
* the PATH of the shell the operator tested in and which they cannot see. That rule was documented
* here and in the README from the start but never actually implemented; it is now.
*/
export const resolveBinary = (configured?: string): BinaryResolution => {
if (configured !== undefined && configured !== "") {
if (!path.isAbsolute(configured)) {
return {
kind: "unresolved",
reason:
`clientBinary must be an absolute path — got "${configured}". A bare name would be ` +
`resolved from the plugin runner's PATH, not the PATH of the shell you tested in. ` +
`Use the full path (e.g. /usr/local/bin/${KNOWN_BINARIES[0]}), or remove the setting ` +
`and let the plugin find the client itself.`,
};
}
if (!isExecutableFile(configured)) {
return {
kind: "unresolved",
reason:
`clientBinary "${configured}" is not an executable file — check the path, and that ` +
`the account running the plugin runner is allowed to execute it.`,
};
}
return { kind: "resolved", binary: configured };
}
const found = probePath();
return found !== undefined
? { kind: "resolved", binary: found }
: {
kind: "unresolved",
reason:
`no VirtualHere client found on PATH (looked for ${KNOWN_BINARIES.join(", ")}). ` +
`Install the VirtualHere client, or set clientBinary to its absolute path.`,
};
};
export const makeExecBackend = (
resolution: BinaryResolution,
): VhIpcBackend => ({
kind: "exec",
send: (verb) =>
Effect.callback<string, VhIpcError>((resume) => {
// argv, never a shell string — operator config never reaches a shell.
const child = execFile(
binary,
["-t", verb],
{ timeout: IPC_TIMEOUT_MS, windowsHide: true },
(err, stdout, stderr) => {
if (err) {
const code = (err as NodeJS.ErrnoException).code;
resume(
Effect.fail(
new VhIpcError({
verb,
reason:
code === "ENOENT"
? `${binary} not found — install the VirtualHere client or set clientBinary`
: `${binary} failed (${code ?? "error"})${stderr ? `: ${stderr.trim()}` : ""}`,
cause: err,
}),
),
);
return;
}
resume(Effect.succeed(stdout));
},
);
return Effect.sync(() => {
child.kill();
});
}),
resolution.kind === "unresolved"
? Effect.fail(new VhIpcError({ verb, reason: resolution.reason }))
: Effect.callback<string, VhIpcError>((resume) => {
const binary = resolution.binary;
// argv, never a shell string — operator config never reaches a shell.
const child = execFile(
binary,
["-t", verb],
{ timeout: IPC_TIMEOUT_MS, windowsHide: true },
(err, stdout, stderr) => {
if (err) {
const code = (err as NodeJS.ErrnoException).code;
resume(
Effect.fail(
new VhIpcError({
verb,
reason:
code === "ENOENT"
? `${binary} vanished between resolution and use — was the VirtualHere client uninstalled?`
: `${binary} failed (${code ?? "error"})${stderr ? `: ${stderr.trim()}` : ""}`,
cause: err,
}),
),
);
return;
}
resume(Effect.succeed(stdout));
},
);
return Effect.sync(() => {
child.kill();
});
}),
});
+5 -1
View File
@@ -15,7 +15,11 @@ import { makeExecBackend, resolveBinary } from "./exec.js";
import { pipeBackend } from "./pipe.js";
import { type VhIpcBackend, type VhIpcError, VhVerbRejected } from "./types.js";
export { KNOWN_BINARIES, resolveBinary } from "./exec.js";
export {
type BinaryResolution,
KNOWN_BINARIES,
resolveBinary,
} from "./exec.js";
export { PIPE_PATH } from "./pipe.js";
export * from "./types.js";
+119
View File
@@ -0,0 +1,119 @@
// Binary resolution: the PATH probe (all architecture variants, not just the first), the
// absolute-path rule the docs always claimed, and the promise that an unresolved binary surfaces
// as a readable IPC error instead of taking the plugin down.
import { afterAll, beforeEach, describe, expect, test } from "bun:test";
import * as fs from "node:fs";
import * as os from "node:os";
import * as path from "node:path";
import { Effect } from "effect";
import {
KNOWN_BINARIES,
makeExecBackend,
resolveBinary,
} from "../src/ipc/exec.js";
const ROOT = fs.mkdtempSync(path.join(os.tmpdir(), "vh-exec-"));
afterAll(() => fs.rmSync(ROOT, { recursive: true, force: true }));
const REAL_PATH = process.env.PATH;
beforeEach(() => {
process.env.PATH = REAL_PATH;
});
afterAll(() => {
process.env.PATH = REAL_PATH;
});
/** Drop an executable stub called `name` into a fresh dir and put that dir on PATH. */
const stubOnPath = (name: string): string => {
const dir = fs.mkdtempSync(path.join(ROOT, "bin-"));
const file = path.join(dir, name);
fs.writeFileSync(file, "#!/bin/sh\nexit 0\n", { mode: 0o755 });
process.env.PATH = dir;
return file;
};
describe("resolveBinary — PATH probe", () => {
test("finds this platform's client", () => {
// Off KNOWN_BINARIES, not a literal: the list differs on darwin, and a hardcoded
// `vhclientx86_64` would make this suite pass only on Linux.
const file = stubOnPath(KNOWN_BINARIES[0] as string);
expect(resolveBinary()).toEqual({ kind: "resolved", binary: file });
});
test("finds a NON-FIRST architecture variant", () => {
// The regression this exists for: KNOWN_BINARIES listed four names and only ever tried
// index 0, so an ARM box with a working client was told to install the client it had.
if (process.platform === "darwin") return; // one name only, nothing to skip past
const file = stubOnPath("vhclientarm64");
expect(resolveBinary()).toEqual({ kind: "resolved", binary: file });
});
test("no client anywhere reports what it looked for", () => {
process.env.PATH = fs.mkdtempSync(path.join(ROOT, "empty-"));
const res = resolveBinary();
expect(res.kind).toBe("unresolved");
if (res.kind !== "unresolved") throw new Error("unreachable");
for (const name of KNOWN_BINARIES) expect(res.reason).toContain(name);
});
test("a non-executable file with the right name is not a client", () => {
const dir = fs.mkdtempSync(path.join(ROOT, "noexec-"));
fs.writeFileSync(path.join(dir, KNOWN_BINARIES[0] as string), "", {
mode: 0o644,
});
process.env.PATH = dir;
expect(resolveBinary().kind).toBe("unresolved");
});
});
describe("resolveBinary — operator-supplied clientBinary", () => {
test("an absolute path to a real executable is taken", () => {
const file = stubOnPath("anything-at-all");
expect(resolveBinary(file)).toEqual({ kind: "resolved", binary: file });
});
test("a RELATIVE path is refused, not executed", () => {
// Documented in the README and in exec.ts from the start; never implemented until now.
// A bare name resolves from the RUNNER's PATH, which the operator cannot see.
const res = resolveBinary("vhclientarm64");
expect(res.kind).toBe("unresolved");
if (res.kind !== "unresolved") throw new Error("unreachable");
expect(res.reason).toContain("absolute path");
});
test("an absolute path that isn't there says so, and names the path", () => {
const missing = path.join(ROOT, "definitely-not-here");
const res = resolveBinary(missing);
expect(res.kind).toBe("unresolved");
if (res.kind !== "unresolved") throw new Error("unreachable");
expect(res.reason).toContain(missing);
});
test("an empty string means 'unset', not 'a binary called empty'", () => {
stubOnPath(KNOWN_BINARIES[0] as string);
expect(resolveBinary("").kind).toBe("resolved");
});
});
describe("an unresolved binary is an IPC error, not a dead plugin", () => {
test("send fails with the resolution's reason", async () => {
const backend = makeExecBackend({
kind: "unresolved",
reason: "no VirtualHere client found on PATH",
});
const exit = await Effect.runPromiseExit(backend.send("LIST"));
expect(exit._tag).toBe("Failure");
// The reason has to reach the operator verbatim — it IS the remedy the ladder shows.
expect(JSON.stringify(exit)).toContain(
"no VirtualHere client found on PATH",
);
});
test("the backend still identifies itself as the exec transport", () => {
// Diagnostics reports the transport before it reports the failure; an unresolved binary
// must not make the plugin claim it is talking a named pipe.
expect(makeExecBackend({ kind: "unresolved", reason: "x" }).kind).toBe(
"exec",
);
});
});