mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 06:30:42 +00:00
fix: harden staged runtime dep safety
This commit is contained in:
@@ -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 () => {
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -432,6 +432,7 @@ describe("stageBundledPluginRuntimeDeps", () => {
|
||||
expect(result).toEqual({
|
||||
applied: false,
|
||||
reason: "unexpected_content",
|
||||
targetPath,
|
||||
});
|
||||
expect(fs.readFileSync(targetPath, "utf8")).toBe(originalText);
|
||||
});
|
||||
|
||||
@@ -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: {
|
||||
|
||||
Reference in New Issue
Block a user