Skip to content

feat: Qwen Code backend (Alibaba) via @qwen-code/sdk - #301

Merged
saucam merged 1 commit into
mainfrom
feat/qwen-backend
Aug 24, 2026
Merged

feat: Qwen Code backend (Alibaba) via @qwen-code/sdk#301
saucam merged 1 commit into
mainfrom
feat/qwen-backend

Conversation

@saucam

@saucam saucam commented Aug 24, 2026

Copy link
Copy Markdown
Collaborator

Adds qwen as a session backend, rebased directly onto main.

Why the SDK, not ACP

qwen-code speaks both. @qwen-code/sdk is a deliberate clone of the Claude Agent SDK — same query({prompt, options}), createSdkMcpServer(), tool() — so this is modeled on ClaudeProvider, not the ACP providers. ACP would have cost us in-process MCP mounting and typed auth selection.

One thing the SDK does not give us, contrary to what its type surface first suggests: QueryOptions has no hooks field. SessionStart/SubagentStart belong to a separate daemon client, so there's no sub-agent ZeroID identity registration here. Subagents are derived from parent_tool_use_id, which is enough to tag subagent text (issue #82) but carries no agent_type.

Auth — both paths

Selected by providers.qwen.authType, auto-detected when unset (a ~/.qwen login beats the key):

  • qwen-oauth — qwen.ai subscription; tokens never transit codeoid
  • openai — OpenAI-compatible key + gateway

Gateway presets ship because the right one isn't discoverable. A Model Studio plan key (sk-sp-…) is rejected by both standard DashScope hosts with invalid_api_key; it needs token-plan.ap-southeast-1.maas.aliyuncs.com (bailian-plan-intl). Plan keys also expose more than Qwen — qwen3.8-max, glm-5.2, deepseek-v4-pro — so providers.qwen.model picks one.

Security: buildQwenEnv() returns more than an allowlist, deliberately

Unlike the Claude SDK (which replaces the child env), @qwen-code/sdk spawns with {...process.env, ...options.env}. A plain allowlist would be a no-op — everything omitted is still inherited, including CODEOID_API_KEY (the root ZeroID key) and TELEGRAM_BOT_TOKEN. A merge can only add or override, so every non-allowlisted name is mapped to "".

Verified end to end, not just unit-tested: the agent ran echo "leak=[$CODEOID_API_KEY]" against a planted decoy and got leak=[].

Two divergences from ClaudeProvider, both forced by the SDK

  1. Tool correlation. Qwen's canUseTool receives only {signal, suggestions} — no toolUseID. The id comes from the assistant message's tool_use block instead. Matching is oldest-first among ungated entries of the same name, never positional, because auto-approved tools never reach the callback — the exact issue fix: PreToolUse↔canUseTool FIFO desync corrupts tool-call correlation when tools are auto-allowed #81 desync.
  2. Backing ids coerced to UUIDs. query() validates sessionId and throws synchronously on a non-UUID, which would escape runTurn() and wedge the session rather than surfacing as a turn error.

Also: permissionMode pinned to "default" (qwen-code's own ACP default is auto, which silently auto-approves edits and shell), and turn_done falls back to the requested model because the gateway frequently omits modelUsage.

⚠️ Known gap — read-only shell commands bypass codeoid's gate

qwen-code's resolveDefaultPermission runs isShellCommandReadOnlyAST(command) and auto-allows anything it classifies read-only without ever raising a permission request, so those never reach canUseTool.

Measured on this backend:

Command Gated?
rm -f … yes
echo hello no
cat /etc/hostname no
cat ~/<decoy> no

Reads outside the workdir included — so an agent here can read anything the daemon user can (~/.codeoid/config.json, ~/.ssh/…) with no prompt. codeoid cannot intercept a call the CLI never makes; qwen-code's own source notes the layer isn't overridable by permission mode. The only blunt mitigation is excludeTools: ["run_shell_command"], which removes shell capability entirely.

Audited against gemini-cli 0.50.0 (the fork parent): the mechanism appears qwen-specific — no isShellCommandReadOnlyAST/resolveDefaultPermission in its bundle, and its shell shouldConfirmExecute has no content-based bypass. That check is static only — the live ACP run was blocked on expired ~/.gemini OAuth, not on anything security-related.

Open question for review: ship as-is documented, or add providers.qwen.allowShellTool: false as an opt-out?

Verification

  • Live end-to-end turn against the Bailian plan gateway driving the provider itself (not just the SDK): file written, tool ids correlated start→complete, approval gate fired, audit rows written.
  • bun run typecheck, bun run lint, bun run build clean.
  • 1994 tests pass, 0 fail across three full runs; 31 new, covering the env blanking, tool correlation, subagent tagging, UUID coercion, and model fallback.

🤖 Generated with Claude Code

@socket-security

socket-security Bot commented Aug 24, 2026

Copy link
Copy Markdown

Review the following changes in direct dependencies. Learn more about Socket for GitHub.

Diff Package Supply Chain
Security
Vulnerability Quality Maintenance License
Added@​qwen-code/​sdk@​0.1.87610010097100

View full report

Adds `qwen` as a session backend, modeled on ClaudeProvider rather than the
ACP providers: @qwen-code/sdk is a deliberate clone of the Claude Agent SDK
(query({prompt, options}), createSdkMcpServer(), tool()), so the warm-loop
model maps across directly. qwen-code also speaks ACP, but that path has no
in-process MCP mount and no typed auth selection.

Both credential paths work, selected by providers.qwen.authType and
auto-detected when unset (a ~/.qwen login wins over the key):
  - qwen-oauth — qwen.ai subscription; tokens never transit codeoid
  - openai     — OpenAI-compatible key + gateway

Gateway presets are included because the right one is not discoverable: a
Model Studio *plan* key (sk-sp-...) is rejected by both standard DashScope
hosts with invalid_api_key and needs token-plan.ap-southeast-1.maas.aliyuncs.com
(`bailian-plan-intl`). Plan keys also expose more than Qwen — qwen3.8-max,
glm-5.2, deepseek-v4-pro — so providers.qwen.model selects one.

SECURITY — buildQwenEnv() returns more than an allowlist by design. Unlike
the Claude SDK (which replaces the child env), @qwen-code/sdk spawns with
{...process.env, ...options.env}. A plain allowlist is therefore a no-op:
everything omitted is still inherited, including CODEOID_API_KEY (the root
ZeroID key) and TELEGRAM_BOT_TOKEN. Since a merge can only add or override,
every non-allowlisted name is mapped to the empty string. Verified end to end:
the agent ran `echo "leak=[$CODEOID_API_KEY]"` and got `leak=[]`.

Two divergences from ClaudeProvider forced by the SDK:
  - Tool correlation. Qwen's canUseTool gets only {signal, suggestions} — no
    toolUseID — so the id comes from the assistant message's tool_use block.
    Matching is oldest-first among ungated entries of the same NAME, never
    positional, since auto-approved tools never reach the callback (issue #81).
  - Backing ids are coerced to UUIDs. query() validates sessionId and throws
    SYNCHRONOUSLY on a non-UUID, which would escape runTurn() and wedge the
    session instead of surfacing as a turn error.

Also: permissionMode is pinned to "default" (qwen-code's own ACP default is
"auto", which silently auto-approves edits and shell), and turn_done falls
back to the requested model because the gateway often omits modelUsage.

Known gap, documented in CLAUDE.md: qwen-code's resolveDefaultPermission runs
isShellCommandReadOnlyAST() and auto-allows anything it classifies read-only
WITHOUT raising a permission request, so those never reach codeoid's gate.
Measured: `rm -f` gates, but `echo`, `cat /etc/hostname`, and a decoy file in
$HOME all ran ungated — including reads outside the workdir. codeoid cannot
intercept a call the CLI never makes. Audited against gemini-cli 0.50.0 (the
fork parent) and the mechanism appears qwen-specific; that check is static
only, as the live ACP run was blocked on expired ~/.gemini OAuth.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@saucam
saucam force-pushed the feat/qwen-backend branch from b244e2a to 7c75c1d Compare August 24, 2026 14:05
@saucam
saucam changed the base branch from feat/local-mode to main August 24, 2026 14:05
@saucam saucam closed this Aug 24, 2026
@saucam saucam reopened this Aug 24, 2026
@saucam
saucam merged commit 3028190 into main Aug 24, 2026
4 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants