Writing 13 min read

Debugging Qwen Code's headless mode: when 2>&1 became a file

Interactively, Qwen Code never gave me trouble. The first time I ran it as a one-shot -p job, the answer was slow and a read-only df was refused. I followed it through the source to two bugs, filed both, and wrote a fix for the one that comes down to a single regular expression.

I use Qwen Code as the agent in front of my local Qwen model, and I use it because it is open source. When something surprises me, I can read the code that did it, learn how it works, and, if it turns out to be a bug, send a fix. This post is one of those times.

Interactively, Qwen Code has never given me any trouble. I ask it to run a command, it runs it, and it tells me what it found. Then I tried the same kind of request as a one-shot job, the way you would from a script or a cron job: qwen -p "…", which runs a single prompt with no interface and exits. The answer took far longer than it should have. Some read-only commands were refused with a message about editing. And on some runs the agent simply gave up and told me it had verified nothing.

Nothing was broken in a way that stopped me. I could have just run df myself. But a read-only command refused as an edit is the kind of thing I can’t leave alone, so I went looking.

The short version: there were three separate things going on.

  1. In -p mode the shell tool is off unless you allow it. That one is by design.
  2. The slow answers came from Qwen Code’s background memory. In -p mode the final result waits for it, which is already reported. What I could not find reported is that in this mode the memory work can never save anything, so the wait buys nothing.
  3. The refusals came from one regular expression in the permission check. It reads 2>&1, which only sends error output to the same place as normal output, as a write to a file named &1. Interactively that mistake is harmless. In -p mode it refuses the command. I wrote a small fix with tests and filed the bug.

This post follows the steps in the order I took them.

Step 1: the same request, two ways

I first noticed this on client01, the VM where my agents run in the Kong posts. In an interactive session, this worked the way it always does:

> Execute df -hT and df -i on this machine. Report the actual disk usage and available space. Do not estimate values

✓ Shell df -hT (Show filesystem disk usage in human-readable form with types)
✓ Shell df -i (Show inode usage per filesystem)

Then I asked something similar with -p:

qwen38-kong -p "Please tell me about the disk space availability on my machine"

The answer started with “Bottom line first: I can’t run df for you. This session has no shell-execution tool …” What came next made me smile. Without a shell, the model read /proc/fs/ext4/sda1/mb_groups, added up the free blocks in all 248 block groups of the root filesystem, and worked out that about 22 GiB was free. It even pointed out that ext4 reserves about 5% for root, which df would not count as available. Resourceful, and close (df said 23G), but not what I asked for.

So the first question was simple: why is there no shell in -p mode? Adding --approval-mode auto didn’t change anything. The answer is in config.ts. When Qwen Code runs without an interface, it adds deny rules for the tools that run commands or change files, unless you have allowed them explicitly (condensed):

if (!bareMode && !interactive && !isAcpMode && inputFormat !== InputFormat.STREAM_JSON) {
  switch (approvalMode) {
    case ApprovalMode.AUTO:
      // AUTO uses an LLM classifier to gate Shell/Monitor/Edit/WriteFile at call time; but
      // non-interactive mode has no UI for the classifier's fallback path, so apply the same
      // denylist as DEFAULT ...
      denyUnlessAllowed(ToolNames.SHELL);
      denyUnlessAllowed(ToolNames.MONITOR);
      denyUnlessAllowed(ToolNames.EDIT);
      denyUnlessAllowed(ToolNames.WRITE_FILE);
      break;

That makes sense. With nobody there to answer “may I?”, the safe default is “no”. The way in is to allow the shell yourself:

qwen38-kong --allowed-tools run_shell_command --approval-mode auto -p "…"

Keep the last two lines of that switch in mind, though: edit and write_file are still denied. That turns out to matter.

Step 2: the shell works, but not always

With the shell allowed, df ran. But the run also printed this:

Warning: Tool "run_shell_command" was not run: This "run_shell_command" invocation was denied
by permission rules. Matching deny rule: "edit".

df doesn’t edit anything. And the model’s answer was no help: it said a follow-up command had been blocked, but not which one.

A model’s final answer is a summary, not a log. To see what actually happened, I switched to --output-format json, which writes every message, tool call and result as JSON, and read it with jq. This is the filter I ended up using most, the list of commands the permission rules refused:

qwen38 --allowed-tools run_shell_command --approval-mode auto --output-format json \
  -p "Check the disk space and block devices on this machine with df and lsblk. Use read-only commands only. Report what you verified." \
  > run.json
jq '.[] | select(.type == "result") | .permission_denials[].tool_input.command' run.json
"df -hT 2>&1 | head -50"
"lsblk -o NAME,MAJ:MIN,SIZE,TYPE,FSTYPE,MOUNTPOINT,MODEL 2>&1 | head -60"

Every refused command, in every run, had one thing in common: 2>&1. Models add it out of habit, so that error messages end up in the output they get to read. When I repeated that prompt nine times on my workstation for this post, the model reached for 2>&1 in seven of the runs, and all 14 of those commands were refused. In six of those runs, the answer said both commands had been blocked and nothing was verified, and none of the six mentioned 2>&1: when they listed what had been tried, they wrote df -hT and lsblk -o …, without the end of the command. From those answers alone, I could not have seen what triggered the refusal.

The seventh run is my favorite. The model guessed that its 2>&1 had “tripped a write-detection heuristic”, ran both commands again without it, and they worked. It was right, and it got there faster than I did.

The json output also has per-run statistics, and those explained the wait.

Step 3: where the time went

The statistics split the token count by source. Besides main, the agent answering me, two more showed up: managed-auto-memory-extractor and managed-auto-memory-dreamer. They are Qwen Code’s background memory. After each prompt, the extractor reads the conversation and saves anything worth remembering to Markdown files in ~/.qwen. Every so often, the dreamer reorganizes those files. In a long interactive session that is a useful feature, and it runs while I’m reading the answer, so I never noticed it.

To see what it costs in -p mode, I changed one thing. Memory can be switched off per project, so I ran the same prompt in a project folder with this in .qwen/settings.json, and in one without it:

{
  "memory": {
    "enableManagedAutoMemory": false,
    "enableManagedAutoDream": false,
    "enableAutoSkill": false
  }
}
Memory off vs on The same prompt, headless, nine times

Agent answering meMemory extractorMemory dreamer

Every run as a table
RunSecondsAgentExtractorDreamerMemory writes savedCommands refused
Off, run 1 6.3 34,920 0 0 – 0
Off, run 2 6.5 52,471 0 0 – 2
Off, run 3 6.4 34,953 0 0 – 0
On, run 1 38.5 52,452 147,726 0 0 of 3 2
On, run 2 28.6 51,991 142,877 0 0 of 3 2
On, run 3 34.7 52,815 150,925 0 0 of 2 2
On, run 4 70.6 90,450 234,432 0 0 of 5 2
On, run 5 26.4 53,978 179,755 0 0 of 2 2
On, run 6 55.9 52,069 192,314 141,054 0 of 4 2
Qwen Code 0.26.1, -p with Auto approval and the shell allowed, Qwen 3.8 27B on my own GPUs, 2026-10-25. Seconds are Qwen Code's own duration_ms. Memory writes are the memory workers' write_file and edit calls; the agent answering me made none. The dreamer waits until a project has five sessions, so in this new project folder it first ran on run 6.

With memory off, each run took about 6.4 seconds. With memory on, the default, the average was 42.5 seconds, and runs used six times as many tokens, most of them by the memory workers. (On client01, where I first saw it, the averages were 5.7 and 54.8 seconds.) The reason is a few lines in nonInteractiveCli.ts. Before it reports the result, -p mode waits for the memory work to finish:

const memoryTaskPromises = config.getLlmClient().consumePendingMemoryTaskPromises();
if (memoryTaskPromises.length > 0) {
  await Promise.allSettled(memoryTaskPromises);
}

The interactive interface doesn’t wait. This part is already known: #13851 describes exactly this gap, with measurements from an IDE plugin where it looked like a hang, and a contributor is working on a fix.

What the wait bought: nothing

The last column of the figure is the part I didn’t expect. Across the six memory-on runs, the memory workers tried to save 19 times and all 19 attempts failed. After the runs, the project’s MEMORY.md was empty. About 1.2 million tokens of work, and nothing was saved.

The cause is the deny rules from Step 1. The memory workers get their own permission rules, which allow writing inside the memory folders. But those are combined with the session’s rules in memory-scoped-agent-config.ts, and the stricter decision wins:

const priority = { deny: 4, ask: 3, allow: 2, default: 1 };
return priority[baseDecision] > priority[scopedDecision] ? baseDecision : scopedDecision;

In -p mode with the default or Auto approval, the session denies edit and write_file. That deny beats the memory workers’ allow, every time. The debug log shows the extractor running out of turns (MAX_TURNS) as it kept trying, and the dreamer ran again on the next run, because a failed dream is not recorded as done (that part is #15018).

To check that this really was the cause, I ran one control: the same prompt with --approval-mode auto-edit, where -p does not deny edits. The memory writes went through, all of them.

Step 4: one command, one difference

The memory work explains the wait, but not the refusals. For those I wanted the smallest test possible: memory off, a prompt that asks for exactly one command, and two runs that differ only in 2>&1.

qwen38 --allowed-tools run_shell_command --approval-mode auto --output-format json \
  -p "Execute exactly this one shell command: df -hT /
Do not retry, and do not run any other command. Report the output." > plain.json

# the same, with the command changed to: df -hT / 2>&1

In both runs the model asked for exactly the command I gave it, so the difference is not the model picking something riskier. The first ran. The second was refused before it ran:

{
  "tool_name": "run_shell_command",
  "tool_input": {
    "command": "df -hT / 2>&1",
    "description": "Show filesystem type and space usage for the root filesystem (read-only)."
  }
}
This "run_shell_command" invocation was denied by permission rules. Matching deny rule: "edit".

Then the same command in an interactive session, in the same Auto mode: ✓ Shell df -hT / 2>&1. So the question was narrow now: what does the permission check think 2>&1 does? The figure below has the whole answer, for each mode.

The decision One command, three ways to run it
Mode
Command
  1. The model asks to run
  2. File operations the check infers
  3. Rules in this mode
  4. Result

Qwen Code 0.26.1 in Auto approval mode, with the shell enabled for headless runs by --allowed-tools run_shell_command. "Patched" is Qwen Code's main branch (still version 0.26.1) built from source with the fix in Step 7. Every combination was run once on 2026-10-25 against Qwen 3.8 27B on my own GPUs.

Step 5: read the parser

Before a shell command runs, Qwen Code works out which files it would read or write, so that file rules apply to shell commands too. That is a good idea: without it, echo x > notes.txt would get around a rule that says “don’t edit notes.txt”. The work happens in shell-semantics.ts. When a redirection is written without a space, as in >file or 2>&1, one regular expression splits it into the operator and the target:

const m = tok.match(/^(<<-?|1>>|1>|>>|>|2>>|2>|&>>|&>|<)([\s\S]+)$/);

For 2>&1, the operator is 2> and the target is &1. The target is then checked by looksLikePath(), which rejects flags, variables and plain numbers, but not &1. So &1 is resolved against the current folder and recorded as a file write: <project>/&1.

In bash, 2>&1 means “send file descriptor 2 (errors) wherever descriptor 1 (normal output) is going”. No file is opened. A file write would be 2>errors.txt.

To be sure I was reading it right, I called that function from the installed 0.26.1 bundle directly, with a handful of commands:

Command What bash does What 0.26.1 infers
df -hT / prints a table nothing
df -hT / 2>&1 errors go to the same place as output write <project>/&1
df -hT / >&2 output goes to the error stream write <project>/&2
df -hT / 2>&- closes the error stream write <project>/&-
df -hT / 2>/dev/null discards errors nothing
df -hT / >/dev/null 2>&1 discards everything write <project>/&1
echo hi >&notes.txt writes notes.txt write <project>/&notes.txt
echo hi > out.txt writes out.txt write <project>/out.txt

The last two rows are worth a second look. >&notes.txt really does write notes.txt (bash treats >&word like &>word), but the check records it as &notes.txt, so a rule about notes.txt would be checked against the wrong path. Same mistake, opposite direction.

And this is why I never saw any of it interactively. The phantom write to <project>/&1 is inside the project folder, and interactive Auto mode allows writes there. The check is wrong, but nothing acts on it. In -p mode, the deny rules from Step 1 act on it: a “write” means edit, and edit is denied.

Step 6: had anyone found it already?

Before writing a fix, I searched the issues and pull requests. The parser has had bugs in this area before: #5316 was the opposite case, a real write in 1>file that the check missed, and it was fixed. #3073, still open, reports shell commands “intermittently denied” with no visible pattern, and one of its examples ends in 2>&1 | tail -5.

Then I found #14121, opened on October 13 by another contributor. It rewrites the redirect parsing to handle every form bash has, and its description mentions that ls -la 2>&1 was being refused under a Write deny rule as a write to a file named &1. So the parser bug had been found, and so had >&file. As with the Spark hang, someone had been there before me, at least partly.

But the pull request is not merged. Its last review asked for changes after finding regressions, and as of October 26 the author is still waiting on the maintainers for a design decision: whether to move this analysis onto the shell parser Qwen Code already uses elsewhere. The issue it refers to no longer opens. Neither the 0.26.1 release nor main has a fix, and I found no issue that describes what this does to -p runs: with no settings of your own, a read-only command is refused because of how it handles error output. I didn’t find a report of the memory side either.

Step 7: a small fix

A full rewrite of the redirect parsing is the right long-term answer, and it is in good hands. In the meantime, the 2>&1 case can be fixed with a small, separate change that doesn’t touch anything else. In the branch that handles a redirection written without a space, a target that starts with & is now checked first:

       const m = tok.match(/^(<<-?|1>>|1>|>>|>|2>>|2>|&>>|&>|<)([\s\S]+)$/);
       if (m) {
         const op = m[1]!;
-        const target = m[2]!;
+        let target = m[2]!;
         if (op.startsWith('<<')) {
           toRemove.add(i);
           continue;
         }
+        // `>&`, `N>&` and `<&` take a file descriptor, not a file name.
+        if (target.startsWith('&') && (op === '>' || op === '1>' || op === '2>' || op === '<')) {
+          const word = target.slice(1);
+          // `2>&1`, `>&2`, `<&0`, `2>&-`: duplicate or close a descriptor. No file is opened.
+          if (/^(\d+|-)$/.test(word)) {
+            toRemove.add(i);
+            continue;
+          }
+          // `>&file` and `1>&file` write `file`. Check that path, not `&file`.
+          target = word;
+        }

I checked each form against bash 5.2 in an empty folder before writing the rule: 2>&1, >&2, 2>&- and <&0 create no file, while >&f1 and 1>&f3 create f1 and f3. (2>&file is an “ambiguous redirect” error in bash. The fix still treats it as a write, which keeps the check strict.)

I added eleven test cases to the existing test file: the descriptor forms above produce no file operation, commands that combine 2>&1 with a real file still report that file, and >&file reports the right path. All eleven fail without the change and pass with it. The permission and shell test suites, 1,653 tests in 18 files, pass, and lint and formatting are clean.

Then the real test: I built the CLI from source with the change and ran the three commands from the figure in -p mode again.

Command 0.26.1 With the fix
df -hT / runs runs
df -hT / 2>&1 refused: edit runs
df -hT / > out.txt refused: edit still refused, and no out.txt

The read-only command runs, and the real write is still refused. That was the point.

If you run Qwen Code 0.26.1 headless

Until a fix is released, these helped me:

  • Allow the shell explicitly: --allowed-tools run_shell_command. --approval-mode auto alone won’t do it. That allows the whole shell tool, so use it with prompts you trust.
  • Ask for commands without 2>&1, or for error output to be left alone. The model follows that instruction well.
  • Switch memory off for headless projects with the project-level settings in Step 3. In -p mode with the default or Auto approval it can’t save anything anyway, and it costs most of the time and tokens. The setting applies to every session in that folder, interactive ones too.
  • Don’t switch to auto-edit or --yolo just to get past this. Both let the agent change files without asking, and the control run in Step 3 shows how far that reaches.

What’s next

I’ve sent both findings back to the Qwen Code project:

  • The -p refusals are filed as #15600, with the one-command reproduction, the parser trace, and a link to #14121. In it I offered the small fix above as a pull request that can land while the larger rewrite is settled, or to leave the case to #14121, whichever the maintainers prefer.
  • Memory that can’t save in -p mode is filed as #15597, linked to #13851. One way to fix it would be to skip the memory work when the session denies writes. Another would be to let the memory folders through the -p deny rules, but not through deny rules a user wrote. That is a call for the people who designed it, so I left the fix to them.

There is also a loose end. A few times, a shell command came back with exit code 0 and no output at all: df -i / on client01, and once in my runs for this post, a plain df -hT /, out of about 40 shell commands. It has nothing to do with permissions, so I left it out of this post. It’s next on my list.

How I tested

  • Qwen Code 0.26.1, installed from npm. It updates itself into ~/.qwen/updates, so I checked that the copy that actually runs has the same parser, and ran my parser check against that copy too.
  • The model: Qwen 3.8 27B, served by SGLang on bigai, through its OpenAI-compatible API. My qwen38 wrapper sets the endpoint and the model name; qwen38-kong on client01 goes through Kong.
  • The machine: my workstation, WSL2 on Windows, bash 5.2.21, Node 24.14. The first observations in Steps 1 and 2, and the 5.7 / 54.8 second averages, are from client01.
  • Each test ran in a new, empty project folder, with memory off unless the test was about memory, and with --output-format json read by jq. Interactive sessions ran inside script, so Qwen Code had a real terminal.
  • The source is the v0.26.1 tag. On main at 4e7fe283 (October 26), the parser, the -p deny rules, the memory permission merge and the wait before the result are all still the same. My fix is on a local branch of main, built with npm run bundle.

Sources