fix: harden windows npm runtime path

This commit is contained in:
Peter Steinberger 2026-03-12 22:03:10 +00:00
parent 92191fcd68
commit 86a3149b2e
No known key found for this signature in database
7 changed files with 81 additions and 8 deletions

1
.npmignore Normal file
View File

@ -0,0 +1 @@
**/node_modules/

1
extensions/.npmignore Normal file
View File

@ -0,0 +1 @@
**/node_modules/

View File

@ -218,6 +218,16 @@ function runPackDry(): PackResult[] {
return JSON.parse(raw) as PackResult[]; return JSON.parse(raw) as PackResult[];
} }
export function collectForbiddenPackPaths(paths: Iterable<string>): string[] {
return [...paths]
.filter(
(path) =>
forbiddenPrefixes.some((prefix) => path.startsWith(prefix)) ||
/(^|\/)node_modules\//.test(path),
)
.toSorted();
}
function checkPluginVersions() { function checkPluginVersions() {
const rootPackagePath = resolve("package.json"); const rootPackagePath = resolve("package.json");
const rootPackage = JSON.parse(readFileSync(rootPackagePath, "utf8")) as PackageJson; const rootPackage = JSON.parse(readFileSync(rootPackagePath, "utf8")) as PackageJson;
@ -422,9 +432,7 @@ function main() {
return paths.has(group) ? [] : [group]; return paths.has(group) ? [] : [group];
}) })
.toSorted(); .toSorted();
const forbidden = [...paths].filter((path) => const forbidden = collectForbiddenPackPaths(paths);
forbiddenPrefixes.some((prefix) => path.startsWith(prefix)),
);
if (missing.length > 0 || forbidden.length > 0) { if (missing.length > 0 || forbidden.length > 0) {
if (missing.length > 0) { if (missing.length > 0) {

View File

@ -36,6 +36,7 @@ const {
resolveDiscordAllowlistConfigMock, resolveDiscordAllowlistConfigMock,
resolveNativeCommandsEnabledMock, resolveNativeCommandsEnabledMock,
resolveNativeSkillsEnabledMock, resolveNativeSkillsEnabledMock,
voiceRuntimeModuleLoadedMock,
} = vi.hoisted(() => { } = vi.hoisted(() => {
const createdBindingManagers: Array<{ stop: ReturnType<typeof vi.fn> }> = []; const createdBindingManagers: Array<{ stop: ReturnType<typeof vi.fn> }> = [];
return { return {
@ -103,6 +104,7 @@ const {
})), })),
resolveNativeCommandsEnabledMock: vi.fn(() => true), resolveNativeCommandsEnabledMock: vi.fn(() => true),
resolveNativeSkillsEnabledMock: vi.fn(() => false), resolveNativeSkillsEnabledMock: vi.fn(() => false),
voiceRuntimeModuleLoadedMock: vi.fn(),
}; };
}); });
@ -210,10 +212,13 @@ vi.mock("../voice/command.js", () => ({
createDiscordVoiceCommand: () => ({ name: "voice-command" }), createDiscordVoiceCommand: () => ({ name: "voice-command" }),
})); }));
vi.mock("../voice/manager.js", () => ({ vi.mock("../voice/manager.runtime.js", () => {
DiscordVoiceManager: class DiscordVoiceManager {}, voiceRuntimeModuleLoadedMock();
DiscordVoiceReadyListener: class DiscordVoiceReadyListener {}, return {
})); DiscordVoiceManager: class DiscordVoiceManager {},
DiscordVoiceReadyListener: class DiscordVoiceReadyListener {},
};
});
vi.mock("./agent-components.js", () => ({ vi.mock("./agent-components.js", () => ({
createAgentComponentButton: () => ({ id: "btn" }), createAgentComponentButton: () => ({ id: "btn" }),
@ -390,6 +395,7 @@ describe("monitorDiscordProvider", () => {
}); });
resolveNativeCommandsEnabledMock.mockClear().mockReturnValue(true); resolveNativeCommandsEnabledMock.mockClear().mockReturnValue(true);
resolveNativeSkillsEnabledMock.mockClear().mockReturnValue(false); resolveNativeSkillsEnabledMock.mockClear().mockReturnValue(false);
voiceRuntimeModuleLoadedMock.mockClear();
}); });
it("stops thread bindings when startup fails before lifecycle begins", async () => { it("stops thread bindings when startup fails before lifecycle begins", async () => {
@ -424,6 +430,38 @@ describe("monitorDiscordProvider", () => {
expect(reconcileAcpThreadBindingsOnStartupMock).toHaveBeenCalledTimes(1); expect(reconcileAcpThreadBindingsOnStartupMock).toHaveBeenCalledTimes(1);
}); });
it("does not load the Discord voice runtime when voice is disabled", async () => {
const { monitorDiscordProvider } = await import("./provider.js");
await monitorDiscordProvider({
config: baseConfig(),
runtime: baseRuntime(),
});
expect(voiceRuntimeModuleLoadedMock).not.toHaveBeenCalled();
});
it("loads the Discord voice runtime only when voice is enabled", async () => {
resolveDiscordAccountMock.mockReturnValue({
accountId: "default",
token: "cfg-token",
config: {
commands: { native: true, nativeSkills: false },
voice: { enabled: true },
agentComponents: { enabled: false },
execApprovals: { enabled: false },
},
});
const { monitorDiscordProvider } = await import("./provider.js");
await monitorDiscordProvider({
config: baseConfig(),
runtime: baseRuntime(),
});
expect(voiceRuntimeModuleLoadedMock).toHaveBeenCalledTimes(1);
});
it("treats ACP error status as uncertain during startup thread-binding probes", async () => { it("treats ACP error status as uncertain during startup thread-binding probes", async () => {
const { monitorDiscordProvider } = await import("./provider.js"); const { monitorDiscordProvider } = await import("./provider.js");
getAcpSessionStatusMock.mockResolvedValue({ state: "error" }); getAcpSessionStatusMock.mockResolvedValue({ state: "error" });

View File

@ -48,7 +48,6 @@ import { resolveDiscordAccount } from "../accounts.js";
import { fetchDiscordApplicationId } from "../probe.js"; import { fetchDiscordApplicationId } from "../probe.js";
import { normalizeDiscordToken } from "../token.js"; import { normalizeDiscordToken } from "../token.js";
import { createDiscordVoiceCommand } from "../voice/command.js"; import { createDiscordVoiceCommand } from "../voice/command.js";
import { DiscordVoiceManager, DiscordVoiceReadyListener } from "../voice/manager.js";
import { import {
createAgentComponentButton, createAgentComponentButton,
createAgentSelectMenu, createAgentSelectMenu,
@ -104,6 +103,17 @@ export type MonitorDiscordOpts = {
setStatus?: DiscordMonitorStatusSink; setStatus?: DiscordMonitorStatusSink;
}; };
type DiscordVoiceManager = import("../voice/manager.js").DiscordVoiceManager;
type DiscordVoiceRuntimeModule = typeof import("../voice/manager.runtime.js");
let discordVoiceRuntimePromise: Promise<DiscordVoiceRuntimeModule> | undefined;
async function loadDiscordVoiceRuntime(): Promise<DiscordVoiceRuntimeModule> {
discordVoiceRuntimePromise ??= import("../voice/manager.runtime.js");
return await discordVoiceRuntimePromise;
}
function formatThreadBindingDurationForConfigLabel(durationMs: number): string { function formatThreadBindingDurationForConfigLabel(durationMs: number): string {
const label = formatThreadBindingDurationLabel(durationMs); const label = formatThreadBindingDurationLabel(durationMs);
return label === "disabled" ? "off" : label; return label === "disabled" ? "off" : label;
@ -663,6 +673,7 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) {
} }
if (voiceEnabled) { if (voiceEnabled) {
const { DiscordVoiceManager, DiscordVoiceReadyListener } = await loadDiscordVoiceRuntime();
voiceManager = new DiscordVoiceManager({ voiceManager = new DiscordVoiceManager({
client, client,
cfg, cfg,

View File

@ -0,0 +1 @@
export { DiscordVoiceManager, DiscordVoiceReadyListener } from "./manager.js";

View File

@ -3,6 +3,7 @@ import {
collectAppcastSparkleVersionErrors, collectAppcastSparkleVersionErrors,
collectBundledExtensionManifestErrors, collectBundledExtensionManifestErrors,
collectBundledExtensionRootDependencyGapErrors, collectBundledExtensionRootDependencyGapErrors,
collectForbiddenPackPaths,
} from "../scripts/release-check.ts"; } from "../scripts/release-check.ts";
function makeItem(shortVersion: string, sparkleVersion: string): string { function makeItem(shortVersion: string, sparkleVersion: string): string {
@ -150,3 +151,15 @@ describe("collectBundledExtensionManifestErrors", () => {
]); ]);
}); });
}); });
describe("collectForbiddenPackPaths", () => {
it("flags nested node_modules leaking into npm pack output", () => {
expect(
collectForbiddenPackPaths([
"dist/index.js",
"extensions/tlon/node_modules/.bin/tlon",
"node_modules/.bin/openclaw",
]),
).toEqual(["extensions/tlon/node_modules/.bin/tlon", "node_modules/.bin/openclaw"]);
});
});