Skip to main content

Chrome Extension Writing Test Data to Production? Add a DRY-RUN Switch

· 3 min read

TL;DR

Chrome extensions submitting collected data via API write directly to the production database — even during development and testing. A 3-layer DRY-RUN switch solves this: set an env variable in .env.development → client reads it and adds an X-Dry-Run header → server intercepts the header and returns a data preview without writing. Production never sets the variable, so it's completely unaffected.


Problem

A Chrome extension collects e-commerce data and submits it via API. During development, every test run writes a record to the production database. After a few rounds of testing, the database is full of dirty data that corrupts production analytics.

There's no "preview only" switch — either comment out the submission code (easy to forget reverting) or accept dirty data in production.


Root Cause

The Chrome extension shares the same API endpoint for both development and production. The fetch request has no marker to distinguish "this is a test submission" from "this is a real one." The backend processes all requests identically — write to the database.

What's needed: a "preview mode" that shows what data would be submitted without actually writing it.


Solution

Three layers: environment variable → HTTP header → server-side interception.

Step 1: Declare the env variable type

// client/src/env.d.ts
interface ImportMetaEnv {
// ... other variables
readonly VITE_INQUIRY_DRY_RUN?: string;
}

Step 2: Development environment config

# client/.env.development (development only)
VITE_INQUIRY_DRY_RUN=true
# client/.env.production (do NOT set this variable)
# Production always uses real writes

Step 3: Client reads env, conditionally adds header

In the Service Worker (background script):

// background.ts
chrome.storage.local.get('sessionToken', (result) => {
const sessionToken = result.sessionToken;
if (!sessionToken) return;

// Read DRY-RUN switch
const dryRun = import.meta.env.VITE_INQUIRY_DRY_RUN === 'true';

const headers: Record<string, string> = {
'Content-Type': 'application/json',
'Authorization': `Bearer ${sessionToken}`,
};

// Add marker header in DRY-RUN mode
if (dryRun) {
headers['X-Dry-Run'] = 'true';
}

fetch(`${baseURL}/inquiry/collect`, {
method: 'POST',
headers,
body: JSON.stringify({ memberId, rows }),
});
});

WXT/Vite inlines import.meta.env.VITE_INQUIRY_DRY_RUN at build time. Dev build (.env.development) gets "true", production build gets undefined, so === 'true' naturally evaluates to false.

Step 4: Server intercepts DRY-RUN requests

// server/src/functions/analytics/inquiry.ts
router.post('/collect', async (req, res) => {
// ... auth, memberId mapping, etc.

const dryRun = req.headers['x-dry-run'] === 'true';

if (dryRun) {
// Skip database write, return preview
return res.json({
code: 200,
message: 'dry-run ok',
data: {
dryRun: true,
shop_id: match.shop_id,
platform_id: match.platform_id,
memberId,
rowCount: rows.length,
sampleRows: rows.slice(0, 3),
},
timestamp: Date.now(),
});
}

// Normal flow: write to database
const result = await analyticsClient.post('/internal/data/import/inquiry', payload);
res.json({ code: 200, data: result });
});

In DRY-RUN mode, the server still runs auth and validation (ensuring data format is correct) — it only skips the final database write. This lets you verify both data format and permissions without producing dirty data.


Important Notes

Never set DRY-RUN in production .env

.env.production should NOT set VITE_INQUIRY_DRY_RUN. In production builds, the variable is undefined and the condition naturally evaluates to false. Never enable this switch in production config.

Client must check the dryRun flag

DRY-RUN mode returns HTTP 200 with { dryRun: true, data: {...} }. Client code that only checks code === 200 will mistakenly treat it as a successful write. Always check response.data.dryRun to distinguish preview from actual submission.

DRY-RUN should still run auth and validation

Don't intercept DRY-RUN requests before authentication. Keep the full request chain (auth → validate → intercept) so you can verify request format and permissions — just skip the final write step.


Chrome Extension Service Worker Can't Read Login Token? Cross-Context Token Sync

· 3 min read

TL;DR

Chrome extension uses a sidepanel as the UI. User logs in, token goes to localStorage. But the Service Worker (background script) has no localStorage — calling it throws ReferenceError. Fix: after login, send the token to the Service Worker via chrome.runtime.sendMessage, which writes it to chrome.storage.local. Sidepanel reads localStorage, Service Worker reads chrome.storage.local.


Problem

User logs in successfully in the sidepanel. localStorage has sessionToken. But when the Service Worker (background script) tries to read the token for API requests:

ReferenceError: localStorage is not defined
at getAuthToken (background.js:42)
at submitCollectedData (background.js:87)

Login works fine in the sidepanel, but all authenticated requests from the Service Worker fail.


Root Cause

Chrome extensions have multiple execution contexts, each with different APIs available:

ContextwindowlocalStoragechrome.storagechrome.runtime
Sidepanel / PopupYesYesMaybe undefinedYes
Content ScriptYesYes (isolated)YesYes
Service WorkerNoNoYesYes

Key constraints:

  • Service Worker has no window, document, or localStorage — only chrome.storage API
  • Sidepanel in DevTools context may have chrome.storage as undefined

Login stores the token in localStorage (sidepanel context). Service Worker tries to read from localStorageReferenceError.


Solution

Architecture

Sidepanel (login)
├→ localStorage.setItem(token) ← sidepanel reads
└→ chrome.runtime.sendMessage(syncToken)
└→ Service Worker
└→ chrome.storage.local.set(token) ← Service Worker reads

Token lives in two places: localStorage (sidepanel reads) and chrome.storage.local (Service Worker reads). Login and logout keep both in sync via messaging.

Step 1: Login function — dual write

After successful login, write to localStorage (sidepanel context), then sync to Service Worker:

// auth.ts — after successful login
localStorage.setItem('sessionToken', token);
localStorage.setItem('userInfo', JSON.stringify(user));
localStorage.setItem('subscriptionInfo', JSON.stringify(subscription));

// Sync to chrome.storage.local (via Service Worker)
try {
chrome.runtime.sendMessage({
action: 'syncToken',
token,
user,
subscription,
});
} catch (e) {
// Non-extension context (unit tests, regular web), ignore
}

The try/catch is necessary — chrome.runtime.sendMessage throws in non-extension contexts.

Step 2: Service Worker — message handlers

In the background script, listen for messages and write token to chrome.storage.local:

// background.ts
chrome.runtime.onMessage.addListener((request, sender, sendResponse) => {
// Sync token: sidepanel → chrome.storage.local
if (request.action === 'syncToken') {
const { token, user, subscription } = request;
const data: Record<string, string> = {};
if (token) data.sessionToken = token;
if (user) data.userInfo = typeof user === 'string' ? user : JSON.stringify(user);
if (subscription)
data.subscriptionInfo =
typeof subscription === 'string' ? subscription : JSON.stringify(subscription);

chrome.storage.local.set(data, () => {
console.log('[syncToken] token synced to chrome.storage.local');
sendResponse({ success: true });
});
return true; // Required for async sendResponse
}

// Clear token on logout
if (request.action === 'clearToken') {
chrome.storage.local.remove(
['sessionToken', 'userInfo', 'subscriptionInfo'],
() => {
console.log('[clearToken] token cleared from chrome.storage.local');
sendResponse({ success: true });
}
);
return true;
}
});

Step 3: Service Worker reads token from chrome.storage.local

// Before (throws in Service Worker):
const sessionToken = localStorage.getItem('sessionToken');

// After (correct approach):
chrome.storage.local.get('sessionToken', (result) => {
const sessionToken = result.sessionToken;
if (!sessionToken) {
console.warn('No session token found');
return;
}
// Make authenticated API request
fetch('https://api.example.com/data', {
headers: { Authorization: `Bearer ${sessionToken}` },
});
});

Step 4: Logout clears both

// auth.ts
function clearAuthData(): void {
localStorage.removeItem('sessionToken');
localStorage.removeItem('userInfo');
localStorage.removeItem('subscriptionInfo');
try {
chrome.runtime.sendMessage({ action: 'clearToken' });
} catch (e) {
// Non-extension context, ignore
}
}

Important Notes

Both stores must stay in sync

Token lives in two places: localStorage (sidepanel reads) + chrome.storage.local (Service Worker reads). Login and logout must update both — otherwise states diverge.

return true in onMessage is mandatory for async

When using async sendResponse (e.g., inside chrome.storage.local.set callback), the onMessage listener must return true. Without it, the message channel closes immediately and sendResponse becomes a no-op.

Don't use chrome.storage directly in sidepanel

In DevTools context, chrome.storage may be undefined. Use localStorage + message forwarding as the reliable approach.


PostgreSQL Authentication Failed in WSL? Docker Is Silently Using Your Port

· 4 min read

TL;DR

Docker Desktop silently occupies port 5432 in WSL2. An SSH tunnel to localhost:5432 actually connects to Docker's PostgreSQL instead of the remote server. The password authentication failed error is misleading — the password is correct, but you're talking to the wrong instance. Fix: change the tunnel to local port 5433 and isolate the dev config with .env.local.


Problem

Connecting to remote PostgreSQL via SSH tunnel fails with:

PostgresError: password authentication failed for user "postgres"
severity: 'FATAL'
code: '28P01'
file: 'auth.c'
routine: 'auth_failed'

The tunnel command appears to succeed without errors:

ssh -L 5432:localhost:5432 -L 3003:localhost:3003 user@server -N &

No error from the tunnel itself, but the connection always fails authentication. The password is verified correct — direct login on the remote server works fine.


Root Cause

In WSL2's network architecture, Docker Desktop creates virtual network interfaces inside WSL2. When a Docker container maps 5432:5432, Docker listens on port 5432 in the WSL2 network layer.

The SSH tunnel -L 5432:localhost:5432 forwards local port 5432 to the remote server's port 5432. But local port 5432 is already taken by Docker — the tunnel binding silently fails and the connection gets intercepted by Docker.

The result: localhost:5432 connects to the PostgreSQL instance inside the Docker container, not the remote server. That container has different user credentials, so it throws password authentication failed.

What makes this deceptive: the message says "wrong password," not "port occupied" or "tunnel failed." You end up re-checking the password repeatedly while the real problem is connecting to the wrong machine.


Solution

Step 1: Identify the conflict

# Inside WSL2
ss -tlnp | grep 5432

WSL may not show it (Docker ports map from the Windows side). Cross-check from PowerShell:

netstat -ano | findstr :5432

If you see docker-proxy on port 5432, the conflict is confirmed.

Step 2: Change the tunnel port

# Before: local port 5432 (conflicts with Docker)
ssh -L 5432:localhost:5432 user@server -N &

# After: local port 5433 (no conflict)
ssh -L 5433:localhost:5432 user@server -N &

The -L format is local_port:remote_host:remote_port. Only the local port changed — the remote server still uses 5432.

Step 3: Create .env.local for dev config

# server/.env.local (add to .gitignore)
DATABASE_URL=postgresql://postgres:your_password@localhost:5433/your_db

Step 4: Modify dotenv loading to prioritize .env.local

// Before:
import 'dotenv/config';

// After:
import dotenv from 'dotenv';
import { existsSync } from 'fs';
import { resolve } from 'path';

if (existsSync(resolve(__dirname, '../.env.local'))) {
dotenv.config({ path: resolve(__dirname, '../.env.local') });
} else {
dotenv.config();
}

Development uses .env.local (port 5433), production continues using .env (port 5432). Two configs, zero interference.


Important Notes

Error messages can mislead you

password authentication failed does not always mean the password is wrong. When connected to a different instance (like Docker's PostgreSQL), that instance may not have the same user or password, producing the same error. If the password is verified correct, prioritize checking whether you're connecting to the right instance.

Don't hardcode a different port in app code

Change the local port at the SSH tunnel level and manage it through .env.local. Don't change the default port in application code — that breaks production and container connections. Environment differences should be resolved through environment variables, not hardcoded values.

WSL port issues: check the Windows side

Docker Desktop in WSL2 mode maps container ports directly in the Windows network layer. ss and lsof inside WSL won't show the PID. When encountering mysterious port conflicts, check Windows side with netstat -ano.


Container Port Unreachable from WSL2? The Docker Desktop network_mode:host Trap

· 3 min read

Encountered this issue while building an AI data analytics platform (Airflow + PostgreSQL) for a client. Here's the root cause and solution.

TL;DR

On Docker Desktop for Windows (WSL2 backend), network_mode: host container ports are unreachable from the WSL2 host. The container shows the port listening, but curl localhost:PORT returns connection refused. The fix: use network_mode: !reset in your override file to remove host mode, then switch to bridge + external network + port mapping.

Problem

A project uses docker-compose.yml with host networking for Airflow:

x-airflow-common:
&airflow-common
image: airflow-ai-dag:latest
network_mode: host # Works on servers, breaks on WSL2
volumes:
- ..:/opt/airflow/project

services:
airflow-webserver:
<<: *airflow-common
command: webserver
environment:
- AIRFLOW__WEBSERVER__WEB_SERVER_PORT=8082

Container starts normally, logs confirm port binding:

[INFO] Listening at: http://0.0.0.0:8082

But unreachable from the host:

$ ss -tlnp | grep 8082
# Empty, nothing listening

$ curl localhost:8082/health
curl: (7) Failed to connect to localhost port 8082

Meanwhile, a pgAdmin container with -p 8080:80 works fine at localhost:8080.

Root Cause

Docker Desktop for Windows has a different network architecture than native Linux Docker:

Windows Browser

WSL2 Host (your terminal)
↕ Docker Desktop pipeline
Docker Desktop Utility VM ← network_mode: host points HERE

Containers

On a Linux server, network_mode: host shares the host's network directly — container ports = host ports. But on Docker Desktop WSL2:

  • "host" in host mode = the Docker Desktop utility VM, not WSL2
  • Container's /proc/net/tcp shows the port listening
  • WSL2 host's /proc/net/tcp has no matching entry
  • Containers with -p port mapping are unaffected (Docker Desktop auto-forwards them)

Verification: Compare network namespaces:

# Inside container: 8082 (hex 1F92) is listening
$ docker exec webserver grep '1F92' /proc/net/tcp
4: 00000000:1F92 00000000:0000 0A ...

# On WSL2 host: 8082 doesn't exist
$ grep '1F92' /proc/net/tcp
# No output

Solution

Step 1: Create docker-compose.override.yml

Use !reset to remove the base's network_mode: host (requires Compose v2.24+):

# docker-compose.override.yml
services:
airflow-webserver:
network_mode: !reset # Remove base's host mode
networks:
- app_network # Join external network
ports:
- "8082:8082" # Port mapping
environment:
- DB_HOST=postgres-db # Use container name for DB

airflow-scheduler:
network_mode: !reset
networks:
- app_network
environment:
- DB_HOST=postgres-db

networks:
app_network:
external: true # Reference existing external network

Step 2: Ensure DB container is on the same network

# Check which network the DB container uses
$ docker inspect postgres-db --format '{{json .NetworkSettings.Networks}}'
# {"app_network": {...}}

# Create the network if it doesn't exist
$ docker network create app_network # Skip if already exists

Step 3: Restart and verify

$ docker compose down
$ docker compose up -d

Verify:

$ ss -tlnp | grep 8082
LISTEN 0 4096 *:8082 *:*

$ curl -s -o /dev/null -w "%{http_code}" http://localhost:8082/health
200

Key Point: !reset Must Be in Service Definitions

This does NOT work:

# ❌ Wrong: inside an anchor
x-airflow-local:
&airflow-local
network_mode: !reset # Doesn't work!
networks:
- app_network

services:
airflow-webserver:
<<: *airflow-local # Base's network_mode:host still present after merge

Correct:

# ✅ Correct: in each service definition
services:
airflow-webserver:
network_mode: !reset # Must be here
networks:
- app_network

Caveats

  • network_mode: !reset requires Docker Compose v2.24+. Check with docker compose version
  • !reset only works on scalar fields (like network_mode), not lists or dictionaries
  • If the base uses YAML anchors (<<: *anchor), !reset must be at the service definition level, not inside another anchor
  • After switching to bridge mode, containers can't reach each other via localhost — use container names or a shared Docker network
  • network_mode and networks are mutually exclusive; having both causes a mutually exclusive error

WordPress Block Theme Changes Not Taking Effect? FSE Development Troubleshooting Guide

· 7 min read

Encountered these five issues repeatedly while developing WordPress Block Themes for clients. Each one took significant debugging time. This guide covers the root causes and provides ready-to-use solutions.

TL;DR

Five issues ranked by frequency: file changes not applying (database cache overrides files), block nesting errors (unclosed comments), child theme content not rendering (missing post-content block), SVG icons disappearing (WP_Filesystem polluted by plugins), and WP-CLI mail failures (SMTP plugins don't hook in CLI). Each scenario includes copy-paste diagnostic commands.

Scenario 1: Changed Theme Files, But the Page Looks the Same

Problem

You modified theme.json, templates/*.html, or parts/*.html in your theme directory, but the page shows no change. Even after git pull updates the code, the frontend still renders the old version.

Root Cause

FSE themes have their templates and global styles saved to the database via the Site Editor—stored as wp_template, wp_template_part, and wp_global_styles custom post types. The database version takes priority over file versions. Even if you update the file, as long as a database record exists, WordPress uses that instead.

Solution

Different file types require different cleanup:

Modified contentCleanup method
templates/*.htmlClear wp_template
parts/*.htmlClear wp_template_part
theme.jsonClear wp_global_styles + flush cache
patterns/*.phpTakes effect immediately, no cleanup needed

One-command cleanup for all database template caches:

# Local Docker environment
docker exec wp_cli bash -c 'wp post delete $(wp post list --post_type=wp_template --format=ids --allow-root) --force --allow-root'
docker exec wp_cli bash -c 'wp post delete $(wp post list --post_type=wp_template_part --format=ids --allow-root) --force --allow-root'
docker exec wp_cli bash -c 'wp post delete $(wp post list --post_type=wp_global_styles --format=ids --allow-root) --force --allow-root'
docker exec wp_cli wp cache flush --allow-root

If theme.json changes still don't apply, verify the JSON has no syntax errors (e.g., trailing commas are invalid in JSON):

docker exec wp_cli wp eval 'echo json_encode(json_decode(file_get_contents(get_template_directory() . "/theme.json")));' --allow-root
# Empty output → JSON syntax error

Important

  • Never save in the Site Editor during development—this prevents database overrides
  • Before clearing in production, verify no custom modifications need to be preserved from the Site Editor
  • patterns/*.php is unaffected—Pattern registration runs through PHP code, not the database
  • Corrupted wp_global_styles JSON from the Site Editor can cause site-wide WP_Theme_JSON_Resolver errors, breaking all page styles

Scenario 2: Site Editor Shows "Attempt Recovery" and the Layout Breaks

Problem

The Site Editor shows "Attempt Recovery" for a Pattern. After saving, the page layout is completely broken. Some blocks are incorrectly nested inside others, and the hierarchy doesn't match the source code.

Root Cause

WordPress block editor uses HTML comments to mark block boundaries:

<!-- wp:group {"layout":{"type":"constrained"}} -->
<div class="wp-block-group">
<!-- content -->
<!-- /wp:group -->

When a container block (like wp:group or wp:columns) is missing its closing comment <!-- /wp:group -->, Gutenberg's parse_blocks() treats all subsequent blocks as children of that container. The consequences:

  1. The parent block's save output is empty
  2. Block validation fails
  3. All subsequent blocks have incorrect nesting

Solution

Diagnose: Check block tree hierarchy in the browser console:

wp.data.select('core/block-editor').getBlocks()

Inspect the returned block tree. If a group block contains blocks that shouldn't be inside it, a previous container is likely missing its closing comment.

Fix: Open the Pattern source file and verify every <!-- wp:xxx --> has a matching <!-- /wp:xxx -->. Search for <!-- wp: and <!-- /wp: and count to confirm the numbers match.

Prevention Tip

Use an editor with bracket matching (like VS Code) with a Block Comment highlight extension to catch unclosed comments immediately. For complex Patterns, write the skeleton first (all open/close comment pairs), then fill in the content.

Scenario 3: Child Theme Override Hides Page Editor Content

Problem

After creating a child theme to override a parent template, content entered in the WordPress page editor (text, images, etc.) is completely blank on the frontend. However, hardcoded Patterns in the template (hero sections, CTAs) display normally.

Root Cause

FSE templates render the page editor's post_content through the <!-- wp:post-content /--> block. If the child theme's template file doesn't include this block, WordPress has nowhere to output the page content.

The result: the template's fixed structure (header, hero, sidebar) renders correctly, but everything written in the editor is lost.

Solution

Ensure your child theme template includes the post-content block:

<!-- wp:group {"layout":{"type":"constrained"}} -->
<div class="wp-block-group">

<!-- Template fixed structure (hero, sidebar, etc.) -->

<!-- wp:post-content {"layout":{"type":"constrained"}} /-->

<!-- More fixed structure (CTA, footer reference, etc.) -->

</div>
<!-- /wp:group -->

When troubleshooting "changes not showing", confirm in this order:

  1. Does the template file include <!-- wp:post-content /-->?
  2. Are you modifying the template file or the page content? They control different content areas
  3. Inline cover blocks in the template are controlled by the template file, not the database post_content

Scenario 4: SVG Icons Disappeared But the Files Are Still There

Problem

Your theme uses WP_Filesystem to read SVG icon files. Suddenly all SVG icons disappear. Accessing the SVG file URL directly returns normal content, but the icon positions on pages are empty.

Root Cause

WordPress's $wp_filesystem global defaults to WP_Filesystem_Direct (direct local file access). Some plugins (backup, security) replace $wp_filesystem with WP_Filesystem_ftpsockets or WP_Filesystem_SSH2 during initialization.

FTP/SSH adapters read files through remote connections. For local paths (like /var/www/html/wp-content/themes/...), they can't access the files correctly and return empty strings. Since the replacement happens in the global scope, all theme and plugin code using WP_Filesystem is affected.

Solution

Step 1 — Diagnose: Check the actual $wp_filesystem type:

# Local Docker environment
docker exec wp_cli wp eval 'global $wp_filesystem; echo get_class($wp_filesystem);' --allow-root

# Returns WP_Filesystem_Direct → normal
# Returns WP_Filesystem_ftpsockets or other → polluted

Step 2 — Identify the source: Disable plugins one by one to find which one replaces the adapter:

docker exec wp_cli wp plugin deactivate <plugin-name> --allow-root
docker exec wp_cli wp eval 'global $wp_filesystem; echo get_class($wp_filesystem);' --allow-root
# Repeat until it returns WP_Filesystem_Direct

Step 3 — Code fallback: Add file_get_contents() as a safety net in your theme:

function mytheme_get_svg( $path ) {
global $wp_filesystem;

// Prefer WP_Filesystem
if ( $wp_filesystem && method_exists( $wp_filesystem, 'get_contents' ) ) {
$content = $wp_filesystem->get_contents( $path );
if ( $content ) {
return $content;
}
}

// Fallback to direct file read
if ( file_exists( $path ) ) {
return file_get_contents( $path );
}

return '';
}

Important

  • file_get_contents() may be disabled via disable_functions on restricted hosts, but VPS and Docker environments typically support it
  • The root fix is to identify and handle the polluting plugin; the code fallback is a temporary measure
  • This issue is deceptive—the SVG files are intact and accessible via URL, but return empty only when read through WP_Filesystem in PHP

Scenario 5: Email Sending Fails from Command Line, Works from Browser

Problem

Calling wp_mail() via wp eval on the command line always fails. However, emails triggered by web requests (user registration, contact forms) send normally. SMTP plugins like WP Mail SMTP are correctly configured.

Root Cause

SMTP plugins intercept wp_mail() via hooks to switch the sending channel from PHP sendmail to an SMTP service. But these hooks depend on WordPress's full bootstrap sequence—particularly stages after wp_loaded.

WP-CLI's wp eval loads the WordPress core, but some plugin hooks don't register in the CLI environment. wp_mail() falls back to PHP sendmail, and most servers don't have sendmail configured, causing the failure.

Solution

Method 1 — Web request test: Add a temporary test route in your theme, trigger via browser:

// Add to functions.php temporarily, remove after testing
add_action( 'wp_loaded', function() {
if ( ! isset( $_GET['test_mail'] ) ) return;
if ( '1' !== $_GET['test_mail'] ) return;

$result = wp_mail( '[email protected]', 'SMTP Test', 'Test email body' );
var_dump( $result ); // true = sent successfully
exit;
} );

Visit https://yoursite.com/?test_mail=1 to trigger the test.

Method 2 — eval-file with full bootstrap:

cat > /tmp/test-smtp.php << 'EOF'
<?php
require_once ABSPATH . 'wp-load.php';
do_action('wp_loaded');

$result = wp_mail('[email protected]', 'CLI SMTP Test', 'Test body');
echo $result ? "Sent\n" : "Failed\n";
EOF

docker exec wp_cli wp eval-file /tmp/test-smtp.php --allow-root

Best Practice

In production, always test email sending through web requests. WP-CLI is great for cron jobs and batch operations, but not for verifying features that depend on the full WordPress hook chain (email, cache warming, etc.).


WooCommerce Blocks Showing core/missing After Upgrade? Block Theme Troubleshooting Guide

· 7 min read

Ran into these four WooCommerce-specific issues while building Block Themes for clients. Each relates to how FSE architecture interacts with WooCommerce's block system. Documenting the troubleshooting process to help others working on WooCommerce theme development.

TL;DR

Four common issues in WooCommerce Block Theme development: block rename causing core/missing (block names gained -block suffix after upgrade), product archive 404 after shop slug change (rewrite cache not flushed), template HTML mismatch with Gutenberg save (dynamic block validation failure), and Cart/Checkout templates not auto-assigning (manual assignment required). Each scenario includes copy-paste fixes.

Scenario 1: Blocks Show as core/missing After WooCommerce Upgrade

Problem

After upgrading WooCommerce, several blocks in the Site Editor display as core/missing (Block Recovery prompt). The frontend renders correctly, but these blocks can't be edited in the editor.

Root Cause

WooCommerce renamed multiple internal blocks in newer versions, adding a -block suffix:

Old nameNew name
cart-order-summary-subtotalcart-order-summary-subtotal-block
cart-order-summary-shippingcart-order-summary-shipping-block
cart-order-summary-taxescart-order-summary-taxes-block
cart-order-summary-totaltotals-block
proceed-to-checkoutproceed-to-checkout-block

If your templates or Patterns use old block comment names (e.g., <!-- wp:woocommerce/cart-order-summary-subtotal -->), Gutenberg can't find the corresponding block registration and renders it as core/missing.

Frontend rendering works because WooCommerce's PHP callbacks still support old names. But the editor depends on JavaScript block registration info—when it's not found, the block shows as missing.

Solution

Diagnose: Confirm registered block names in the browser console:

wp.blocks.getBlockTypes()
.map(b => b.name)
.filter(n => n.includes('cart-order-summary'))

Compare the output with the names used in your templates to find mismatches.

Fix: Batch replace old block names in template and Pattern files:

# Run in your theme directory
find . -name "*.html" -exec sed -i 's/woocommerce\/cart-order-summary-subtotal/woocommerce\/cart-order-summary-subtotal-block/g' {} +
find . -name "*.html" -exec sed -i 's/woocommerce\/cart-order-summary-shipping/woocommerce\/cart-order-summary-shipping-block/g' {} +
find . -name "*.html" -exec sed -i 's/woocommerce\/cart-order-summary-taxes/woocommerce\/cart-order-summary-taxes-block/g' {} +
find . -name "*.html" -exec sed -i 's/woocommerce\/cart-order-summary-total/woocommerce\/totals-block/g' {} +
find . -name "*.html" -exec sed -i 's/woocommerce\/proceed-to-checkout/woocommerce\/proceed-to-checkout-block/g' {} +

After replacing, clear the database template cache (see the previous article's "file changes not applying" scenario).

Important

  • Back up template files before upgrading WooCommerce—block renames typically appear in major version upgrades
  • Normal frontend rendering doesn't mean there's no issue—always check in the Site Editor
  • Subscribe to WooCommerce developer changelogs to learn about block name changes in advance

Scenario 2: Changed the Shop URL, Now the Product Page Is Gone

Problem

After changing the WooCommerce shop page slug (e.g., from /shop/ to /products/) or modifying the permalink structure, visiting the new shop URL returns 404 or displays as a plain page without the product listing.

Root Cause

WordPress's Rewrite API caches URL routing rules in the database on first access. After changing a page slug, the cached routing still maps to the old slug. The new URL has no matching route, so WordPress renders it as a regular page instead of a WooCommerce product archive.

Solution

After changing slugs or permalink structures, flush the rewrite cache:

# Local Docker environment
docker exec wp_cli wp rewrite flush --allow-root

# Server environment
ssh your-server "docker exec prod_cli wp rewrite flush --allow-root"

One command fixes it. You can also do this in the WordPress admin: Settings → Permalinks → Save Changes. Saving automatically flushes rewrite rules.

Best Practice

Run wp rewrite flush after any URL structure change—modifying slugs, changing permalinks, or adding custom post types. Consider adding it to your deployment script to avoid forgetting.

Scenario 3: Cart/Checkout Blocks Show Validation Errors

Problem

WooCommerce dynamic blocks like Cart and Checkout show Block validation failed in the Site Editor. The error message says Expected HTML and Actual HTML don't match.

Root Cause

Many WooCommerce blocks are server-side rendered (SSR) dynamic blocks—frontend content is generated by PHP in real time, not dependent on JavaScript's save() function. However, Gutenberg still requires the block markup in templates to exactly match the save() output.

If the HTML in your template doesn't match what Gutenberg expects (extra or missing classes, styles, or inner elements), validation errors are triggered.

Reference for WooCommerce block save output:

Blocksave output
product-price<div class="is-loading"></div>
price-filter<div class="wp-block-woocommerce-price-filter is-loading"><span aria-hidden="true" class="wc-block-product-categories__placeholder"></span></div>
woocommerce/cart<div class="wp-block-woocommerce-cart alignwide is-loading"></div>
woocommerce/checkout<div class="wp-block-woocommerce-checkout alignwide wc-block-checkout is-loading"></div>
cart-order-summary-*-block<div class="wp-block-woocommerce-{block-name}"></div>
proceed-to-checkout-block<div class="wp-block-woocommerce-proceed-to-checkout-block"></div>

Solution

Key rules:

  1. Blocks with html: false (save returns null, like core/query-pagination) can use self-closing tags
  2. Blocks where JS save returns placeholder HTML must not be self-closing—they need complete content
  3. core/query-pagination must not have a wrapper div; InnerBlocks go directly between open/close comments

Troubleshooting steps:

  1. Open the Site Editor and browser DevTools Console
  2. Find the Block validation failed log entry
  3. Compare Expected (Gutenberg-generated) vs. Actual (from your template)
  4. Fix the HTML using the reference table above

Safe to Ignore

These WooCommerce validation errors can be safely ignored:

ErrorExplanation
woocommerce/cart or woocommerce/checkout validation failureDynamic blocks rendered by PHP in real time; frontend displays correctly
woocommerce-blocktheme-css loading errorWooCommerce plugin CSS issue; wait for official fix

Scenario 4: Custom Cart/Checkout Template Not Showing Up

Problem

You created custom Cart or Checkout template files (e.g., templates/cart.html, templates/checkout.html), but the corresponding WooCommerce pages still use the default template. The custom template is visible in the Site Editor but hasn't been applied to the Cart/Checkout pages.

Root Cause

WooCommerce Cart and Checkout pages auto-match templates by name. But if matching fails (e.g., template slug doesn't exactly correspond, or page creation order issues), manual assignment is needed.

Pages that rely on shortcodes (like My Account) will never auto-match templates and always require manual assignment.

Solution

Manually assign templates to their corresponding pages:

# 1. Find the Cart page ID
docker exec wp_cli wp post list --post_type=page --fields=ID,post_title,post_name --allow-root | grep -i cart

# 2. Assign the template
docker exec wp_cli wp post meta update <page_id> _wp_page_template <template_slug> --allow-root

# Example: assign cart-block template to the Cart page
docker exec wp_cli wp post meta update 42 _wp_page_template cart-block --allow-root

Verify the assignment:

docker exec wp_cli wp post meta get <page_id> _wp_page_template --allow-root

Template Naming Tips

Ensure template file names correspond to WooCommerce page functions. Standard naming:

  • cart.html or cart-block.html → Cart page
  • checkout.html or checkout-block.html → Checkout page
  • single-product.html → Product detail page
  • archive-product.html → Product archive page (shop)

Consistent naming increases the auto-matching success rate.


Embedding a Timeline in SITE123 Event Pages? First Dodge These 5 Platform Limits

· 5 min read

TL;DR

Embedding a self-hosted timeline into a SITE123 event page hits 5 platform walls: Custom Code can't target a page or position, scripts run before DOM is ready, selectors hit hidden elements, float layout pushes adjacent blocks out of place, and platform cache injects the script twice. Fix: JS DOM manipulation + horizontal fishbone layout + DOMContentLoaded wrapper.


The Problem

The client runs their event site on SITE123 and needed a timeline below the Hero banner showing key dates. The Timeline service was already deployed (timeline.aidevhub.ai), but after injecting the embed script via SITE123 Custom Code:

  • Timeline didn't appear
  • Timeline showed up at the page footer
  • After inserting the timeline, the Tickets section got pushed below

Root Causes

Limit 1: Custom Code Has No Precise Positioning

SITE123 Custom Code offers only 4 injection spots (head start, head end, body start, body end). You can't target a specific page and can't insert between sections.

This is the fundamental constraint with no workaround—only JS DOM manipulation after page load can compensate.

Limit 2: Script Runs Before DOM Is Ready

If the script is placed in "Before closing head tag", document.body is null at execution time. Stranger still: changing the injection position in SITE123 dashboard sometimes doesn't reflect in the actual page source.

Fix: Wrap everything in DOMContentLoaded regardless of where the script ends up.

Limit 3: Selector Matches a Hidden Element

tl.js prioritizes .container, but on SITE123 event pages .container is the breadcrumb area with visibility: hidden—the timeline gets inserted before the hidden element and stays invisible.

querySelector takes the first match only, and .container is a generic class name that easily collides.

Lesson: Always verify selectors in the browser console on the actual page. Never rely on assumptions.

Limit 4: Float Layout Conflict

SITE123 event pages use Bootstrap float layout (col-sm-5 + col-sm-7, exactly 12 columns). Inserting a full-width block before .product-container pushes the Tickets column to the next row.

This is a structural conflict—inside a float container, you cannot insert a full-row element without breaking the layout.

Fix: Switch to a horizontal fishbone layout (贯穿横线 + 圆点 + alternating cards), which uses width:100% naturally and doesn't participate in float calculation.

Limit 5: Platform Cache Injects Script Twice

After changing the Custom Code position, the page source showed two timeline.aidevhub.ai entries—one script injected twice. SITE123 platform bug, root cause unknown, doesn't affect the final result.


Solutions

1. Execution Timing Safeguard

Wrap all insertion logic in DOMContentLoaded so it works regardless of SITE123's placement:

document.addEventListener('DOMContentLoaded', function() {
// insertion logic
});

2. Always Verify Selectors in Console

Three-step browser console checklist:

// Step 1: Confirm element exists and is visible
const el = document.querySelector('.product-container');
console.log(el.getBoundingClientRect());

// Step 2: Confirm uniqueness (cross-page, should return 1)
const all = document.querySelectorAll('.product-container');
console.log(all.length);

// Step 3: Insert a red test block to verify position visually
const test = document.createElement('div');
test.style.cssText = 'background:red;height:100px;';
el.parentNode.insertBefore(test, el);

3. Layout Choice

Horizontal fishbone: a single horizontal line with dots positioned on it and cards alternating above and below. Vertical layouts easily break in float-based structures; horizontal layouts naturally don't participate in float calculation.

4. Insertion Target

Verified working target: .product-container (parent of event details and Tickets). Insert before it—the timeline lands below Hero, above event details.

5. Deployment

After modifying routes/script.js, run pm2 restart timeline and it takes effect immediately. No need to update the SITE123 embed code.


Notes

Always Verify Selectors in Console

SITE123 page structure is undocumented—never assume. .container is a generic class that may match the breadcrumb nav instead of your target. Always use the browser console on the actual page to confirm.

Custom Code Position Settings Are Unreliable

Changing the injection position in the SITE123 dashboard may not reflect in the actual page source. Make your script self-sufficient with DOMContentLoaded—don't rely on platform settings.

No Full-Width Elements Inside Float Layouts

Inserting a width:100% block inside or before a Bootstrap float container (col-*) pushes subsequent columns to the next row. Workaround: insert outside the container, or switch to a layout that doesn't rely on floats.


Self-Hosted Timeline Service—What Server to Use?

This project runs on Express + sql.js deployed on Vultr. A $5/month instance is sufficient. PM2 manages the process. Includes one-command setup script—running in 5 minutes.

Vultr — From $5/month →

Key Takeaways

  1. SITE123 Custom Code can't target pages or positions—JS DOM manipulation is the only workaround
  2. Wrap scripts in DOMContentLoaded to be self-sufficient, don't trust platform position settings
  3. Verify selectors with querySelectorAll + getBoundingClientRect in the browser console
  4. Full-width elements inside float layouts cause structural conflicts—horizontal layout bypasses this
  5. After modifying server code, pm2 restart timeline takes effect immediately, no need to touch the embed code

Docker Container Can't See Host Files? Anonymous Volume Overrides Bind Mount

· 3 min read

Encountered this issue while deploying a WordPress site for a client. Here's the root cause and solution.

TL;DR

The VOLUME declaration in an image's Dockerfile creates anonymous volumes with higher mount priority than docker-compose.yml bind mounts. Solution: Stop container → Delete anonymous volume → Restart.

Problem

After CI deployment, new static files or PHP code don't exist inside the container:

  • assets/images/logo.png exists on host, missing in container → Logo doesn't display
  • inc/setup.php has new filter code, container has old version → Filter doesn't work
  • Files updated after git pull, container still has old content

Root Cause

WordPress official image's Dockerfile includes:

VOLUME /var/www/html

Even if your docker-compose.yml configures bind mount:

volumes:
- ./wordpress/wp-content:/var/www/html/wp-content

Docker still creates an anonymous volume for the VOLUME declared path. Anonymous volumes have higher mount priority than bind mounts, causing:

  1. /var/www/html is taken over by anonymous volume
  2. Your bind mount targets /var/www/html/wp-content
  3. But the anonymous volume already "occupies" the parent directory, bind mount gets overridden

Verify with docker inspect:

docker inspect prod_wordpress --format '{{range .Mounts}}{{.Type}}: {{.Source}} -> {{.Destination}}{{"\n"}}{{end}}'

Output similar to:

volume: /var/lib/docker/volumes/wp-prod_wp_html/_data -> /var/www/html  # Anonymous volume!
bind: /var/www/wp-prod/wordpress/wp-content -> /var/www/html/wp-content

Solution

# 1. Stop containers
cd /var/www/wp-prod && docker compose down

# 2. Delete anonymous volume
docker volume rm wp-prod_wp_html

# 3. Restart
docker compose up -d

Verification

docker inspect prod_wordpress --format '{{range .Mounts}}{{.Type}}: {{.Source}} -> {{.Destination}}{{"\n"}}{{end}}'

Should only show bind mount, no anonymous volume:

bind: /var/www/wp-prod/wordpress/wp-content -> /var/www/html/wp-content

Prevention

When using bind mount deployment, check if the image declares VOLUME. If declared:

  1. Before first start, confirm no residual anonymous volumes exist
  2. Or modify docker-compose.yml to make bind mount path match VOLUME path (mount to same level)

After fixing, git pull updates are automatically read by the container—no docker cp or container restart needed.

Important

  • Backup data before deleting anonymous volumes or ensure it can be restored via git pull
  • Test in staging environment before production operations
  • If container has critical data, backup with docker cp first

Theme JS Broken After Disabling a Plugin? Check Query Monitor's Footer Scripts Callback

· 3 min read

Encountered this issue while developing a WordPress theme for a client. Here's the root cause and solution.

TL;DR

The WordPress theme's navigation scroll effect (.is-scrolled class) stopped working after disabling WooCommerce. Investigation revealed that Query Monitor plugin's action_print_footer_scripts callback with priority 9999 was prematurely executing and terminating all footer scripts output. Deleting Query Monitor resolved the issue.

Problem

The WordPress theme implements a navigation scroll effect: when users scroll down the page, the header gains the .is-scrolled class, triggering a glassmorphism effect and shadow.

Testing revealed:

  • With WooCommerce active: scroll effect works normally, page size 144KB
  • With WooCommerce disabled: scroll effect broken, page size only 94KB

Playwright testing confirmed:

  • With WooCommerce active: theme.js loads normally, .is-scrolled class added/removed correctly
  • With WooCommerce disabled: theme.js not loaded. Page HTML ends directly after </footer>, missing </body></html>, all footer scripts not output

Root Cause

Investigation Process

  1. Check theme JS enqueue condition: inc/setup.php lines 44-50 wp_enqueue_script executes unconditionally, no WooCommerce dependency

  2. Check page script output: Using curl to fetch page HTML, found that with WooCommerce disabled the page only has 2 script tags (importmap, speculationrules), no theme or WordPress core scripts

  3. Check page integrity: Page HTML ends directly after footer, missing </body></html> tags

  4. Search wp_print_footer_scripts callbacks: Found in query-monitor/collectors/assets.php:55:

add_action( 'wp_print_footer_scripts', array( $this, 'action_print_footer_scripts' ), 9999 );

Key Findings

Priority 9999 is extremely low: In WordPress hook system, higher numbers mean lower priority. This callback executes after all normal script output, but its internal logic may prematurely terminate the output process.

Activates without config file: Query Monitor still registers this callback even without a query-monitor.php config file.

Callback behavior: The action_print_footer_scripts method outputs empty content and terminates all subsequent script output.

Impact

Any scripts relying on wp_print_footer_scripts or wp_footer hooks are affected, including:

  • Theme JavaScript (theme.js)
  • WordPress core scripts
  • Other plugins' footer scripts

Solution

# Delete inside container
docker exec -it wordpress_container rm -rf /var/www/html/wp-content/plugins/query-monitor

# Or use WP-CLI
wp plugin delete query-monitor

Option 2: Modify Callback Priority

If you need to keep Query Monitor, modify query-monitor/collectors/assets.php:55:

// Original code (priority 9999 - too low)
add_action( 'wp_print_footer_scripts', array( $this, 'action_print_footer_scripts' ), 9999 );

// Change to (priority 999 - normal range)
add_action( 'wp_print_footer_scripts', array( $this, 'action_print_footer_scripts' ), 999 );

Verify Fix

# Re-test page size
curl -s http://localhost:8080 | wc -c
# Should return to around 144KB

# Check if theme.js loads
curl -s http://localhost:8080 | grep -o "theme.js"
# Should see theme.js script tag

Important Notes

During investigation, we found that Query Monitor can interfere with normal WordPress script output flow under certain configurations. Recommend using this plugin only in development environments; production environments should disable or delete it.

Troubleshooting Tips Summary:

  1. Page size comparison: Normal vs abnormal page size difference is obvious (144KB vs 94KB)
  2. HTML integrity check: Check for </body></html> closing tags
  3. Script loading check: Use Playwright or curl to check if specific JS files load
  4. Hook priority analysis: Search for wp_print_footer_scripts and 9999 or similar low priority callbacks
  5. Plugin isolation: Disable plugins one by one to identify conflict source

Fix WSL2 Cannot Access Windows Host Proxy — Three Invisible Pitfalls

· 5 min read

Encountered this issue while using AI coding tools (Claude Code, Roo) in WSL2 that need to access APIs through the Windows host proxy. After fixing the firewall, encountered two more hidden pitfalls. Documenting all root causes and solutions.

TL;DR

WSL2's vEthernet (WSL) virtual NIC is created on every launch. Windows Firewall cannot assign a Network Profile to it, so all inbound rules are ineffective (EnforcementStatus: NotApplicable). Don't add rules — disable the firewall on that interface directly:

# Run in Windows PowerShell (Administrator)
Set-NetFirewallProfile -DisabledInterfaceAliases "vEthernet (WSL)"

Additionally, after fixing the firewall, you may encounter two more pitfalls:

  1. Dynamic IP Issue: Host IP changes after WSL/Windows restart
  2. Config Cache Issue: Stale API keys in ~/.claude.json and ~/.claude/settings.json cause auth conflicts

Problem

Environment: Windows 10 21H2 + WSL2 2.5.10.0 (NAT mode), Clash Verge on host (port 7897, Allow LAN enabled).

Symptoms:

# Ping host from WSL — no response
ping 172.22.80.1

# Test proxy port — no response
nc -zv 172.22.80.1 7897

# Claude Code / Roo error
# API Error: ConnectionRefused

# But direct external access works fine
curl https://example.com # OK

On the Windows side, Clash is healthy: netstat shows 0.0.0.0:7897 listening, and Test-NetConnection localhost 7897 succeeds.

Root Cause 1: Firewall Blocking

vEthernet (WSL) is a virtual NIC dynamically created by WSL2 on every launch. Windows Firewall's Network Profiles (Domain / Private / Public) cannot be associated with this interface.

This means:

  1. Firewall inbound rules bind to Profiles
  2. vEthernet(WSL) belongs to no Profile
  3. All inbound rules show EnforcementStatus: NotApplicable for this interface
  4. Rules are simply skipped — not denied, not evaluated at all

No amount of port rules or InterfaceAlias bindings will work, because the rules never execute against this interface.

Solution 1: Fix Firewall with One Command

Step 1: Confirm the Root Cause

In Windows PowerShell (Administrator):

# Check the virtual NIC
Get-NetAdapter | Where-Object { $_.Name -like "*WSL*" }

# Check rule enforcement status
Get-NetFirewallRule -DisplayName "*7897*" |
Get-NetFirewallInterfaceFilter |
Get-NetFirewallPortFilter |
Format-Table -Property * -AutoSize

If rules exist but WSL still can't connect, Profile enforcement is the issue.

Step 2: Fix with One Command

# Disable firewall on the WSL virtual NIC only
Set-NetFirewallProfile -DisabledInterfaceAliases "vEthernet (WSL)"

Effects:

  • Only applies to the vEthernet (WSL) virtual NIC
  • Physical adapters (Wi-Fi, Ethernet) remain fully protected
  • Setting persists across reboots

Step 3: Verify

# Test connectivity from WSL
nc -zv 172.22.80.1 7897
# Output: Connection to 172.22.80.1 7897 port [tcp/*] succeeded!

# Test proxy
curl -x http://172.22.80.1:7897 https://api.anthropic.com

Root Cause 2: Dynamic IP Changes

After fixing the firewall, proxy may still fail. Why? vEthernet (WSL) IP changes on every launch.

If you hardcoded the IP in .bashrc like:

export http_proxy=http://172.22.80.1:7897  # This IP may change!

Fix: Dynamically detect host IP in .bashrc:

# Auto-detect host IP from DNS server
export WINDOWS_IP=$(cat /etc/resolv.conf | grep nameserver | awk '{print $2}')
export http_proxy="http://${WINDOWS_IP}:7897"
export https_proxy="http://${WINDOWS_IP}:7897"

Root Cause 3: Config File Cached Keys

After fixing firewall and dynamic IP, Claude Code/GLM may still report auth conflicts. Why? Stale keys cached in:

  1. ~/.bashrc global export of ANTHROPIC_API_KEY
  2. ~/.claude.json stores previously approved keys in customApiKeyResponses.approved
  3. ~/.claude/settings.json may have hardcoded environment variables

When switching to GLM mode, unset ANTHROPIC_API_KEY works temporarily, but bashrc reload re-exports it. Claude Code reads all three sources and reports conflict.

Solution 3: Clear Cached Keys

# 1. Check for conflicts
echo $ANTHROPIC_API_KEY $ANTHROPIC_AUTH_TOKEN

# 2. Clear ~/.claude.json cache
cat ~/.claude.json | python3 -c "import json,sys; d=json.load(sys.stdin); print(d.get('customApiKeyResponses', {}))"

# 3. Check ~/.claude/settings.json
cat ~/.claude/settings.json | grep -A2 '"env"'

Fix: Remove hardcoded values from settings.json:

python3 -c "
import json
with open('/home/aptop/.claude/settings.json') as f:
d = json.load(f)
d['env'].pop('ANTHROPIC_API_KEY', None)
d['env'].pop('HTTPS_PROXY', None)
d['env'].pop('HTTP_PROXY', None)
with open('/home/aptop/.claude/settings.json', 'w') as f:
json.dump(d, f, indent=2)
"

For ~/.claude.json, manually edit the file to remove stale entries from customApiKeyResponses.approved.

Important Notes

  • Firewall Fix

    • If your Clash port is not 7897, replace it with your actual port — the key is fixing the firewall interface, not the port
    • WSL updates or major Windows updates may reset the virtual NIC config; re-run the same command if the issue recurs
    • Windows 10 does not support WSL2's mirrored networking mode (only Windows 11 23H2+), do not try this direction
  • Dynamic IP

    • Always use dynamic IP detection in .bashrc — never hardcode the host IP
    • The DNS server IP in /etc/resolv.conf is stable across WSL restarts
  • Config Cache

    • After clearing cached keys, restart Claude Code to apply changes
    • If auth still fails, check all three sources: environment variables, ~/.claude.json, and ~/.claude/settings.json

Troubleshooting Checklist (For Future Reference)

  1. echo $ANTHROPIC_API_KEY $ANTHROPIC_AUTH_TOKEN — confirm if both exist (conflict)
  2. cat ~/.claude.json | python3 -c "import json,sys; d=json.load(sys.stdin); print(d.get('customApiKeyResponses', {}))" — check cached keys
  3. cat ~/.claude/settings.json | grep -A2 '"env"' — check for hardcoded env vars
  4. curl https://open.bigmodel.cn — confirm GLM API is accessible

Dead Ends: 5 Directions Ruled Out

For anyone currently troubleshooting, here are the paths already confirmed as dead ends:

#DirectionResultReason
1Proxy environment variablesRuled out.bashrc unset/export pattern is by design for model switching
2Clash Allow LAN not enabledRuled outAllow LAN is on, listening on 0.0.0.0:7897, localhost works
3Firewall port ruleRuled outAdded port 7897 inbound rule, still blocked — NotApplicable
4Bind rule to InterfaceAliasRuled outSet-NetFirewallRule -InterfaceAlias "vEthernet (WSL)" had no effect
5Mirrored networking modeRuled outWindows 10 does not support it

The key insight: We kept trying to add rules, but the problem wasn't missing rules — it was that rules don't apply to this interface at all.