fix: harden staged runtime dep safety

This commit is contained in:
Frank Yang
2026-04-14 18:10:49 +08:00
parent 426ab5d58c
commit b5db59b8fe
6 changed files with 298 additions and 25 deletions

View File

@@ -127,7 +127,11 @@ describe("web session", () => {
fetchAgent?: unknown;
};
expect(passed.agent).toBeDefined();
expect(passed.fetchAgent).toBe(passed.agent);
expect(passed.fetchAgent).toBeDefined();
expect(passed.fetchAgent).not.toBe(passed.agent);
expect(typeof (passed.fetchAgent as { dispatch?: unknown } | undefined)?.dispatch).toBe(
"function",
);
});
it("does not create a proxy agent when no env proxy is configured", async () => {

View File

@@ -125,6 +125,7 @@ export async function createWaSocket(
const { state, saveCreds } = await useMultiFileAuthState(authDir);
const { version } = await fetchLatestBaileysVersion();
const agent = await resolveEnvProxyAgent(sessionLogger);
const fetchAgent = await resolveEnvFetchDispatcher(sessionLogger, agent);
const sock = makeWASocket({
auth: {
creds: state.creds,
@@ -137,7 +138,9 @@ export async function createWaSocket(
syncFullHistory: false,
markOnlineOnConnect: false,
agent,
fetchAgent: agent,
// Baileys types still model `fetchAgent` as a Node agent even though the
// runtime path accepts an undici dispatcher for upload fetches.
fetchAgent: fetchAgent as Agent | undefined,
});
sock.ev.on("creds.update", () => enqueueSaveCreds(authDir, saveCreds, sessionLogger));
@@ -199,6 +202,58 @@ async function resolveEnvProxyAgent(
});
}
async function resolveEnvFetchDispatcher(
logger: ReturnType<typeof getChildLogger>,
agent?: unknown,
): Promise<unknown> {
const proxyUrl = resolveProxyUrlFromAgent(agent);
const envProxyUrl = resolveEnvHttpsProxyUrl();
if (!proxyUrl && !envProxyUrl) {
return undefined;
}
try {
const { EnvHttpProxyAgent, ProxyAgent } = await import("undici");
return proxyUrl
? new ProxyAgent({ allowH2: false, uri: proxyUrl })
: new EnvHttpProxyAgent({ allowH2: false });
} catch (error) {
logger.warn(
{ error: String(error) },
"Failed to initialize env proxy dispatcher for WhatsApp media uploads",
);
return undefined;
}
}
function resolveProxyUrlFromAgent(agent: unknown): string | undefined {
if (typeof agent !== "object" || agent === null || !("proxy" in agent)) {
return undefined;
}
const proxy = (agent as { proxy?: unknown }).proxy;
if (proxy instanceof URL) {
return proxy.toString();
}
return typeof proxy === "string" && proxy.length > 0 ? proxy : undefined;
}
function resolveEnvHttpsProxyUrl(env: NodeJS.ProcessEnv = process.env): string | undefined {
const lowerHttpsProxy = normalizeEnvProxyValue(env.https_proxy);
const lowerHttpProxy = normalizeEnvProxyValue(env.http_proxy);
const httpsProxy =
lowerHttpsProxy !== undefined ? lowerHttpsProxy : normalizeEnvProxyValue(env.HTTPS_PROXY);
const httpProxy =
lowerHttpProxy !== undefined ? lowerHttpProxy : normalizeEnvProxyValue(env.HTTP_PROXY);
return httpsProxy ?? httpProxy ?? undefined;
}
function normalizeEnvProxyValue(value: string | undefined): string | null | undefined {
if (typeof value !== "string") {
return undefined;
}
const trimmed = value.trim();
return trimmed.length > 0 ? trimmed : null;
}
export async function waitForWaConnection(sock: ReturnType<typeof makeWASocket>) {
return new Promise<void>((resolve, reject) => {
type OffCapable = {

View File

@@ -331,7 +331,7 @@ export function applyBaileysEncryptedStreamFinishHotfix(params = {}) {
}
if (!dispatcherResolved) {
return { applied: false, reason: "unexpected_content" };
return { applied: false, reason: "unexpected_content", targetPath };
}
if (!applied) {

View File

@@ -49,15 +49,43 @@ function replaceDir(targetPath, sourcePath) {
removePathIfExists(sourcePath);
}
function dependencyPathSegments(depName) {
if (typeof depName !== "string" || depName.length === 0) {
return null;
}
const segments = depName.split("/");
if (depName.startsWith("@")) {
if (segments.length !== 2) {
return null;
}
const [scope, name] = segments;
if (
!/^@[A-Za-z0-9._-]+$/.test(scope) ||
!/^[A-Za-z0-9._-]+$/.test(name) ||
scope === "@." ||
scope === "@.."
) {
return null;
}
return [scope, name];
}
if (segments.length !== 1 || !/^[A-Za-z0-9._-]+$/.test(segments[0])) {
return null;
}
return segments;
}
function dependencyNodeModulesPath(nodeModulesDir, depName) {
return path.join(nodeModulesDir, ...depName.split("/"));
const segments = dependencyPathSegments(depName);
return segments ? path.join(nodeModulesDir, ...segments) : null;
}
function readInstalledDependencyVersion(nodeModulesDir, depName) {
const packageJsonPath = path.join(
dependencyNodeModulesPath(nodeModulesDir, depName),
"package.json",
);
const depRoot = dependencyNodeModulesPath(nodeModulesDir, depName);
if (depRoot === null) {
return null;
}
const packageJsonPath = path.join(depRoot, "package.json");
if (!fs.existsSync(packageJsonPath)) {
return null;
}
@@ -119,7 +147,7 @@ const defaultStagedRuntimeDepPruneRules = new Map([
["@jimp/plugin-quantize", { paths: ["src/__image_snapshots__"] }],
["@jimp/plugin-threshold", { paths: ["src/__image_snapshots__"] }],
]);
const runtimeDepsStagingVersion = 2;
const runtimeDepsStagingVersion = 3;
function resolveRuntimeDepPruneConfig(params = {}) {
return {
@@ -132,11 +160,18 @@ function resolveRuntimeDepPruneConfig(params = {}) {
function resolveInstalledDependencyRoot(params) {
const candidates = [];
if (params.parentPackageRoot) {
candidates.push(
path.join(params.parentPackageRoot, "node_modules", ...params.depName.split("/")),
const nestedDepRoot = dependencyNodeModulesPath(
path.join(params.parentPackageRoot, "node_modules"),
params.depName,
);
if (nestedDepRoot !== null) {
candidates.push(nestedDepRoot);
}
}
const rootDepRoot = dependencyNodeModulesPath(params.rootNodeModulesDir, params.depName);
if (rootDepRoot !== null) {
candidates.push(rootDepRoot);
}
candidates.push(dependencyNodeModulesPath(params.rootNodeModulesDir, params.depName));
for (const depRoot of candidates) {
const installedVersion = readInstalledDependencyVersionFromRoot(depRoot);
@@ -179,7 +214,7 @@ function collectInstalledRuntimeDependencyRoots(rootNodeModulesDir, dependencySp
}
seen.add(seenKey);
const record = { name: current.depName, root: depRoot };
const record = { name: current.depName, root: depRoot, realRoot: canonicalDepRoot };
allRoots.push(record);
if (current.direct) {
directRoots.push(record);
@@ -213,6 +248,69 @@ function pathIsInsideCopiedRoot(candidateRoot, copiedRoot) {
return candidateRoot === copiedRoot || candidateRoot.startsWith(`${copiedRoot}${path.sep}`);
}
function findContainingRealRoot(candidatePath, allowedRealRoots) {
return (
allowedRealRoots.find((rootPath) => pathIsInsideCopiedRoot(candidatePath, rootPath)) ?? null
);
}
function copyMaterializedDependencyTree(params) {
const { activeRoots, allowedRealRoots, sourcePath, targetPath } = params;
const sourceStats = fs.lstatSync(sourcePath);
if (sourceStats.isSymbolicLink()) {
let resolvedPath;
try {
resolvedPath = fs.realpathSync(sourcePath);
} catch {
return false;
}
const containingRoot = findContainingRealRoot(resolvedPath, allowedRealRoots);
if (containingRoot === null) {
return false;
}
if (activeRoots.has(containingRoot)) {
return true;
}
const nextActiveRoots = new Set(activeRoots);
nextActiveRoots.add(containingRoot);
return copyMaterializedDependencyTree({
activeRoots: nextActiveRoots,
allowedRealRoots,
sourcePath: resolvedPath,
targetPath,
});
}
if (sourceStats.isDirectory()) {
fs.mkdirSync(targetPath, { recursive: true });
for (const entry of fs
.readdirSync(sourcePath, { withFileTypes: true })
.toSorted((left, right) => left.name.localeCompare(right.name))) {
if (
!copyMaterializedDependencyTree({
activeRoots,
allowedRealRoots,
sourcePath: path.join(sourcePath, entry.name),
targetPath: path.join(targetPath, entry.name),
})
) {
return false;
}
}
return true;
}
if (sourceStats.isFile()) {
fs.mkdirSync(path.dirname(targetPath), { recursive: true });
fs.copyFileSync(sourcePath, targetPath);
fs.chmodSync(targetPath, sourceStats.mode);
return true;
}
return true;
}
function selectRuntimeDependencyRootsToCopy(resolution) {
const rootsToCopy = [];
@@ -221,7 +319,7 @@ function selectRuntimeDependencyRootsToCopy(resolution) {
}
for (const record of resolution.allRoots) {
if (rootsToCopy.some((entry) => pathIsInsideCopiedRoot(record.root, entry.root))) {
if (rootsToCopy.some((entry) => pathIsInsideCopiedRoot(record.realRoot, entry.realRoot))) {
continue;
}
rootsToCopy.push(record);
@@ -272,7 +370,7 @@ function createInstalledRuntimeClosureFingerprint(rootNodeModulesDir, dependency
const hash = createHash("sha256");
for (const depName of [...dependencyNames].toSorted((left, right) => left.localeCompare(right))) {
const depRoot = dependencyNodeModulesPath(rootNodeModulesDir, depName);
if (!fs.existsSync(depRoot)) {
if (depRoot === null || !fs.existsSync(depRoot)) {
return null;
}
hash.update(`package:${depName}\n`);
@@ -335,6 +433,9 @@ function pruneDependencyFilesBySuffixes(depRoot, suffixes) {
function pruneStagedInstalledDependencyCargo(nodeModulesDir, depName, pruneConfig) {
const depRoot = dependencyNodeModulesPath(nodeModulesDir, depName);
if (depRoot === null) {
return;
}
const pruneRule = pruneConfig.pruneRules.get(depName);
for (const relativePath of pruneRule?.paths ?? []) {
removePathIfExists(path.join(depRoot, relativePath));
@@ -482,6 +583,7 @@ function stageInstalledRootRuntimeDeps(params) {
return false;
}
const rootsToCopy = selectRuntimeDependencyRootsToCopy(resolution);
const allowedRealRoots = rootsToCopy.map((record) => record.realRoot);
const nodeModulesDir = path.join(pluginDir, "node_modules");
const stampPath = resolveRuntimeDepsStampPath(pluginDir);
@@ -497,10 +599,24 @@ function stageInstalledRootRuntimeDeps(params) {
for (const record of rootsToCopy.toSorted((left, right) =>
left.name.localeCompare(right.name),
)) {
const sourcePath = record.root;
const sourcePath = record.realRoot;
const targetPath = dependencyNodeModulesPath(stagedNodeModulesDir, record.name);
if (targetPath === null) {
return false;
}
fs.mkdirSync(path.dirname(targetPath), { recursive: true });
fs.cpSync(sourcePath, targetPath, { recursive: true, force: true, dereference: true });
const sourceRootReal = findContainingRealRoot(sourcePath, allowedRealRoots);
if (
sourceRootReal === null ||
!copyMaterializedDependencyTree({
activeRoots: new Set([sourceRootReal]),
allowedRealRoots,
sourcePath,
targetPath,
})
) {
return false;
}
}
pruneStagedRuntimeDependencyCargo(stagedNodeModulesDir, pruneConfig);

View File

@@ -432,6 +432,7 @@ describe("stageBundledPluginRuntimeDeps", () => {
expect(result).toEqual({
applied: false,
reason: "unexpected_content",
targetPath,
});
expect(fs.readFileSync(targetPath, "utf8")).toBe(originalText);
});

View File

@@ -298,7 +298,7 @@ describe("stageBundledPluginRuntimeDeps", () => {
).toBe("module.exports = 'nested';\n");
});
it("does not change the runtime-deps stamp when only a symlinked directory target changes", () => {
it("falls back to install when a dependency tree contains an unowned symlinked directory", () => {
const { pluginDir, repoRoot } = createBundledPluginFixture({
packageJson: {
name: "@openclaw/fixture-plugin",
@@ -321,14 +321,29 @@ describe("stageBundledPluginRuntimeDeps", () => {
fs.writeFileSync(path.join(linkedTargetDir, "marker.txt"), "first\n", "utf8");
fs.symlinkSync(linkedTargetDir, linkedPath);
stageBundledPluginRuntimeDeps({ cwd: repoRoot });
const stampPath = path.join(pluginDir, ".openclaw-runtime-deps-stamp.json");
const firstStamp = fs.readFileSync(stampPath, "utf8");
let installCount = 0;
stageBundledPluginRuntimeDeps({
cwd: repoRoot,
installPluginRuntimeDepsImpl: ({ fingerprint }: { fingerprint: string }) => {
installCount += 1;
const nodeModulesDir = path.join(pluginDir, "node_modules");
fs.mkdirSync(nodeModulesDir, { recursive: true });
fs.writeFileSync(path.join(nodeModulesDir, "marker.txt"), "installed\n", "utf8");
fs.writeFileSync(
path.join(pluginDir, ".openclaw-runtime-deps-stamp.json"),
`${JSON.stringify({ fingerprint }, null, 2)}\n`,
"utf8",
);
},
});
fs.writeFileSync(path.join(linkedTargetDir, "marker.txt"), "second\n", "utf8");
stageBundledPluginRuntimeDeps({ cwd: repoRoot });
expect(fs.readFileSync(stampPath, "utf8")).toBe(firstStamp);
expect(installCount).toBe(1);
expect(
fs.existsSync(path.join(pluginDir, "node_modules", "direct", "node_modules", "linked")),
).toBe(false);
expect(fs.readFileSync(path.join(pluginDir, "node_modules", "marker.txt"), "utf8")).toBe(
"installed\n",
);
});
it("dedupes cyclic dependency aliases by canonical root", () => {
@@ -377,6 +392,88 @@ describe("stageBundledPluginRuntimeDeps", () => {
).toBe("module.exports = 'b';\n");
});
it("falls back to install when a dependency name escapes node_modules", () => {
const { pluginDir, repoRoot } = createBundledPluginFixture({
packageJson: {
name: "@openclaw/fixture-plugin",
version: "1.0.0",
dependencies: { "../escape": "1.0.0" },
openclaw: { bundle: { stageRuntimeDependencies: true } },
},
});
let installCount = 0;
stageBundledPluginRuntimeDeps({
cwd: repoRoot,
installPluginRuntimeDepsImpl: ({ fingerprint }: { fingerprint: string }) => {
installCount += 1;
const nodeModulesDir = path.join(pluginDir, "node_modules");
fs.mkdirSync(nodeModulesDir, { recursive: true });
fs.writeFileSync(path.join(nodeModulesDir, "marker.txt"), "installed\n", "utf8");
fs.writeFileSync(
path.join(pluginDir, ".openclaw-runtime-deps-stamp.json"),
`${JSON.stringify({ fingerprint }, null, 2)}\n`,
"utf8",
);
},
});
expect(installCount).toBe(1);
expect(fs.existsSync(path.join(pluginDir, "escape"))).toBe(false);
expect(fs.readFileSync(path.join(pluginDir, "node_modules", "marker.txt"), "utf8")).toBe(
"installed\n",
);
});
it("falls back to install when a staged dependency tree contains a symlink outside copied roots", () => {
const { pluginDir, repoRoot } = createBundledPluginFixture({
packageJson: {
name: "@openclaw/fixture-plugin",
version: "1.0.0",
dependencies: { direct: "1.0.0" },
openclaw: { bundle: { stageRuntimeDependencies: true } },
},
});
const directDir = path.join(repoRoot, "node_modules", "direct");
const escapedDir = path.join(repoRoot, "outside-root");
fs.mkdirSync(path.join(directDir, "node_modules"), { recursive: true });
fs.mkdirSync(escapedDir, { recursive: true });
fs.writeFileSync(
path.join(directDir, "package.json"),
'{ "name": "direct", "version": "1.0.0" }\n',
"utf8",
);
fs.writeFileSync(path.join(directDir, "index.js"), "module.exports = 'direct';\n", "utf8");
fs.writeFileSync(path.join(escapedDir, "secret.txt"), "host secret\n", "utf8");
fs.symlinkSync(escapedDir, path.join(directDir, "node_modules", "escaped"));
let installCount = 0;
stageBundledPluginRuntimeDeps({
cwd: repoRoot,
installPluginRuntimeDepsImpl: ({ fingerprint }: { fingerprint: string }) => {
installCount += 1;
const nodeModulesDir = path.join(pluginDir, "node_modules");
fs.mkdirSync(nodeModulesDir, { recursive: true });
fs.writeFileSync(path.join(nodeModulesDir, "marker.txt"), "installed\n", "utf8");
fs.writeFileSync(
path.join(pluginDir, ".openclaw-runtime-deps-stamp.json"),
`${JSON.stringify({ fingerprint }, null, 2)}\n`,
"utf8",
);
},
});
expect(installCount).toBe(1);
expect(
fs.existsSync(
path.join(pluginDir, "node_modules", "direct", "node_modules", "escaped", "secret.txt"),
),
).toBe(false);
expect(fs.readFileSync(path.join(pluginDir, "node_modules", "marker.txt"), "utf8")).toBe(
"installed\n",
);
});
it("falls back to install when the root transitive closure is incomplete", () => {
const { pluginDir, repoRoot } = createBundledPluginFixture({
packageJson: {