< Back to all hacks

#32 ESM Stubs at HOST Path

Debloat
Problem
Stubs created in rootfs are invisible — proot bind mount shadows them with real packages.
Solution
Create stubs at HOST path ($PREFIX/lib/node_modules/...) directly, not inside rootfs.
Lesson
proot bind mounts shadow rootfs files. Host version always wins for bound paths.

Context

Hack #15 introduced ESM dead module stubs to prevent OpenClaw from loading unused channel SDKs (Anthropic, Google AI, Discord, Slack, etc.). Each stub is a minimal ESM file that exports empty classes and functions, satisfying the import resolver without loading the actual 5-50 MB packages.

The stubs worked perfectly when placed directly in the node_modules directory. But when running inside proot, a subtle filesystem layering problem emerged. Proot uses bind mounts to overlay the Termux host filesystem onto the rootfs. Specifically, $PREFIX (which is /data/data/com.termux/files/usr) is bind-mounted into the proot environment so that Node.js and other Termux binaries are accessible.

This means the proot rootfs has TWO versions of every path under $PREFIX: - The rootfs copy: $ROOTFS/data/data/com.termux/files/usr/lib/node_modules/openclaw/node_modules/... - The host copy: $PREFIX/lib/node_modules/openclaw/node_modules/... (bind-mounted over the rootfs)

When Node.js resolves an import inside proot, it sees the bind-mounted host version, NOT the rootfs version. Stubs placed in the rootfs copy are completely invisible.

LEGACY: This hack documents a proot-era issue. The native gateway migration eliminated proot and the bind mount problem entirely.

Implementation

The fix is straightforward: create stubs at the host path, not the rootfs path.

# WRONG: placing stubs inside the rootfs (invisible due to bind mount)
ROOTFS=$PREFIX/var/lib/proot-distro/installed-rootfs/ubuntu
WRONG_PATH=$ROOTFS/data/data/com.termux/files/usr/lib/node_modules/openclaw/node_modules

# CORRECT: placing stubs at the host path (visible to Node.js in proot)
OCDIR=$PREFIX/lib/node_modules/openclaw/node_modules

Create the stub files at the correct host path:

#!/bin/bash
# create-stubs.sh — place ESM stubs at HOST path
OCDIR=$PREFIX/lib/node_modules/openclaw/node_modules

# Anthropic SDK stub
mkdir -p "$OCDIR/@anthropic-ai/sdk"
cat > "$OCDIR/@anthropic-ai/sdk/index.mjs" << 'EOF'
export class Anthropic { constructor() {} }
export default Anthropic;
EOF
cat > "$OCDIR/@anthropic-ai/sdk/package.json" << 'EOF'
{"name":"@anthropic-ai/sdk","version":"0.0.0","type":"module","main":"index.mjs"}
EOF

# Google Generative AI stub
mkdir -p "$OCDIR/@google/generative-ai"
cat > "$OCDIR/@google/generative-ai/index.mjs" << 'EOF'
export class GoogleGenerativeAI { constructor() {} }
export class GenerativeModel { constructor() {} }
export default GoogleGenerativeAI;
EOF
cat > "$OCDIR/@google/generative-ai/package.json" << 'EOF'
{"name":"@google/generative-ai","version":"0.0.0","type":"module","main":"index.mjs"}
EOF

# AWS Bedrock stub
mkdir -p "$OCDIR/@aws-sdk/client-bedrock-runtime"
cat > "$OCDIR/@aws-sdk/client-bedrock-runtime/index.mjs" << 'EOF'
export class BedrockRuntimeClient { constructor() {} }
export class InvokeModelCommand { constructor() {} }
EOF
cat > "$OCDIR/@aws-sdk/client-bedrock-runtime/package.json" << 'EOF'
{"name":"@aws-sdk/client-bedrock-runtime","version":"0.0.0","type":"module","main":"index.mjs"}
EOF

# Discord.js stub
mkdir -p "$OCDIR/discord.js"
cat > "$OCDIR/discord.js/index.mjs" << 'EOF'
export class Client { constructor() {} login() {} }
export class GatewayIntentBits {}
export default Client;
EOF
cat > "$OCDIR/discord.js/package.json" << 'EOF'
{"name":"discord.js","version":"0.0.0","type":"module","main":"index.mjs"}
EOF

# Slack Web API stub
mkdir -p "$OCDIR/@slack/web-api"
cat > "$OCDIR/@slack/web-api/index.mjs" << 'EOF'
export class WebClient { constructor() {} }
export default WebClient;
EOF
cat > "$OCDIR/@slack/web-api/package.json" << 'EOF'
{"name":"@slack/web-api","version":"0.0.0","type":"module","main":"index.mjs"}
EOF

echo "Stubs created at HOST path: $OCDIR"

Verification

# Verify stubs exist at HOST path:
ls $PREFIX/lib/node_modules/openclaw/node_modules/@anthropic-ai/sdk/index.mjs
# Expected: file exists

# Verify stubs are visible INSIDE proot:
proot-distro login ubuntu -- ls /data/data/com.termux/files/usr/lib/node_modules/openclaw/node_modules/@anthropic-ai/sdk/index.mjs
# Expected: file exists (same file, via bind mount)

# Verify Node.js resolves the stub (not the real package):
proot-distro login ubuntu -- node22-icu -e "
  import('@anthropic-ai/sdk').then(m => {
    const a = new m.Anthropic();
    console.log('Stub loaded:', typeof a === 'object');
  });
"
# Expected: Stub loaded: true

# Check that the stub is tiny (not the real multi-MB package):
du -sh $PREFIX/lib/node_modules/openclaw/node_modules/@anthropic-ai/sdk/
# Expected: 4.0K (just the stub files)

Gotchas

  • This ONLY applies to proot setups. In native Termux (post migration), there is no bind mount — the host path is the only path. Stubs placed anywhere under $PREFIX/lib/node_modules/ work as expected
  • The bind mount direction matters: Termux host overlays the rootfs, not the other way around. If you edit a file inside proot at a bind-mounted path, you're editing the host file. Changes to the rootfs copy at the same path are invisible
  • When npm updates or reinstalls OpenClaw (npm install -g openclaw), it OVERWRITES the stubs at the host path with the real packages. You must re-run the stub creation script after every npm install
  • Stubs must export the correct named exports that OpenClaw's import statements expect. A stub with wrong export names causes SyntaxError: The requested module does not provide an export named 'X' at ESM link time
  • Each stub needs a package.json with "type": "module" for Node.js to treat the .mjs files as ESM. Without it, Node.js may try to parse them as CommonJS and fail
  • The rootfs copy of node_modules is wasted disk space. After confirming stubs work at the host path, the rootfs copy can be deleted to reclaim space

Result

MetricRootfs Path (broken)Host Path (working)
Stub visibility in prootInvisible (shadowed)Visible (bind-mounted)
Node.js import resolutionLoads real packageLoads stub
RAM per stubbed package5-50 MB each~0 MB each
Total RAM saved (9 stubs)0 MB (stubs ignored)~80 MB
Packages stubbed9 (Anthropic, Google, AWS, Discord, Slack, Carbon, Baileys, Signal, Lark)Same 9, now working