项目文件夹

文件
2026-07-13 13:16:30 +08:00

584 行
21 KiB
JavaScript

/**
* glibc-compat.js - Minimal compatibility shim for glibc Node.js on Android
*
* This is the successor to bionic-compat.js, drastically reduced for glibc.
*
* What's NOT needed anymore (glibc handles these):
* - process.platform override (glibc Node.js reports 'linux' natively)
* - renameat2 / spawn.h stubs (glibc includes them)
* - CXXFLAGS / GYP_DEFINES overrides (glibc is standard Linux)
*
* What's still needed (kernel/Android-level restrictions, not libc):
* - os.cpus() fallback: SELinux blocks /proc/stat on Android 8+
* - os.networkInterfaces() safety: EACCES on some Android configurations
* - /bin/sh path shim: Android 7-8 lacks /bin/sh (Android 9+ has it)
*
* Loaded via node wrapper script: node --require <path>/glibc-compat.js
*/
'use strict';
const os = require('os');
const fs = require('fs');
const path = require('path');
// ─── process.execPath fix ────────────────────────────────────
// When node runs via grun (ld.so node.real), process.execPath points to
// ld.so instead of the node wrapper. Apps that spawn child node processes
// using process.execPath (e.g., openclaw) will call ld.so directly,
// bypassing the wrapper's LD_PRELOAD unset and compat loading.
// Fix: point process.execPath to the wrapper script.
const _wrapperPath = process.env._OA_WRAPPER_PATH || path.join(
process.env.HOME || '/data/data/com.termux/files/home',
'.openclaw-android', 'bin', 'node'
);
try {
if (fs.existsSync(_wrapperPath)) {
Object.defineProperty(process, 'execPath', {
value: _wrapperPath,
writable: true,
configurable: true,
});
}
} catch {}
// ─── LD_PRELOAD cleanup ─────────────────────────────────────
// The node wrapper unsets LD_PRELOAD to prevent bionic libtermux-exec.so
// from loading into the glibc node.real process.
//
// Previously, we restored LD_PRELOAD here so bionic child processes
// (like /bin/sh) would get libtermux-exec.so for path translation
// (e.g., /usr/bin/env → $PREFIX/bin/env in shebang resolution).
//
// However, libtermux-exec.so re-injects LD_PRELOAD into execve() calls
// even after the shell unsets it. This causes glibc processes (ld.so)
// spawned from node to crash with "Could not find a PHDR" errors.
//
// Fix: Do NOT restore LD_PRELOAD. Instead, the spawn/spawnSync wrapper
// below handles shebang resolution (#!/usr/bin/env → PATH lookup) in
// JavaScript, eliminating the need for libtermux-exec.so in child processes.
delete process.env.LD_PRELOAD;
delete process.env._OA_ORIG_LD_PRELOAD;
// ─── os.cpus() fallback ─────────────────────────────────────
// Android 8+ (API 26+) blocks /proc/stat via SELinux + hidepid=2.
// libuv reads /proc/stat for CPU info → returns empty array.
// Tools using os.cpus().length for parallelism (e.g., make -j) break with 0.
const _originalCpus = os.cpus;
os.cpus = function cpus() {
const result = _originalCpus.call(os);
if (result.length > 0) {
return result;
}
// Return a single fake CPU entry so .length is at least 1
return [{ model: 'unknown', speed: 0, times: { user: 0, nice: 0, sys: 0, idle: 0, irq: 0 } }];
};
// ─── os.networkInterfaces() safety ──────────────────────────
// Some Android configurations throw EACCES when reading network
// interface information. Wrap with try-catch to prevent crashes.
//
// Additionally, Android/Termux typically only exposes the loopback
// interface (`lo`) to Node.js. In that situation, OpenClaw's Bonjour
// advertiser can't send multicast announcements and logs noisy
// "Announcement failed as of socket errors!" repeatedly.
// Auto-disable Bonjour via OPENCLAW_DISABLE_BONJOUR when only
// loopback interfaces are visible.
const _originalNetworkInterfaces = os.networkInterfaces;
function _createLoopbackInterfaces() {
return {
lo: [
{
address: '127.0.0.1',
netmask: '255.0.0.0',
family: 'IPv4',
mac: '00:00:00:00:00:00',
internal: true,
cidr: '127.0.0.1/8',
},
],
};
}
function _hasNonLoopbackInterface(interfaces) {
try {
return Object.values(interfaces).some(entries =>
Array.isArray(entries) && entries.some(entry => entry && entry.internal === false)
);
} catch {
return false;
}
}
os.networkInterfaces = function networkInterfaces() {
let interfaces;
try {
interfaces = _originalNetworkInterfaces.call(os);
} catch {
interfaces = _createLoopbackInterfaces();
}
if (!process.env.OPENCLAW_DISABLE_BONJOUR && !_hasNonLoopbackInterface(interfaces)) {
process.env.OPENCLAW_DISABLE_BONJOUR = '1';
}
return interfaces;
};
// ─── Shell override for exec/execSync ────────────────────────
// Node.js child_process hardcodes /bin/sh as the default shell on Linux.
// On Android:
// - Android 7-8: /bin/sh doesn't exist at all
// - Android 9+: /bin/sh exists (/system/bin/sh) but is minimal (toybox/mksh)
// and lacks Termux PATH, environment, and proper command support
// Always use Termux's shell for exec/execSync to ensure consistent behavior.
{
const child_process = require('child_process');
const termuxSh = (process.env.PREFIX || '/data/data/com.termux/files/usr') + '/bin/sh';
if (fs.existsSync(termuxSh)) {
const _originalExec = child_process.exec;
const _originalExecSync = child_process.execSync;
child_process.exec = function exec(command, options, callback) {
if (typeof options === 'function') {
callback = options;
options = {};
}
options = options || {};
if (!options.shell) {
options.shell = termuxSh;
}
return _originalExec.call(child_process, command, options, callback);
};
child_process.execSync = function execSync(command, options) {
options = options || {};
if (!options.shell) {
options.shell = termuxSh;
}
return _originalExecSync.call(child_process, command, options);
};
}
}
// ─── DNS resolver fix ────────────────────────────────────────
// glibc's getaddrinfo() reads /data/data/com.termux/files/usr/glibc/etc/resolv.conf
// for DNS servers. This file may be missing or inaccessible:
// - Standalone APK: runs under com.openclaw.android, can't access com.termux paths
// - Termux: resolv-conf package may not be installed
// Without a valid resolv.conf, dns.lookup() fails with EAI_AGAIN errors.
//
// Fix: Override both dns.lookup (callback) and dns.promises.lookup (promise)
// to use c-ares resolver (dns.resolve) which respects dns.setServers(),
// then fall back to getaddrinfo.
try {
const dns = require('dns');
// Read DNS servers from our resolv.conf or use Google DNS as fallback
let dnsServers = ['8.8.8.8', '8.8.4.4'];
try {
const resolvConf = fs.readFileSync(
(process.env.PREFIX || '/data/data/com.termux/files/usr') + '/etc/resolv.conf',
'utf8'
);
const parsed = resolvConf.match(/^nameserver\s+(.+)$/gm);
if (parsed && parsed.length > 0) {
dnsServers = parsed.map(l => l.replace(/^nameserver\s+/, '').trim());
}
} catch {}
// Set DNS servers for c-ares resolver
try { dns.setServers(dnsServers); } catch {}
// Override dns.lookup (callback API) to use c-ares resolver
const _originalLookup = dns.lookup;
// Localhost must never go to external DNS. Android/glibc may lack /etc/hosts,
// causing getaddrinfo to fail or return 0.0.0.0. Short-circuit it here.
const _localhostNames = new Set(['localhost', 'localhost.localdomain', 'ip6-localhost', 'ip6-loopback']);
dns.lookup = function lookup(hostname, options, callback) {
if (typeof options === 'function') {
callback = options;
options = {};
}
const originalOptions = options;
const opts = typeof options === 'number' ? { family: options } : (options || {});
const wantAll = opts.all === true;
const family = opts.family || 0;
// Short-circuit localhost — never send to external DNS
if (_localhostNames.has(hostname)) {
if (family === 6) {
if (wantAll) return callback(null, [{ address: '::1', family: 6 }]);
return callback(null, '::1', 6);
}
if (wantAll) return callback(null, [{ address: '127.0.0.1', family: 4 }]);
return callback(null, '127.0.0.1', 4);
}
const resolve = (fam, cb) => {
const fn = fam === 6 ? dns.resolve6 : dns.resolve4;
fn(hostname, cb);
};
const tryResolve = (fam) => {
resolve(fam, (err, addresses) => {
if (!err && addresses && addresses.length > 0) {
const resFam = fam === 6 ? 6 : 4;
if (wantAll) {
callback(null, addresses.map(a => ({ address: a, family: resFam })));
} else {
callback(null, addresses[0], resFam);
}
} else if (family === 0 && fam === 4) {
tryResolve(6);
} else {
_originalLookup.call(dns, hostname, originalOptions, callback);
}
});
};
tryResolve(family === 6 ? 6 : 4);
};
// Override dns.promises.lookup (promise API) to use c-ares resolver.
// OpenClaw's SSRF guard uses this API for web_search DNS resolution.
const _originalPromiseLookup = dns.promises.lookup;
dns.promises.lookup = async function lookup(hostname, options) {
const opts = typeof options === 'number' ? { family: options } : (options || {});
const wantAll = opts.all === true;
const family = opts.family || 0;
// Short-circuit localhost
if (_localhostNames.has(hostname)) {
if (family === 6) {
return wantAll ? [{ address: '::1', family: 6 }] : { address: '::1', family: 6 };
}
return wantAll ? [{ address: '127.0.0.1', family: 4 }] : { address: '127.0.0.1', family: 4 };
}
const resolve = (fam) => {
return new Promise((res, rej) => {
const fn = fam === 6 ? dns.resolve6 : dns.resolve4;
fn(hostname, (err, addresses) => err ? rej(err) : res(addresses));
});
};
const tryResolve = async (fam) => {
try {
const addresses = await resolve(fam);
if (addresses && addresses.length > 0) {
const resFam = fam === 6 ? 6 : 4;
if (wantAll) {
return addresses.map(a => ({ address: a, family: resFam }));
}
return { address: addresses[0], family: resFam };
}
} catch {}
if (family === 0 && fam === 4) return tryResolve(6);
return _originalPromiseLookup.call(dns.promises, hostname, options);
};
return tryResolve(family === 6 ? 6 : 4);
};
} catch {}
// ─── ELF binary auto-wrapping for spawn/spawnSync ──────────
// npm/npx-installed native binaries (e.g., @zed-industries/codex-acp)
// are standard Linux ELF files whose interpreter is /lib/ld-linux-aarch64.so.1.
// Android lacks this path, so the kernel returns ENOENT on direct execution.
// Intercept child_process spawn APIs to detect ELF binaries and automatically
// route them through the glibc dynamic linker (ld.so).
const _glibcLdso = (process.env.PREFIX || '/data/data/com.termux/files/usr')
+ '/glibc/lib/ld-linux-aarch64.so.1';
const _glibcLibPath = (process.env.PREFIX || '/data/data/com.termux/files/usr')
+ '/glibc/lib';
function _needsGlibcWrap(filePath) {
// Read ELF header and check PT_INTERP to distinguish glibc binaries
// from bionic (Android/Termux) binaries. Only glibc binaries need wrapping.
// Bionic binaries use /system/bin/linker64; glibc use /lib/ld-linux-*.so.1
try {
const fd = fs.openSync(filePath, 'r');
try {
const ehdr = Buffer.alloc(64);
if (fs.readSync(fd, ehdr, 0, 64, 0) < 64) return false;
// Check ELF magic
if (ehdr[0] !== 0x7f || ehdr[1] !== 0x45 || ehdr[2] !== 0x4c || ehdr[3] !== 0x46) return false;
// ELF64: e_phoff at offset 32 (8 bytes), e_phentsize at 54 (2 bytes), e_phnum at 56 (2 bytes)
const phoff = Number(ehdr.readBigUInt64LE(32));
const phentsize = ehdr.readUInt16LE(54);
const phnum = ehdr.readUInt16LE(56);
// Scan program headers for PT_INTERP (type = 3)
for (let i = 0; i < phnum; i++) {
const phBuf = Buffer.alloc(phentsize);
if (fs.readSync(fd, phBuf, 0, phentsize, phoff + i * phentsize) < phentsize) continue;
const pType = phBuf.readUInt32LE(0);
if (pType === 3) { // PT_INTERP
const interpOff = Number(phBuf.readBigUInt64LE(8));
const interpSize = Number(phBuf.readBigUInt64LE(32));
const interpBuf = Buffer.alloc(Math.min(interpSize, 256));
fs.readSync(fd, interpBuf, 0, interpBuf.length, interpOff);
const interp = interpBuf.toString('utf8').replace(/\0+$/, '');
return interp.includes('ld-linux');
}
}
return false;
} finally {
fs.closeSync(fd);
}
} catch {
return false;
}
}
function _resolveCommand(command, env) {
if (command.includes('/')) {
try { return fs.realpathSync(command); } catch { return command; }
}
const searchPath = (env && env.PATH) || process.env.PATH || '';
const dirs = searchPath.split(':');
for (const dir of dirs) {
const full = path.join(dir, command);
try {
fs.accessSync(full, fs.constants.X_OK);
return full;
} catch {}
}
return null;
}
function _readShebang(filePath) {
// Read first 256 bytes to extract shebang line from script files.
// Returns null if not a script, or { interpreter, args } if shebang found.
try {
const fd = fs.openSync(filePath, 'r');
try {
const buf = Buffer.alloc(256);
const n = fs.readSync(fd, buf, 0, 256, 0);
if (n < 2 || buf[0] !== 0x23 || buf[1] !== 0x21) return null; // not #!
const line = buf.toString('utf8', 2, n).split('\n')[0].trim();
const parts = line.split(/\s+/);
return { interpreter: parts[0], args: parts.slice(1) };
} finally {
fs.closeSync(fd);
}
} catch {
return null;
}
}
function _resolveShebang(resolved, spawnEnv) {
// For a resolved file path, check if it has a shebang pointing to a
// non-existent interpreter (e.g. #!/usr/bin/env node on Android).
// Returns { interpPath, interpArgs } or null if no fixup needed.
const shebang = _readShebang(resolved);
if (!shebang) return null;
let interpPath = shebang.interpreter;
let interpArgs = shebang.args;
if (interpPath === '/usr/bin/env' && interpArgs.length > 0) {
// #!/usr/bin/env <cmd> — resolve <cmd> from PATH
let cmdIdx = 0;
while (cmdIdx < interpArgs.length && interpArgs[cmdIdx].startsWith('-')) cmdIdx++;
if (cmdIdx >= interpArgs.length) return null;
const cmd = interpArgs[cmdIdx];
const resolvedCmd = _resolveCommand(cmd, spawnEnv);
if (!resolvedCmd) return null;
interpPath = resolvedCmd;
interpArgs = interpArgs.slice(0, cmdIdx).concat(interpArgs.slice(cmdIdx + 1));
} else if (!fs.existsSync(interpPath)) {
// Interpreter at non-existent absolute path — try resolving by basename from PATH
const basename = path.basename(interpPath);
const resolvedInterp = _resolveCommand(basename, spawnEnv);
if (!resolvedInterp) return null;
interpPath = resolvedInterp;
} else {
// Shebang interpreter exists, kernel can handle it
return null;
}
return { interpPath, interpArgs };
}
// Detect shell invocation patterns: spawn('/path/to/sh', ['-c', 'cmd args...'])
// or spawn('cmd', args, { shell: true })
function _isShellInvocation(file, args) {
if (!args || args.length < 2 || args[0] !== '-c') return null;
const base = path.basename(file);
if (base !== 'sh' && base !== 'bash' && base !== 'dash' && base !== 'zsh') return null;
return args[1]; // the shell command string
}
function _tryFixShellCommand(cmdStr, spawnEnv) {
// Extract the command name from a simple shell command string.
// Only handle simple cases: "cmd arg1 arg2..." — no pipes, redirects, etc.
const shellChars = /[|><&;`$(){}]/;
if (shellChars.test(cmdStr)) return null;
const parts = cmdStr.trim().split(/\s+/);
if (parts.length === 0) return null;
const cmd = parts[0];
const cmdArgs = parts.slice(1);
const resolved = _resolveCommand(cmd, spawnEnv);
if (!resolved) return null;
// glibc ELF binary
if (_needsGlibcWrap(resolved)) {
const env = Object.assign({}, spawnEnv);
delete env.LD_PRELOAD;
return {
file: _glibcLdso,
args: ['--library-path', _glibcLibPath, resolved].concat(cmdArgs),
env: env,
};
}
// Script with broken shebang
const shebang = _resolveShebang(resolved, spawnEnv);
if (shebang) {
return {
file: shebang.interpPath,
args: shebang.interpArgs.concat([resolved]).concat(cmdArgs),
};
}
return null;
}
function _tryWrapSpawn(file, args, options) {
const spawnEnv = options && options.env ? options.env : process.env;
// Detect shell invocations: spawn('sh', ['-c', 'command...']) or spawn(cmd, args, { shell: true })
// npm/npx uses spawn('/path/to/sh', ['-c', 'cmd args...']) to execute package binaries.
// Without libtermux-exec.so, #!/usr/bin/env shebangs in these scripts fail.
if (options && options.shell) {
// shell: true — Node.js will convert to sh -c 'file args...'
if (!file.includes(' ') && !(/[|><&;`$()]/).test(file)) {
const resolved = _resolveCommand(file, spawnEnv);
if (resolved) {
if (_needsGlibcWrap(resolved)) {
const env = Object.assign({}, spawnEnv);
delete env.LD_PRELOAD;
return {
file: _glibcLdso,
args: ['--library-path', _glibcLibPath, resolved].concat(args || []),
options: Object.assign({}, options, { env: env, shell: false }),
};
}
const shebang = _resolveShebang(resolved, spawnEnv);
if (shebang) {
return {
file: shebang.interpPath,
args: shebang.interpArgs.concat([resolved]).concat(args || []),
options: Object.assign({}, options, { shell: false }),
};
}
}
}
return null;
}
// Direct shell invocation: spawn('/path/to/sh', ['-c', 'cmd args...'])
const shellCmd = _isShellInvocation(file, args);
if (shellCmd) {
const fix = _tryFixShellCommand(shellCmd, spawnEnv);
if (fix) {
return {
file: fix.file,
args: fix.args,
options: fix.env ? Object.assign({}, options, { env: fix.env }) : options,
};
}
return null;
}
const resolved = _resolveCommand(file, spawnEnv);
if (!resolved) return null;
if (resolved === _glibcLdso || resolved.endsWith('/ld-linux-aarch64.so.1')) return null;
// Case 1: glibc ELF binary — wrap with ld.so
if (_needsGlibcWrap(resolved)) {
const env = Object.assign({}, spawnEnv);
delete env.LD_PRELOAD;
return {
file: _glibcLdso,
args: ['--library-path', _glibcLibPath, resolved].concat(args || []),
options: Object.assign({}, options, { env: env }),
};
}
// Case 2: Script with shebang pointing to non-existent path
const shebang = _resolveShebang(resolved, spawnEnv);
if (!shebang) return null;
return {
file: shebang.interpPath,
args: shebang.interpArgs.concat([resolved]).concat(args || []),
options: options,
};
}
if (fs.existsSync(_glibcLdso)) {
const _cp = require('child_process');
// Normalize optional args parameter: spawn(cmd[, args][, opts])
function _normalizeArgs(args, options) {
if (args != null && !Array.isArray(args)) {
return { args: [], options: args };
}
return { args: args || [], options: options };
}
const _origSpawn = _cp.spawn;
_cp.spawn = function spawn(command, args, options) {
const n = _normalizeArgs(args, options);
const w = _tryWrapSpawn(command, n.args, n.options);
if (w) return _origSpawn.call(_cp, w.file, w.args, w.options);
return _origSpawn.call(_cp, command, args, options);
};
const _origSpawnSync = _cp.spawnSync;
_cp.spawnSync = function spawnSync(command, args, options) {
const n = _normalizeArgs(args, options);
const w = _tryWrapSpawn(command, n.args, n.options);
if (w) return _origSpawnSync.call(_cp, w.file, w.args, w.options);
return _origSpawnSync.call(_cp, command, args, options);
};
const _origExecFile = _cp.execFile;
_cp.execFile = function execFile(file, args, options, callback) {
// execFile(file[, args][, options], callback)
if (typeof args === 'function') {
callback = args; args = []; options = {};
} else if (typeof options === 'function') {
callback = options;
if (Array.isArray(args)) { options = {}; } else { options = args; args = []; }
}
const w = _tryWrapSpawn(file, args, options);
if (w) return _origExecFile.call(_cp, w.file, w.args, w.options, callback);
return _origExecFile.call(_cp, file, args, options, callback);
};
const _origExecFileSync = _cp.execFileSync;
_cp.execFileSync = function execFileSync(file, args, options) {
const n = _normalizeArgs(args, options);
const w = _tryWrapSpawn(file, n.args, n.options);
if (w) return _origExecFileSync.call(_cp, w.file, w.args, w.options);
return _origExecFileSync.call(_cp, file, args, options);
};
}