7 Commits
Author SHA1 Message Date
enricobuehler e21e7ffdb4 Merge pull request 'The lockfile still recorded the caret pin 0.1.1 removed' (#3) from fix/lockfile-effect-pin-drift into main
CI / build (push) Failing after 10m4s
CI / publish (push) Skipped
2026-08-13 07:36:27 +00:00
enricobuehler 189dc05811 fix(deps): the lockfile still recorded the caret pin 0.1.1 removed
CI / build (pull_request) Successful in 1m39s
CI / publish (pull_request) Skipped
0.1.1 pinned effect exactly — `4.0.0-beta.102`, no caret — because a caret
range on a fast-moving prerelease is what crash-looped this plugin and
rom-manager: `^4.0.0-beta.99` resolved beta.106, which had dropped
`Schema.TaggedErrorClass`, and the module threw at import before any plugin
code ran.

bun.lock never caught up. Its `plugin` workspace entry still recorded
`"effect": "^4.0.0-beta.102"` and `"version": "0.1.0"`, so the file that
`--frozen-lockfile` trusts still described the shape the fix removed, one
release after it landed.

Nothing was resolving wrongly. The lock's resolution entry pins
effect@4.0.0-beta.102, verified in a clean clone — the only effect anywhere
in the installed tree is beta.102 — and the published 0.1.1 tarball carries
the exact pin, so no user is exposed. `bun install --frozen-lockfile` also
passes against the stale file today: bun tolerates workspace-metadata drift
rather than failing on it, which is precisely why this sat unnoticed.

What it costs is the record. The lockfile is the artifact CI reproduces
from and the first thing anyone reads to ask "is the caret hazard still
here?" — and it answered yes. Regenerating changes exactly these two lines
and no resolution at all, so the install graph is bit-identical before and
after.

No version bump: bun.lock is not published (the plugin ships `files:
["dist"]`, and the lock is at the workspace root), so 0.1.1 on the registry
is unaffected and needs no re-release.
2026-08-13 09:26:06 +02:00
enricobuehler 22e764bea3 Merge pull request 'fix: 0.1.1 — pin effect exactly; a caret range on a beta crash-looped the plugin' (#2) from fix-effect-pin into main
CI / build (push) Successful in 1m18s
CI / publish (push) Successful in 27s
Reviewed-on: #2
2026-08-09 08:08:36 +00:00
enricobuehler 58839c2d32 fix(deps): 0.1.1 — pin effect exactly; a caret range on a beta crash-looped the plugin
CI / build (pull_request) Successful in 27s
CI / publish (pull_request) Skipped
The plugin would not start. Every launch died at module import with

    TypeError: Schema3.TaggedErrorClass is not a function

reported by the runner as the uninformative `UnknownError: An error occurred in Effect.tryPromise`
(`sdk/src/runner.ts` wraps the import in a bare `tryPromise` and logs only the first line of the
cause, so the real TypeError never reaches a log line).

Cause: `"effect": "^4.0.0-beta.102"`. A caret range spans breaking prereleases. effect still had
`Schema.TaggedErrorClass` at beta.102 but had removed it by beta.106, so a fresh install resolved
beta.106 and the contract's Schema-backed error classes failed to construct at import time — before
any of this plugin's code ran.

Pinned to exactly the version it was already written against, beta.102, which still has the API;
this is the smallest change that makes runtime match the build. Measured: beta.99 and beta.102 both
export TaggedErrorClass, beta.106 does not.

Verified by reproduction rather than inspection — clean sandbox, same command:
  0.1.0 (^4.0.0-beta.102) -> resolves effect beta.106 -> import fails with the TypeError above
  0.1.1 (4.0.0-beta.102)  -> resolves effect beta.102 -> import OK, default export `virtualhere`

Gates: frozen-lockfile install, typecheck x3, 73 tests, biome — all clean.

Same class of bug as punktfunk-plugin-rom-manager#4. The other plugins (epic, gog, heroic, lutris,
steam, xbox) already pin exactly and are unaffected; playnite still carries `^4.0.0-beta.99` and
has the same latent bug.
2026-08-09 09:46:34 +02:00
enricobuehler 768cf2f460 Merge pull request 'fix(ipc): the client is found on ARM too, and a relative path is refused instead of run' (#1) from worktree-exec-binary-resolution into main
CI / build (push) Successful in 37s
CI / publish (push) Skipped
Reviewed-on: #1
2026-08-03 16:49:20 +00:00
enricobuehlerandClaude Opus 5 7d3654ad41 docs: don't name a release that doesn't exist yet
CI / build (pull_request) Successful in 2m4s
CI / publish (pull_request) Skipped
The /tmp and log-page notes referenced punktfunk 0.23.1 as the version carrying
the fix. 0.23.0 is the latest tag and the next number isn't decided, so that
claim could simply be wrong on arrival. Phrase both against 0.23.0, which is a
fact.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-03 16:00:39 +02:00
enricobuehlerandClaude Opus 5 1dc5ac9b25 fix(ipc): the client is found on ARM too, and a relative path is refused instead of run
Two bugs in how the Linux/macOS backend decides which VirtualHere binary to
run, both reported by a user whose plugin could not use a client that was
installed and working.

`KNOWN_BINARIES` has listed four architecture variants since the first commit,
but `resolveBinary` only ever returned the first of them. On anything that is
not x86_64 the plugin therefore asked for `vhclientx86_64`, got ENOENT, and told
the operator to install the client they already had. It now probes PATH across
every name and hands back the absolute path it found.

The absolute-path rule was documented in this file and in the README from the
start — "anything else is rejected rather than executed" — and was never
implemented: whatever the operator typed went straight to execFile. So a bare
name silently resolved out of the *runner's* PATH, which is not the PATH of the
shell they tested in and which they cannot see. It is now enforced, and says
which of the two mistakes was made.

Resolution failure is carried to the first send as an ordinary VhIpcError rather
than failing the layer, so the plugin stays up and the Diagnostics tab (and
`doctor`) can render the reason with its remedy — losing the console page that
explains a missing client would be a poor trade for a client that is missing by
default.

Also records, here and in the README, that the `/tmp` FIFOs this backend depends
on were invisible to the plugin runner until punktfunk 0.23.1: the unit set
PrivateTmp=yes, so the binary launched and could never reach the daemon behind
it. That is the shape of the original report — works in a shell, fails from the
plugin, and no config change helps.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-03 15:19:32 +02:00
6 changed files with 281 additions and 43 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
+2 -2
View File
@@ -24,14 +24,14 @@
},
"plugin": {
"name": "@punktfunk/plugin-virtualhere",
"version": "0.1.0",
"version": "0.1.1",
"bin": {
"punktfunk-plugin-virtualhere": "./dist/cli.js",
},
"dependencies": {
"@punktfunk/host": "^0.1.2",
"@punktfunk/plugin-kit": "^0.2.0",
"effect": "^4.0.0-beta.102",
"effect": "4.0.0-beta.102",
},
"devDependencies": {
"@types/bun": "^1.3.0",
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@punktfunk/plugin-virtualhere",
"version": "0.1.0",
"version": "0.1.1",
"private": false,
"type": "module",
"description": "Punktfunk plugin: hands a couch's physical USB device (pad, wheel, HOTAS) to the host for the length of a session over VirtualHere, and gives it back after. Integration only \u2014 VirtualHere is sold separately by VirtualHere Pty. Ltd.",
@@ -40,7 +40,7 @@
"dependencies": {
"@punktfunk/host": "^0.1.2",
"@punktfunk/plugin-kit": "^0.2.0",
"effect": "^4.0.0-beta.102"
"effect": "4.0.0-beta.102"
},
"devDependencies": {
"@types/bun": "^1.3.0",
+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",
);
});
});