15 July 2026

Giving a Multi-Agent Framework Shell Access — What We Fixed and Why It Matters

Background

jiuwenclaw is a Chinese-origin multi-agent framework built on top of the openjiuwen agent-core. It supports a "team mode" where a leader agent coordinates a group of specialist teammates, each with their own skills, tools, and workspace.

This post documents three connected bugs we found and fixed, all triggered by a single user request: "Can the agent run shell commands the way Gemini CLI does?"


Problem 1: The React Loop (Agent Generates Text Instead of Tool Calls)

Symptom

After asking a question that required inspecting the machine (e.g. "what WiFi networks can you see?"), the agent entered an infinite loop. Each iteration the LLM produced a natural-language response describing how it would run a command — but never actually ran one. The runner log showed finish_reason: stop on every turn, meaning no tool was ever called.

Root Cause

The react loop in the openjiuwen harness re-invokes the LLM when the response has no tool call (finish_reason != tool_calls). The LLM was generating text because no shell tool existed in the agent's tool card list.

The mcp_exec_command tool was already implemented in command_tools.py and wired into the SkillDevService, but the team/agent mode uses a different tool registration path. In interface_deep.py, _get_tool_cards() built the list of available tools — and mcp_exec_command was never added to it.

Fix

Register mcp_exec_command in _get_tool_cards():

# jiuwenclaw/agentserver/deep_agent/interface_deep.py
from jiuwenclaw.agentserver.tools.command_tools import mcp_exec_command

def _get_tool_cards(self, ...) -> list[ToolCard]:
    ...
    # After wiki tools:
    if not Runner.resource_mgr.get_tool(mcp_exec_command.card.id):
        Runner.resource_mgr.add_tool(mcp_exec_command)
    tool_cards.append(mcp_exec_command.card)
    return tool_cards

This is the pattern used by all other tools in that method. Once registered, the LLM can actually call the tool instead of narrating about it.


Problem 2: The Sandbox Restriction Blocked Useful Commands

Symptom

After fixing the registration, agents could call mcp_exec_command — but any workdir outside the agent workspace raised a ValueError, producing [ERROR]: workdir is outside project workspace. This prevented running commands like ip addr in /tmp or inspecting files anywhere outside the agent's sandbox.

Root Cause

_resolve_command_workdir unconditionally called:

candidate.relative_to(project_root)  # raises ValueError if outside workspace

This was appropriate for a sandboxed skill dev environment but too restrictive for an agent with system-level access.

Fix

Introduce a MCP_EXEC_COMMAND_SANDBOX environment variable (default false) that opts into the restriction rather than enforcing it always:

def _is_workdir_sandbox_enabled() -> bool:
    raw = os.getenv("MCP_EXEC_COMMAND_SANDBOX", "false").strip().lower()
    return raw in ("1", "true", "yes", "on")

def _resolve_command_workdir(workdir: str) -> Path:
    project_root = get_agent_workspace_dir()
    candidate = Path(workdir) if workdir else project_root
    if not candidate.is_absolute():
        candidate = project_root / candidate
    candidate = candidate.resolve()
    if _is_workdir_sandbox_enabled():
        candidate.relative_to(project_root)  # raises ValueError if outside workspace
    return candidate

The switch is documented in .env:

# mcp_exec_command: restrict workdir to agent workspace (true) or allow any path (false)
MCP_EXEC_COMMAND_SANDBOX=false

Problem 3: Workspace Files Written in Chinese Despite preferred_language: en

Symptom

After the agent's workspace was initialised, the generated files (AGENT.md, SOUL.md, HEARTBEAT.md, IDENTITY.md) were written in Chinese, even though the config contained preferred_language: en.

Root Cause — Tracing the Data Flow

The language setting travels through three layers before it reaches the template selector:

  1. Configpreferred_language: en
  2. load_team_spec_dict → builds per-agent WorkspaceSpec dicts
  3. WorkspaceSpec.language → passed to get_workspace_schema(language) → selects EN or CN template

The bug was in step 2. _DEFAULT_AGENT_WORKSPACE was defined as:

_DEFAULT_AGENT_WORKSPACE = {"stable_base": True}

No language key. When this dict was used to construct each agent's workspace spec via merged.setdefault("workspace", deepcopy(default_workspace)), the resulting spec had no language, so WorkspaceSpec.language fell back to its model default — "cn".

The preferred_language value was read in load_team_spec_dict but only assigned to spec_dict["language"] (the top-level field) — it was never propagated into the per-agent workspace dicts.

Fix

Thread preferred_language as a language parameter through _build_agents_config, then inject it into every workspace spec via setdefault (respecting any explicit per-agent override):

def _build_agents_config(
    team_raw: dict[str, Any], config_base: dict[str, Any], language: str = "zh"
) -> dict[str, Any]:
    default_model = _build_default_model_dict(config_base)
    default_workspace, max_iterations, completion_timeout = _build_agent_defaults()
    default_workspace.setdefault("language", language)  # ← inject here

    for agent_key, raw_agent_config in agents_raw.items():
        agent_config = dict(raw_agent_config) if isinstance(raw_agent_config, dict) else {}
        if "workspace" in agent_config and isinstance(agent_config["workspace"], dict):
            agent_config["workspace"].setdefault("language", language)  # ← and per-agent
        ...

Also propagate to the team-level workspace spec:

# in load_team_spec_dict:
resolved_lang = str(config_base.get("preferred_language", "zh")).strip().lower()
agents = _build_agents_config(team_raw, config_base, language=resolved_lang)
...
workspace_spec = _build_workspace_spec(team_raw)
if workspace_spec is not None:
    workspace_spec.setdefault("language", resolved_lang)
    spec_dict["workspace"] = workspace_spec

Using setdefault throughout means an agent that explicitly configures workspace: {language: zh} won't have it overridden.


Skill File

To guide the agent on what commands are available and how to use them, a skill was created at:

~/.jiuwenclaw/agent/jiuwenclaw_workspace/skills/system-commands/SKILL.md

The allowed_tools frontmatter field in jiuwenclaw's skill system is advisory — it hints to the LLM which tools the skill uses. Actual tool availability is determined by what's registered in _get_tool_cards(). The skill body includes common command patterns for networking, hardware inspection, process listing, and log tailing.


Unit Tests

test_command_tools.py — 19 tests

TestSandboxSwitch (3 tests): verifies _is_workdir_sandbox_enabled() reads the env var correctly, including case-insensitive truthy/falsy parsing for true/True/TRUE/1/yes/on and false/False/FALSE/0/no/off.

TestResolveCommandWorkdir (6 tests): verifies that:
- Empty workdir falls back to agent workspace
- Relative paths are resolved under the workspace
- Sandbox off: absolute paths outside workspace are allowed
- Sandbox on: absolute paths outside workspace raise ValueError
- Sandbox on: paths inside workspace (including root) are allowed

test_team_config_loader.py — 8 new tests in TestLanguagePropagation

  • preferred_language: en sets spec["language"] == "en" at the top level
  • Absent preferred_language defaults to "zh"
  • "en" propagates to each agent's workspace spec
  • "en" propagates to all agents when multiple agents are configured
  • "en" is injected into explicit per-agent workspace dicts (that set other keys)
  • An explicit workspace: {language: zh} on an agent is not overridden
  • "en" propagates to the team-level workspace spec
  • An explicit language: zh in the team workspace spec is not overridden

Key Architectural Learnings

  1. _get_tool_cards() is the source of truth for tool availability in team/agent mode — not get_mcp_tools(), not skill allowed_tools. Any tool that should be callable must be explicitly added here.

  2. The react harness loops on finish_reason != tool_calls — if the model can't call a tool (because none are registered for the task), it will narrate endlessly. The fix is always to provide the right tool, not to adjust the loop logic.

  3. setdefault is the right pattern for propagating config defaults — it lets outer layers provide defaults while inner (explicit) config takes precedence.

  4. MCP_EXEC_COMMAND_SANDBOX follows the same bool-string convention as other env vars1/true/yes/on enable, everything else (including absence) disables.


Files Changed

File Change
jiuwenclaw/agentserver/deep_agent/interface_deep.py Register mcp_exec_command in _get_tool_cards()
jiuwenclaw/agentserver/tools/command_tools.py Add _is_workdir_sandbox_enabled(), make sandbox conditional
jiuwenclaw/agentserver/team/config_loader.py Propagate preferred_language into all workspace specs
tests/unit_tests/agentserver/test_command_tools.py New — 19 tests for sandbox switch and workdir resolution
tests/unit_tests/agentserver/test_team_config_loader.py 8 new TestLanguagePropagation tests + updated 1 existing assertion
~/.jiuwenclaw/agent/.../skills/system-commands/SKILL.md New skill providing shell command guidance
~/.jiuwenclaw/config/.env Document MCP_EXEC_COMMAND_SANDBOX=false

Installing JiuwenClaw from Source: The Errors You Will Hit and How to Fix Them

JiuwenClaw is an open-source Python AI agent platform with a multi-agent team mode, a web UI, and support for local LLM backends via Ollama. Installing it from source is straightforward — until it isn't. This post documents every error I hit setting it up on Ubuntu and exactly how I resolved each one.

The Setup

  • Ubuntu, Intel Ultra 7 165H, RTX 4070 8GB
  • Ollama running locally with qwen3:8b
  • JiuwenClaw 0.1.11 installed from source via uv

Error 1: GitHub Rejects Your Password

fatal: could not read Username for 'https://github.com': No such device or address

GitHub dropped password authentication for git operations in 2021. You need a Personal Access Token (PAT).

Fix: Generate a PAT at https://github.com/settings/tokens (tick the repo scope), then embed it in the clone URL:

git clone https://USERNAME:TOKEN@github.com/openJiuwen-ai/jiuwenclaw /path/to/dest

If your username is an email address containing @, URL-encode it as %40 — otherwise git misparses the URL and rejects it with a cryptic port number error:

URL rejected: Port number was not a decimal number between 0 and 65535
# Correct for email usernames
git clone https://user%40domain.com:TOKEN@github.com/openJiuwen-ai/jiuwenclaw /path/to/dest

Also configure the credential store so you only do this once:

git config --global credential.helper store

Error 2: jiuwenclaw-init Hangs or Crashes When Run Non-Interactively

EOFError: EOF when reading a line

The init script prompts for language preference interactively. When run via a non-interactive shell (or via Claude Code's ! command prefix), there is no TTY and it crashes immediately.

Fix: Run jiuwenclaw-init directly in your terminal with the virtual environment activated — not via any automation layer:

source .venv/bin/activate
jiuwenclaw-init

Error 3: Startup Fails with dist directory not found

dist directory not found: /home/user/.jiuwenclaw/web/dist
[start_services] web exited with code 1

Source installs do not ship the pre-built frontend. The web/dist directory is in .gitignore and must be built manually.

Fix:

cd jiuwenclaw/web
npm install
npm run build
cp -r dist ~/.jiuwenclaw/web/dist

The build takes about 3 seconds. You only need to redo this if the frontend source changes (e.g. after a git pull).


Error 4: team must be a non-empty array

ValueError: team must be a non-empty array

This is thrown by the config parser when modes.team in config.yaml is written as a named mapping (dict) instead of a list. The web UI expects an array.

Wrong:

modes:
  team:
    my_team:          # dict key — causes the error
      team_name: my_team

Correct:

modes:
  team:
    - team_name: my_team    # list item — works
      lifecycle: persistent

This error also appears if the UI saves an empty team list after you delete all teams. Always ensure at least one team entry exists before saving.


Error 5: Team Configuration Shows Stale or Empty Data After Editing config.yaml

You edit config.yaml directly, restart the server, refresh the browser — and the Team Configuration panel still shows old data or nothing at all.

Root cause: The web UI does not read team configuration back from the server. The config.get WebSocket call only returns environment variable values. Team data is stored exclusively in browser localStorage under the key jiuwenclaw_agents_teams_cache. Direct edits to config.yaml are invisible to the UI until you also update localStorage.

Fix: Seed localStorage via the browser console. Because the JiuwenClaw frontend runs on localhost:5173, a plain fetch() from the same origin works — but loading a file from a different port requires CORS headers.

Start a CORS-enabled local file server:

python3 -c "
from http.server import HTTPServer, SimpleHTTPRequestHandler
import os

class CORSHandler(SimpleHTTPRequestHandler):
    def end_headers(self):
        self.send_header('Access-Control-Allow-Origin', '*')
        super().end_headers()
    def log_message(self, *a): pass

os.chdir('/tmp')
HTTPServer(('127.0.0.1', 8765), CORSHandler).serve_forever()
" &

Place your team JSON in /tmp/jw_setup.js as a single localStorage.setItem(...) call, then run in the browser console on http://localhost:5173:

fetch('http://localhost:8765/jw_setup.js').then(r=>r.text()).then(code=>eval(code))

Refresh the page. The team config will appear. Click Save to write it back to config.yaml.

Why not just paste the JSON directly in the console? A multi-line paste causes SyntaxError: string literal contains an unescaped line break because the browser console treats each line as a separate statement. The file-serving approach sidesteps this completely.

Why not use Python's built-in http.server? It works for serving files but does not add Access-Control-Allow-Origin headers, so the browser blocks the fetch with a CORS error even though the status code is 200. You need the custom subclass shown above.


Error 6: kill Exit Code 1 After Stopping Orphaned Processes

kill 80613 80615 && ss -tlnp | grep 19001 || echo "Ports clear"
# Ports clear   ← correct, but exit code was 1

The ss | grep returns exit code 1 when it finds nothing (standard grep behaviour), which causes the && chain to short-circuit. The ports were actually clear — the message was correct.

Fix: Use ; echo rather than || echo if you want to see the grep output regardless, or check the port state with a separate command after killing.


Summary

Error Cause Fix
GitHub auth failure Password auth removed 2021 Use PAT in clone URL
@ in username breaks URL Git misparses the host URL-encode @ as %40
jiuwenclaw-init EOFError No interactive TTY Run directly in terminal
dist directory not found Frontend not built npm install && npm run build && cp -r dist ~/.jiuwenclaw/web/dist
team must be a non-empty array modes.team is a dict not a list Use - team_name: list syntax
Team config not visible in UI UI reads localStorage, not config.yaml Seed localStorage via CORS fetch
CORS error on fetch Default http.server adds no CORS headers Use custom handler with Access-Control-Allow-Origin: *
kill exit code 1 grep exits 1 on no match Separate the kill and verify commands

JiuwenClaw is a capable platform once it's running — the team mode with local Ollama models works well for multi-agent engineering workflows. The setup friction is mostly undocumented edge cases that are easy to fix once you understand the architecture.

13 July 2026

I thought Cloudflare Access was guarding my Proxmox box. It wasn't.

I'd just put a Proxmox server behind a Cloudflare Tunnel — two public hostnames, one for SSH and one for the web UI — and I was quietly pleased with myself. I already run a Zero Trust setup on the domain, a wildcard application that demands an email one-time-code before it lets anyone near anything. So the new box was protected too. Obviously.

It was not protected. And the way I found out is worth writing down, because the failure is silent and the test for it is genuinely easy once you know what you're looking at.

The niggle

I was testing the new web UI hostname and ran a plain curl at it — no browser, no cookies, nothing. It cheerfully handed me back the Proxmox login page. HTTP 200.

That shouldn't happen. If Cloudflare Access were sitting in front, an unauthenticated request should never reach the origin — it should be bounced to the login screen. Getting the actual Proxmox page from a credential-free curl is the sound of a stable door swinging in the wind.

My first instinct was to explain it away — "this machine's already authenticated, it's passing through." Plausible! And wrong, as it turned out, which is the useful bit.

The insight: where Access actually sits

Cloudflare Access runs at the edge, before your request is proxied down the tunnel to your origin. So for a hostname an Access policy actually covers, an unauthenticated request gets a 302 redirect to your-team.cloudflareaccess.com and never touches the tunnel. You can test it without even involving the origin:

curl -s -o /dev/null -w "%{http_code} %{redirect_url}\n" -i https://your-host.example.com
  • 302 → *.cloudflareaccess.com — Access is enforcing. Good.
  • 200 (origin), or 530 (tunnel down) — your request reached the tunnel layer, which means Access did not stop it. Not protected.

That last point is the neat one: even with my origin offline later (tunnel down, returning 530), the test still worked — because a 530 only happens after a request has cleared the edge. If Access were guarding the hostname I'd have got the login redirect regardless of whether the origin was up. A 530 to an unauthenticated caller is itself a finding.

The confounder: don't test from your own machine

Here's the trap I nearly fell into. If you test from a device that's enrolled in WARP, or that's already completed the email-code dance in a browser, your requests carry a valid Access session — so they sail straight through and everything looks protected. False negative.

Two things to check on your testing machine first:

curl -s https://www.cloudflare.com/cdn-cgi/trace | grep -E 'warp=|gateway='   # want warp=off gateway=off
ls ~/.cloudflared/                                                            # cached *-token files = you hold sessions

In my case the laptop wasn't on WARP — but ~/.cloudflared/ held an org-level Access token and a token for a different host entirely. So my "I'm already authenticated, it's fine" hunch was half right: I held a session, but for other applications. The new box had no application at all. Test from somewhere with an empty ~/.cloudflared, no WARP, and no browser cookies.

The actual cause

This is the part worth tattooing somewhere. A wildcard Access application does not automatically protect every subdomain you later create. And creating a tunnel route — cloudflared tunnel route dns <tunnel> <hostname> — only writes the CNAME. It does not create an Access application. So my two new hostnames had a working tunnel, a working DNS record, and precisely nothing standing between the public internet and a Proxmox login (and, for the SSH hostname, only the root password). The wildcard app I was relying on simply didn't match them in the way I'd assumed.

The fix

Add a real Access application for each hostname (Zero Trust → Access → Applications → Add → Self-hosted):

  • One application per hostname (or a wildcard you've verified matches), Allow policy, with an identity requirement — email OTP at minimum, ideally device posture or a group.
  • Keep the session short.
  • Re-run the curl test from a clean machine afterwards and confirm you get the 302 to *.cloudflareaccess.com.

For SSH hostnames specifically, the Access app is the front door but it isn't the only lock — make sure the origin itself isn't accepting password root logins behind it. Belt and braces.

How to audit the whole lot

If you've made this mistake once, you've probably made it more than once, so audit everything rather than spot-fixing. Briefly:

  1. List your DNS records and flag every CNAME pointing at *.cfargotunnel.com — those are tunnel-backed hostnames.
  2. List your tunnels and their ingress rules (note: locally-managed tunnels keep their ingress in /etc/cloudflared/config.yml on the origin, not in the dashboard API — you have to read the box).
  3. List your Access applications and their policies — note any bypass decisions, which are effectively "no protection" (sometimes that's deliberate; document why).
  4. Cross-reference: for every tunnel-backed hostname, is there an Access app, what's the policy, and does it require MFA? Anything fronting SSH, RDP or a hypervisor UI without an Allow+MFA policy is exposed.
  5. Verify each one empirically with the clean-machine curl test. Config and reality don't always agree.

The Cloudflare API (read-only token) makes steps 1–3 scriptable; I'll spare you the JSON here.

The lesson

The uncomfortable bit isn't that I'd misconfigured something — it's that I'd assumed a protection existed and never tested the assumption, because the dashboard looked tidy and the tunnel worked. A tunnel coming up is not the same as a door being locked. Test the lock, from outside the building, with no keys in your pocket.

Ta ta for now, and do go and curl your own hostnames — you might get a surprise.

A Realtek RTL8821AU on kernel 6.8, and the `new_id` trap that hung my reboot

I plugged a USB wifi dongle into a headless Linux box and got... nothing. No wl interface, no driver bound. What followed was a proper little saga involving an out-of-tree driver, the wrong driver grabbing the device, and a reboot that wouldn't reboot. Here's the whole thing so you can skip the bits I didn't.

Identifying the chipset (and the usual gripe)

lsusb
... ID 2357:0120 TP-Link 802.11ac WLAN Adapter

USB 2357:0120 is a TP-Link Archer T2U Plus, chipset Realtek RTL8821AU. The product string just says "802.11ac WLAN Adapter", which is no help at all — and TP-Link cheerfully reuse that string and shuffle USB IDs across hardware revisions. (When will manufacturers learn not to make us reverse-engineer which silicon we actually bought?)

On kernel 6.8 there's no in-tree driver that binds it: rtl8xxxu lists a clutch of 2357:01xx IDs but not 0120, and rtw88 covers newer chips. So it's the out-of-tree DKMS driver.

Building the driver

morrownr's maintained fork does the job. On a Debian/Proxmox box you'll need the matching kernel headers and a toolchain first:

apt-get install -y dkms git build-essential
# headers matching `uname -r` (on a no-subscription Proxmox host, from the no-subscription repo)
git clone https://github.com/morrownr/8821au-20210708.git
cd 8821au-20210708 && ./install-driver.sh NoPrompt

DKMS reported rtl8821au/5.12.5.2 ... installed and the module loaded — but still no interface. That's where it got interesting.

The trap: the wrong driver had already grabbed it

While poking about earlier I'd modprobe'd one of the rtw88 USB drivers and added the device with a runtime new_id override. That driver had claimed the dongle — so the correct 8821au driver couldn't bind it, even though it was loaded. Worse, when I tried to unbind it:

echo -n "1-3:1.0" > /sys/bus/usb/drivers/rtw_8822bu/unbind   # <-- hung

The write blocked in uninterruptible sleep (D state). You cannot kill a D-state process; it's wedged in the kernel waiting on the driver's disconnect path. And here's the sting in the tail: that stuck process then hung the rebootsystemd-shutdown waited 90 seconds for it, failed to remount the root filesystem read-only ("Device or resource busy"), forced the reboot anyway, and I finished it with a hard power-cycle. (No harm done — ext4 journalled-recovered on the way back up.)

The fix that stuck

Blacklist the drivers that shouldn't touch it, so on boot only the right one matches:

# /etc/modprobe.d/wifi-dongle.conf
blacklist rtw88_8822bu
blacklist rtw88_8821cu
blacklist rtl8xxxu

Reboot. With the imposters out of the way, 8821au claimed the device and wlx<mac> appeared, type managed, ready for wpa_supplicant.

The lesson I'll carry: new_id overrides are runtime-only and brilliant for experimenting, but if one goes wrong, don't fight the live binding — a reboot clears the runtime state and a blacklist makes the result permanent. Trying to unbind a wedged USB driver by hand is how you end up power-cycling a server.

Happy prototyping!