fix(server): keep the local speech library path out of agent environments (#5987)

The supervisor prepended the sherpa-onnx platform package directory to the
daemon worker's loader variable (PATH, LD_LIBRARY_PATH, DYLD_LIBRARY_PATH),
so every agent and terminal inherited it. In packaged desktop builds that
directory resolves inside app.asar, and on Windows Git Bash truncates the
PATH it hands to native executables at that entry.

The local speech worker already sets the loader variable on its own
environment when it is forked, so the daemon worker does not need it.

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
Co-authored-by: hanhnt2 <56497031+hanh9898@users.noreply.github.com>
This commit is contained in:
paseo-bot[bot]
2026-10-03 22:04:28 +02:00
committed by GitHub
co-authored by hanhnt2
parent 0d05584d04
commit 749fe3e5ae
2 changed files with 85 additions and 3 deletions
@@ -0,0 +1,85 @@
import { spawn } from "node:child_process";
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
import { expect, test } from "vitest";
import {
resolveSherpaLoaderEnv,
sherpaLoaderEnvKey,
} from "../src/server/speech/providers/local/sherpa/sherpa-runtime-env.js";
const loaderKey = sherpaLoaderEnvKey();
const loaderEnv = resolveSherpaLoaderEnv();
// Agents and terminals inherit the daemon worker's environment, so the local speech
// runtime's library directory must stay scoped to the speech worker.
test.runIf(loaderKey && loaderEnv)(
"daemon worker environment does not carry the local speech library directory",
async () => {
const root = await mkdtemp(path.join(tmpdir(), "paseo-supervisor-env-"));
const reportPath = path.join(root, "worker-env.json");
const preloadPath = path.join(root, "report-worker-env.mjs");
await writeFile(
preloadPath,
`
import { writeFileSync } from "node:fs";
if (/daemon-worker\\.(ts|js)$/.test(process.argv[1] ?? "")) {
writeFileSync(${JSON.stringify(reportPath)}, JSON.stringify({ value: process.env[${JSON.stringify(loaderKey)}] ?? "" }));
process.exit(0);
}
`,
);
const env = Object.fromEntries(
Object.entries(process.env).filter(([key]) => !key.startsWith("PASEO_")),
);
// Start from a parent environment that does not already list the speech library directory.
const envLoaderKey =
Object.keys(env).find((key) => key.toLowerCase() === loaderKey!.toLowerCase()) ?? loaderKey!;
const parentValue = (env[envLoaderKey] ?? "")
.split(path.delimiter)
.filter((entry) => entry && entry !== loaderEnv!.libDir)
.join(path.delimiter);
env[envLoaderKey] = parentValue;
const child = spawn(
process.execPath,
["--import", "tsx", fileURLToPath(new URL("./supervisor-entrypoint.ts", import.meta.url))],
{
cwd: fileURLToPath(new URL("../../../", import.meta.url)),
env: {
...env,
HOME: root,
USERPROFILE: root,
PASEO_HOME: path.join(root, "home"),
NODE_OPTIONS: `--import=${pathToFileURL(preloadPath).href}`,
},
stdio: ["ignore", "pipe", "pipe"],
},
);
let output = "";
child.stdout.on("data", (chunk) => {
output += chunk;
});
child.stderr.on("data", (chunk) => {
output += chunk;
});
try {
await expect
.poll(async () => readFile(reportPath, "utf8").catch(() => null), {
timeout: 20_000,
message: output,
})
.not.toBeNull();
const report = JSON.parse(await readFile(reportPath, "utf8")) as { value: string };
expect(report.value).toBe(parentValue);
} finally {
if (child.exitCode === null) {
const closed = new Promise((resolve) => child.once("close", resolve));
child.kill("SIGKILL");
await closed;
}
await rm(root, { recursive: true, force: true });
}
},
30_000,
);
@@ -14,7 +14,6 @@ import { PRIVATE_FILE_MODE } from "../src/server/private-files.js";
import { loadPersistedConfig } from "../src/server/persisted-config.js";
import { runSupervisor } from "./supervisor.js";
import { resolveSupervisorLogFile } from "./supervisor-log-config.js";
import { applySherpaLoaderEnv } from "../src/server/speech/providers/local/sherpa/sherpa-runtime-env.js";
process.title = "Paseo Supervisor";
@@ -108,8 +107,6 @@ async function main(): Promise<void> {
? resolvePackagedNodeEntrypointRunnerPath(fileURLToPath(import.meta.url))
: null;
applySherpaLoaderEnv(workerEnv);
const paseoHome = resolvePaseoHome(workerEnv);
const persistedConfig = loadPersistedConfig(paseoHome);
const supervisorLogFile = resolveSupervisorLogFile(paseoHome, persistedConfig, workerEnv);